diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7cd32886 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +examples/012_1_midfreq_cross_exchange/run.py text eol=lf +examples/012_1_midfreq_cross_exchange/strategy.py text eol=lf +examples/012_1_midfreq_cross_exchange/config.yaml text eol=lf +examples/012_2_event_driven_cross_exchange/run.py text eol=lf +examples/012_2_event_driven_cross_exchange/strategy.py text eol=lf +examples/012_2_event_driven_cross_exchange/config.yaml text eol=lf diff --git a/.gitignore b/.gitignore index 0a7284fb..5d0c9a15 100644 --- a/.gitignore +++ b/.gitignore @@ -165,6 +165,9 @@ docs/_internal/opts/requirements/迭代3-宏源期货完成穿透式认证/ examples/007_ctp/live_certification/hongyuan_penetration/reports/ examples/003_hft_notebook_examples/data/ examples/007_ctp/live_certification/simnow_penetration/reports/ +examples/007_ctp/live_certification/simnow_penetration/config.yaml +examples/007_ctp/live_certification/simnow_penetration/secrets.yaml +examples/007_ctp/live_certification/simnow_penetration/cases/**/secrets.yaml examples/007_ctp/live_certification/hongyuan_penetration/*.docx # Generated visualizations and reports from runnable examples diff --git a/AGENTS.md b/AGENTS.md index 32f401cd..78365237 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,12 +73,84 @@ The strategy regression suite is large (~10 min full). Tests are split into tiers by **measured per-file duration**, applied dynamically at collection time (no test files are edited): +The local Iteration 41 risk candidate at `2201316f` keeps root/core analytical +exports lazy, so durable admission no longer imports NumPy/scikit-learn and +related analytical modules. Its source suite passed 206 tests and independent +fresh-process import/reserve checks passed 3. Packaging metadata still lists +the prior required dependencies; this is not optional-dependency packaging. +Separate clean-clone builds of base `3de0fa4`, risk `2201316f`, and monitor +`f3583e7` produced byte-identical wheel pairs. A real isolated consumer venv +passed the risk/monitor suites (206/90), RECORD/payload/origin checks and +`pip check`; initial global-interpreter hybrid runs were excluded. This base +is version 0.15.4. The risk artifact at that checkpoint has a metadata/module +version mismatch (0.1.0/1.0.0). The later clean commit `d4bc0304` aligns the +public module version to metadata 0.1.0. Its two independent builds produce +the same wheel SHA-256 `d7e7bb28cfd049be3aa0b50f1e9deb7a83e97f24da3bc9bce51dc6f599a5202f`; +an actual isolated consumer venv passes all 206 risk tests, installed +RECORD/origin/pip checks and module/metadata version equality. Base and +monitor wheels were reused, without rerunning their suites. These are local +artifacts, not a release or a unified CTP pin. +The parent SDK MD identity consumer at +`62e683bc` independently passed 82 contract tests and 21 extra fake cases; +the SDK producer, unified wheel and native account lifecycle remain separate +acceptance work. + +The Framework source-outbox projection now derives incremental price and fee +from adjacent immutable Decimal snapshots with exact Fraction arithmetic, +then converts each increment once for the existing Broker float interface. +It verifies the actual execution bits and cumulative state before advancing +the cursor; unsupported quantity resolution and economic corrections fail +closed. The frozen three-file focus independently passed 64 tests, including +large cumulative-value cancellation and restart recovery. The final source +also imports and runs its Fraction/ULP helpers on actual CPython 3.8.20 without +loading an execution SDK. This is an AC41-27 slice; the combined multi-data, +partial-fill runonce/runnext fixture and account integration remain separate. + +Iteration 41's standalone `scripts/run_iteration41_monitor_benchmark.py` +measures a separate synthetic monitor process at 20 events/s using the real +SQLite outbox and checkpoint API. Its consumer polls every 100ms with a +32-event batch limit and retains per-event WAL/FULL acknowledgements; resource +sampling remains 50ms. Its default 30-minute profile and `--smoke` +both require an explicit new evidence directory and Python 3.11. Smoke is +only harness verification; the current short run exceeds the 5% single-core +CPU target, and the full serial/platform performance matrix is not accepted. +The harness now reports raw process-CPU boundaries and planned-window offsets; +the observed short-run Windows CPU granularity does not excuse a failed check +or establish the 30-minute average. +A later five-minute diagnostic consumed all 6,000 measured events and used +4.03% of one core, but four producer arrivals exceeded the unchanged 50ms +lateness limit. It remains `SMOKE_ONLY / NOT_ACCEPTED_FULL_MATRIX` (exit 2); +the five-round Windows/Linux 30-minute matrix has not run. +`scripts/run_iteration41_direct_benchmark.py` separately measures real +Store submit/cancel methods with a synthetic replay config and fake API. +Its smoke and four focused contracts passed independent review; the full +five-paired-round p50/p99 regression gate is <=5% per operation. Performance +failures return exit 3, harness failures exit 2; smoke does not evaluate the +performance gate. The formal Windows/Linux matrix remains unrun. +`scripts/run_iteration41_actor_capacity_benchmark.py` is a separate, +standard-library-only BM58 diagnostic with two spawned clients and its own +shared SQLite admission/claim tables. The default profile is a 10-intent/s, +2-second smoke; `--profile capacity-30m` explicitly selects 50 intents/s for +30 minutes. It reports duplicate attempts, losses, peak backlog, drain time, +and backpressure. It does not call a registered runtime or actor-server API, +and every result is `FAKE_LOCAL_DIAGNOSTIC / NOT_ACCEPTED`; smoke does not +establish AC41-58 acceptance. + +Iteration 41 source-integration tests use +`tests/test_utils/iteration41_source_roots.py`. An explicit +`BT_API_TEST_SOURCE_ROOTS` JSON map supplies validated absolute package roots; +subprocesses do not inherit an ambient `PYTHONPATH`. Optional guard and +source-only metadata roots are separate. Source metadata enables API tests, +but is not installed-wheel, RECORD, release or deployment evidence. The +dedicated source-QA environment uses MCP-compatible Pydantic 2.13.5; it is +separate from the accepted risk-wheel consumer environment with 2.5.0. + ```bash make test-fast # parallel non-performance tests + serial wall-clock # microbenchmarks; excludes slowest ~65% of strategy tests. # Daily "did I break anything" loop. make test-slow # the slowest ~65% strategy tests test-fast skips -make test-strategies # all 1,271 strategy regression tests (~9 min) +make test-strategies # all 1,286 strategy regression tests (~9 min) make test-all # parallel functional suite + serial wall-clock microbenchmarks make test-performance # wall-clock microbenchmarks without xdist make test-coverage # coverage report @@ -122,6 +194,12 @@ pytest ... --use-installed-backtrader # CLI flag The active `backtrader.__file__` is printed in the pytest session header. The switch works under `pytest-xdist` parallel mode. Logic lives in `conftest.py`. +The Iteration 41 CPython 3.8 core harness also executes fresh-process import +boundary tests. Store binding dataclasses use slots only on Python >=3.10, +preserving default package import on 3.8/3.9 without loading the optional SDK. +The latest local Windows 3.8 harness passed 111 tests and skipped 8; the +managed SDK still requires its newer interpreter and hosted CI remains separate. + ### Code quality ```bash @@ -134,6 +212,13 @@ make quality-check # all of the above (no tests) bash scripts/optimize_code.sh # pyupgrade + isort + black + ruff + tests ``` +The `mypy backtrader` gate checks the core package. Its `pyproject.toml` +override keeps type information from the separate `backtrader_runtime` package +but silences that package's diagnostics; runtime is not yet covered by a +dedicated mypy gate. +The release wheel consumer verifies every packaged Python source file in both +`backtrader/` and `backtrader_runtime/` against the source and isolated install. + ### Docs & utilities ```bash @@ -261,13 +346,17 @@ backtrader/ core library commissions/ stores/ channels/ mixins/ plot/ bokeh/ reports/ configs/ utils/ notifications/ alert delivery: dingtalk/wecom/feishu/telegram/email/slack/ discord/ntfy/gotify/bark/webhook/wechat_clawbot/qq_bot +backtrader_runtime/ Iteration 41 config-first CLI, sealed preset policy and + reviewed runtime registry (`bt-runtime` entry point) + _local_fake_account_actor_candidate/ isolated local fake-only protocol + snapshot; unregistered and absent from the default runtime AI strategy products are maintained outside this repository: cloudQuant/backtrader-skills standalone author/review/test skills product cloudQuant/backtrader-mcp standalone local-stdio MCP product cloudQuant/backtrader-agent standalone stateful agent product tests/ unit/ functional/ integration/ performance/ original_tests/ add_tests/ strategies/ bench/ datas/ fixtures/ factories/ test_utils/ - functional/strategies/ 1,271 inlined regression tests in ~30 categories + functional/strategies/ 1,286 inlined regression tests in ~30 categories docs/ Sphinx docs (EN + ZH) + design/bug notes scripts/ optimize_code.sh, refresh_strategy_durations.py, run_strategy_branch_compare.py, … @@ -280,6 +369,566 @@ examples/strategy_candidate_approval.py candidate-specific receipt/provenance p Makefile pyproject.toml setup.py pytest.ini requirements.txt conftest.py ``` +### Iteration 41 runtime configuration + +#### Acceptance snapshot (2026-09-27; historical) + +Store sdk_api=None r1 was integrated at the historical Store source SHA-256 A028A68DF87ABE84A1D38F4020D81AF56E0DFB860DED43D36C3830933106696D. The mechanical fail-close source is SHA-256 549276111279275BEB000D8104C4330A6D11B7C181AE66087079A555AF26D81F. The guarded Store/mechanical focus passed 38/38; the Store/Runtime run at that checkpoint passed 2,725, skipped 43, xfailed 2, failed 0. See the [Store r1 main integration archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-store-sdk-api-none-r1-main-integration-2026-09-27/README.md) and the [mechanical fallback fail-close archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-mechanical-sdk-api-none-fail-close-main-integration-2026-09-27/README.md). These are local regression results only. + +The separate guarded fake/offline integration QA passed 97 tests and skipped 10. Optional source-gated tests did not execute; CTP account/native-adjacent tests and deployment interop were statically excluded. The [QA archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-fake-offline-main-integration-qa-2026-09-27/README.md) does not establish live or real-provider acceptance. Store getter proxy r0 is NO_MERGE because same-process reflection recovers the raw API. G4 exact CTP pinned-wheel rebuild remains unproven: the rebuilt CTP native payload differs, no fresh install/native acceptance was performed, and G4 remains closed; see the [independent rebuild QA](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/g4-base-ctp-pinned-wheel-rebuild-independent-qa-2026-09-27/README.md). + +That 2026-09-27 snapshot's R2 inventory statement is historical. The 2026-09-28 CE04 Store route-identity integration first refreshed the inventory to `0874A81A…`; the later official 2026-09-28 inventory is `A959A3BC…`, as recorded in the current checkpoint below. + +V21 native-floor r0 remains SAFE_LOCAL_FAKE / NO_MERGE (271 passed, 2 skipped); it supplies no trusted native-floor authority. A later current-source G5/V21 cross-package fake-contract QA passed 4 focused tests and 275 tests with 2 optional SDK-import tests blocked and skipped. It exercised the current G5 verifier (source SHA-256 7CAED2A83447173FF93755C5561713C2E2F8F410D21AE78E4A07CCCD3F681C66) with the V21 Store claim gate: the claim transaction checks the durable ActionRef allocation mapping before READY-to-CLAIMED, and a missing mapping left the fake sender untouched. The historical dual-ledger repro used only the older SDK allocator candidate (SHA-256 8E7ABDD2819F66B6EC3D5FF1D5A049FCBB91AE8E71327665EE925098D35F647D), not the active verifier. This is LOCAL_FAKE_ONLY contract evidence, not G5/F14 acceptance, trusted floor authority, or an account-wide fence; NO_AUTHORITY / NO_WRITE / LIVE_NO_GO / NO_MERGE. See the [current cross-package QA archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-g5-v21-current-contract-independent-qa-2026-09-27/QA-INDEX.md). I22's 11-node read-port replacement is NO_MERGE_AS_REPLACEMENT / FAKE_PORT_ONLY; the frozen audit remains in D:\temp\ac41-63-store-readonly-query-port-group-r1-20260927 with report SHA-256 3A250265F57D725CB497F7402B63F872AE8BDE96BF45DBDDDB6077967624D022. The current R2 scanner inventory remains 460/460 verified (363 writer, 97 dynamic, 355 files, plus six historical tombstones); a fresh collector found identical IDs, order, and locator lines, with zero additions/removals/moves, so no new inventory version was created. All active rows remain REVIEW_REQUIRED / NOT_AVAILABLE; this is inventory integrity, not writer closure. + +The separate [Store lazy-connect audit](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ensure-api-ready-lazy-connect-audit-2026-09-27/report.md), report SHA-256 F799E48F20783B964B63B164E77D19DAEE85E39349A3F82CA36F9917F1695481, is **NO_MERGE_PATCH / BLOCKED_BY_SHARED_I22_LIFECYCLE**. In fake-only probes, six routes reached fake connect/query behavior: get_balance(force=True), get_symbol_info(), direct start(), gateway start(), forwarding start(), and an example-shaped btapi/direct CTP flow. Public sdk_api was None on CTP/gateway/forwarding cases; fake order/cancel writes were zero, and import/network guards were empty. The audit statically identified 14 I22-related bounded-probe test functions in the I21 test file; they were not run. A shared CTP deny at start/_ensure_api_ready would block Store-owned typed read-only I22 lifecycle; allowing the in-process probe still permits Store-owned native startup in-process, so no patch was created. This does not treat sdk_api=None as an isolation boundary and does not establish native/provider behavior. + +Default CTP writes, live dispatch, and real SimNow/production trading remain closed: NO_WRITE / LIVE_NO_GO. + +The Iteration 41 inventory currently has 17 registrations, including a 007 suite-root zero-write `simulation/sandbox` runtime/front-check route. Its protected, Git-ignored config and directory ACL are aligned with 013_3; the schema-v4 config is rebound to `example.007_ctp.simnow_penetration` and contains five configured MD/TD pairs. Offline `doctor` exited 0 with `provider_preflight_started=false`, five pairs, and `preflight_available=false`; ordinary `preflight` remains fail-closed pending a bounded Windows Job supervisor and independent acceptance. On 2026-09-28, one explicit credential-free `check-ctp-fronts` TCP probe selected pair index 3 (MD and TD each 3/3 reachable); indexes 0/1/2/4 each had 0/3. This is a host-and-time-specific transport observation only: no SDK import, provider login, market-data subscription, order, or cancel occurred, and it does not establish future reachability. `bt-runtime run` on the suite root returns `profile_dispatch_unavailable`/exit 2 with `provider_preflight_started=false`; this route has no runner. The 007 route has no certification case runner, trading runner, or write permission. All 33 case directories are staged at `examples/007_ctp/live_certification/simnow_penetration/cases//{config.yaml,_strategy.py,run.py}`; per-case configs contain scenario parameters only and must not duplicate account credentials. Their strategy files describe planned real actions and evidence, not executable provider strategies. Historical flat `cases/*.py` remain closed source. `managed_case_entry` returns `BLOCKED`/exit 2 for the cases; that unavailable-case-runner result is never real SimNow `PASS`. The latest six-file fake/offline runtime focus passed 176 tests, skipped 13, with one existing pytest-configuration warning; it does not establish TCP, provider, account, or trading behavior. Keep `NO_WRITE / LIVE_NO_GO`. + +The suite-root 007 `config.yaml` and any `secrets.yaml` now have exact Git-ignore rules. Offline CI smoke skips both reviewed CTP private-read runtimes before config loading; the synthetic same-path CLI test clones only its matching read-only binding. These checks do not read the private config or enable a runner. + +At an earlier checkpoint, `tests/unit/live_certification` passed 170 offline tests. The legacy SimNow `CaseTimer.pass_result()`, `CaseResult.to_dict()`/`exit_code()`, and `save_result()` fail closed for unverified or hand-built `PASS` results; caller details and JSONL logs cannot establish provider certification. An individual-process check of all 33 staged `run.py` entrypoints returned 33 `BLOCKED` / exit 2 results, zero unexpected results, and zero network/order-write requests. The earlier full `tests/unit/runtime` regression passed 2,118, skipped 30, and xfailed 2; offline CI smoke passed 12 of 14 eligible runtimes, while two managed L2 replay profiles reported the current environment's missing `bt_api_execution` capability. These are local checks, not real SimNow acceptance; 0/33 real cases passed. + +At the later 2026-09-28 checkpoint, all 33 case-local strategy files have unregistered read-only typed observation logic; none has provider execution, dispatch, authenticated source proof, or a certification `PASS` route. `common/completion_invariants.py` now checks C01 auth/login/query order, M02/M03 session generation and query/callback order, managed request time against native callbacks and account snapshots, L01 trade-log fields against native facts, EM01/EM02/EM03 external-control references, and V01/V02/V03 actual validation conditions; V02/V03 also bound decimal shapes before exact arithmetic. `common/decision_engine.py` binds the M02/M03 control receipts and TH04 combined submit/cancel threshold to typed request/native references. Independent QA found stale, mismatched-contract, malformed-order, and cross-account synthetic evidence reaching review; the read-only order and typed six-case candidates now require recent evidence, consistent contract/account/session generation, and valid native order states. The 007 read-only `managed_case_scope.py` now derives a scheme-marked pseudonymous account fingerprint only from an already sealed runtime config in memory, includes it in case-scope v2 digest, and passes it to `DecisionScope`; it is guessable and does not authenticate provider callbacks or credentials. At that checkpoint, the full `tests/unit/live_certification` run passed 357 offline tests; `tests/unit/runtime` passed 2,131, skipped 30, and xfailed 2, with a later 48-test 007/static-inventory focus passing; `tests/unit/stores` passed 742 and skipped 13; the Broker module passed 149. A fresh 33-process entry check still returned 33 `BLOCKED` and 0 real `PASS`. The exact old G4 base/CTP pin wheels and clean source commits were located; two isolated dependency-closed environments passed `pip check` and loaded the pinned CTP native extension with its expected hash alongside a clean 0.15.5 parent candidate. Exact old CTP build reproducibility, native lifecycle, and provider behavior remain unaccepted. Strict whole-command G1 remains closed. No real CTP order/cancel has been accepted; keep `NO_WRITE / LIVE_NO_GO`. + +The unregistered 007 `managed_case_scope.py` now binds exact case identity to the sealed suite runtime, with SHA-256 digests for the case config, static strategy plan, and `run.py`; it grants no provider I/O. The new `managed_case_invocation.py` adds a non-authorizing, offline per-invocation binding of that scope to a freshly revalidated sealed config, the exact configured MD/TD pair, consistent TCP evidence ranking, contract/account identity. It rejects caller-supplied lease claims until a trusted current owner verifier exists, and rechecks the three case files for drift, hides endpoints from redacted output, and passed 9 focused synthetic tests plus Ruff; TCP evidence and object identity are not provider or selector authenticity. Caller-constructed lease snapshots are rejected; this binding does not verify current lease ownership. Neither module is called by the 33 blocked entries. The unregistered managed_case_front_selection.py binds a freshly resealed 007 config and issued case scope to one credential-free TCP checker result; its issuer-local object check rejects caller-built clones and config/pair-order drift, but gives no provider or write authority. Its 22-test main focus and the 357-test live-certification suite passed. `decision_scope_from_case_scope()` binds case digests to the unregistered `common/decision_engine.py`, whose 33 typed intent specifications are review-only (`dispatch_permitted=false`, certification `PASS=false`). `common/case_engine.py` checks the 33 descriptive plans and offline event provenance, but evidence completeness stops at `REVIEW_REQUIRED`. Historical reconciliation policy now requires a real request for E03/EM01, a real trade for B01, and allows reconciled fill races for cancel cases; B01 `partial_count` no longer accepts a caller-supplied count or local partial label. The separate `ctp_simnow_managed_md_bridge.py` is a read-only, lease-scoped tick handoff contract, not a Feed or client owner. The pinned child SDK's public tick callback lacks callback-bound generation/sequence and its subscription callback drops request ID/terminal flag, so the managed owner cannot yet supply the bridge's required source watermark. None of these source-level changes supplies native callback attestation, a case runner, trusted account snapshot, or write admission. The bridge now requires an owner-issued, strictly increasing per-account lease_generation bound in both lease_snapshot and source identity, rejects boolean-only lease sources, and poisons on generation changes; this remains an unregistered offline owner contract, not independently authenticated lease continuity or provider acceptance. + +The unregistered `common/completion_invariants.py` adds pure-data checks across the 33 case IDs for canonical scenario/provenance rows, managed requests, native order/trade lifecycle, and final order/position/account snapshots. Missing scenario evidence keeps all cases below review; the 33-ID negative loop is not full positive/negative coverage for every case class. Independent QA found missing trade/order external-ID and submitted-quantity conservation checks, which now have negative tests. Ordinary required-order cases now reject an unexpected terminal provider rejection; the explicitly expected remote-rejection cases retain that path. A partial-fill-then-cancel synthetic positive test confirms matched trade and position facts remain reviewable. Result flags for certification, dispatch, and source authenticity remain false even when synthetic evidence reaches `REVIEW_REQUIRED`. It does not authenticate caller-provided rows or bind them to a sealed account, and fill-related cash, fee, and margin effects still need independent provider review. + +The [CTP MD subscription ACK source audit](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ctp-md-subscription-ack-correlation-source-audit-2026-09-28.md) confirms the native request has no caller request-ID parameter, while the callback's `nRequestID` is not correlated by the current high-level wrapper; a local single-pending epoch is not provider authentication. The clean parent G4 follow-up found `bt_api_py 0.15` lacks five required managed-module paths and the request builder requires 0.15.5; its isolated compatibility report is at `D:/temp/iteration41-g4-parent-compat-gap-analysis-20260928`. No compatibility patch or wheel was made, and neither finding changes `NO_WRITE / LIVE_NO_GO`. + +The 013_3 synthetic replay uses an explicit `ReplayClient` with +`LocalReplayStore`, `BtApiFeed(provider="local_replay")`, and +`LocalReplayBroker`. This path does not construct the CTP Store or Broker; +the broker rejects order entry and does not run the inherited matcher. +Replay provenance resolves optional SDK module specs without importing their +parent packages. This local replay is not a SimNow session or trading route. + +`backtrader_runtime` requires `/config.yaml` and only +accepts schema-v4 `backtest`, `simulation`, or `live` mode/preset pairs. The +CLI does not permit mode or preset overrides through flags, environment, CWD, +or AI-produced files. `bt-runtime bootstrap`, `doctor`, and `run` are routine +operator entry points. The ordinary CTP `preflight --strategy-dir` CLI is +currently fail-closed: synchronous native start/stop/Join/Release may wait +without a hard bound. Reconsider it only after a bounded Windows Job supervisor +is implemented and independently accepted. `doctor` stays offline/read-only; +for the 013_3 private runtime it adds a redacted `operator_actions` summary +without changing legacy `next_actions`. A live config request is shown as +unavailable and unauthorised. `run`, live dispatch, and writes remain closed. +The native lifecycle source +review is at +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ctp-native-join-source-review-2026-09-25.md`. +Adding a runnable directory requires an explicit `inventory.py` registration +and tests. +The static candidate tool `scripts/collect_iteration41_writer_inventory.py` +scans its Iteration 41 runtime scope, Python source under `examples/` except +generated output/private runtime state, and every repository-root `.py` file +without importing providers or executing examples. Its controlled path baseline is +`scripts/iteration41_writer_inventory_scope.json`; generated caches/log output +are labeled separately, and the private CTP runtime state subtree is summarized +without scanning its contents. New example directories or root Python scripts +remain `UNCLASSIFIED` until reviewed; the repository contract test fails while +such paths are unclassified. These path/candidate results are static coverage +only and do not prove writer closure or authorize a route. The JSON artifact +uses `source_root: "."` so it carries no host-specific absolute path. +The collector also records simple local aliases of writer methods and indirect +`getattr` callables as conservative candidates; it does not prove their runtime +reachability. Every discovered candidate must have a `REVIEW_REQUIRED` / +`NOT_AVAILABLE` disposition in the checked-in checklist. +The CtpClientWrapper capability/arm-path statement below describes the A039 r4 Store snapshot, not the current Store. At A039, `arm_registered_sim_execution` unconditionally raised the CTP direct registered-sim admission error, and `_send_native_order_insert` / `_send_native_order_action` raised before native dispatch (`backtrader/stores/btapistore.py:1896-1917` in that snapshot). The mutable capability field remained present, but those methods did not accept it to arm or dispatch. This was a narrow wrapper fail-close, not account-level authorization or proof that other writers were closed. See the historical [Store r1 integration evidence](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-store-sdk-api-none-r1-main-integration-2026-09-27/README.md) and [r4 queue evidence](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ac41-63-ctp-generic-queue-failclose-r4-2026-09-27/README.md). The current CE04 R2 Store has a conditional route-identity guard; its [main integration archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ac41-63-store-api-route-identity-r2-main-2026-09-28/README.md) documents the remaining same-process case where a custom API hides CTP `exchange_kwargs` through `__getattribute__` and a local fake direct sink is reached. This is not writer closure; `NO_WRITE / LIVE_NO_GO` remains. + +Historical pre-Store-r1 Store/Runtime run passed 2,641 tests, skipped 43, and xfailed 2; it is superseded by the 2,725/43/2/0 post-r1 result recorded above. Its raw JUnit/log/exit sidecars remain in [the evidence archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ac41-63-direct-mechanical-cycle-simnow-failclose-main-integration-2026-09-27/README.md); it is regression evidence only, not CTP or write acceptance. +The Iteration 41 generic `MechanicalCycle` and direct `SimNowLiveRunner` +dispatch APIs also fail closed before broker-operation attribute lookup when +trusted dispatch evidence is unavailable. Their three-file focus passed 52 +tests and skipped the optional L2 integration because `bt_api_execution` source +roots were unavailable. The registered fake L2 positive remains unverified: +candidate and exact-base copies failed the same `UNKNOWN` assertion, so the +requested 9/1/0 result is not established. This API-boundary regression does +not sandbox arbitrary same-process subclasses or broker calls and does not +enable CTP/SimNow writes; keep `NO_WRITE / LIVE_NO_GO`. +The 013_1 and 013_2 `ctp_example_support` modules no longer invoke +`load_dotenv_if_available()` at import, and their legacy live Store/Broker +helpers reject before config or constructor access. That explicit dotenv helper +remains callable, and strategy submit methods can still use a caller-supplied +custom broker; this is not full writer closure. The three-file main focus passed +33 tests; four existing `SIM112` lower-case environment-alias Ruff findings in +untouched source lines remain. Keep CTP routes `NO_WRITE / LIVE_NO_GO`. +The canonical private CTP block is `ctp:` with the same MD/TD, contract, +account, and authentication fields for `simulation/sandbox` and +`live/managed_live_direct`. The explicit `mode`/`preset` in that same +`config.yaml` selects the requested path; parsing a live config does not +register or authorize a live runner. The legacy `ctp_simnow:` and +`ctp_production:` parsing remains only for compatibility tests; new operator +configuration uses canonical `ctp:` in the shared file. +The 013_3 registration declares `live/managed_live_direct` unavailable, with +a dedicated resolution reason; that declaration adds no capabilities and +rejects before credential resolution, SDK import, network access, or runner +dispatch. Its zero-write `simulation/sandbox` RuntimeProfile grants no write +capabilities. The ordinary `preflight` CLI is currently fail-closed pending a +bounded Windows Job supervisor and independent acceptance; this does not +enable `run`, live execution, or writes. +`ctp:` production read-only on the single protected runtime config has an offline candidate contract (104 passed / 1 skipped); the default live route remains closed. The same-file canonical schema is a future production-operation target only after production runner/admission is implemented and independently accepted; changing config alone cannot enable production because the default live route remains unavailable. The canonical `ctp`/`front_pairs` doctor display fix passed 37 tests, and the local protected-config doctor reported only `diagnostic simulation/sandbox` with five pair entries. Backtrader handoff (27) and the risk/monitor/execution production-scope checker (10) are offline and non-authorizing. The combined CTP managed dispatch/adapter plus MD request-ID audit focus passed 38 tests with Ruff clean; it remains opt-in/unregistered, with no provider or default-route integration. The four-file Store/Broker handoff focus passed 125 tests with one existing pytest-configuration warning and targeted Ruff clean. The `bt_api_execution` package suite passed 57 tests; `py_compile` and applicable Ruff checks passed. The v5 command outbox is an offline candidate with 57 package tests, not connected to the SDK or default runner. Caller-supplied seed proof is non-authorizing; UNKNOWN has no trusted reconciliation/resolution path and permanently fences account claims; cross-trading-day cancel is unsupported. No command channel is accepted. ADR-41-15 records `MaxOrderRef`, legacy-mapping, and command-queue blockers. The old CTP-entry audit passed 96 focused tests; its no-reachable-bypass result is limited to static analysis and tests. No real session, order, or cancel has been accepted. At that historical checkpoint, the full runtime suite passed 1,447 tests, skipped 24, with one existing pytest-configuration warning in 65.64s; the latest full runtime result is recorded below; the six-file Iteration 41 integration rerun passed 7 tests with one existing pytest-configuration warning in 36.63s. These local checks do not authorize CTP or live execution. +`RegisteredRuntime` also has an additive `RuntimeProfile` registry contract for +synthetic registrations that need separate policy fields for multiple exact +mode/preset pairs under one runtime identity. It requires inert legacy fields, +includes profile facts in the registration/effective seals, and honors existing +unavailable-profile declarations before selection. The additive `profiles` +field follows the original positional `bootstrap_parameters` field. The +profile-aware sandbox read-only admission and live `sealed_config` scope +binding have local contracts. The default 013_3 sandbox profile remains a +zero-write binding. Ordinary direct CLI native `preflight` is currently +fail-closed pending a bounded Windows Job supervisor and independent +acceptance. The profile-backed production TCP selector rejects before opening +a socket. The pure live scope binder validates the selected `effective.profile` +without authorizing a session. These contracts do not enable production +credentials, SDK access, runner dispatch, or writes. + +`examples/013_3_sa_midfreq_simnow/runtime-ctp-private/` has exact Git-ignore +rules for its local `config.yaml` and local state. The default inventory +registers a zero-write CTP SimNow `simulation/sandbox` RuntimeProfile with +`sandbox_write_policy=deny` and no trading runner or execution capabilities. +The ordinary private-read `preflight` CLI is currently fail-closed pending a +bounded Windows Job supervisor and independent acceptance. `doctor` remains +offline, and `check-ctp-fronts` performs only credential-free TCP checks of +sealed-config candidates. The private configuration requires one +explicit `md_front`/`td_front` pair or an ordered `front_pairs` list of 1–8 +exact MD/TD pairs, along with its contract/account/authentication fields. A +protected, Git-ignored local private config has been prepared from the +owner-only project `.env`; it currently contains five distinct, ordered +MD/TD pairs under the canonical `ctp:` block, including the directly configured +official SimNow 7x24 pair, and offline `doctor` accepts it. The official pair +was TCP-unreachable from this host at the latest probe; this is not an address +allowlist or a time-based selection rule. +Historical I2/earlier supervised observations below do not make ordinary CLI +`preflight` available. The separate `check-ctp-fronts` command is limited to +credential-free TCP evidence. Historical I2 runs used a scoped, code-owned +base/CTP wheel pin: a real TD login and all seven native query streams reached +terminal replies in H2, I1, and an I2 retry. The I2 certificate accepted the +83-row same-exchange instrument response with one exact target and recorded +both present-but-blank rate `ExchangeID` values as `unverified`, not exact +scope. The historical registered preflight still rejected because native Join +did not complete. The separate I2 MD-only probe observed one login callback +with request ID zero and response error ID zero, rejected by the SDK as a +request-ID mismatch; login timed out, native Join remained pending, and there +was no subscription acknowledgement or tick. The mismatch is not yet +root-caused; see the native Join source review above. +`bt-runtime prepare-ctp-config` creates the canonical `ctp:` block in +the ignored private file from an explicitly named owner-only `.env`, a YAML +`ctp` or legacy `ctp_simnow` source (which may contain an explicit +`front_pairs` list), or a +collector YAML `ctp` source plus an explicit contract/exchange/HedgeFlag source. +`prepare-ctp-simnow-config` remains a legacy CLI alias. The old +`prepare-ctp-production-config` CLI command and its internal writer functions +are retired and reject before inspecting sources or destinations. The +`ctp_production` runtime schema parser remains only for migration/audit +compatibility; no second operator file can be prepared through this module. +At most two sources may be merged and overlapping fields must agree; the +collector's `env` label never selects a front. The helper never infers fronts, +overwrites an existing config, or grants a route. +The CLI and public preparation helpers accept only the code-owned 013_3 +default or the exact registered 007 private runtime ID; 007 requires an +explicit `--runtime-id`. Public path and arbitrary strategy overrides are +closed. Existing protected files remain no-overwrite. +The `.env` parser accepts legacy `CTP_INSTRUMENT`/`CTP_EXCHANGE` names and +equal-value aliases for the same CTP field; conflicting aliases reject. During +explicit preparation it also turns complete `CTP_SET1_*` numbered MD/TD pairs +and the complete `CTP_SET2_*` MD/TD pair into one ordered, deduplicated +`front_pairs` list with no set labels. Incomplete pairs reject. Without those +additional fields, the legacy single-pair output is preserved. The runtime +never reads these `.env` names or chooses a front by set or time. The +prepared config's strategy identity matches the selected registered 013_3 or +007 runtime. The local project `.env` is UTF-8 and owner-only; its +original bytes are retained in an ignored, protected local state directory. +On Windows, private config creation is relative to the verified target directory +handle; every target path component rejects reparse points, and an identity +mismatch after creation deletes the new file by its retained handle. A failed +deletion is reported explicitly. On POSIX the target is opened component by +component with `O_NOFOLLOW`; its symlink-ancestor tests still need a POSIX host. +The registered read-only binding uses only addresses in the sealed config. A +single pair is used as configured; for multiple pairs, a bounded, +credential-free TCP probe selects the lowest measured MD/TD pair score and +passes that selected pair unchanged into read-only admission. It never +selects by time, calendar, CLI flag, environment variable, or static SimNow +address allowlist. TCP reachability is transport evidence only, with no +account or write authority. The sealed config supplies the instrument, +exchange, and hedge scope, checked against supported CTP field formats. The +tracked Iteration 22 strategy +`config.yaml` remains separate and cannot supply this runtime contract. The +read-only base/CTP pin does not grant any write route; managed SimNow +requires a third, separately reviewed `bt_api_py` parent pin. That pin is not +accepted: `D:/bt_api_py` source is dirty and the old +`D:/source_code/bt_api_py` wheel is incomplete and cannot be pinned; a clean +isolated candidate is in progress. No CTP orders have been accepted. The +registration grants no write route. The +internal `_ctp_credential_binding.py` derives a keyed, pathless tag from a +freshly validated private config and exact read-only admission; it grants no +approval or write authority and has no key storage. Default SDK +arm/submit/cancel remain closed; the separate opt-in neutral +`config_front_pair` managed SDK contract requires an exact per-action grant. +The module also exposes a typed reviewed +refresh adapter for +the SDK's nominal credential-binding scope. Each refresh rechecks the sealed +config, full candidate list, selected exact fronts, account, and registration +before reading current key material; its versioned HMAC result is +non-authorizing and no deployed key +source is provided. The legacy static profile binding is validation-only and +the CLI/operator dispatcher reject it. The selected-front read-only route has no +injected UTC source requirement; its bounded observation uses a monotonic +deadline. A future write admission needs its own trusted expiry/time policy. + +The current default registry has 17 registrations: 11 nonmanaged +`simulation/replay` paths (eight ordinary strategy examples, the no-action +legacy profiles for `examples/007_ctp` and `examples/010_live_examples`, and +the separate `examples/sample.py` no-action migration profile), two zero-write +CTP SimNow `simulation/sandbox` RuntimeProfiles with read-only bindings: the +013_3 private runtime and the 007 certification-suite root runtime/front-check +route. Offline `doctor` and credential-free `check-ctp-fronts` are available +for both. Ordinary CTP `preflight` for 013_3 and 007 remains fail-closed +pending a bounded hard Windows Job supervisor and independent acceptance. The +registry also has one `examples/010_live_examples` public-market-only +`simulation/shadow` runtime +(fixed OKX public instruments, bounded duration and requested five-level depth), two +local fake-provider managed L2 replay paths, and one package-owned +`backtest/local_backtest` fixture. The fixture executes four packaged CSV bars +through Cerebro with zero network, external writes, orders, fills, or provider +submissions; it is an acceptance fixture, not a general deployment route. +The public shadow passes validated books to the historical +`LiveMultiSymbolStrategy.notify_orderbook` callback on a brokerless observer; +it does not construct Cerebro, run `next()`, or expose an order route. +There is no registered live runner. Do not make a replay configuration or a +template into a live route by adding a fallback or override; live registration +requires its own reviewed implementation and acceptance evidence. + +For managed execution, `backtrader_runtime.managed_execution` derives a stable +versioned runtime order ID from the SDK `ExecutionScope.key` and an explicit +`managed_intent_id`; it rejects caller-supplied order/client IDs. The CTP Store +checks the SDK scope before durable OrderRef reservation. The same intent name +in a different scope is a different identity. These are local contracts, not +real-account recovery or write admission. +The managed CTP queue-receipt classifier distinguishes a local queue rejection +from an unknown queued outcome; neither is a provider acknowledgement. +`backtrader_runtime/ctp_managed_reconciliation.py` is an unregistered pure +SimNow snapshot classifier requiring injected native-query evidence. It does +not prove a common cross-query snapshot or account-wide writer exclusion. +The Store now has typed managed CTP order/cancel request contracts that carry +the intent, runtime order and cancel identities into BtApi bindings. The +code-owned runtime still binds CTP Stores to a typed fail-closed placeholder: +current CTP admission is private-read only, and the asynchronous SDK queue +receipt cannot satisfy the managed facade's synchronous provider-observation +contract. No CTP managed write route is enabled. +`ctp_simnow_managed_operator.py` is an unregistered offline selector for a +future SimNow writer. Its code-owned policy fixes the runtime identity, +approval key, and hard risk envelope; the account, ordered 1–8 MD/TD pairs, +instrument, exchange, and HedgeFlag come from a freshly resealed canonical +`ctp:` block. The selected whole pair, candidate-set digest, config digest, +and effective digest are bound to the per-run execution registration and thus +to each short-lived action approval. Changing those config values does not +require a code policy edit, but invalidates an old approval. TCP reachability +alone does not prove native MD/TD login readiness. +`ctp_trader_client_port.py` and `ctp_simnow_managed_composition.py` now provide +an opt-in, unregistered SimNow managed adapter using neutral `simnow` / +`config_front_pair` labels. It revalidates a sealed `simulation/sandbox` +config, requires an explicitly registered MD/TD pair present in its sealed +candidate list, and binds that pair into registration, signed approval, +session identity, and per-action SDK scope. The composition verifies the exact +selected pair against the code-owned SDK artifact pins before extracting +credential fields, importing the SDK, or constructing the unconnected client. +It requires base, CTP, and parent pins; the parent is absent, so the default +managed composition rejects. Its native +write path requires a disarmed gate, current per-action approval, credential +binding, and fresh exact SDK scope verifier. Raw SDK submit codes and exact +immutable request/account/session/target evidence become typed local dispatch +receipts: zero is only `QUEUED`, a negative code is `REJECTED` only with +verified no-callback evidence, and mismatched or uncertain outcomes are +`UNKNOWN` and freeze further writes. None is a provider acknowledgement. The SDK +source now has an explicit MD/profile constructor binding seam, but the +ordinary SDK remains source/editable; the isolated I2 wheel pair has a scoped +read-only code pin but no managed-write or production approval. No real-client +deployment has been accepted. +After journal reservation, failed staging freezes the affected action as +`UNKNOWN`. Local fake-client tests cover submit and cancel; the default +inventory/CLI do not expose this route, and private Python attributes do not +isolate untrusted in-process strategy code from the client. +The account-keyed SQLite managed journal has versioned scope metadata and a +bounded scope history. A newly selected pair or edited config can take over +only after every prior intent is verified terminal; unresolved or unknown +rows block before native client construction. Each historical row retains its +original scope digest. Managed admission reloads the protected config and +checks exact account, contract, and selected candidate pair before the native +factory; it never extracts password material for this comparison. +`ctp_simnow_managed_runtime.py` is a further unregistered composition root: +it selects one configured pair, invokes the artifact-first port helper, and +opens the journaled execution session under the account lease. All approval, +query-evidence, external writer-fence, started native-client, and typed TD/MD +readiness adapters are injected. `ctp_simnow_native_readiness.py` supplies an +unregistered, one-shot public-SDK TD/MD login, exact subscription, and first- +tick adapter; its typed observation is explicitly read-only and does not +prove settlement or trading readiness. The composition root now transfers +the MD client to a lease-scoped resource owner: normal close and failure +rollback stop MD before TD, and failed close poisons the account lease. No +failed login or later uncertain action selects another pair. Local fake tests +cover the lifecycle. The SDK now exposes a bounded stop receipt, but an +active native Join may remain pending and native startup is still synchronous; +there is no accepted real provider session or account-wide writer fence. The default +registry still grants no managed CTP write path. +`ctp_managed_account_runtime_candidate.py` adds an unregistered shared-mode +account-runtime candidate. It holds the existing local account flow lock +before opening the same account journal path used by the legacy SimNow +execution journal, and requires a typed V20 execution Store with a persistent +account-family owner. The simulation-to-live fake integration check confirms +the second mode is blocked on that same account ledger. `_ExecutionJournal` +preflights the typed Store before switching SQLite to WAL and refuses to attach +its legacy tables to a V20 database; recognizable legacy scope failures are +rejected before write-open. The fake session test stops after authenticate/login +and issues no order or cancel request. The local flow lock is not an external +account-wide fence; there is no family-owner handoff/release proof, default +registration, or accepted provider write route. +`ctp_simulation_query_evidence.py` is a separate, unregistered TraderClient +adapter for verifying terminal native query provenance, exact filters and +current callback-history readback. It remains read-only and non-authorizing: +separate queries are not an atomic account snapshot, the SDK has no durable +post-restart cancel-action history, and an account-wide writer fence is still +external. Missing or evicted evidence fails closed. +Direct managed startup fences interrupted cancellation dispatches while holding +the execution writer lease before accepting managed mutations. If another +active runtime owns that lease, startup defers recovery but keeps submit, +cancel, and reconciliation blocked until a retry succeeds. +In local CTP simulation recovery, an explicitly missing cold-restart cancel +callback can resolve to `TARGET_TERMINAL` only when the exact native target +order is terminal and trade/position evidence agrees. Same-process pending +cancels and ambiguous snapshots remain blocked; this is not provider write +admission or real-account recovery evidence. + +`backtrader_runtime/provider_deployment.py`, `provider_preflight.py`, and +`test_execution_profile.py` provide pure standard-library prerequisites for +future provider deployment. The version-2 receipt/profile contracts bind a +code-owned runtime, preissued approval digest, opaque OS-secret reference, +account/artifact/config/capability digests, environment, and expiry. The +preflight binding checks the sealed `config.yaml` resolution and rejects +sandbox/production environment mismatch. Default verifiers reject, and every +successful observation remains non-authoritative. `ctp_preflight.py` composes +these checks with an injected, read-only SimNow session protocol; local tests +use only a fake session. Separately, `ctp_sandbox_readonly_admission.py` +validates a sealed `simulation/sandbox` private-read route with no write +approval, `credential_resolver.py` resolves an exact authentication schema +(protected private `config.yaml`, POSIX owner-only `secrets.yaml`, or Windows +Generic Credential Manager), `ctp_sdk_readonly.py` adapts the SDK's seven +native trader read queries with write counters checked, and +`ctp_simnow_readonly_runtime.py` composes these local prerequisites and invokes +`ctp_sdk_market_readonly.py` for one configured `md_front` login and exact- +instrument subscription after the seven TD queries. `ctp_simnow_operator.py` +binds the CLI's zero-write sandbox RuntimeProfile to its private-read +`preflight` binding; ordinary CLI dispatch is fail-closed pending bounded Windows Job supervision and independent acceptance. This profile has no trading runner or execution capability. +`ctp_artifact_provenance.py` checks installed package pin/RECORD and the +selected configured front pair's syntax before credential access; its +code-owned catalog pins the independently reviewed I2 base/CTP wheels only for +registered SimNow sandbox read-only candidate. Managed SimNow additionally +checks a third `bt_api_py` parent wheel, which remains unpinned. The SDK certificate and local +composition tests are not real account evidence. The read-only composition contract imports +`MdClient` only after exact registry/config, artifact, credential, and TD +read-only gates; ordinary CLI dispatch is separately fail-closed pending bounded Windows Job supervision and independent acceptance; it uses the selected configured `md_front` without endpoint fallback +and requires a matching login/subscription response. This composition has +local fake-client tests. In the isolated H2 and I1 installations, real TD login +and all seven query streams completed. The certificate accepted the 83-row +same-exchange instrument set with one selected instrument; the margin and +commission rate exchange fields were present but blank, so their scope remains +`unverified`. Historical native Join shutdown was incomplete, so that preflight attempt +did not accept the combined TD/MD observation. +An unregistered, one-shot `ctp_simnow_md_diagnostic.py` performs only the +config-selected MD login/subscription/tick probe after the TD diagnostic +process exits. In the isolated I2 installation, the selected TCP-reachable +pair returned one MD login callback that the SDK classified as +`request_id_mismatch`: the response error code was zero, but the callback +request ID was zero rather than the pending request ID. Login timed out, with +no subscription ACK or matching tick, and native Join remained pending. The +fifth configured official 7x24 pair was +TCP-unreachable at the latest probe. This is separate diagnostic +evidence, not a successful full preflight or write admission. +An independent, unregistered I4 candidate now uses +`ctp_i4_oneshot_md_diagnostic.py` and the shared one-shot read-only MD adapter. +It has a separate base/CTP artifact pin table; the registered I2 pin and +preflight are unchanged. The isolated I4 SDK source is clean commit +`809239fdc0b7982d3512f4289e3e8dbcbd43a523`; the wheel, installed +RECORDs, module origins, native extension and 85 installed-origin fake tests +passed local offline audit. Main-repo I4 composition/fake tests pass. Two +supervised real SimNow MD-only diagnostics rejected with +`market_client_stop_failed`. The first reported only the broad +`market_login_identity_mismatch`; after value-free callback classification was +added, the second reported one login callback, zero request ID and response +error code, and `broker_id_mismatch`. The SDK observed a mismatch, but the +audit does not reveal whether the response field was blank, transformed, or +from a different account scope. Native Join remained pending in both runs; +neither had a subscription ACK, matching tick, or write. There is still no +accepted real I4 MD login, subscription, tick or shutdown observation, and +this candidate grants no Trader or write route. See +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ctp-i4-md-real-diagnostic-2026-09-25.md`. +The later independent I5 candidate uses clean isolated SDK commit +`a101590f5f29070439c13b6062b3487abee936cc`; its wheel/RECORD and import-origin +verifier passed. SDK installed/source focused tests reported 100/83 passed, +the broad offline CTP suite reported 892 passed with one network test +deselected and two parent-dependent write test files excluded. After I5 +pin/verifier, the full runtime suite was 1225 passed, 24 skipped, 1 warning in +56.72s; I4/I5 focused tests were 62 passed and Ruff passed. A supervised +one-shot I5 MD diagnostic ended `incomplete / market_client_stop_failed` after +15,141 ms without timeout: the intentional request ID zero matched the +callback, response error status was zero, but the SDK BrokerID getter returned +an empty string/bytes shape and the login was rejected. This does not establish +why the native field was empty or prove an account/configuration error. There +was no login acceptance, subscription ACK, matching tick, or write; client +stop returned while native Join remained pending/uncertain. The protected audit +record passed a scan for configured account, credential, contract and front +literals. The candidate is unregistered and adds no default route or write +authority; see +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ctp-i5-md-diagnostic-2026-09-25.md`. +The subsequent independent I6 candidate uses clean isolated SDK commit +`d85cd1571000c63d38bb9417a4942ec2692c5ad6`. Its wheel/installed RECORD, +module origins and native extension passed artifact verification; 94 +installed-origin focused SDK tests passed. A local diagnostic entrypoint +initially called a nonexistent verifier symbol and rejected before front +selection; that name was corrected and covered by an offline regression test. +The supervised I6 MD-only retry then reached one login callback with a matching +zero request ID and zero response error status. The SDK observed empty BrokerID +and UserID getter shapes and a valid TradingDay shape, rejected identity, and +received no subscription ACK or matching tick. Native Join remained pending; +trade and settlement write counters stayed at zero. A synthetic installed +native SWIG login-response struct roundtrip returned nonempty BrokerID/UserID +and TradingDay as set, so the actual callback's empty fields remain unexplained. +I6 is unregistered and grants no write or live route; see +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ctp-i6-md-diagnostic-2026-09-25.md`. +A separate exact-I6 no-login loopback child, assigned to a kill-on-close +Windows Job before resume, reported `join_required=true`, +`join_completed=false`, `native_released=false`, `thread_alive=true` after a +bounded stop. It submitted zero login requests and observed no front callback. +Normal child exit did not prove native shutdown. An offline fake in which Join +requires Release demonstrates a possible wait cycle, not vendor semantics; +neither probe called Release concurrently with Join. These results do not +change read-only or write admission. +The unregistered `ctp_i7_oneshot_md_diagnostic.py` is a read-only one-shot +MD diagnostic. The I7 SDK wheel/RECORD pin and installed-origin audit passed. +The first supervised run verified process containment (Job empty, no +descendant; `containment_verified=true`) but ended +`incomplete / market_client_stop_failed`, with native Join pending and no +subscription ACK, tick, or writes. The first run's native-shape fields were null because the shared main-repo +I3 failure-diagnostics helper omitted the enums. After that projection fix, +the second supervised run reported native BrokerID/UserID fields empty and +TradingDay shape valid. This rules out a simple SWIG getter loss, but the official Mini API manual does not promise these fields must be nonempty or echoed; the empty observation means strict identity validation failed and identity remains unverified, not that the account configuration is wrong. It does not +explain the empty identity source; the I3/I7 focused set passed 68 tests. The latest profile-focused set passed 230 tests with 14 skipped; after CTP OrderRef reservation/session hardening, writer-fence post-authorization recheck, the production action trust-source contract, I8 tri-state partial evidence, and same-file/legacy-path tests, the latest full runtime run after the CLI preflight gate, I12 private helper, I13 updates, and optional front-probe budget-accounting update (`PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest -p no:asyncio tests/unit/runtime -q`) passed with 1,561 passed, 26 skipped, and one existing pytest-configuration warning in 69.12s (exit 0); the prior 1,558 passed, 26 skipped in 67.04s, 1,536 passed, 26 skipped in 53.52s, and earlier 1,447 passed, 24 skipped in 65.64s are historical checkpoints. The six-file Iteration 41 integration rerun passed 7 tests with one existing pytest-configuration warning in 36.63s; MCP --no-cov boundary tests passed 23. An optional front-probe deadline currently performs budget accounting only; it is not a hard whole-command deadline guarantee. These are offline test results only; they do not establish provider/session readiness, a default live route, or write acceptance. Later code changes require a new full-suite run. An earlier overlay-backed offline fake/replay smoke reported 14 passed, 0 failed, 14 selected, 2 skipped, but used extra local bt_api_risk/bt_api_monitor source overlays and does not prove the clean bt_api_execution wheel alone. The earlier execution-only wheel smoke, with no dirty source overlay, reported 12 passed and 2 failures because bt_api_risk was missing; socket/DNS guard attempts were zero. Clean execution/risk/monitor candidate wheels have been built. The earlier no-system 60-wheel bundle passed pip check, RECORD, and import-origin checks (57 wheel hashes unchanged; 3 replaced). That earlier 60-wheel bundle's offline fake/replay CLI smoke was 12 passed, 2 failed, 14 selected, 2 skipped (private CTP and public shadow), with zero network-guard attempts. Both managed fake replay CLIs reached the runner and failed when the older parent runtime_plugins code used generic resolve_freeze on a dispatch-inflight latch; hardened bt_api_risk correctly rejected that clear, and the safety latch could not be reasserted. The diagnostic conservatively reported provider_io_may_have_started=true and provider_preflight_started=false, but used only a fake provider with no real provider/network access; this is not execution or write acceptance. A separate system-site-packages 14/0 attempt inherited editable SDK sources/provider adapters and is diagnostic only, not clean-origin evidence. This is offline fake/replay evidence, not provider/account evidence. Separately, the main-repo managed-replay integration passed 2/2, but its subprocess harness put dirty SDK base/execution/risk/monitor src paths first on PYTHONPATH; that does not validate candidate-wheel integration. The candidate wheel itself was covered only by 32 package tests in a strict venv. The main repo's dual-field credential-scope bridge passed 40 focused tests and Ruff. The initial review BLOCKED the parent 0.15.5 candidate because its shortened acct_<16> token did not match the full 64-character scope; the frozen dual-field candidate at commit `af538469…` (wheel SHA prefix `cfa83b1a…`) passed independent RECORD and 151-source-member review; this only supports offline fake/replay bundle integration, not CTP or default-route acceptance. After the SDK test-harness-only fix, its normal plugin-enabled broad suite was 999 passed, 2 skipped, 1 warning (test-only commit 3bec55d); the wheel SHA prefix 81c9ee62… was unchanged at that historical test-only checkpoint; it predates frozen candidate `af538469…` and does not establish current CTP-triwheel acceptance. The latest isolated bt_api_risk source suite was 164 passed after test-only commit b809800; candidate dce2c84d API/wheel stayed unchanged. The I8 SDK candidate's offline fake suites reported 10, 61, and 35 passed. One supervised I8 MD-only diagnostic ran with fixed wheel prefix f354…; before run, `doctor` exited 0 while the one-shot latch was absent; the fixed supervisor atomically created it before starting the child, and it was present after the run. No real retry was performed. The parent supervisor verified child create/assign/resume/exit, Job empty, no termination request, and child exit 3. The diagnostic ended incomplete / native_shutdown_uncertain at market_data; the value-free receipt is documented at `docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ctp-i8-md-diagnostic-2026-09-25.md`. Receipt false callback flags do not prove callbacks were absent because error projection may lose partial state; `identity_unverified=false` does not establish verified identity, `native_join_pending=false` does not prove orderly close, and the historical `client_stop_returned=false` came from an older projection that did not propagate the actual client stop return; current code exposes that value as true/false/null. A separate I8 SDK fake shutdown-contract focus passed 63 tests; it proves only conditional local interface behavior, not vendor semantics. The pure offline `ctp_production_action_binding.py` contract provides per-action production scope/credential binding and rejects the old SimNow binding type; 8 focused synthetic tests and Ruff passed. It is not connected to the SDK or a real verifier and adds no default route; `NO_WRITE / LIVE_NO_GO` remains. The I8 wheel prefix `f354…` and source commit `a7d9…` are located, but independent clean-clone builds with `autocrlf=true` and `autocrlf=false` both differ from the retained wheel because the source has mixed EOL bytes; a fixed EOL policy and build receipt are required before claiming byte reproducibility. The I9 offline candidate has two relevant artifact checkpoints. Clean-clone rebuilds of old commit `353e9d8…` with fixed `SOURCE_DATE_EPOCH` and `LINK=/Brepro` produced identical wheel SHA-256 `29f50faa37d145f9b82bdaf4e38b483eff898272925ac082e0d12eb8125cfaf3`, but the artifact metadata still says i8, so it is not an I9 pin/deployment (receipt: `D:\temp\i9-artifact-repro-20260925\I9-offline-wheel-repro-receipt.md`). The final offline candidate is version `2.0.3+iteration41.i9`, commit `157d0c0cffa4c8a86e196159cdf227e9014e9118`; two independent clean-clone wheels are byte-identical, SHA-256 `aa094c039788a41adf975cfeee839fa3baf1bcef3878ae53d10eefb44410a4a3`, embedded RECORD SHA-256 `d030ccf23d59a5f77230b490df52aa48c4cbecd67b2a78b7e782600126565841` (receipt: `D:\temp\i9-final-wheel-repro-20260925\I9-final-wheel-repro-receipt.md`). Each installed wheel passed 95 fake tests; the main repo adapter focus using the installed wheel passed 26; the six-file SDK source suite passed 204; Ruff/diff were clean. The previously identified submit-rejection race was fixed locally and barrier-tested. The opt-in SDK `managed_outbox_pre_dispatch` fail-closed boundary had 9 focused and 47 combined tests; there is no fresh approval verifier, claim, or native submit. This is offline evidence only: the main-repo pin/default route and default I8 pin are unchanged. I9 locally addresses owned-copy `on_login` handling, callback deferral, and the submit-rejection race; native Join/Release, real-provider/session behavior, and writes remain unaccepted; no real diagnostic or write occurred. `NO_WRITE / LIVE_NO_GO` remains. The I8 read-only wheel and CTP triwheel remain unregistered, with no default route or write acceptance. The bt_api_execution submodule has independent commit 4bf6da1…; exact wheel-byte reproducibility remains under verification. These observations are not full +preflight, account readiness, or write acceptance. I7 has no runtime +registration or CLI route. + +The latest official-PyYAML frozen-parent wheel-only fake/replay rerun is documented in `docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/clean-wheel-bundle-fake-replay-2026-09-25.md`: its 61-wheel manifest SHA is `4b189520d2a5b5b9b83a3d380096b75137beb1eb375937c916ab9a863f535289`, with 60 prior wheel hashes unchanged and only the local PyYAML repack replaced. The official PyYAML 6.0.1 wheel SHA `bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34` matched PyPI JSON; its RECORD was 24 rows / 23 hashed / 0 invalid. A fresh short-path no-system venv passed `pip check`, nine installed SDK/PyYAML RECORD checks, and SDK/YAML import-origin checks. Frozen parent commit `af538469…` / wheel SHA prefix `cfa83b1a…` passed independent RECORD and 151-source-member review. CLI result: `14 passed, 0 failed, 2 skipped` (private CTP/public shadow); both managed fake L2 routes passed, socket/DNS guard attempts were zero, and provider/account I/O was none (only PyPI metadata and wheel download used network). This is offline fake/replay integration only, not CTP, provider/account, SimNow, production-route, or write readiness. `NO_WRITE / LIVE_NO_GO` remains. +I7 containment does not prove SDK orderly-close. The official Mini API manual +and 6.7.7 MD/TD headers define Join as waiting for API-thread exit and Release +as deleting the API object; no Stop/timed Join or concurrent Release/pending +Join or SPI-detach callback-quiescence guarantee was found. Any future native +route requires a supervised worker, account writer lease, and unknown-result +recovery; forced termination records the worker as abandoned. No such worker +route is implemented or accepted. See the [official Mini API manual](https://www.simnow.com.cn/DocumentDown/api_3/5_2_4/CTPIIMini_API_Ver1.2.pdf) +and [API download page](https://www.simnow.com.cn/static/apiDownload.action). + +The exact Windows 6.7.7 MD callback identity and shutdown-contract questions +remain an unsent vendor draft at +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/CTP原生关闭厂商确认问题.md`. +The MD probe's same-account local lock root comes from the POSIX account +database home or Windows `LocalAppData` known folder, never `TMP`/`TEMP`; local +process tests inject a separate root. This primitive is not a cross-host or +account-wide writer fence, and its Windows DACL/path-replacement properties +still require independent deployment review. +The default inventory has the read-only SimNow binding but no verified real +provider session or external-write authority. There is still no live runner. +Synthetic live registration with `--confirm-live` is also rejected +at final dispatch until production execution admission is independently verified. + +`examples/007_ctp/runtime-production/` is a legacy reserved, unregistered +CTP production config location, not the planned routine operator path. Its +parser-only schema accepts a separate +`live/managed_live_direct` plus `ctp_production` contract there and seals its +private fields, including one required `md_front`/`td_front` pair or an +ordered `front_pairs` list of 1–8 pairs. Parsing only seals the candidate +list and never selects its first item; the README records +the independent production approval +boundary. The old `bt-runtime prepare-ctp-production-config` CLI command and +the internal second-file writer both reject before source reads or target +creation. The `ctp_production` parser remains for migration/audit compatibility; +it does not select a pair or register a route. This path contains no tracked +config or account values. Its +directory-local `.gitignore` names only `/config.yaml`, `/calendar/`, +`/reports/`, `/state/`, `/secrets.yaml`, `/approval-receipt.json`, and +`/deployment-receipt.json`. This path does not appear in the default inventory +and adds no provider import, network, or write route. The intended operator +flow uses the canonical `ctp:` fields in one `config.yaml` and switches +explicit `mode`/`preset` plus account/front/contract values there; the live +runner and admission for that flow are not implemented. Production receipts, +account/artifact bindings, and credential-version keys must still be bound +to the live environment and cannot inherit SimNow authority. +Syntax-valid production fronts in `config.yaml` do not authorize a connection; +the default inventory has no production registration or populated code-owned +front/account pin. `resolve_runtime_config()` still rejects the production +contract. `backtrader_runtime/ctp_production_credentials.py` can resolve the +sealed production authentication fields only under an injected registry and +exact pin, rechecking the existing platform ACL/file-identity gate before +credential release. `backtrader_runtime/ctp_production_readonly_runtime.py` +defines a seven-query/zero-write session contract and labels results as +unverified session-protocol evidence. Its unregistered composition probes +every sealed configured MD/TD pair, including a single pair, with the bounded +credential-free TCP selector before opening a session. A selected pair must +belong to that sealed candidate set; legacy code-owned exact-front fields, if +present, are validation-only and cannot select a different address. The +unregistered `backtrader_runtime/ctp_production_sdk_readonly.py` adapter +rechecks the sealed config, selected pair, account and instrument scope, then +calls the separate +`ctp_production_artifact_provenance.py` gate before resolving credentials. +Its production wheel-pin catalog is empty, so the default implementation +rejects before credential access or SDK import. With a reviewed pin it would +lazily import the SDK and perform the seven native TD reads. It checks +account identity, the SDK's generation-fenced local TD-front connection +callback state, stable connection generation, query scope, zero write +counters, and bounded client close. Unknown native request-counter names +fail closed even if their reported count is zero. The MD front is passed +unchanged from the sealed production config to the SDK and locally bound by +login, subscription, and tick callback state. This does not verify the remote +endpoint (`remote_front_identity_verified=false`). +The returned Python session still holds a private native-client reference; +private attributes are not an isolation boundary against untrusted in-process +strategy code. A future production route must keep credentials in a trusted +process/service boundary rather than handing this object to strategy plugins. +Fake-client tests do not prove SDK artifact provenance, production Windows +DACL acceptance, or a real provider session. The default registry and CLI +still have no production read route, runner, or write route. +`backtrader_runtime/ctp_production_readonly_admission.py` provides a pure, +non-authorizing check that a sealed production config and selected candidate +match an exact code-owned account/instrument registration. Its optional legacy +front pin can only narrow a config-selected pair. It is not wired into the +default registry or CLI and cannot import the SDK or open a provider session. +`backtrader_runtime/ctp_production_execution_admission.py` separately binds a +sealed production config to a code-owned production execution registration, +receipt/artifact identities, and bounded opening-order limits. Its result is +explicitly non-authorizing; no production write route, verifier, or provider +session is registered. +For canonical `ctp:` live configs, its `sealed_config` scope mode derives the +account, contract and ordered front candidates from the same sealed file; an +optional sealed effective runtime is included in its non-authorizing scope +identity. The pure binder derives scope from the sealed inputs and a supplied +exact configured pair. Pure binding remains offline-only; profile-backed production +selection, credentials, SDK access, session construction, runner, and writes +remain closed. The profile-backed TCP selector rejects before opening a socket. +`backtrader_runtime/ctp_production_approval_binding.py` now provides a separate +pure, unregistered receipt-to-scope lookup contract: it rederives the selected +scope from sealed config/effective runtime inputs and asks an injected verifier +about an externally trusted `(receipt digest, scope identity)` pair. The default +verifier rejects; fake-map tests cover stale account/front/contract scope and +receipt rotation. The external mapping avoids a receipt/scope self-hash cycle; +it is not a trusted verifier or authority source. Local fake-map results are +non-authorizing. Trusted verifier/mapping, trusted time and revocation, fresh +on-disk config identity, provider artifact/session gates, and a live writer +remain unimplemented. The default sandbox registration does not become +production authority when `config.yaml` changes mode. Legacy +`runtime-production`/`ctp_production:` remains migration compatibility, not +the intended second operator configuration. + The three AI products are not vendored and are not Git submodules. Make product changes, packaging releases, and product-specific acceptance changes in their respective repositories; this repository only links to them from its README. @@ -299,6 +948,10 @@ native market-data objects; there is no second Backtrader trading client. `examples/strategy_candidate_approval.py` binds the two example candidates' manifest, offline receipt and source provenance. It is example admission policy, not a Backtrader utility or SDK protocol. +The two folder-local 012 manifests hash the raw bytes of each folder's `run.py`, +`strategy.py` and `config.yaml`; root `.gitattributes` pins those six exact paths +to LF so strict SHA-256 values match Git blobs across Windows, Linux and macOS. +The canonical manifest remains unchanged and demo approval stays `NOT_APPROVED`. OKX endpoint selection belongs to the SDK through `api_region=global|eea|us|tr`: REST plus public/private/business WebSockets use one atomic region/environment profile. Global/EEA/US support production and @@ -344,9 +997,17 @@ requires a hash-bound approval receipt plus a complete first-set observation gate. The example never treats replay output or a single SimNow day as evidence that the strategy is profitable. +Its imported `run.py::main()` now accepts only explicit offline replay, and +`run_network()` rejects before reading legacy `.env` or constructing a Store. +The direct script and the options SimNow operator/launcher also route through +Iteration 41 registration or reject before provider access. The options +operator's imported front probe and Store builder, and the 015 launcher lazy +session helper, reject default/real-provider use; only explicitly marked fake +test doubles are retained for local tests. + ## Tests -- `tests/functional/strategies/` holds 1,271 inlined regression tests across ~30 +- `tests/functional/strategies/` holds 1,286 inlined regression tests across ~30 categories (trend_following, mean_reversion, asset_allocation, machine_learning, options, pairs_trading, …). Each is self-contained: inline strategy + data loader + `cerebro.run()` + assertions against master-baselined @@ -355,6 +1016,10 @@ that the strategy is profitable. `tests/original_tests/`, `tests/add_tests/` cover the framework itself. - Config: `pytest.ini` (markers incl. `slow`, warning filters), `conftest.py` (temp cleanup, installed-vs-local switch, slow auto-marking). +- `python scripts/ci/smoke_iteration41_registered_offline.py` runs the + registered offline replay/backtest CLI entries; managed L2 replay requires + the local SDK source packages on `PYTHONPATH`. It deliberately skips the + public-network shadow and all private/live routes. - `tests/datas/` holds fixtures; MT5 daily CSVs in `tests/datas/mt5_1d_data/`. - New regression tests should pass on **both** `dev` and `master` (bake master's output as the expected values). Some `tests/unit/brokers/*_performance` tests @@ -444,7 +1109,11 @@ that the strategy is profitable. - WeChat ClawBot and QQ bot are **session-anchored**: they need an inbound message (a `context_token` / an `openid`) before they can push, so an unbound channel returns `not_bound` and is never retried. Anchor files are written - `0600` under `~/.backtrader/notifications/`. + `0600` under `~/.backtrader/notifications/` on POSIX. On Windows, + `notifications/_windows_anchor.py` uses handle-relative creation and update + with a protected TokenUser ACL; it rejects reparse points, unsafe ancestors, + external file ACEs, and volumes without persistent ACLs before content writes. + Windows updates are in place and can leave partial JSON after interruption. - Child processes default to `worker_silent` so `cerebro.run(maxcpus>1)` cannot become a message storm; `cerebro.run()` never flushes — long-running processes call `flush_notifications()` themselves. Process *exit* is covered by an `atexit` @@ -484,3 +1153,1017 @@ CRITICAL: 当你遇到文件引用时(例如 @rules/general.md),使用你 后端开发规范:@.joyincode/rules/backend.md 前端开发规范:@.joyincode/rules/frontend.md + +I9 source commit `157d0c0cffa4c8a86e196159cdf227e9014e9118` and its +reproduced wheel identity are retained in `ctp_artifact_provenance.py` as +historical, unregistered audit evidence. The I9 installed-artifact pin table is +empty: its earlier `record_sha256` was the wheel's embedded RECORD hash, while +the verifier checks the installed RECORD, which differs by `direct_url.json` +source path. I9 verification now rejects before inspecting installed packages. +`ctp_i9_artifact_candidate.py` holds +only an independently named attempt-marker contract for synthetic tests; it +has no provider launcher or CLI entry and requires an explicit latch path. +I9 is not approved for a real diagnostic: native login return handling and +the publication order of login-ready state remain unresolved. Fake tests and +artifact hashes do not establish SimNow readiness or production acceptance. + +The current managed SimNow composition is still unregistered. Its native MD/TD +resource owner now requires lifecycle handoff hooks and an exact SDK +`CtpNativeStopReceipt` showing Join/Release/thread completion before releasing +an account lease; a failed or inconsistent close poisons the session. The SDK +timeout only bounds Join observation after synchronous `stop()`, so this code +does not provide a hard wall-clock shutdown deadline without a supervised +worker. `backtrader_runtime.managed_execution` retains the fail-closed CTP +placeholder: a fake adapter that could invoke Store's SDK dispatch callback was +removed from runtime code and is test-only. The separate SDK v5 outbox fresh +verifier/claim helper is also unregistered and non-authorizing; it has no +trusted verifier or native dispatch. The latest local runtime suite after these +main-repo changes passed 1,465 tests with 24 skips; six related integration +files passed 7 tests. These are offline checks, not SimNow order/cancel evidence. + +I10 is a separate, unregistered MD-only diagnostic candidate. Its clean SDK +source commit is `a6253a58b1ebca11f58c8836fbed757d0daf7582`; the twice +reproduced CTP wheel and the installed RECORD for a fixed wheel source are +pinned only by `verify_ctp_i10_oneshot_md_diagnostic_artifact_provenance_for_fronts`. +The I2 default read-only pin and route are unchanged. A no-system-site-packages +Python 3.11.5 offline environment at `D:\temp\i10-runtime-env-20260925` passed +`pip check`, strict installed-origin SDK/native import, and the I10 provenance +gate with synthetic fronts. Its fixed wheelhouse lacks pytest, so tests inside +that specific environment are `NOT_RUN`; two separate installed-target copies +each passed 171 fake MD tests. `ctp_i10_oneshot_md_readonly.py` and +`ctp_i10_attempt_latch.py` expose no trading capabilities. A normalized path +alias of the canonical latch still goes through the registered-directory +check. The I10 supported operator entry ran once under a Windows Job, but all +five explicit config MD/TD pairs timed out in its credential-free TCP probe. +The value-free child receipt was `front_selection_rejected`; credential +resolution, native client construction, login, subscription, tick, order, and +cancel were not reached. The I10 latch is permanently consumed. Job empty +proves process containment only, not SDK orderly close. No real I10 SimNow +session has been accepted; `NO_WRITE / LIVE_NO_GO` remains. This one-shot +operator contract does not attest against deliberate same-user direct SDK or +private-Python invocation outside the supported entry. + +`bt-runtime check-ctp-fronts --strategy-dir ` is a +separate credential-free TCP diagnostic for the same protected canonical +`config.yaml`. It runs only after the exact sandbox registry/binding gate and +reports pair indexes and MD/TD sample counts without addresses or credential +values. It neither uses a CTP SDK nor consumes a native diagnostic latch. A +2026-09-25 08:00 UTC check found configured pair index 3 reachable on both +MD and TD (3/3 each); the other four pairs were 0/3. At 15:40 UTC, a new +check found no eligible whole pair under the old 3/3 rule: index 3 had MD +2/3 and TD 3/3. Selection now requires a strict majority of samples on each +side (2/3 for the operator's three-sample check) and scores only successful +samples. A fresh 15:55 UTC check selected index 3 at MD/TD 3/3; the other +four pairs remained 0/3. These are time-local transport observations, +not a CTP login, account, settlement, or write acceptance. +The independent I11 MD-only supervised attempt has run once and ended `incomplete / native_join_pending`; its I11 marker is consumed and must not be reset or retried. +A subscription ACK was observed, but identity remains unverified; tick and TradingDay evidence are null, and native Join remains pending. See the I11 value-free evidence at +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ctp-i11-md-diagnostic-2026-09-25.md`. Trading and settlement writes were zero. Default CTP write and live routes remain closed. + +`ctp_i12_td_only_readonly.py` and `ctp_i12_td_only_latch.py` remain unregistered TD-only read candidates, not dispatchable runtimes. One supervised I12 attempt has run and consumed its independent marker: Job containment verified/empty, child exit 2, `rejected / runtime_policy_rejected` at `sdk_artifact`, login `not_observed`, all queries `unverified`, close `not_attempted`, and receipt fields `order_submission_authorized=false`, `settlement_confirmation_called=false`. Offline review found a strong, reproducible candidate defect matching the receipt: a sealed-config `Mapping` was passed to `_route`, which requires `CtpConfiguredFrontPair`. The child exception was not retained, so this is not proven as the unique cause of the real attempt; the recorded stage was before credentials, not an account diagnosis. Do not retry. Offline remediation is in progress. Its independent TD installed-artifact pin and metadata-only Job precheck reuse the exact I10 base/CTP wheel installation but do not call or inherit the I10 MD-only verifier. The candidate delays credential resolver and TD SDK/preflight imports until after fresh seal/pair and artifact checks, runs seven certified read queries, and requires positive typed native close evidence before emitting `td_readonly_complete`; Join pending and unknown close remain incomplete/fail-closed. The parent probes configured MD/TD endpoints with bounded unauthenticated TCP and selects a whole pair; the child probes only the same selected pair. These probes prove transport only. The native/API path is TD-only, with no `MdClient`, MD login, or subscription. Fake-only tests passed 14 cases with Ruff clean. No successful TD session or settlement was accepted; no order or settlement write was accepted, and no default route was added. + +`ctp_managed_cancel_binding.py` adds an offline, non-authorizing structural contract that validates the v1 Backtrader cancel handoff against the full target fields used by the SDK schema-v8 candidate, including typed `ExchangeID`; the cancel action ID remains distinct from the target order IDs. Its focused fake-only tests pass 8 cases and Ruff is clean. The handoff types currently have no runtime consumers. The next insertion point is before SDK command staging inside a future async adapter replacing `CtpManagedExecutionAdapterPlaceholder.cancel_order`; wiring this into the current placeholder would only validate then reject because the generic facade still requires a synchronous provider observation. The binding does not authenticate callback source, produce a provider observation, enable an outbox/SDK dispatch path, or change the shared canonical `ctp:` config. + +2026-09-25 I12 follow-up: `run_readonly_child` now accepts an optional shared +absolute `time.monotonic()` deadline. I12 starts one 90-second budget before +credential-free sealing/front selection and passes it through its metadata +and TD Jobs. Normal child exit waits within the remaining budget for natural +Job emptiness before terminating descendants; pending native Join, timeout, +and error paths still terminate immediately. The strict I12 acceptance +predicate was not relaxed. A separate real Windows Job run of only the +metadata verifier (no config, credentials, marker, SDK import, or provider) +returned `artifact_verified`, exit 0, Job empty, and no termination request. +The historical I12 one-shot marker remains consumed and its original child +exception remains unavailable; this offline check is not a TD session retry. +The latest full runtime suite at this checkpoint was 1,579 passed, 26 skipped +with one existing pytest config warning. The deadline is cooperative around +synchronous Python/Win32 operations, not a hard whole-command bound; ordinary +CTP `preflight`, SimNow writes, and the live route remain closed. + +`ctp_i13_md_observability.py` is an unregistered, value-free projection of an +opt-in SDK MD diagnostic receipt. It keeps SDK callback/subscription/tick facts, +adapter acceptance, TradingDay checks, and native Join separate; contradictory +facts reject, and missing evidence remains unknown. The isolated I13 CTP SDK +candidate at commit `c68bebe8631419801e7a24e13b98c42867df0beb` has an +independently audited, byte-identical two-clone Windows wheel build (SHA-256 +`c6eb83c1389b8f0e96edf9f727c20901b2411961489e19abb4ee1aef6ec2ce5d`) +and a recorded 234-pass installed-wheel fake-only run. The main projection's +13 focused tests pass. No I13 supervised real diagnostic has been accepted, +and these local results do not prove login, tick, native close, or trading. + +`ctp_dispatch_read_model.py` is a pure opt-in reader/reducer for one exact +durable CTP command projection. It preserves local queue outcome separately +from provider order and cancel-action facts, including target-order state; +it never emits a Backtrader accepted/fill status or authorizes a write. The +isolated `bt_api_execution` v9 combined candidate has reviewed callback +envelope mapping plus a read-only durable projection (95 package tests), but +its callback and reconciliation verifiers still default to deny. The reviewed +CTP TraderClient source-event candidate is source-only and not bound to a +managed session. No trusted native source bridge, external account-wide +writer fence, default write route, or production admission exists. + +The later I13 MD-only and I15 TD-only source-seal candidates are unregistered +and fail closed: I13 has an all-zero source pin and no manifest, and I15 has a +`None` pin and no manifest. Their child processes use `-I -S -B`, source-only +imports, and an exact fixed venv install root. A real one-shot is still +blocked because ordinary parent imports can execute runtime bytecode before +the source gate. The I13 finder also admits an unlisted `.py` sibling after +inventory, confirmed by an independent temporary-directory reproduction; +its directory lease does not prevent creation. A trusted stdlib-only parent +launcher and a manifest path/hash-bound I13 finder are required before either +candidate can use a real account or marker. The I15 child finder already +checks its manifest allowlist and hashes at each import. The latest full +runtime suite after the shared-config CLI wording check was 1,647 passed, 26 skipped, +one existing pytest configuration warning, in 71.96 seconds; this is offline +only. The current protected SimNow configuration and future production use +the same canonical `ctp:` block and physical `config.yaml`; the target is one +mode-aware managed CTP runner with distinct sandbox/live admission, while the +default registry currently has no CTP write runner or live dispatch. The +unregistered `scripts/ctp_i13_i15_sealed_import.py` is an offline, CPython +3.11.5-only source-import foundation with no supervisor or operator entry. +Its Windows file-ID lease now covers manifest seal and source-loader bytes; +external descriptor, interpreter/PyYAML pins, complete dependency closure +and independent review are still required before any real read-only use. + +The Iteration 41 operator target is one protected +`examples/013_3_sa_midfreq_simnow/runtime-ctp-private/config.yaml` with the +same canonical `ctp:` fields and one future managed CTP runner for SimNow and +production; mode-specific admissions remain separate. This local file exists, +is exactly Git-ignored, and is not tracked. A configuration edit alone still +cannot dispatch a CTP writer or live profile. The unregistered CTP queue-lease +source at `232a14d` produced a locally reproducible `2.0.2` native wheel and +its installed `TraderClient` plus fake API passed the two previously skipped +v9 execution bridge tests. That version is below the SDK superproject's +`bt_api_ctp>=2.0.3,<3.0` requirement, so it is not a formal integration pin. +An independent version-only candidate at `19349b8` now labels the same queue +lease/callback source `2.0.4+iteration41.i9`; two clean native wheel builds +are byte identical, installed RECORD and origins pass, and a no-system venv +passes 10 queue fake tests plus the two real-`TraderClient`/fake-API v9 bridge +tests. This candidate meets the superproject version range but is not pinned +in this repository or accepted for real CTP callbacks or writes. +The execution API-breaking cutover proof is now separately versioned as an +unregistered `0.2.0` source candidate at `60102bf`; two clean-archive wheels +are byte identical and their RECORD/source checks pass. Neither candidate +authorizes real callbacks, SimNow orders, cancels, or production. Agent/Skills/MCP +now have metadata-only private-runtime path guards with independent offline +tests, but arbitrary Python and the checkout-root `.env` are not isolated; +AI-to-CTP execution remains unavailable. + +The generic config-v4 profile dispatcher now permits only trusted code-owned +offline `simulation/replay` and `backtest/local_backtest` profiles with a +runner, no secrets, network, external writes, managed execution, capabilities, +approval, or sandbox write policy. The default 013_3 CTP profile remains +read-only and has no runner. A separate non-authorizing CTP mode-scope binder +proves that synthetic sandbox/live profiles can use the same physical config +and runner identity while binding distinct account, receipt and front scopes. +A synthetic receipt-required SimNow profile can now open a fake native session +through the unregistered session composition; default deny/live unavailable +still reject. Profile-backed submit/cancel revalidate the sealed config before +reservation and native dispatch, but a final check-to-native-call TOCTOU remains +and requires a linearizable lease or trusted external per-action grant before +deployment. The latest full local runtime suite at this checkpoint was 1,793 +passed and 27 skipped, with one existing pytest configuration warning; this +does not establish provider or write readiness. An isolated +test-only native I13 callback shim traverses C++ virtual to SWIG director to +Python, and its full MD one-shot file passes 107 fake tests. It did not explain +the actual vendor callback's empty identity fields. +I2/I4/I6/I7 static artifact audit found the same full 6.7.7 MD DLL/header +family in the reviewed source and wheel payloads, with no Mini 1.7.0 files. +It lacks the child processes' actual mapped-module receipts and official +archive hashes, so the callback and Join root causes remain unknown. +An isolated mapped-module identity receipt candidate `a3b3fd89` passed 16 +fake tests but is unregistered and has not queried a real Windows child; its +path hash is linkable and it cannot exclude reparse-to-network or concurrent +file mutation. +The unregistered I13/I15 +sealed-import slice passed nine fake tests. Its Windows source file-ID lease is +integrated into manifest seal and source loader; loader execution identity now +rejects a forged, unexecuted module in `sys.modules`. The I13/I15 parent +launcher now has an offline external-descriptor and clean import-closure gate, +with injected metadata-Job tests. An unregistered outer watchdog and Windows +Job backend candidate now cover suspended atomic Job assignment, handle +escrow, and an inert local child in 28 targeted tests; the single Windows smoke +is not provider or hard-deadline acceptance. An accepted external descriptor, +complete dependency closure, artifact pins, independently supervised whole- +command deadline, and provider worker dispatch are still absent. +An isolated local source-only snapshot `64c271bf` matched the 91 runtime +Python files in that checkpoint byte for byte and produced positive/negative I13/I15 +candidate manifest checks. The runtime manifest paths and reviewed pins remain +unset, so this does not authorize diagnostic launch or close G1. +The static writer-candidate inventory also scans historical 014/015 CTP +examples; discovered calls remain review-required and unavailable, never +runner registrations. +Its default scope now includes Python sources under ordinary `examples/` +directories and root `.py` scripts, with a reviewed path baseline in +`scripts/iteration41_writer_inventory_scope.json`. New unclassified paths +At the 2026-09-28 CE04 Store route-identity checkpoint, the R2 scanner inventory was refreshed to SHA-256 `0874A81AC23BA4F659ACAAC833A976ECEC44FA892A3A97778EA3D9779DB67A67`; it retained 460 active candidates (363 writer, 97 dynamic) across 355 files, plus six historical tombstones. Candidate IDs and order were unchanged; source-linked locators reflected that snapshot. The verifier reported 460/460 rows, all active dispositions `REVIEW_REQUIRED / NOT_AVAILABLE`, and no reason codes; the R2 scanner focus passed 15 tests. The current official inventory is the later `A959A3BC…` snapshot recorded in the Iteration 41 checkpoint below. These checks prove static inventory and disposition integrity only, not writer closure or route authorization. The private CTP runtime state is summarized by path without reading its contents. + +The [AC41-63 full writer review archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ac41-63-writer-review-460-2026-09-27/README.md) covers the exact prior A039 Store-era inventory SHA-256 `03658257D97E31C2D8F8BFD48685441533552B32674F5D63810141E3038AB456`: 460 unique positions, with no gaps or overlap, and 20/20 checksum and 14/14 link checks. It records three scoped static findings (a mutable CTP wrapper flag reaching a fake sink after same-process mutation; `create_live_broker` accepting a caller-supplied Store; and `RuntimeRegistry(trusted=True)` not being code-enforced), plus 31 stale line-only checklist locators across eight files. The verifier still passes and all official dispositions remain `REVIEW_REQUIRED / NOT_AVAILABLE` (six tombstones). This is historical review evidence: at its later 2026-09-28 refresh the inventory was `0874A81A…`; the subsequent official inventory is `A959A3BC…` with the same candidate identities. It does not establish current-source writer closure or authorize writes. + +The credential-free CTP front selector now considers a pair transport-reachable +only when MD and TD each connect in a strict majority of bounded samples (2/3 +for the registered diagnostic), and ranks eligible pairs by their successful- +sample median latency. The operator diagnostic rechecks the score and fastest +configured pair; native login/readiness and write admission remain separate. +At 2026-09-25 15:55 UTC the current protected config's pair index 3 had MD/TD +3/3 and was selected for transport observation only; the other four pairs +were 0/3. The same file's earlier 15:40 UTC 2/3 MD observation was rejected +under the then-current 3/3 rule and remains historical evidence. + +`ctp_shared_managed_runner.py` adds an unregistered, non-authorizing common +preparation path for synthetic SimNow and production profiles using the same +canonical `ctp:` config, runner identity, and exact selected pair. It now +rechecks the issued admission object and rereads the registered `config.yaml` +before mode selection and after front probing. A stale in-memory effective +config is rejected; final-check-to-native-call linearization is not solved. +`ctp_shared_managed_runtime.py` is an unregistered common composition root: +the simulation arm delegates to the existing SimNow managed session, while +the live arm rejects before front probing, credentials, SDK import, or native +construction because production session and durable-journal authorities are +absent. Its public context rechecks route/account/front/contract changes in +the same config path and closes a just-opened session if the config changed +during the opener. Private password/AuthCode rotation is left to the +underlying per-action credential-binding gate; it grants no write route. +`ctp_f14_external_admission.py` defines only a typed external account-wide +fence/coherent-snapshot action claim; no trusted authority or dispatch binding +exists. `backtrader/stores/ctp_managed_projection_bridge.py` defines an outbox +projection port, but current SimNow session and BtApiStore queue worker do not +share one durable command authority; the bridge rejects that session. The +Store's managed `_sdk_order_request()` and `bt_api_execution` still have +different OrderRef reservation sources, so a queued Store command cannot yet +be claimed as the same execution-ledger row. A new unregistered typed v2 +`CtpManagedDispatchBinding` carries complete command/session/approval/cancel +target identity. The managed Store queue can persist its exact receipt under +the worker condition lock before publishing the command; the managed worker +requires that typed binding and a single dispatcher, with no generic fallback. +The opt-in I9 bridge checks the Store receipt's reserved OrderRef, +queued/status fields, priority, and queue depth before recording it; these +local checks do not establish a shared durable worker authority. +No live caller supplies those ports yet, and the old v1 bridge records its +receipt after queue dispatch. Managed submit/cancel therefore reject before +the second OrderRef allocation or native send. A single reservation source, +v2 cutover and crash-safe no-resend proof are required before native dispatch +is allowed. The candidate Windows `ctp_config_action_linearization.py` exposes +a `ConfigPathReadLease` diagnostic only. It pins local NTFS config path handles +and rejects remote/nonfixed drives and config hard links, but is not integrated. +A focused local negative test shows +that a parallel `FILE_WRITE_ATTRIBUTES` handle can set a custom reparse tag on +the held config during a fake native-action window: the share lease is not a +final-seal-to-native-call fence. A trusted external broker owning config/path +mutation and the single native dispatch is only a type-level future contract, +not an implementation or write grant. The I9 base/CTP/execution three-wheel +artifact-set verifier is an +audit-only offline candidate with no default pin; its CTP wheel does not +include the separate bounded Join source candidate. I13/I15 parent preflight +rejects missing external source pins/manifests and remains disconnected from +provider sessions. SimNow's official 7x24 environment is API-test-only with +no settlement service, so it cannot supply the current managed TD settlement +readback needed for complete write acceptance; config front selection still +never infers environment from set labels or time. + +An isolated `bt_api_ctp` I9+Join/Feed Ref synthesis has a uniquely versioned +`2.0.4+iteration41.i9.join1` Windows CPython 3.11 wheel. Independent roots +produced identical SHA-256 +`8d2d845501f43939704c9e6c61610ec1747d613a4e07485245bbb006c8242fbe`; +254 fake tests passed and one checkout-relative test was excluded. A fresh +no-system venv installed exact base/CTP/execution wheels and a recorded +dependency closure; installed RECORD, origin, and `pip check` passed without +loading `_ctp`. This wheel lacks the `order_action` evidence and query-source +request-filter APIs used by the separate G5 query-target candidate. It is +incompatible with that candidate and has no default pin or native acceptance. +An I13-to-I9+Join1 ancestry audit stopped without a merged wheel: the exact +I13 branch and I9+Join1 tree have 14 `client.py` merge conflict regions across +native Join/Release, API-origin and callback queues, and order-action history. +The required `get_order_action_evidence` needs those callback/lifecycle +semantics, so copying only its DTO or query filters cannot close G5. A +separate native lifecycle/callback merge review found that Join/Release must +share one per-API state machine and source/order/action callback records must +be admitted atomically under the same API-generation lock. The isolated manual +fake-only source candidate at `D:/bt_api_ctp_i13_g5_manual_candidate_20260926` +passed 264 tests with one path-dependent test deselected; it adds the shared +Release fence, source/order/action evidence and exact query filters. It still +lacks managed-cancel identity arguments/response envelope, bounded stop, +wrapper/consumer compatibility, package export/version review, and the Store +I9 reservation-to-request handoff. No unified wheel, pin or managed artifact +is accepted. + +An inert-only Windows guardian candidate now connects a sealed parent launcher +to a separate guardian and sleeping Python child. Its external descriptor +binds source hashes, the interpreter, and a fixed receipt directory; tests +terminate the parent and observe the guardian empty the child Job. The six +focused modules passed 82 tests in the compatible Windows venv after the +fixture used the actual pipe-service and owner PIDs. A blocked-service negative +test shows that caller IPC timeout leaves the service/child alive, while killing +the service closes its Job and kills the inert child. This is not a hard G1 supervisor: synchronous +`Popen/CreateProcess` and guardian Win32 calls can block beyond a deadline, +the descriptor's external trust anchor and receipt directory ACL are not +verified, and the ordinary CTP `preflight` does not use this path. No SDK, +private config, marker, network or trading write was used. + +An additional unregistered, inert-only outer deadline supervisor owns a +separate Windows Job around that guardian. After the owner process dies, an +independent process reaches the same monotonic deadline, requests Job +termination and writes an UNKNOWN receipt; the service and sleeping child then +exit. Seven guardian modules passed 84 tests twice in the compatible venv, +with one existing pytest-configuration warning; a root rerun also passed 84. +This does not prove a whole-command hard deadline: receipt write can occur +after the deadline, and supervisor hangs, source trust, deployment ACL, +native lifecycle and ordinary preflight integration remain unverified. + +The guardian pipe service now uses a protected current-user DACL verified by +handle readback, remote-client rejection in `dwPipeMode`, two pipe instances, +and impersonated SID checks for both the HMAC response and the actual request. +The exception path also avoids closing the pipe handle twice. The final +service file passed 11 tests in both author and independent root runs; the +root run took 24.28 seconds with one existing pytest-configuration warning. +This local current-user boundary supplies neither an external deployment +trust root nor a whole-command hard deadline. G1 remains NOT_PASSED. + +The 2026-09-26 broad default-plugin regression in a compatible isolated +pytest 8.2.2 / pytest-asyncio 0.24.0 environment, covering +`tests/unit/runtime` and all eight `tests/integration/test_iteration41_*.py` +while excluding runtime one-shot files, passed with 1,639 passed, 27 skipped, +2 xfailed, 0 failed and no warnings in 93.68 seconds. This was superseded by +the latest F14/SimNow candidate run: 1,674 passed, 27 skipped, 2 xfailed, +0 failed and no warnings in 99.92 seconds. This range excludes +the separate seven-module guardian script focus described above. +Gateway's +fake provider returned once while +Router recorded `RETURNED_UNVERIFIED` and the managed client retained +`UNKNOWN`; a repeated submission did not call the provider again. This is +offline fake/inproc coverage, not a CTP session or trading acceptance. + +`ctp_managed_action_authority.py` is an opt-in signed-action verifier for the +exact execution Store, issued callback owner, writer lease, and active session. +It binds the persisted command to an Ed25519 permit, sealed runtime scope, +revocation observation, external-fence observation, and trusted UTC port. +Returned authority expires no later than either trust observation's 250 ms +freshness deadline and is consumed by the Store's existing one-use ledger. +The default verifier rejects. The pinned key mapping rejects ordinary mutation; +this is not isolation from hostile code in the same Python process. +`ctp_managed_action_scope_resolver.py` revalidates the canonical config and +exact mode-specific admission on each resolve. Its account reference uses a +versioned canonical broker/user digest, and it fixes the selected configured +MD/TD pair. It performs no transport probe or default registration. These +modules do not provide a deployed permit issuer, external account fence, +active native host-pair continuity, or an atomic fence through the final +native request. Independent V18 Store review found that the first 41-test +candidate could authorize a signed request whose login account and request +account/contract fields differed from the sealed config. The frozen v2 fix +and READY-only defer passed 75 independent source tests; eight additional +signed request/session mismatches were rejected by the actual V18 Store, +with READY rows and zero native dispatches. This used SDK29/parent76 sources, +base 0.15.4, and an explicit QA execution metadata shim, not an installed +wheel bundle. The v2 test file retains one Ruff E731. Final CLAIMED-phase +expiry/revocation/fence revalidation is a separate slice: the next repair +checks the original Store binding expiry against the final trusted-clock +sample and has 98 author focus passes. An earlier independent run imported +mutable main-tree code through its CWD; its frozen-r3 attribution is invalid. +A corrected same-process origin-guarded r3 probe reproduced the expiry gap +within the allowed 250 ms wall-clock skew and called only a fake SDK Req. +The successor fix still needs independent acceptance; neither version proves +an external atomic fence through the native request. + +`scripts/ctp_i13_i15_guardian_deployment_anchor.py` is an unregistered fixed +ProgramData descriptor/handle-lease candidate. It separates code integrity +permissions from the service's fixed receipt-directory writes, retains the +source and selected venv interpreter paths, and creates receipts with +CREATE_NEW. Its deployment pins remain unset. This does not yet seal the +base Python distribution: CPython loads encodings before bootstrap, and the +current local Anaconda base grants Users write access. A separate fixed +runtime manifest, protected base DLL/stdlib inventory and retained directory +leases are required before deployment. The client-safe descriptor reader +binds the fixed BacktraderCtpReadonlyGuardian service name without reading +the legacy shared-HMAC key. The new read-only pipe route uses OS service +identity and remains under integration review. The source manifest has a +narrow data-only exception for the exact read-only worker path; the child +bootstrap and dependency finder are being integrated. The frozen r5 anchor +passed 68 independent tests, including a temporary Windows retained-handle +absent/present cache-leaf test with ACL checks stubbed. Its code-derived +`disabled-bytecode-cache` path does not accept caller overrides. Guardian +route r1 has a cross-session inherited-handle deployment blocker and a +failed-RevertToSelf follow-up bug. A later pre-orchestration snapshot passed +41 independent service tests and one parent manifest-gate test; its seven +Revert/fail-stop tests are a subset of the 41. Both impersonation helpers now +use process-fatal restoration failure handling. Those tests did not execute +real process termination. A bounded named-pipe output channel and per-request +coordinator/receipt supervision remain separate follow-up slices. Caller timeout still +does not prove a whole-command service deadline. Ordinary preflight, +service deployment, real native sessions, and default write routes remain +closed. + +The V18 execution Store's cancel postcondition slice was independently +verified at 155 passed / 2 skipped, including six legacy migration cases; +the skips lack the ambient SDK callback consumer lease. Local commit +33d132d6 matches that frozen source after CRLF normalization. V18 still +partitions account identity by environment, so a new mode-independent CTP +account-family owner gate is being implemented. Its old journal cannot +reconstruct raw account references from hashes; nonempty legacy history +must not be silently reassigned. A separate main-bridge regression found +that UNKNOWN cancellation recovery was scoped to one strategy. The V19 r0 +Store claim gate rejected a live bridge's new order after another strategy +left UNKNOWN cancellation state; the order stayed PENDING_DISPATCH with +zero attempts, and restart reconstructed the risk freeze. Independent QA +then reproduced a writer-lease generation ABA: release deleted the row, +reacquire reused token 1, and an old lease could mutate or release the new +lease. V19 r1 retains an expired tombstone and passed 240 author package +tests with two skips. Independent r1 public-API/SQLite checks accepted the +reviewed family owner, generic/CTP unresolved-cancel claim gates, V18 migration +fence and close/reopen stale-lease rejection; root rehashed 28 source/test +files and checked nine copied synthetic databases. Clean-close ownership +handoff is still unavailable. The broader main bridge focus then reported +63 passed / 4 failed; old test doubles and actual offline replay integration +are being repaired, so this is not unified runtime acceptance. The shared +candidate's public factory opens the existing account journal filename under +its existing flow lock, rejects legacy history before schema changes, and +binds an immutable exact account identity. It does not create a second ledger +or claim cross-host/hostile-file protection. V20 r0 incorrectly accepted a +same-name trigger with an altered body. V20 r1 added full SQLite schema +comparison and precise empty legacy orders-only recognition, but independent +QA rejected public-API cross-account mutation. V20 r2 adds transactional +bound-account checks; 29 frozen source/test files matched the manifest, and +28 independent tests plus a public API probe accepted the scoped single-account +binding. The r1 snapshot remains rejected and immutable. The main shared- +account candidate independently matched 105 frozen files and reran 84 passed / +2 expected xfailed in a source-only fake bundle. The xfails retain the final +config-check-to-native-dispatch race. Separate V20r2 and R3r2 ordinary +installed-wheel consumers independently passed full offline dependency, +origin and RECORD checks (51 distributions, 9,077 hashed RECORD rows each), +but both Execution wheels are labeled 0.2.0 despite distinct hashes and need a +unique final version/pin. None proves a real-account restart or deployment. +The R3r2 cancellation claim source bundle independently matched 29 frozen +files and passed 278 package tests with two optional SDK bridge skips; those +two tests passed only in a separate QA fixture with complete fake login fields. +An integer-zero risk claim prevents any cancel provider call and freezes +UNKNOWN; an integer-zero provider result follows one cancel call and also +freezes UNKNOWN. Parent terminal-proof issuance and main runtime positive +integration remain unfinished. +The clean-handoff design audit requires +issuer-bound native-close and final callback-drain evidence before any new +owner generation; current permanent owner fences are not a usable restart path. +The claimed-action expiry r4a source bundle independently passed 100 fake-call +tests, including 100 microseconds before/after the original Store binding +expiry with a bounded 250 ms trusted-time-to-call skew. It does not supply an +external account fence, native CTP proof, or write authority. The main +cancellation bridge now obtains an exact risk dispatch claim before a fake +provider cancel call but still needs a typed terminal cancellation proof before +releasing its risk latch. G1 output-channel r3 failed independent QA because a +forged outer +result could claim Job empty while the worker was alive; r4 independently +passed 20 local helper tests and r6 passed 56 post-resume tests. The +coordinator/worker Job and subsequent receipt-writer Job still require +complete service acceptance. Default routes remain `NO_WRITE / LIVE_NO_GO`. + +The G4 fake blocked-stop Job probe and an independent Windows rerun both +observed timeout, process termination and Job empty for blocking +`RegisterSpi(None)` and `Release()` child calls. This proves only host-local +fake containment. The SDK `stop_and_wait(timeout)` does not bound those +synchronous close calls before its Join timer, and the wider SDK focus retains +one historical failed concurrent-cleanup assertion. An isolated test-only +synchronization fix then passed 56 focused tests independently; e75 production +SDK source was unchanged. Real native close and the full G1 service supervisor +remain unaccepted. +A separate fake service counterexample independently reproduced a synchronous +`create_receipt` blocking after worker Job empty and beyond the request +deadline; the handler stayed alive past an external 250 ms wait and returned +`observed` only after release. No real SCM/CTP was used. The current service +therefore has no accepted whole-command hard deadline; ordinary CTP +`preflight` stays fail-closed. +The installed-wheel G5 fake chain independently reproduced a different gap: +after a post-claim synthetic admission denial, the account family and callback +owners are POISONED and new owner acquisition rejects across reopen, but the +command remains CLAIMED with native_call_inflight=1 and no explicit UNKNOWN +receipt. No native Req occurred. A narrow poisoned-generation UNKNOWN +finalizer and a full rerun are pending; this is not a replay bypass or G5 pass. +The first G5 author evidence JSON mistyped the Execution wheel SHA as 65 hex +characters; a separate v2 corrigendum verified the existing 64-character +wheel against the prior unified installed-consumer receipt and RECORD. + +`ctp_simnow_operational_window.py` adds unregistered, non-authorizing G6-S +scope, one-action permit, revocation, risk-ceiling and single-query stream +contracts. Their account-wide exclusion, common snapshot, signature and write +authority flags remain false. They are separate from strict F14 and have no +trusted reviewer/verifier, Store or SDK dispatch connection. The independent +I13 MD value-free source candidate is commit `297f37e` in +`D:/source_code/bt_api_ctp_i13_md_value_free_review`; its 14-entry Windows +byte-stable manifest is SDK source-review evidence, not the complete parent +launcher source manifest/pin. It has no candidate `_ctp.pyd` or wheel, and +native/provider tests were not run. These candidates do not alter the default +`NO_WRITE / LIVE_NO_GO` route. + +`ctp_simnow_signed_review.py` adds an unregistered HMAC review verifier and an +explicit host-local SQLite permit/action replay guard. On Windows the guard +checks owner/protected DACL, ancestors, database identity and SQLite sidecars, +and rejects UNC/remote/unknown volumes and untrusted generic-write ACEs. Its +two-file focused contract passed 77 tests in the compatible local venv. No +deployed key/issuer, account-wide writer fence, trusted callback, Store/SDK +dispatch, production ACL deployment or cross-host claim is supplied; the +permit stays non-authorizing and ADR-41-16 option B is still proposed. + +`backtrader_runtime/ctp_f14_signed_receipt_contract.py` is an unregistered, +offline receipt-wire contract candidate. It validates canonical versioned +receipt bytes against explicitly injected issuer/key/audience pins and an +injected Ed25519 verifier, then returns only a non-authorizing shape +observation. A local `CtpF14PinnedEd25519Verifier` checks genuine signatures +against injected public keys and SHA-256 pins; no production trust pin or +account-control service is supplied. Its +replay/epoch cache is process-local; an optional explicit host-local SQLite +observation fence persists replay keys across restarts and concurrent local +processes, but its ACL/owner and cross-host exclusion are unverified. Neither +implements external account control, service-side durable dedupe, a +`CtpF14ExternalAdmissionAuthority`, or dispatch. +Signed assertions do not prove account-wide writer exclusion or snapshot +truth. G6-P remains blocked, G8-P remains unrun, and the default route stays +`NO_WRITE / LIVE_NO_GO` until a unique external account actor owns credentials, +session, snapshot and final provider dispatch. + +The isolated `bt_api_execution` I9 single-worker source candidate now has +follow-up commit `b676fe6`: its SQLite queue receipt precedes claim, and any +post-claim sender `REJECTED` is persisted as `UNKNOWN` because there is no +trusted no-send/no-callback proof. Only a pre-send durable `queued=false` +receipt is locally rejected. This candidate passed 133 SDK package tests with +two skips, but has no wheel, default pin, native sender, or shared OrderRef +authority with `BtApiStore`. The main Store's broad unit suite passed 662; +ten Iteration 41 integration tests passed. None is provider acceptance. + +A later Store/Broker broad local run used six fresh detached source checkouts +at exact parent/base/CTP/execution/risk/monitor commits +`5de97235/3de0fa4/9976bcbb/55798072/dce2c84/8498ca7d`. The compatible +venv run passed 1,121 tests and skipped 148, with no warnings. An audit hook +allowed 53 literal loopback socket events needed by Windows asyncio and +blocked DNS, non-loopback network, native CTP imports and private config +reads; no such blocked attempt occurred. Five initial `bt_api_py` import +failures came from the venv lacking those external sources and passed with the +exact clean source paths. An independent rerun after narrowing the audit +import guard to the exact native module leaves `_ctp` and `ctp_wrap` again +passed 1,121 tests with 148 skips in 10.37 seconds, exit 0 and no warnings; +the guard no longer mistakes the Python `bt_api_ctp` package for an extension. +It recorded the same 53 loopback events and no blocked attempts or native +CTP modules loaded. Skip review then confirmed the optional SDK helper checks +installed distribution metadata before import: the 13 CTP wrapper test +locations were skipped despite the CTP source path. This broad result does +not establish CTP Python client compatibility or installed-artifact coverage; +with explicitly labelled temporary source-only distribution metadata, the 13 +CTP wrapper test locations expanded to 15 passing tests. The expanded +Store/Broker run was 1,261 passed, 11 failed, 1 skipped: nine request-DTO +signature mismatches and two rejection-message mismatches. A subsequent +six-test rerun passed after non-CTP cancellation stopped sending the CTP-only +`runtime_order_id` keyword and the same-store rejection message was unified. +The legacy Store now keeps runtime IDs in its internal mappings and omits +unsupported top-level runtime identity keywords from parent request DTOs; +the pinned normalized Store file passed all 44 tests. Managed CTP still +rejects before the separate legacy allocator. An adjacent source-only I9 +queue smoke needs the new fresh same-store cancel target handle; that +compatibility check and the opt-in parent request builder remain in progress. +The expanded run blocked two optional native import attempts and loaded no +native CTP module; temporary metadata is not installed-wheel evidence. + +The isolated CTP source compatibility checkpoint is now +`07012dda628c8c0d7fefa6e1b925f7f8b3f8f518`: query getter readback, +managed cancel identity, terminal login observation/certificate and a native +shutdown receipt view coexist in one source tree. Its copied five-file fake +focus passed 302 tests with one parent-layout exclusion; the actual SDK fake +receipt to main shutdown consumer focus passed 45 tests independently. No +native CTP module was loaded. A subsequent independent clean-checkout audit +of all seven files passed 347 tests with one layout exclusion and two warnings +in 57.38s; external network and protected config access were absent. The audit +allowed only numeric loopback for Windows event-loop socketpairs and is not +an OS isolation proof. When no observer thread exists, the receipt reports +`thread_alive=False`; Join completion remains a separate condition. +Synchronous `stop()` still has no whole-call hard bound. +This is not a wheel or default pin. MD/profile credential compatibility and +the 155-callback durable session ingress remain incomplete; the current +three-event source queue cannot prove complete account callback coverage. + +`BtApiBroker.get_strategy_allocation(strategy_id)` now delegates through +`BtApiStore` to an attached `ManagedExecutionBridge` and the optional SDK +local risk reader. It enforces the runtime strategy and returned account +scope, and performs no SDK startup or balance refresh. The returned snapshot +is advisory local reservation information, not cash, margin, a provider +account snapshot, or order authorization. The ten main-repository forwarding +tests pass; together with the existing managed bridge focus, 73 tests pass. +The opt-in `NotionalAllocationSizer` uses a reviewed SDK instrument registry, +an explicit Decimal limit-price callback and metadata clock, and the new +reader. Its SDK helper reuses the instrument assessor and floors to the +quantity lattice; it does not infer margin or remaining position capacity. +Four source-only SQLite risk-to-Broker/sizer integration tests pass, including +an over-budget custom request rejected by the hard gate; the combined main +sizer/reader/bridge focus passed 85 tests. Independent source review verified +the immutable account-policy binding rejects widened caps, removed allocation +requirements and changed units, and the two-process distinct-strategy race +admits only one 600-unit reservation under a 1,000-unit account cap. The +six-file risk package passed 201 tests at that checkpoint. Subsequent small +changes add a snapshot arithmetic-scale bound and clarify zero-allocation +exhaustion; their final candidate result is recorded in the parallel +checkpoint. These contracts do not establish full AC41-48 or a deployed +writer. Existing `getcash/getvalue` semantics are unchanged. + +The unregistered `backtrader/stores/ctp_i9_managed_dispatch.py` bridge now +rejects structural fake ports before any reservation or worker call. It +requires Python 3.11+, the exact installed `bt_api_execution==0.2.0` I9 +classes, and a worker whose `_store` is the same object used for durable +readback. This is a local wiring guard, not a trusted code-origin or +in-process isolation proof; cancel still lacks typed provider-order +provenance. Four focused offline tests against isolated real I9 source and +the 667 Store unit tests passed. The source smoke establishes local submit +stage/readback and a fake sender path, not installed-wheel or provider +acceptance. The bridge refuses forged/cross-instance/cancel handles before +queue callbacks because cancel still has no trusted provider-order projection. +An additional local negative test supplies complete caller-owned cancel +target fields but still observes zero stage, queue receipt, or dispatch; the +missing I9-to-native-query scope receipt remains an explicit G5/G7 blocker. +The current managed cancel DTO requires both native target forms even though +the CTP native API permits either one; source-only negative tests keep both +single-route forms closed until a trusted tagged provider projection exists. +The isolated SDK consume-only OrderRef mirror, managed Feed/Gateway Ref +format sentinel, and I9 single-worker source candidate remain separate, +unregistered slices. Neither mirror nor sentinel grants dispatch authority; +trusted SDK request-intent binding and accepted allocator cutover remain G5 blockers. + +An isolated SDK follow-up commit +`87776bf38bd9b4ac0b9b0963deb074a4996e10a8` rejects the legacy +time-based allocator for bound/managed CTP sessions and rejects exact CTP +venues in the no-session allocator facade before clock or journal effects; +independent fake review found no P1 in that narrow cutoff. The no-session +`make_order` legacy UUID path is distinct from that cutoff. An independent +offline composition smoke followed it through the real DirectBackend and a +byte-matched pinned CTP Feed: no execution capability was passed, and the +Feed rejected before OrderRef allocation, RequestID, or native submit, each +observed at zero. This is only a narrow no-capability fail-closed result. +The older Feed/Gateway Ref sentinel commit `03f0b96` is on a divergent SDK +tree and cannot be cherry-picked into the current parent: Gateway and Feed +now live in pinned `bt_api_base`/`bt_api_ctp` subpackages. The pinned CTP +Gateway adapter rejects direct writes, but a fake managed/armed CTP Feed can +still allocate a missing OrderRef locally. An isolated CTP child source +commit `0609b05afee97d0cde644dac22f5c620aa088ec9` directly on the pinned +`ce1edd60785eb4c66fefa16a994a66946a1e068f` removes that Feed fallback, +requires an exact 12-digit ASCII Ref, and passes it unchanged; 218 focused +offline tests passed and an independent review reran 190 affected-file tests. +An isolated source-only parent commit `7fe53a07981ec46c01a0a9993dae124b779435ee` +updates only its CTP Gitlink to that child; the accepted wheel and default +runtime are unchanged. Independent audit found a managed fake-transport path +that accepts a valid Ref without an I9 mirror, and ordinary Python import +selected site-packages CTP rather than the candidate Gitlink until explicit +source paths were supplied. This format check is not I9 reservation/intent +binding, a native direct-call guard, or managed +cancel provenance; no provider writes are enabled. + +A newer isolated CTP child `9976bcbbbe331ee77e2d90e05da08472259a625a` +combines the Feed Ref guard with the full Join/Release source chain. The +clean parent candidate `6d24217d858bb6ac27ca46ad8a1a13123063d047` +in `D:\bt_api_py_codex_ctp_gitlink_9976_20260926` updates only its CTP +Gitlink to that child. Exact pinned-source testing passed 255 child fake +tests and 309 parent I9/execution/forwarding/normalized API tests with two +skips; Ruff, compile and diff checks passed. The default pin/wheel remains +unchanged, the execution Gitlink is still an initial commit, and the result +does not establish I9 authority, native close, or provider acceptance. +The same isolated parent branch then cherry-picked equivalent forwarding +scope patches, yielding clean HEAD `da2286911a376678c8f22bf8080090d4c4fdbfe2`. +Its exact source-path parent fake suite passed 469 with 21 async skips; +the child focus passed 255. Independent audit verified the original +forwarding SHAs are patch-equivalent, not exact ancestors, and separately +passed 18 Feed/callback, 107 Router-scope, and 29 I9-consumer fake tests +with explicit root/base/CTP import origins. The execution Gitlink is still +the package-empty initial commit and there is no installed wheel/native or +default write route. + +A separate clean parent Gitlink-only candidate +`c4407b09bb4fd470a3dcb498d56fd0c6034d02c7` in +`D:\bt_api_py_codex_execution_gitlink_b676_20260926` fast-forwards only +`bt_api_execution` from its package-empty `2700cb54` to source commit +`b676fe666de5373c58ff59bc0b856b7f6d4ce7fd` (`0.2.0`). +`installable=false` and the default route remain unchanged; the SDK has no +installed I9 distribution metadata from this change. I9 source fake tests +passed 133 with two skips, CTP child tests 255. The parent expanded suite +reported 640 passes, two skips and 19 failures; all five arming fixture +binding failures and 14 risk `AccountScope`-API failures reproduce at the +previous Gitlink. Full I9 Ruff reports 38 findings. This is not an accepted +wheel, runtime authority, or provider write path. +Independent Gitlink review passed 19 I9 store/contract, 29 SDK consumer, +and 5 installer focused tests, confirmed the one-pointer diff and unchanged +installer behavior, and reproduced the 38 I9 Ruff findings; it did not +accept the expanded parent suite. +Independent risk-source review found current Gitlink `d0c18a9` lacks the +managed fake-dispatch APIs. The first `AccountScope` child `7343430` is +insufficient and would allow generic clearing of a dispatch-inflight latch. +The first source commit with typed `VerifiedDispatchResolution`, journal +authority, and generic-clear rejection is `dce2c84d3216cdb215f6db7157dda181178260af`; +at that checkpoint it was not pinned or independently tested. The parent runtime's old +generic resolver calls also need conversion to the exact proof path, so a +risk Gitlink update alone does not close the 14 fake-dispatch failures. + +A later clean, isolated parent **source-only** candidate at +`D:\bt_api_py_codex_i9_integrated_20260926` HEAD `5de97235` combines the +I9 child quality-only `5579807`, risk `dce2c84`, monitor `8498ca7`, +typed parent dispatch-freeze resolution, and repaired old arming test +fixtures. With exact clean local source paths for base `3de0fa4`, CTP +`9976bcbb`, and those three children, the parent non-network contract and +fake-dispatch suite reports 1039 passed / 7 skipped. Risk child tests report +159 passed, monitor 40, I9 133 passed / 2 skipped; I9 Ruff is clean. +Independent risk review found no path in this typed composition to clear a +dispatch-inflight latch without the injected fake journal authority. The +monitor successor covers a durable outbox for local fake facts; its separate +control ledger accepts caller-asserted issuer/authorization and is **not** +an authentication boundary. The execution submodule still has +`installable=false`, newer Gitlinks have not all been published to a shared +remote, and no accepted combined wheel/RECORD/import-origin, native session, +cancel-target issuer, default write route, or real provider order/cancel exists. +G5 remains `NOT_PASSED`, `NO_WRITE / LIVE_NO_GO`. + +A later unregistered `bt_api_monitor` candidate gates its control ledger +through `ControlIngress` and a domain-separated HMAC verifier with injected +key and issuer policy. The full isolated package suite passed 64 tests, +including async tests. No deployed key resolver, rotation/revocation, +trusted issuer/receipt policy, or production route is present; this remains +an offline authentication contract and does not change G5 or live status. + +An isolated source-only G5 child `c10ccf5f` adds a durable pre-cancel CTP +target projection bound to one I9 reservation, a one-use same-store handle, +and monotonic freshness checks under the staging/claim transactions, including +a final staging check before return. Its full fake suite passed 141 tests with +two skips. A separate parent integration `097a98a7` pins that child and +passed 1039 non-network contract/fake journal +tests with seven skips. The verifier is still injected and has only a +rejecting default; no code-owned native query-to-reservation issuer, trusted +sender, wheel/origin proof, or registered cancel path exists. G5 stays closed. +The main runtime's unregistered query-to-target candidate now derives a +non-authorizing, at-most-two-second order observation from a candidate SDK +`TraderClient.query_orders_result` source and fake native callback. It checks +the current request filter, record digest, TradingDay, connection generation, +and precise remaining order quantity. Nine focused tests pass with the +candidate SDK source; the default installed SDK lacks `order_action` and +query-source filter metadata, so the SDK-dependent test skips there. No I9 +issuer-to-durable-store readback or trusted sender is connected, and no +cancel projection or dispatch authority is issued. +ADR-41-17 proposes startup-sealed, immutable CTP run scope with no session +hot reload for the same protected config and shared runner. It is not accepted +or implemented; current action-level config mutation tests remain strict +xfail and P2 stays open. + +A separate SDK source-only commit +`decd760012ad77d0c7ddab6400951a28fea16516` adds nested CTP identity +labels to `OrderRequest` and checks managed requests against the mirrored I9 +account/day/scope/intent/runtime ID/Ref before journal or fake transport. +Its related contracts passed 403 with two skips; independent fake review +confirmed missing/mismatched mirrors reject and exact mirrors still face the +write gate. The consumer still accepts class-name/module-name spoofed I9 +objects, so the mirror is not a trusted authority; this source commit has no +accepted wheel, Feed Gitlink, default route, or provider/native acceptance. +A separate source-only follow-up `5744d3cb98dbbf729078753417a22b6dbfac8cd7` +requires `bt_api_execution==0.2.0` metadata and exact imported I9 classes; +29 independently rerun fake tests show name/module spoofing and absent I9 +reject. A temporary-metadata real-I9-source smoke is API/type compatibility, +not installed wheel/RECORD/origin trust or in-process isolation. +Independent review also found no verified forwarding/wire propagation of the +nested field and no I9 cancel-action binding; model serialization tests alone +do not close those paths. An isolated source-only forwarding branch +(`fbd08485` then `121675dbabac20dcaf30c924da827a8e1d182a5a`) +intended to reject CTP make/cancel/cancel-all before client/send and report +those capabilities unavailable; 152 author fake tests and 95 independent +focused tests passed. A later P1 whitespace alias bypass was repaired in +source-only commit `c33ce478a5f2557bdc9fc118cd852e8dcef2c187` with 15 +alias negatives and 167 related fake tests. Independent review then found a +broader P1 receiver route bypass: `UNKNOWN___FUTURE` can pass the client +guard and wire to `OrderRouter.handle_command`, which dispatches by command +type to a CTP-configured adapter without checking the command venue. Direct +wire clients can bypass client guards entirely. Source-only commits +`e6cd73bbddf1875a351c8519f15ece4369fa7284` and +`2fec245ec885589edc337efb144ff86a28a8f518` close the observed +declared-scope/default-router bypass: ZMQ writes require explicit matching +exchange/market/account scope, CTP scope is disabled, and direct Router or +embedded Runtime defaults to no writes and rechecks a nonempty scope. +Author tests reported 379 synchronous passes with 18 async skips; +independent exact-commit review passed 132 focused fake tests and found no +new P1 for correctly declared adapter identity. The in-process adapter's +static exchange/account are still self-reported; a CTP-capable custom +adapter falsely reporting SIM can receive SIM-bound writes. Peer/token +authentication and client capability agreement are also absent in this +parent forwarding path. A separate unregistered local +`bt_api_transport_zmq` candidate now authenticates remote Curve clients by +ZAP public-key metadata and checks a server-owned account/strategy/kind ACL; +its 12 local tests do not integrate the gateway. Independent review found +that its grant and `GatewayPrincipal` shapes differ and no strict gateway +command decoder is wired between transport and Router. These +conditional source results are not a trusted provider identity, installed +wheel, working CTP forwarding route, or default route. + +A later unregistered gateway/transport candidate has local Curve/ZAP client +key ACLs and a strict parent transport-to-gateway decoder/principal adapter. +The transport suite passed 12 tests; the parent adapter/composition focus +passed 13. The gateway SQLite account writer authority and event cursor +suite passed 69 tests after bounded WAL-lock initialization retry, +database-path identity rechecks, and a same-transaction final action/command +journal identity check. A fake provider's returned `ACKED` payload +remains `RETURNED_UNVERIFIED` in the Router and `UNKNOWN` to the client; +retries do not resubmit. This is a same-database local contract and does +not exclude other hosts or manual CTP clients, prove a common account +snapshot, install production identity/keys, or open the default write route. + +The external `backtrader-mcp` candidate worker now fails closed before +approval consumption, payload loading, and process launch because no +code-owned Windows or POSIX OS network isolation backend with descendant +containment is installed. Its CTP/path four-file focus passed 115 tests with +18 isolation-dependent skips (`--no-cov`); this deliberately disables +candidate execution on the current host. Static Windows protected-path +literal checks remain, but dynamic paths and OS network isolation have not +passed AC41-84. The main repository's SimNow signed operational review is +also unregistered: two focused files passed 75 tests. An explicitly injected +local SQLite replay guard now durably claims permit and action keys together, +but injected HMAC policy, UTC and that local ledger supply no real issuer, +protected key, Windows ACL proof, account writer fence or write authorization. +ADR-41-16 remains proposed and `NO_WRITE / LIVE_NO_GO` continues. + +`scripts/ctp_i13_source_manifest_candidate.py` creates only a labelled +dirty-tree artifact under `artifacts/` with the 91-file current +`backtrader_runtime` Python inventory minus two code-owned pin modules. Its +fixed output path and reparse checks have eight offline tests; the trusted +runtime manifest remains absent and the I13 pin remains zero, so the parent +launcher rejects. The offline parent launcher now also binds a returned seal +to the preflight source root and ordered standard-library paths before finder +installation or metadata Job setup; failed seal cleanup rejects explicitly, +and the three related fake files passed 42 tests. The external descriptor +source and real Windows Job setup remain +unimplemented. The isolated `bt_api_ctp` Join/Release fail-closed source +candidate is commit `df565f6debef1fccd22fe378a2ccd774b4c61a67` with +31 fake shutdown tests. A separate child source-only synthesis combines the +Feed guard and the complete five-commit Join ancestry at +`9976bcbbbe331ee77e2d90e05da08472259a625a` with 255 affected fake +tests; neither the parent Gitlink nor default runtime points to this synthesis. +There is no native build or real G4 close evidence. + +The repaired G1 token/control r2 helper freeze had 60 focused tests and an +independent origin-guarded Windows rerun of 60 tests; root reverified its 73 +source artifacts and 85 QA artifacts. Current-process token smoke used only +`TOKEN_QUERY`; a local named-pipe smoke exchanged one synthetic frame. Fake +tests cover bounded SID pointer parsing and requested token access masks, not +real `DuplicateTokenEx` or owner-token transfer. `_OwnerTokenControlServer` +is not yet constructed in the full command path, and the coordinator/worker +and receipt-writer two-Job chain and whole-command deadline remain unproven. +The earlier buffer-lifetime snapshot is withdrawn. Ordinary CTP `preflight` +stays fail-closed; no write or live route is enabled. + +The frozen Execution R3r3 cancellation candidate adds a same-transaction +`CancelObservationCommitV1` and lease-checked `read_terminal_cancel_commit`. +Compared with R3r2, only the Store, its public exports and one new test file +changed. In an independent exact-source copy the cancellation focus passed 35 +tests and the full package passed 284 with two optional SDK skips; native +imports and direct network attempts were zero. A first full collection failed +because pytest-asyncio was disabled, then passed with the compatible plugin. +The source digest is a normalized local journal fact, not authenticated +provider evidence. Parent fake proof wiring and three main-repository positive +cancellation cases remain outstanding; this does not enable CTP writes. + +R3r3 cannot yet be given an installed-wheel acceptance. R3r2 and R3r3 both +declare `bt_api_execution 0.2.0`; keeping that version duplicates distribution +identity, while the frozen parent76 `_execution_session.py` and five main +runtime modules exact-pin `0.2.0` and reject a unique `0.2.1`. The audit was +verified against the exact parent archive because its outer working tree had +changed. A coordinated parent/runtime pin migration and fresh wheel RECORD, +origin and integration checks are required; the R3r3 284-test result is +source-only. + +The parent76 fake cancellation-proof issuer r2 snapshot passed 23 author tests +and 28 independent combined tests against the exact frozen Execution R3r3 and +risk sources. Its 15 manifested artifacts and 43 synthetic SQLite QA files +were hash-checked and archived. It derives typed cancel-action proof only +from the lease-checked terminal journal readback; stale leases, changed target +facts and foreign account scopes reject. This applies only to the offline +`SIMULATION_JOURNAL` fake authority. The source digest does not authenticate +a provider, and parent control-release/main bridge wiring remains absent. + +The later G1 whole-command supervisor review is archived at +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-g1-whole-command-supervisor-no-go-2026-09-27/`. +Independent inert/fake verification passed 65 supervisor tests and one ordinary +CLI fail-closed test. The frozen source packet omitted two directly used helper +files, and the replay needed 105 then-current support files; intermediate +five-timeout runs remain unexplained. Synchronous process creation and cleanup +are not proved to complete within the current whole-command hard deadline. +The proposed prewarmed-service request-only SLA would change that requirement. +G1 remains closed and ordinary CTP `preflight` remains fail-closed. + +The controlled G4 CTP wheel rebuild evidence is at +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/g4-msvc-pe-timestamp-author-review-2026-09-27/`. +A strict isolated C build produced a native image the same size as the pinned +image, differing at six PE timestamp bytes; reproducible `/Brepro` A/B images +were byte-identical to each other but 512 bytes larger than the pin. The +corrected archive passed independent checksum and link recheck. The exact +pinned wheel has not been rebuilt, so G4, native acceptance, and CTP routes +remain closed. A subsequent inert linker-input audit found no supported MSVC +timestamp setter in the captured command or local `LINK /?`; plain linking +uses the current time, while `/Brepro` changed much more than the timestamp. +The archived study added no new build or native load. + +An accidental shared-tree Ruff format on 2026-09-27 affected tests during an +isolated G5 validator task. Recovery preserved the immediate test snapshot, +restored 1,267 formerly clean tracked files, 24 formerly dirty tracked files, +and 67 untracked files from verified pre-incident candidate sources, and +removed three accident-created empty/docs paths. The 31 pre-existing tracked +test diff paths remain. Ruff formatting cannot be uniquely inverted, so the +recovery provenance and residual style-only uncertainty are recorded in +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-test-tree-formatter-incident-recovery-2026-09-27.md`. +Post-recovery scanner/inert-import focus passed 16 tests, and the restored +Store/Runtime baseline passed 2,725 tests with 43 skips, two xfails and zero +failures. A subsequent narrow CTP generic-queue fail-close r3 candidate was +reversed after its main broad run had 13 failures (2,724 passed); the Store +SHA-256 at that rollback checkpoint was `A028A68DF87ABE84A1D38F4020D81AF56E0DFB860DED43D36C3830933106696D`. +The failed candidate and raw results are preserved for a compatible revision; +they grant no CTP write route. +An isolated public-Store fake audit also found conditional same-process route +identity bypasses when an injected API advertises CTP under a different Store +provider or mutates its route map after Store construction. Four direct and two +manually completed async fake sink calls were observed in those two cases; +the correctly labelled CTP control made zero calls. No SDK/native/provider +write occurred. See +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-store-write-narrow-fake-bypass-audit-2026-09-27/`. +Neither route metadata nor same-process private attributes are account authority. + +The compatible AC41-63 generic CTP SDK queue fail-close r4 is a historical +integration snapshot, not the current Store: it recorded Store SHA-256 +`A0393FC4F0B7212C6EE4B4F32DE2AA9E6E9A0ECFC5FE976F72160E2EC11F6ABE`. It +rejected CTP generic submit/cancel queue and `_invoke_sdk_command` fallback +before SDK writer method lookup, while recovery exits disarmed on early +rejection; `_cancel_managed` remained byte-identical to the A028 baseline. +Independent fake-only QA passed 12 queue-contract, four recovery, three +budget/sink, and ten managed-cancel/forwarding compatibility cases. The Store/ +Runtime plus queue-contract run passed 2,740 tests with 43 skips, two xfails, +zero failures and one existing pytest-configuration warning. At that checkpoint +the scanner inventory was `03658257D97E31C2D8F8BFD48685441533552B32674F5D63810141E3038AB456`; +the later CE04 refresh is documented above. See the [historical r4 archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ac41-63-ctp-generic-queue-failclose-r4-2026-09-27/README.md). + +#### Current Store, G1, G5 and AC41-63 checkpoint (2026-09-28) + +The current Store is CE04 route-identity R2 (`CE04ECBADDD3D3EA01C707313EA9B144650C47E322D0BDFC915000C0110094CA`). Its [main integration archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ac41-63-store-api-route-identity-r2-main-2026-09-28/README.md) records a 28-test focus; guarded Store results of 730 passed / 13 skipped / 18 exact optional CTP node IDs deselected; and guarded Runtime results of 2,010 passed / 30 skipped / 14 exact CTP node IDs deselected / 2 xfailed / 0 failures. The first invalid harness result is preserved but excluded. Current official inventory SHA-256 is `A959A3BC6284232EA90191F13ADC71A6931F5DFCBA6DB16E7AC72B1476B9D142`, with 460/460 dispositions still `REVIEW_REQUIRED / NOT_AVAILABLE`; four locator lines changed from 491 to 495 after the CE04 snapshot, the writer-dispositions SHA-256 `B55A054A8E53CEC27A7B20AD43A653CEE07082FEBE51844CCCB129C0ED365426` is unchanged, and 15 scanner contracts passed. The custom-API `__getattribute__` residual remains; this conditional guard does not close writers or authorize CTP. `NO_WRITE / LIVE_NO_GO`. + +The [G5/V21 offline ActionRef ledger audit r0](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-g5-v21-offline-actionref-ledger-audit-r0-2026-09-28/README.md) passed 10 synthetic tests and independent QA as a local offline projector. It does not establish G5 authority, ledger cutover, native ActionRef floor, an account-wide writer fence, or a write route: `NO_AUTHORITY / NO_CUTOVER / NO_WRITE / LIVE_NO_GO`. + +The unregistered G5 managed-action verifier now requires a typed account-wide ActionRef snapshot for CANCEL at both READY claim verification and the final CLAIMED recheck. Its consumer contract checks declared G5/V21 sources, cutover identity/floor stability and in-process epoch/high-water/mapping monotonicity, exact CANCEL payload-to-target binding, mapping uniqueness, counter equality, snapshot freshness, and zero account `UNKNOWN` or unrelated `CLAIMED` allocations. Source digest strings are only format-checked; they are not authenticated, and in-memory monotonic state does not survive restart. There is no trusted snapshot producer or runtime registration; absent producer denies CANCEL. The offline focus exercises fake contract ports only. This does not establish G5 acceptance, trusted floor provenance, writer fencing, provider behavior, or a live route; retain `NO_AUTHORITY / NO_CUTOVER / NO_WRITE / LIVE_NO_GO`. + +The [G1 overlapped-I/O custody archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-g1-overlapped-io-custody-main-2026-09-28/README.md) records a local subgate only: main guarded focus 33 passed and adjacent guarded suite 305 passed, with independent QA verdict `GO_LOCAL_SUBGATE`. Strict whole-command G1 remains `NO_GO`, ordinary CTP preflight remains closed, and this gives no live dispatch or write permission. + +The AC41-63 007 support module now has three narrow fail-closed retired helpers: `create_live_broker()` (guarded focus 5/5), `add_live_feeds()` (6/6), and `run_cerebro_with_timeout()` (7/7). The [timeout-helper archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ac41-63-007-timeout-helper-failclose-main-2026-09-28/README.md) records an empty guard and independent archive QA of 27/27 payloads, 28/28 sums, and 20/20 links; README SHA-256 `2024A461449239389571649B4841EE05165DEE567378C3388592FFE635AEB80C`, manifest SHA-256 `4C1CB5995691BB0CF9549950CC420171E4085973496FDDD855F1BEAAB0871DDB`. The integrated `examples/007_ctp/ctp_example_support.py` SHA-256 is `37536B9598E3EE2414DE2AF787EA1E15C8C52E42558E9679107DB4F3F34C273A`; the new timeout test SHA-256 is `C5F88209C110CABB57F71003CFF9B56C0DA4D54DB43B3675ECB7414840DE5EF3`. The prior [Feed archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ac41-63-007-live-feed-helper-failclose-main-2026-09-28/README.md) independently passed 20/20 payloads, 21/21 sums, and 18/18 links; its hashes identify the earlier feed snapshot, not the current support source. These three helpers close only their named entry points. General `Cerebro.run()`, generic Timer use, and public Store/Broker/Feed constructors remain available; overall writer closure is not established. The Feed archive has no separate main-focus exit-code sidecar. + +The 013_1/013_2 `ctp_example_support` R4 is integrated with eight legacy-helper fail-close guards. Source SHA-256 values are `560B5D60DF8BFDDDDABDCB8F6EB53468498328FA1E541B5323EBFFA4F968EBAC` and `675A5E3315559C90A1D7E39AC7D3E1ED2F9555E4A27F6C8A9F5DB0D8196D40A9`; the new `tests/unit/test_iteration41_legacy_ctp_support_inert_import.py` SHA-256 is `3832B447E9606BC00D75316B0BCF29FAF87404969B9DAB520784BAE209D45069`. The valid guarded main safety focus is 32 passed / 1 private-config node deselected; isolated candidate guard passed 1 case with an empty guard. Source Ruff still reports four existing `SIM112` findings on unchanged lines. The initial 33-case run read two untracked config files, is marked `INVALID`, and is excluded; its raw log/JUnit are not archived and no private config values are recorded here. The first archive draft was returned because its candidate manifest retained an unqualified 33-pass claim. The corrected [canonical R4 archive](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/ac41-63-013-legacy-ctp-support-r4-main-integration-2026-09-28/README.md) passed independent final QA: 32/32 payloads, 33/33 sums, three local links, and a 34-member CRC-clean evidence ZIP. README SHA-256 `71C09D95BB1C87446DBD4091A795F0D27FF06DBC4C4DB487EE195D3C9CBE9E07`, payload-manifest SHA-256 `E7110B9D59925F553A13F2807983C193131D7C192D3C0946D1FD1CD8E21FB83D`, and sums SHA-256 `6462C1E33946A8ABDF4BBDB5588C4A567815DFF3F3E28CC0D95E48A71CA50E83`. QA marked all nine unqualified 33-pass statements `INVALID / EXCLUDED`; the original 33-run logs, JUnit, and config files are absent. Current official inventory SHA-256 is `A959A3BC6284232EA90191F13ADC71A6931F5DFCBA6DB16E7AC72B1476B9D142`; only four locator lines changed (491→495), checklist SHA-256 `B55A054A8E53CEC27A7B20AD43A653CEE07082FEBE51844CCCB129C0ED365426` is unchanged, and the verifier passed 460/460 with 15 scanner contracts. G1, G4, G5, and G6-P blockers remain; `NO_WRITE / LIVE_NO_GO`. + +The [hidden-route negative QA](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-hidden-route-negative-qa-2026-09-28/README.md) rejected the isolated Store candidate after a spoofed static snapshot still reached local fake submit/cancel sinks; the main CE04 Store was not changed. [ActorPort r4 exact-binding QA](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-ctp-account-actor-port-r4-exact-binding-independent-qa-2026-09-28/README.md) remains an unintegrated fake-only candidate without G6-P authority. The [registry trust-boundary static review](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/runtime-registry-trust-boundary-static-review-2026-09-28.md) finds `RuntimeRegistry(trusted=True)` is a mutable same-process cooperative assertion. These local findings do not change `NO_WRITE / LIVE_NO_GO`. + +The isolated G5/V21 ActionRef cutover validator r1 is archived at +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-g5-v21-offline-cutover-validator-r1-2026-09-27/`. +Its exact replay and 28 synthetic tests plus five selected adversarial tests +passed independent QA. It rejects differing state, UNKNOWN, differing supplied +snapshot boundary labels, and malformed objects that would invoke Python +callbacks. The evidence manifest's mistaken r0 commit pointer was corrected +and independently rechecked without changing code or test bytes. The boundary +label and digests remain caller-supplied and unauthenticated; no deployed +exporter, native ActionRef floor, account-wide writer fence or migration +procedure is established. Status is `LOCAL_TYPED_CONTRACT_ONLY / NO_AUTHORITY / +NOT_A_MIGRATION_TOOL / NO_WRITE / LIVE_NO_GO`; it is not integrated into the +runtime. + +A static G6-P resource audit at +`docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/g6p-account-actor-resource-discovery-2026-09-27/` +found no trusted, deployed external AccountActor in the reviewed local +repositories. The older `D:/source_code/bt_api_py` gateway can own a CTP client +but its command boundary lacks authenticated account-wide authorization and +durable identity/fencing. The dirty `D:/bt_api_py` checkout has newer +transport/router/execution building blocks, but no accepted CTP provider +composition, sole credential-owning service, cross-host writer fence, or +same-version complete account snapshot. This was source-only review with no +private config, SDK/native, network, or provider contact. G6-P remains blocked; +strict SimNow and production live remain `NO_GO`. + +#### SimNow 007 C01 r3 与 G1/G4 来源复核(2026-09-28) + +C01 r3 的 17 个目标已主树集成。冻结候选完整套件为 482 passed;主树 `tests/unit/live_certification` 集成后实测 **488 passed、1 个既有 warning**。17 个目标 Ruff 通过,主树集成内容与冻结候选在 CRLF 归一化后逐字一致,`git diff --check` clean。独立 schema QA 对精确 r3 patch 给出 `GO for source-only integration`;独立 alias/session 复核为 197 passed。上述来源级结果不代表 C01 完成。C01 仍为 `INCOMPLETE`:可信 SDK issuer 私有 RequestID ledger verifier 与 baseline/final query receipt path 尚未接通,也没有可认证的 native callback 来源、受信账号 owner 或 provider 证据。逐目录入口复查为 33 个 `BLOCKED` / exit 2,原因均为 `managed_ctp_certification_not_registered`,network/order_write 均为 0,真实 `PASS` 为 0。不得据离线测试声称 33 个真实案例通过。详情增补在 [007 SimNow 33-case acceptance evidence](docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-007-simnow-33-case-staging-acceptance-2026-09-28.md)。 + +独立的 issuer-side RequestID ledger 研究将完整 C01 请求序列按八个 ID 建模:auth、login,以及 baseline/final 各自的 orders、positions、funds 查询。隔离 fake-only 原型的 16 项测试通过,覆盖请求方法映射、ID/回调关联、跨客户端/API、重连、拒绝派发、终态和八 ID 区分。它没有调用真实 native API;fake 可直接调用 Python SPI,因此这些结果只验证本地关联合同,不证明 native/provider callback 来源。该原型没有接入受信 SDK ledger,也没有改变 C01 completion 状态。 + +新的 G1 whole-command 故障注入审查包含六项 fake 注入:backend 创建、launcher resume、Job termination、句柄释放、control escrow 和回执 `fsync`。本地 6 项测试通过,证明这些同步调用可以在配置 deadline 之后才返回;结论为 `G1_STRICT_WHOLE_COMMAND = NO_GO`。它没有测试真实 Win32 卡顿、CTP、provider 或存储设备故障,也没有开放 preflight。 + +G4 parent/gateway source pin provenance 审计与独立 QA 均为 `HOLD_PROVENANCE`:兼容 gateway 实现来自本地未跟踪快照,而记录的 upstream gitlink 树不含该实现。独立 QA 确认候选 wheel、安装 RECORD 与 fake consumer 结果内部一致;这不能认证上游来源,也不构成 G4 精确来源/制品准入、native lifecycle、provider 行为或写入授权。 + +以上为离线或 source-only 证据;保持 `NO_WRITE / LIVE_NO_GO`。 + +#### G1 request-level offline contracts and Gateway source drafts (2026-09-28) + +The owner no longer requires a 0.8-second hard limit for the entire CTP CLI +command. The proposed replacement is a code-bounded request accepted by a +pre-started protected guardian, with a separate client wait and an UNKNOWN +result on missing or late evidence. No such guardian is deployed or registered. +`backtrader_runtime/ctp_guardian_service.py` and +`ctp_guardian_request_journal.py` are unregistered offline contracts for the +fixed read-only `ctp_readonly_preflight` operation and a 300-second server +request budget. They do not provide a Windows service, protected named pipe, +trusted peer identity, bounded native runner, Job supervision, terminal journal +wiring, or CTP provider evidence. Independent local verification passed 39 +fake/contract tests with Ruff and Black clean; the ordinary private CTP +`preflight` CLI, all 33 real case entries, and every trading write route remain +closed. G1 remains `NO_GO` until the complete deployed request path and Windows +failure injection are independently accepted. + +The Gateway source and MIT license are now on the draft +`cloudQuant/bt_api_gateway` PR #1, and the draft `cloudQuant/bt_api_py` PR #13 +pins reviewed base, CTP, and Gateway source commits. These unmerged source +branches resolve the earlier README-only Gateway pointer for review, but do +not establish an authenticated account Actor, native binary provenance, +provider reconciliation, a trusted writer fence, or a managed CTP route. +G4/G6-P and real SimNow certification remain `NO_GO`. diff --git a/backtrader/brokers/btapibroker.py b/backtrader/brokers/btapibroker.py index 3e513e05..ef767c8a 100644 --- a/backtrader/brokers/btapibroker.py +++ b/backtrader/brokers/btapibroker.py @@ -2464,9 +2464,7 @@ def _account_risk_snapshot_incomplete(snapshot, error_code): result = deepcopy(snapshot) errors = result.get("evidence_errors") - if isinstance(errors, Mapping): - existing = [item for item in errors if isinstance(item, str)] - elif isinstance(errors, (list, tuple, set, frozenset)): + if isinstance(errors, (Mapping, list, tuple, set, frozenset)): existing = [item for item in errors if isinstance(item, str)] elif isinstance(errors, str) and errors: existing = [errors] @@ -2540,9 +2538,7 @@ def get_account_risk_snapshot(self): ) if has_refresh_marker: - return self._account_risk_snapshot_incomplete( - safe_snapshot, refresh_marker - ) + return self._account_risk_snapshot_incomplete(safe_snapshot, refresh_marker) return safe_snapshot routes_method = getattr(self.store, "get_symbol_routes", None) diff --git a/backtrader/notifications/_windows_anchor.py b/backtrader/notifications/_windows_anchor.py new file mode 100644 index 00000000..d23d40f1 --- /dev/null +++ b/backtrader/notifications/_windows_anchor.py @@ -0,0 +1,999 @@ +"""Handle-relative Windows persistence for notification session anchors. + +No pathname is used for ACL mutation. Existing directories and files are +opened relative to validated handles with FILE_OPEN_REPARSE_POINT. New objects +receive a protected TokenUser ACL in the NtCreateFile call. Existing file ACLs +are repaired only if their ACEs grant access solely to the token user; any +external ACE is rejected before ACL mutation or content writes. ACL changes and +content writes use the opened file handle. Updates are not crash-atomic; +interruption can leave a partial anchor file. +""" + +from __future__ import annotations + +import ctypes +import json +import ntpath +import os +from ctypes import wintypes +from typing import Any, Optional, Tuple + +_ERROR = "cannot safely persist Windows notification anchor" +_STATUS_NOT_FOUND = {0xC0000034, 0xC000003A} # NAME_NOT_FOUND, PATH_NOT_FOUND +_STATUS_COLLISION = 0xC0000035 +_FILE_OPEN = 1 +_FILE_CREATE = 2 +_FILE_DIRECTORY_FILE = 0x00000001 +_FILE_NON_DIRECTORY_FILE = 0x00000040 +_FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020 +_FILE_OPEN_REPARSE_POINT = 0x00200000 +_FILE_ATTRIBUTE_DIRECTORY = 0x10 +_FILE_ATTRIBUTE_REPARSE_POINT = 0x400 +_FILE_SHARE_ALL = 0x1 | 0x2 | 0x4 +_FILE_READ_ATTRIBUTES = 0x80 +_FILE_WRITE_ATTRIBUTES = 0x100 +_FILE_LIST_DIRECTORY = 0x1 +_FILE_WRITE_DATA = 0x2 +_FILE_TRAVERSE = 0x20 +_FILE_ADD_FILE = 0x2 +_FILE_ADD_SUBDIRECTORY = 0x4 +_FILE_DELETE_CHILD = 0x40 +_READ_CONTROL = 0x00020000 +_WRITE_DAC = 0x00040000 +_DELETE = 0x00010000 +_SYNCHRONIZE = 0x00100000 +_FILE_ALL_ACCESS = 0x001F01FF +_FILE_PERSISTENT_ACLS = 0x00000008 +_DANGEROUS_DIR_MASK = ( + _FILE_ADD_FILE + | _FILE_ADD_SUBDIRECTORY + | _FILE_WRITE_ATTRIBUTES + | _FILE_DELETE_CHILD + | _DELETE + | 0x00040000 # WRITE_DAC + | 0x00080000 # WRITE_OWNER + | 0x40000000 # GENERIC_WRITE + | 0x10000000 # GENERIC_ALL +) +_TRUSTED_ANCESTOR_SIDS = { + "S-1-5-18", # Local System + "S-1-5-32-544", # Builtin Administrators + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", # TrustedInstaller +} +_OWNER_RIGHTS_SID = "S-1-3-4" + + +def _windows_dll(name: str, *, use_last_error: bool = False) -> Any: + """Load a DLL only when the Windows persistence adapter is constructed.""" + + return getattr(ctypes, "WinDLL")(name, use_last_error=use_last_error) + + +def _win_last_error() -> int: + """Read the calling thread's Win32 error through a Windows-only API.""" + + return int(getattr(ctypes, "get_last_error")()) + + +class _UnicodeString(ctypes.Structure): + _fields_ = ( + ("Length", wintypes.USHORT), + ("MaximumLength", wintypes.USHORT), + ("Buffer", wintypes.LPWSTR), + ) + + +class _ObjectAttributes(ctypes.Structure): + _fields_ = ( + ("Length", wintypes.ULONG), + ("RootDirectory", wintypes.HANDLE), + ("ObjectName", ctypes.POINTER(_UnicodeString)), + ("Attributes", wintypes.ULONG), + ("SecurityDescriptor", ctypes.c_void_p), + ("SecurityQualityOfService", ctypes.c_void_p), + ) + + +class _IoStatusBlock(ctypes.Structure): + _fields_ = (("Status", ctypes.c_void_p), ("Information", ctypes.c_size_t)) + + +class _FileAttributeTagInfo(ctypes.Structure): + _fields_ = (("FileAttributes", wintypes.DWORD), ("ReparseTag", wintypes.DWORD)) + + +class _ByHandleFileInformation(ctypes.Structure): + _fields_ = ( + ("FileAttributes", wintypes.DWORD), + ("CreationTimeLow", wintypes.DWORD), + ("CreationTimeHigh", wintypes.DWORD), + ("LastAccessTimeLow", wintypes.DWORD), + ("LastAccessTimeHigh", wintypes.DWORD), + ("LastWriteTimeLow", wintypes.DWORD), + ("LastWriteTimeHigh", wintypes.DWORD), + ("VolumeSerialNumber", wintypes.DWORD), + ("FileSizeHigh", wintypes.DWORD), + ("FileSizeLow", wintypes.DWORD), + ("NumberOfLinks", wintypes.DWORD), + ("FileIndexHigh", wintypes.DWORD), + ("FileIndexLow", wintypes.DWORD), + ) + + +class _TokenUser(ctypes.Structure): + _fields_ = (("Sid", ctypes.c_void_p), ("Attributes", wintypes.DWORD)) + + +class _FileDispositionInfo(ctypes.Structure): + _fields_ = (("DeleteFile", wintypes.BOOLEAN),) + + +class _FileEndOfFileInfo(ctypes.Structure): + _fields_ = (("EndOfFile", ctypes.c_longlong),) + + +def _validate_owner_only_acl( + owner_sid: str, + token_user_sid: str, + protected: bool, + entries: Tuple[Tuple[int, int, int, str], ...], +) -> None: + """Validate the exact protected DACL used for new anchor objects.""" + + if ( + owner_sid != token_user_sid + or not protected + or entries != ((0, 0, _FILE_ALL_ACCESS, token_user_sid),) + ): + raise OSError(_ERROR) + + +def _validate_owner_only_directory_acl( + owner_sid: str, + token_user_sid: str, + protected: bool, + entries: Tuple[Tuple[int, int, int, str], ...], +) -> None: + """Accept the owner's exact ACE with or without directory inheritance flags.""" + + if ( + owner_sid != token_user_sid + or not protected + or len(entries) != 1 + or entries[0][0] != 0 + or entries[0][1] not in (0, 0x1 | 0x2) # OI | CI are safe for owner-only children. + or entries[0][2] != _FILE_ALL_ACCESS + or entries[0][3] != token_user_sid + ): + raise OSError(_ERROR) + + +def _validate_repairable_owner_acl( + owner_sid: str, + token_user_sid: str, + entries: Tuple[Tuple[int, int, int, str], ...], +) -> None: + """Reject externally accessible existing files before changing their DACL.""" + + if owner_sid != token_user_sid or not entries: + raise OSError(_ERROR) + # A handle opened while any external ACE granted access remains usable after + # an ACL change. Refuse such files before tightening or writing their data. + if any( + ace_type != 0 or trustee_sid != token_user_sid + for ace_type, _flags, _mask, trustee_sid in entries + ): + raise OSError(_ERROR) + + +def _validate_safe_ancestor_acl( + owner_sid: str, + token_user_sid: str, + entries: Tuple[Tuple[int, int, int, str], ...], +) -> None: + """Reject existing path ancestors writable by principals outside the user.""" + + trusted = _TRUSTED_ANCESTOR_SIDS | {token_user_sid} + if owner_sid not in trusted: + raise OSError(_ERROR) + for ace_type, _flags, mask, trustee_sid in entries: + if ace_type != 0: # Only simple ACCESS_ALLOWED_ACE records are understood. + raise OSError(_ERROR) + # OWNER RIGHTS is a well-known placeholder for this object's owner, + # which was checked against trusted SIDs above. Python 3.12+ creates + # private temporary directories with this ACE on Windows. + effective_trustee = owner_sid if trustee_sid == _OWNER_RIGHTS_SID else trustee_sid + if effective_trustee not in trusted and mask & _DANGEROUS_DIR_MASK: + raise OSError(_ERROR) + + +def _status_code(status: int) -> int: + return ctypes.c_uint32(status).value + + +def _as_handle(handle: Any) -> Any: + return handle if isinstance(handle, ctypes.c_void_p) else wintypes.HANDLE(handle) + + +def _reject_reparse(attributes: int) -> None: + if attributes & _FILE_ATTRIBUTE_REPARSE_POINT: + raise OSError("reparse point in notification anchor path") + + +class _WindowsAnchorApi: + """Small ctypes boundary kept separate so policy has pure fake tests.""" + + def __init__(self) -> None: + self.kernel32 = _windows_dll("Kernel32", use_last_error=True) + self.advapi32 = _windows_dll("Advapi32", use_last_error=True) + self.ntdll = _windows_dll("ntdll") + self._bind() + + def _bind(self) -> None: + k = self.kernel32 + a = self.advapi32 + n = self.ntdll + k.GetCurrentProcess.argtypes = () + k.GetCurrentProcess.restype = wintypes.HANDLE + k.CloseHandle.argtypes = (wintypes.HANDLE,) + k.CloseHandle.restype = wintypes.BOOL + k.CreateFileW.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + k.CreateFileW.restype = wintypes.HANDLE + k.GetFileInformationByHandle.argtypes = ( + wintypes.HANDLE, + ctypes.POINTER(_ByHandleFileInformation), + ) + k.GetFileInformationByHandle.restype = wintypes.BOOL + k.GetFileInformationByHandleEx.argtypes = ( + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ) + k.GetFileInformationByHandleEx.restype = wintypes.BOOL + k.GetDriveTypeW.argtypes = (wintypes.LPCWSTR,) + k.GetDriveTypeW.restype = wintypes.UINT + k.GetVolumeInformationW.argtypes = ( + wintypes.LPCWSTR, + wintypes.LPWSTR, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ctypes.POINTER(wintypes.DWORD), + ctypes.POINTER(wintypes.DWORD), + wintypes.LPWSTR, + wintypes.DWORD, + ) + k.GetVolumeInformationW.restype = wintypes.BOOL + k.WriteFile.argtypes = ( + wintypes.HANDLE, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ctypes.c_void_p, + ) + k.WriteFile.restype = wintypes.BOOL + k.ReadFile.argtypes = ( + wintypes.HANDLE, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ctypes.c_void_p, + ) + k.ReadFile.restype = wintypes.BOOL + k.GetFileSizeEx.argtypes = (wintypes.HANDLE, ctypes.POINTER(ctypes.c_longlong)) + k.GetFileSizeEx.restype = wintypes.BOOL + k.SetFilePointerEx.argtypes = ( + wintypes.HANDLE, + ctypes.c_longlong, + ctypes.POINTER(ctypes.c_longlong), + wintypes.DWORD, + ) + k.SetFilePointerEx.restype = wintypes.BOOL + k.FlushFileBuffers.argtypes = (wintypes.HANDLE,) + k.FlushFileBuffers.restype = wintypes.BOOL + k.SetFileInformationByHandle.argtypes = ( + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ) + k.SetFileInformationByHandle.restype = wintypes.BOOL + a.OpenProcessToken.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + ctypes.POINTER(wintypes.HANDLE), + ) + a.OpenProcessToken.restype = wintypes.BOOL + a.GetTokenInformation.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ) + a.GetTokenInformation.restype = wintypes.BOOL + a.IsValidSid.argtypes = (ctypes.c_void_p,) + a.IsValidSid.restype = wintypes.BOOL + a.GetLengthSid.argtypes = (ctypes.c_void_p,) + a.GetLengthSid.restype = wintypes.DWORD + a.ConvertSidToStringSidW.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.LPWSTR), + ) + a.ConvertSidToStringSidW.restype = wintypes.BOOL + a.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.DWORD), + ) + a.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = wintypes.BOOL + a.GetSecurityInfo.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ) + a.GetSecurityInfo.restype = wintypes.DWORD + a.SetSecurityInfo.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ) + a.SetSecurityInfo.restype = wintypes.DWORD + a.GetSecurityDescriptorControl.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.WORD), + ctypes.POINTER(wintypes.DWORD), + ) + a.GetSecurityDescriptorControl.restype = wintypes.BOOL + a.GetSecurityDescriptorOwner.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.BOOL), + ) + a.GetSecurityDescriptorOwner.restype = wintypes.BOOL + a.GetSecurityDescriptorDacl.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.BOOL), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.BOOL), + ) + a.GetSecurityDescriptorDacl.restype = wintypes.BOOL + a.GetAclInformation.argtypes = ( + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ) + a.GetAclInformation.restype = wintypes.BOOL + a.GetAce.argtypes = (ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)) + a.GetAce.restype = wintypes.BOOL + n.NtCreateFile.argtypes = ( + ctypes.POINTER(wintypes.HANDLE), + wintypes.DWORD, + ctypes.POINTER(_ObjectAttributes), + ctypes.POINTER(_IoStatusBlock), + ctypes.c_void_p, + wintypes.ULONG, + wintypes.ULONG, + wintypes.ULONG, + wintypes.ULONG, + ctypes.c_void_p, + wintypes.ULONG, + ) + n.NtCreateFile.restype = wintypes.LONG + n.RtlNtStatusToDosError.argtypes = (wintypes.LONG,) + n.RtlNtStatusToDosError.restype = wintypes.ULONG + + def close(self, handle: Any) -> None: + if handle and not self.kernel32.CloseHandle(_as_handle(handle)): + raise OSError(_win_last_error(), _ERROR) + + def free_security_descriptor(self, descriptor: Any) -> None: + self.kernel32.LocalFree.argtypes = (ctypes.c_void_p,) + self.kernel32.LocalFree.restype = ctypes.c_void_p + self.kernel32.LocalFree(descriptor) + + def token_user_sid(self) -> str: + """Read the SID from TOKEN_USER; environment account names are ignored.""" + + token = wintypes.HANDLE() + if not self.advapi32.OpenProcessToken( + self.kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token) + ): + raise OSError(_win_last_error(), _ERROR) + try: + buffer = ctypes.create_string_buffer(64 * 1024) + required = wintypes.DWORD() + if not self.advapi32.GetTokenInformation( + token, 1, buffer, ctypes.sizeof(buffer), ctypes.byref(required) + ): + raise OSError(_win_last_error(), _ERROR) + token_user = ctypes.cast(buffer, ctypes.POINTER(_TokenUser)).contents + sid = token_user.Sid + if not sid or not self.advapi32.IsValidSid(sid): + raise OSError(_ERROR) + sid_size = int(self.advapi32.GetLengthSid(sid)) + buffer_start = ctypes.addressof(buffer) + buffer_end = buffer_start + min(int(required.value), ctypes.sizeof(buffer)) + if sid < buffer_start or sid + sid_size > buffer_end: + raise OSError(_ERROR) + text = wintypes.LPWSTR() + if not self.advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)) or not text: + raise OSError(_win_last_error(), _ERROR) + try: + return str(text.value) + finally: + self.kernel32.LocalFree.argtypes = (ctypes.c_void_p,) + self.kernel32.LocalFree.restype = ctypes.c_void_p + self.kernel32.LocalFree(ctypes.cast(text, ctypes.c_void_p)) + finally: + self.close(token) + + def security_descriptor(self, sid: str) -> ctypes.c_void_p: + if not sid.startswith("S-1-") or any(ch not in "S-1234567890-" for ch in sid): + raise OSError(_ERROR) + descriptor = ctypes.c_void_p() + sddl = "O:{0}D:P(A;;FA;;;{0})".format(sid) + if not self.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, 1, ctypes.byref(descriptor), None + ): + raise OSError(_win_last_error(), _ERROR) + return descriptor + + def acl(self, handle: Any) -> Tuple[str, bool, Tuple[Tuple[int, int, int, str], ...]]: + owner = ctypes.c_void_p() + group = ctypes.c_void_p() + dacl = ctypes.c_void_p() + sacl = ctypes.c_void_p() + descriptor = ctypes.c_void_p() + result = self.advapi32.GetSecurityInfo( + _as_handle(handle), + 1, # SE_FILE_OBJECT + 0x1 | 0x4, # OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION + ctypes.byref(owner), + ctypes.byref(group), + ctypes.byref(dacl), + ctypes.byref(sacl), + ctypes.byref(descriptor), + ) + if result or not descriptor: + raise OSError(int(result or _win_last_error()), _ERROR) + try: + control = wintypes.WORD() + revision = wintypes.DWORD() + if not self.advapi32.GetSecurityDescriptorControl( + descriptor, ctypes.byref(control), ctypes.byref(revision) + ): + raise OSError(_win_last_error(), _ERROR) + owner_defaulted = wintypes.BOOL() + if ( + not self.advapi32.GetSecurityDescriptorOwner( + descriptor, ctypes.byref(owner), ctypes.byref(owner_defaulted) + ) + or owner_defaulted.value + or not owner + ): + raise OSError(_ERROR) + owner_sid = self._sid_string(owner) + dacl_present = wintypes.BOOL() + dacl_defaulted = wintypes.BOOL() + if ( + not self.advapi32.GetSecurityDescriptorDacl( + descriptor, + ctypes.byref(dacl_present), + ctypes.byref(dacl), + ctypes.byref(dacl_defaulted), + ) + or not dacl_present.value + or dacl_defaulted.value + or not dacl + ): + raise OSError(_ERROR) + + class _AclSizeInformation(ctypes.Structure): + _fields_ = ( + ("AceCount", wintypes.DWORD), + ("AclBytesInUse", wintypes.DWORD), + ("AclBytesFree", wintypes.DWORD), + ) + + info = _AclSizeInformation() + if not self.advapi32.GetAclInformation( + dacl, ctypes.byref(info), ctypes.sizeof(info), 2 + ): + raise OSError(_win_last_error(), _ERROR) + entries = [] + for index in range(int(info.AceCount)): + pointer = ctypes.c_void_p() + if not self.advapi32.GetAce(dacl, index, ctypes.byref(pointer)): + raise OSError(_win_last_error(), _ERROR) + pointer_value = pointer.value + if pointer_value is None: + raise OSError(_ERROR) + header = ctypes.string_at(pointer, 4) + ace_type, ace_flags = header[0], header[1] + ace_size = int.from_bytes(header[2:4], "little") + if ace_size < 8 or ace_size > int(info.AclBytesInUse): + raise OSError(_ERROR) + mask = int.from_bytes(ctypes.string_at(pointer_value + 4, 4), "little") + trustee_sid = "" + if ace_type in (0, 1): + if ace_size < 12: + raise OSError(_ERROR) + trustee = ctypes.c_void_p(pointer_value + 8) + if not self.advapi32.IsValidSid(trustee): + raise OSError(_ERROR) + sid_size = int(self.advapi32.GetLengthSid(trustee)) + if sid_size > ace_size - 8: + raise OSError(_ERROR) + trustee_sid = self._sid_string(trustee) + entries.append((ace_type, ace_flags, mask, trustee_sid)) + return owner_sid, bool(control.value & 0x1000), tuple(entries) + finally: + self.kernel32.LocalFree.argtypes = (ctypes.c_void_p,) + self.kernel32.LocalFree.restype = ctypes.c_void_p + self.kernel32.LocalFree(descriptor) + + def set_owner_only_acl(self, handle: Any, descriptor: Any) -> None: + """Protect the owner-only DACL through this already-open handle.""" + + dacl_present = wintypes.BOOL() + dacl_defaulted = wintypes.BOOL() + dacl = ctypes.c_void_p() + if ( + not self.advapi32.GetSecurityDescriptorDacl( + descriptor, + ctypes.byref(dacl_present), + ctypes.byref(dacl), + ctypes.byref(dacl_defaulted), + ) + or not dacl_present.value + or dacl_defaulted.value + or not dacl + ): + raise OSError(_ERROR) + security_information = 0x4 | 0x80000000 # DACL | PROTECTED_DACL; owner was verified. + result = self.advapi32.SetSecurityInfo( + _as_handle(handle), + 1, # SE_FILE_OBJECT + security_information, + None, + None, + dacl, + None, + ) + if result: + raise OSError(int(result), _ERROR) + + def _sid_string(self, sid: ctypes.c_void_p) -> str: + output = wintypes.LPWSTR() + if not self.advapi32.ConvertSidToStringSidW(sid, ctypes.byref(output)) or not output: + raise OSError(_win_last_error(), _ERROR) + try: + return str(output.value) + finally: + self.kernel32.LocalFree.argtypes = (ctypes.c_void_p,) + self.kernel32.LocalFree.restype = ctypes.c_void_p + self.kernel32.LocalFree(ctypes.cast(output, ctypes.c_void_p)) + + def attributes(self, handle: Any) -> int: + info = _FileAttributeTagInfo() + if not self.kernel32.GetFileInformationByHandleEx( + _as_handle(handle), 9, ctypes.byref(info), ctypes.sizeof(info) + ): + raise OSError(_win_last_error(), _ERROR) + _reject_reparse(int(info.FileAttributes)) + return int(info.FileAttributes) + + def identity(self, handle: Any) -> Tuple[int, int, int, int]: + info = _ByHandleFileInformation() + if not self.kernel32.GetFileInformationByHandle(_as_handle(handle), ctypes.byref(info)): + raise OSError(_win_last_error(), _ERROR) + return ( + int(info.VolumeSerialNumber), + int(info.FileIndexHigh), + int(info.FileIndexLow), + int(info.NumberOfLinks), + ) + + def _relative( + self, + parent: Any, + name: str, + access: int, + disposition: int, + options: int, + share: int = _FILE_SHARE_ALL, + security_descriptor: Any = None, + ) -> Any: + name_buffer = ctypes.create_unicode_buffer(name) + name_bytes = len(name.encode("utf-16-le")) + unicode_name = _UnicodeString( + name_bytes, + name_bytes + ctypes.sizeof(wintypes.WCHAR), + ctypes.cast(name_buffer, wintypes.LPWSTR), + ) + attributes = _ObjectAttributes( + ctypes.sizeof(_ObjectAttributes), + _as_handle(parent), + ctypes.pointer(unicode_name), + 0x40, # OBJ_CASE_INSENSITIVE + security_descriptor, + None, + ) + io_status = _IoStatusBlock() + handle = wintypes.HANDLE() + status = int( + self.ntdll.NtCreateFile( + ctypes.byref(handle), + access, + ctypes.byref(attributes), + ctypes.byref(io_status), + None, + 0x80, # FILE_ATTRIBUTE_NORMAL + share, + disposition, + options, + None, + 0, + ) + ) + if status < 0: + raise OSError(_status_code(status), _ERROR) + return handle + + def open_root(self, drive: str) -> Any: + path = drive + "\\" + handle = self.kernel32.CreateFileW( + path, + _FILE_TRAVERSE | _FILE_READ_ATTRIBUTES | _READ_CONTROL | _SYNCHRONIZE, + _FILE_SHARE_ALL, + None, + 3, # OPEN_EXISTING + 0x02000000 | 0x00200000, # BACKUP_SEMANTICS | OPEN_REPARSE_POINT + None, + ) + if handle == ctypes.c_void_p(-1).value: + raise OSError(_win_last_error(), _ERROR) + try: + self.attributes(handle) + except BaseException: + self.close(handle) + raise + return handle + + def volume_supports_persistent_acls(self, drive: str) -> bool: + """Read FILE_PERSISTENT_ACLS from GetVolumeInformationW for a drive.""" + + volume_name = ctypes.create_unicode_buffer(261) + filesystem_name = ctypes.create_unicode_buffer(261) + serial_number = wintypes.DWORD() + max_component_length = wintypes.DWORD() + filesystem_flags = wintypes.DWORD() + if not self.kernel32.GetVolumeInformationW( + drive + "\\", + volume_name, + len(volume_name), + ctypes.byref(serial_number), + ctypes.byref(max_component_length), + ctypes.byref(filesystem_flags), + filesystem_name, + len(filesystem_name), + ): + raise OSError(_win_last_error(), _ERROR) + return bool(filesystem_flags.value & _FILE_PERSISTENT_ACLS) + + def open_or_create_directory( + self, + parent: Any, + name: str, + *, + is_final: bool, + token_sid: str, + descriptor: ctypes.c_void_p, + ) -> Tuple[Any, bool]: + access = ( + _FILE_LIST_DIRECTORY + | _FILE_TRAVERSE + | _FILE_READ_ATTRIBUTES + | _READ_CONTROL + | _SYNCHRONIZE + ) + if is_final: + access |= _FILE_ADD_FILE | _FILE_DELETE_CHILD | _DELETE + options = _FILE_DIRECTORY_FILE | _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_OPEN_REPARSE_POINT + try: + handle = self._relative(parent, name, access, _FILE_OPEN, options) + created = False + except OSError as error: + if ( + getattr(error, "winerror", None) not in _STATUS_NOT_FOUND + and error.args[0] not in _STATUS_NOT_FOUND + ): + raise + try: + handle = self._relative( + parent, name, access, _FILE_CREATE, options, security_descriptor=descriptor + ) + created = True + except OSError as create_error: + status = create_error.args[0] if create_error.args else -1 + if status != _STATUS_COLLISION: + raise + handle = self._relative(parent, name, access, _FILE_OPEN, options) + created = False + try: + attributes = self.attributes(handle) + if not attributes & _FILE_ATTRIBUTE_DIRECTORY: + raise OSError(_ERROR) + owner, protected, entries = self.acl(handle) + if is_final: + _validate_owner_only_directory_acl(owner, token_sid, protected, entries) + elif created and owner == token_sid and protected: + _validate_owner_only_acl(owner, token_sid, protected, entries) + else: + _validate_safe_ancestor_acl(owner, token_sid, entries) + except BaseException: + self.close(handle) + raise + return handle, created + + def open_relative_file(self, parent: Any, name: str) -> Any: + return self._relative( + parent, + name, + _FILE_READ_ATTRIBUTES | _READ_CONTROL | _SYNCHRONIZE, + _FILE_OPEN, + _FILE_NON_DIRECTORY_FILE | _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_OPEN_REPARSE_POINT, + ) + + def open_relative_update_file(self, parent: Any, name: str) -> Any: + """Open a file or reparse object for same-handle ACL and content update.""" + + return self._relative( + parent, + name, + _FILE_WRITE_DATA + | _FILE_READ_ATTRIBUTES + | _READ_CONTROL + | _WRITE_DAC + | _DELETE + | _SYNCHRONIZE, + _FILE_OPEN, + _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_OPEN_REPARSE_POINT, + ) + + def open_relative_read_file(self, parent: Any, name: str) -> Any: + return self._relative( + parent, + name, + 0x1 | _FILE_READ_ATTRIBUTES | _READ_CONTROL | _SYNCHRONIZE, + _FILE_OPEN, + _FILE_NON_DIRECTORY_FILE | _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_OPEN_REPARSE_POINT, + ) + + def read_handle_bytes(self, handle: Any) -> bytes: + size = ctypes.c_longlong() + if not self.kernel32.GetFileSizeEx(_as_handle(handle), ctypes.byref(size)): + raise OSError(_win_last_error(), _ERROR) + if size.value < 0 or size.value > 1024 * 1024: + raise OSError(_ERROR) + buffer = ctypes.create_string_buffer(max(1, int(size.value))) + read = wintypes.DWORD() + if ( + not self.kernel32.ReadFile( + _as_handle(handle), buffer, int(size.value), ctypes.byref(read), None + ) + or read.value != size.value + ): + raise OSError(_win_last_error(), _ERROR) + return buffer.raw[: read.value] + + def create_relative_file(self, parent: Any, name: str, descriptor: Any) -> Any: + return self._relative( + parent, + name, + 0x2 | _FILE_READ_ATTRIBUTES | _READ_CONTROL | _DELETE | _SYNCHRONIZE, + _FILE_CREATE, + _FILE_NON_DIRECTORY_FILE | _FILE_SYNCHRONOUS_IO_NONALERT | _FILE_OPEN_REPARSE_POINT, + security_descriptor=descriptor, + ) + + def write_and_flush(self, handle: Any, payload: bytes) -> None: + if len(payload) > 0xFFFFFFFF: + raise OSError(_ERROR) + buffer = ctypes.create_string_buffer(payload, max(1, len(payload))) + written = wintypes.DWORD() + if not self.kernel32.WriteFile( + _as_handle(handle), buffer, len(payload), ctypes.byref(written), None + ) or written.value != len(payload): + raise OSError(_win_last_error(), _ERROR) + if not self.kernel32.FlushFileBuffers(_as_handle(handle)): + raise OSError(_win_last_error(), _ERROR) + + def seek_start(self, handle: Any) -> None: + if not self.kernel32.SetFilePointerEx(_as_handle(handle), 0, None, 0): + raise OSError(_win_last_error(), _ERROR) + + def truncate_and_write(self, handle: Any, payload: bytes) -> None: + end = _FileEndOfFileInfo(0) + if not self.kernel32.SetFileInformationByHandle( + _as_handle(handle), 6, ctypes.byref(end), ctypes.sizeof(end) # FileEndOfFileInfo + ): + raise OSError(_win_last_error(), _ERROR) + self.seek_start(handle) + self.write_and_flush(handle, payload) + + def mark_for_delete(self, handle: Any) -> None: + info = _FileDispositionInfo(True) + if not self.kernel32.SetFileInformationByHandle( + _as_handle(handle), 4, ctypes.byref(info), ctypes.sizeof(info) + ): + raise OSError(_win_last_error(), _ERROR) + + +def _absolute_parts(path: str) -> Tuple[str, Tuple[str, ...], str]: + absolute = ntpath.abspath(path) + drive, tail = ntpath.splitdrive(absolute) + if not drive or not drive.endswith(":") or not tail.startswith("\\"): + raise OSError("anchor path must be on a drive-letter local volume") + if any(part in ("", ".", "..") for part in tail.split("\\")[1:] if part): + raise OSError(_ERROR) + parts = tuple(part for part in tail.split("\\") if part) + if not parts: + raise OSError("anchor path cannot be a volume root") + leaf = parts[-1] + if any(ch in leaf for ch in "\\/:\x00") or leaf.endswith((" ", ".")): + raise OSError(_ERROR) + return drive.upper(), parts[:-1], leaf + + +def _open_secure_directory( + api: _WindowsAnchorApi, path: str, token_sid: str, descriptor: Any +) -> Tuple[Any, list]: + absolute = ntpath.abspath(path) + drive, tail = ntpath.splitdrive(absolute) + if not drive or not drive.endswith(":") or not tail.startswith("\\"): + raise OSError("anchor directory must be on a drive-letter local volume") + parts = tuple(part for part in tail.split("\\") if part) + if not parts: + raise OSError("anchor directory cannot be a volume root") + if int(api.kernel32.GetDriveTypeW(drive + "\\")) != 3: # DRIVE_FIXED + raise OSError("anchor path must be on a fixed local volume") + if not api.volume_supports_persistent_acls(drive): + raise OSError("anchor path requires persistent ACL support") + root = api.open_root(drive) + handles = [root] + try: + parent = root + for index, component in enumerate(parts): + child, _created = api.open_or_create_directory( + parent, + component, + is_final=index == len(parts) - 1, + token_sid=token_sid, + descriptor=descriptor, + ) + handles.append(child) + parent = child + return parent, handles + except BaseException: + for handle in reversed(handles): + try: + api.close(handle) + except Exception: + pass + raise + + +def _write_in_directory( + api: _WindowsAnchorApi, + directory_handle: Any, + leaf: str, + token_sid: str, + descriptor: Any, + payload: bytes, +) -> None: + """Secure and update one regular file relative to a verified private handle.""" + + target_handle = None + created = False + completed = False + try: + for _attempt in range(4): + try: + target_handle = api.open_relative_update_file(directory_handle, leaf) + break + except OSError as error: + if not error.args or error.args[0] not in _STATUS_NOT_FOUND: + raise + try: + target_handle = api.create_relative_file(directory_handle, leaf, descriptor) + created = True + break + except OSError as create_error: + if create_error.args and create_error.args[0] == _STATUS_COLLISION: + continue + raise + if target_handle is None: + raise OSError(_ERROR) + + attributes = api.attributes(target_handle) + identity = api.identity(target_handle) + if attributes & _FILE_ATTRIBUTE_DIRECTORY or identity[3] != 1: + raise OSError(_ERROR) + owner, protected, entries = api.acl(target_handle) + if owner != token_sid: + raise OSError("anchor file is not owned by the current token user") + if created: + _validate_owner_only_acl(owner, token_sid, protected, entries) + else: + _validate_repairable_owner_acl(owner, token_sid, entries) + api.set_owner_only_acl(target_handle, descriptor) + owner, protected, entries = api.acl(target_handle) + _validate_owner_only_acl(owner, token_sid, protected, entries) + + api.truncate_and_write(target_handle, payload) + + final_attributes = api.attributes(target_handle) + final_identity = api.identity(target_handle) + if ( + final_attributes & _FILE_ATTRIBUTE_DIRECTORY + or identity[:3] != final_identity[:3] + or final_identity[3] != 1 + ): + raise OSError("anchor file identity changed during update") + owner, protected, entries = api.acl(target_handle) + _validate_owner_only_acl(owner, token_sid, protected, entries) + completed = True + finally: + if target_handle is not None: + if created and not completed: + try: + api.mark_for_delete(target_handle) + except Exception: + pass + api.close(target_handle) + + +def persist_anchor(path: str, data: Any, *, api: Optional[_WindowsAnchorApi] = None) -> str: + """Write an anchor through a verified handle after enforcing its owner-only ACL.""" + + api = api or _WindowsAnchorApi() + payload = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + absolute = ntpath.abspath(os.fspath(path)) + drive, parent_parts, leaf = _absolute_parts(absolute) + del drive, parent_parts + parent_path = ntpath.dirname(absolute) + token_sid = api.token_user_sid() + descriptor = api.security_descriptor(token_sid) + directory_handles: list[Any] = [] + directory_handle = None + try: + directory_handle, directory_handles = _open_secure_directory( + api, parent_path, token_sid, descriptor + ) + _write_in_directory(api, directory_handle, leaf, token_sid, descriptor, payload) + return path + finally: + for handle in reversed(directory_handles): + api.close(handle) + api.free_security_descriptor(descriptor) diff --git a/backtrader/notifications/session.py b/backtrader/notifications/session.py index 44bb59a0..9e44c71e 100644 --- a/backtrader/notifications/session.py +++ b/backtrader/notifications/session.py @@ -118,9 +118,26 @@ def _json_bytes(payload): return json.dumps(payload, ensure_ascii=False).encode("utf-8") +def _restrict_anchor_permissions(path, directory=False): + """Apply POSIX mode bits to an anchor path.""" + + if os.name == "nt": + raise OSError("Windows anchor permissions require handle-based persistence") + try: + os.chmod(path, 0o700 if directory else 0o600) + except OSError: # nosec B110 - best effort on platforms without POSIX modes + pass + + def persist_anchor(path, data): """Persist anchor credentials with owner-only permissions. + Windows accepts only a drive-letter fixed local volume that advertises + FILE_PERSISTENT_ACLS. UNC, network, removable, non-fixed, and non-ACL + volumes fail closed. Existing Windows anchors are updated in place + through one verified handle; interruption can leave partial JSON. POSIX + keeps its existing path and permission behavior. + Args: path: Destination file path. data: JSON-serialisable anchor mapping. @@ -131,19 +148,18 @@ def persist_anchor(path, data): Raises: OSError: If the file cannot be written. """ + if os.name == "nt": + from ._windows_anchor import persist_anchor as persist_windows_anchor + + return persist_windows_anchor(path, data) + directory = os.path.dirname(os.path.abspath(path)) os.makedirs(directory, mode=0o700, exist_ok=True) - try: - os.chmod(directory, 0o700) - except OSError: # nosec B110 - best effort on platforms without POSIX modes - pass + _restrict_anchor_permissions(directory, directory=True) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=2) - try: - os.chmod(path, 0o600) - except OSError: # nosec B110 - best effort on platforms without POSIX modes - pass + _restrict_anchor_permissions(path) return path diff --git a/backtrader/runtime/__init__.py b/backtrader/runtime/__init__.py new file mode 100644 index 00000000..d0d45bea --- /dev/null +++ b/backtrader/runtime/__init__.py @@ -0,0 +1,7 @@ +"""Compatibility facade for :mod:`backtrader_runtime`. + +New launchers must import :mod:`backtrader_runtime` directly so strict +configuration rejection happens without initializing the legacy package. +""" + +from backtrader_runtime import * # noqa: F401,F403 diff --git a/backtrader/runtime/__main__.py b/backtrader/runtime/__main__.py new file mode 100644 index 00000000..6c3e1b5f --- /dev/null +++ b/backtrader/runtime/__main__.py @@ -0,0 +1,5 @@ +"""Compatibility module entry point; prefer ``python -m backtrader_runtime``.""" + +from backtrader_runtime.cli import main + +raise SystemExit(main()) diff --git a/backtrader/runtime/cli.py b/backtrader/runtime/cli.py new file mode 100644 index 00000000..185276bd --- /dev/null +++ b/backtrader/runtime/cli.py @@ -0,0 +1,6 @@ +"""Compatibility imports for :mod:`backtrader_runtime.cli`.""" + +import backtrader_runtime.cli as _implementation + +__all__ = [name for name in vars(_implementation) if not name.startswith("_")] +globals().update({name: getattr(_implementation, name) for name in __all__}) diff --git a/backtrader/runtime/config.py b/backtrader/runtime/config.py new file mode 100644 index 00000000..7f24da8c --- /dev/null +++ b/backtrader/runtime/config.py @@ -0,0 +1,6 @@ +"""Compatibility imports for :mod:`backtrader_runtime.config`.""" + +import backtrader_runtime.config as _implementation + +__all__ = [name for name in vars(_implementation) if not name.startswith("_")] +globals().update({name: getattr(_implementation, name) for name in __all__}) diff --git a/backtrader/runtime/errors.py b/backtrader/runtime/errors.py new file mode 100644 index 00000000..5dd34797 --- /dev/null +++ b/backtrader/runtime/errors.py @@ -0,0 +1,11 @@ +"""Compatibility imports for :mod:`backtrader_runtime.errors`. + +Use the top-level :mod:`backtrader_runtime` package for configuration-first +entry points. Importing a ``backtrader.*`` child necessarily initializes the +legacy Backtrader package before this facade can run. +""" + +import backtrader_runtime.errors as _implementation + +__all__ = [name for name in vars(_implementation) if not name.startswith("_")] +globals().update({name: getattr(_implementation, name) for name in __all__}) diff --git a/backtrader/runtime/policy.py b/backtrader/runtime/policy.py new file mode 100644 index 00000000..067146c3 --- /dev/null +++ b/backtrader/runtime/policy.py @@ -0,0 +1,6 @@ +"""Compatibility imports for :mod:`backtrader_runtime.policy`.""" + +import backtrader_runtime.policy as _implementation + +__all__ = [name for name in vars(_implementation) if not name.startswith("_")] +globals().update({name: getattr(_implementation, name) for name in __all__}) diff --git a/backtrader/runtime/registry.py b/backtrader/runtime/registry.py new file mode 100644 index 00000000..9f74a559 --- /dev/null +++ b/backtrader/runtime/registry.py @@ -0,0 +1,6 @@ +"""Compatibility imports for :mod:`backtrader_runtime.registry`.""" + +import backtrader_runtime.registry as _implementation + +__all__ = [name for name in vars(_implementation) if not name.startswith("_")] +globals().update({name: getattr(_implementation, name) for name in __all__}) diff --git a/backtrader/stores/btapistore.py b/backtrader/stores/btapistore.py index 4793715f..90f01ee8 100644 --- a/backtrader/stores/btapistore.py +++ b/backtrader/stores/btapistore.py @@ -1885,9 +1885,7 @@ def arm_registered_sim_execution( """ entry = getattr(self.trader_client, "arm_execution_for_registered_sim", None) if not callable(entry): - raise BtApiStoreError( - "SDK does not expose registered-sim execution admission" - ) + raise BtApiStoreError("SDK does not expose registered-sim execution admission") capability, state = entry( instrument_id=instrument_id, exchange_id=exchange_id, @@ -7371,9 +7369,7 @@ def arm_registered_sim_execution( api = self._ensure_api_ready() arm = getattr(api, "arm_registered_sim_execution", None) if not callable(arm): - raise BtApiStoreError( - "CTP adapter does not support registered-sim execution admission" - ) + raise BtApiStoreError("CTP adapter does not support registered-sim execution admission") if not exchange_id: _, exchange_id = _split_ctp_symbol(str(instrument_id)) if not exchange_id: diff --git a/backtrader/stores/managed_execution.py b/backtrader/stores/managed_execution.py new file mode 100644 index 00000000..cc3460c3 --- /dev/null +++ b/backtrader/stores/managed_execution.py @@ -0,0 +1,817 @@ +"""Narrow managed-execution delegation port for :class:`BtApiStore`. + +The port deliberately owns neither a provider client nor a journal. A trusted +runtime composition supplies an adapter only after it has validated the +mandatory Iteration 41 configuration and its sealed capability contract. The +Store gives that adapter a private legacy dispatch callable solely so the +adapter can invoke it *after* durable admission; a failure in the adapter is +never retried through the legacy path by the Store. +""" + +from __future__ import annotations + +import hashlib +import json +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType +from typing import Any, Optional, Protocol + + +class ManagedExecutionAdapterError(RuntimeError): + """A supplied managed adapter is absent, malformed, or cannot project an action.""" + + +class CtpManagedProjectionState(str, Enum): + """Non-provider states a durable CTP projection may expose to Backtrader.""" + + PENDING = "PENDING" + UNKNOWN = "UNKNOWN" + LOCAL_REJECTED = "LOCAL_REJECTED" + + +def ctp_managed_command_id( + operation: str, + managed_intent_id: str, + runtime_order_id: str, + managed_cancel_intent_id: Optional[str] = None, +) -> str: + """Return the one versioned command key shared by Store and outbox. + + The runtime order identity is already scoped to the sealed execution scope. + The complete scope and trading-day values are still echoed and validated in + :class:`CtpManagedDispatchBinding`; they are not omitted from the durable + command row merely because they do not participate in this stable key. + """ + + if operation not in ("submit", "cancel"): + raise ManagedExecutionAdapterError("managed CTP command operation is invalid") + _managed_identity_text(managed_intent_id, "managed_intent_id") + _managed_identity_text(runtime_order_id, "runtime_order_id") + cancel_id = managed_cancel_intent_id or "" + if operation == "submit" and cancel_id: + raise ManagedExecutionAdapterError("managed CTP submit cannot have a cancel identity") + if operation == "cancel": + _managed_identity_text(cancel_id, "managed_cancel_intent_id") + material = "\0".join( + ( + "backtrader.ctp.managed-outbox.v1", + operation, + managed_intent_id, + runtime_order_id, + cancel_id, + ) + ) + try: + digest = hashlib.sha256(material.encode("ascii")).hexdigest() + except UnicodeEncodeError as error: + raise ManagedExecutionAdapterError("managed CTP command identity is not ASCII") from error + return "ctp-outbox-v1:" + digest + + +def _managed_sha256(value: Any, name: str) -> str: + if ( + type(value) is not str + or len(value) != 64 + or any(character not in "0123456789abcdef" for character in value) + ): + raise ManagedExecutionAdapterError("managed CTP " + name + " digest is invalid") + return value + + +def _managed_payload_sha256(value: Mapping[str, Any], name: str) -> str: + if not isinstance(value, Mapping) or not value: + raise ManagedExecutionAdapterError("managed CTP " + name + " must be a non-empty mapping") + try: + payload = json.dumps( + _managed_thaw_json(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError) as error: + raise ManagedExecutionAdapterError( + "managed CTP " + name + " is not canonical JSON" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def _managed_freeze_json(value: Any) -> Any: + """Recursively freeze the canonical JSON subset used in dispatch bindings.""" + + if isinstance(value, Mapping): + return MappingProxyType({key: _managed_freeze_json(item) for key, item in value.items()}) + if isinstance(value, (list, tuple)): + return tuple(_managed_freeze_json(item) for item in value) + return value + + +def _managed_thaw_json(value: Any) -> Any: + """Return a detached JSON-shaped value from an immutable binding field.""" + + if isinstance(value, Mapping): + return {key: _managed_thaw_json(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_managed_thaw_json(item) for item in value] + return value + + +# This module is on Backtrader's default import path, including Python 3.8/3.9. +# Keep slots on the newer interpreters used by the optional managed SDK. +_BINDING_DATACLASS_OPTIONS = {"slots": True} if sys.version_info >= (3, 10) else {} + + +@dataclass(frozen=True, **_BINDING_DATACLASS_OPTIONS) +class CtpManagedDispatchBinding: + """Exact V2 Store-to-outbox binding for one managed CTP action. + + This is an identity envelope, not an approval capability or provider + observation. The logical request is retained separately from the native + request: for cancellation, the Store adds its allocated integer + ``OrderActionRef`` only after staging. The managed action ID remains a + distinct opaque string. A code-owned outbox must derive both payloads and + their digests from its durable row and echo the Store's queue receipt ID + unchanged. + """ + + operation: str + command_id: str + account_key: str + scope_key: str + trading_day: str + managed_intent_id: str + runtime_order_id: str + managed_action_id: str + order_ref: str + cancel_target_order_ref: Optional[str] + request_payload_sha256: str + request_payload: Mapping[str, Any] + native_request_payload_sha256: str + native_request_payload: Mapping[str, Any] + approval_use_id: str + approval_digest: str + session_binding_sha256: str + session_binding: Mapping[str, Any] + session_generation_id: str + dispatch_front_id: int + dispatch_session_id: int + native_request_id: int + native_action_ref: Optional[int] + local_queue_receipt_id: str + order_ref_reservation_created_at_ns: int + local_queue_receipt_queued: Optional[bool] = None + managed_cancel_intent_id: Optional[str] = None + cancel_target_exchange_id: Optional[str] = None + cancel_target_order_sys_id: Optional[str] = None + cancel_target_front_id: Optional[int] = None + cancel_target_session_id: Optional[int] = None + version: int = 2 + + def __post_init__(self) -> None: + if type(self.version) is not int or self.version != 2: + raise ManagedExecutionAdapterError("managed CTP dispatch binding version is invalid") + if self.operation not in ("submit", "cancel"): + raise ManagedExecutionAdapterError("managed CTP dispatch binding operation is invalid") + expected_command_id = ctp_managed_command_id( + self.operation, + self.managed_intent_id, + self.runtime_order_id, + self.managed_cancel_intent_id, + ) + if self.command_id != expected_command_id: + raise ManagedExecutionAdapterError("managed CTP dispatch command key does not match") + for name in ("account_key", "scope_key", "managed_action_id", "approval_use_id"): + _managed_identity_text(getattr(self, name), name) + if type(self.trading_day) is not str or ( + len(self.trading_day) != 8 + or not self.trading_day.isascii() + or not self.trading_day.isdigit() + ): + raise ManagedExecutionAdapterError("managed CTP dispatch trading day is invalid") + _managed_identity_text(self.managed_intent_id, "managed_intent_id") + if ( + type(self.runtime_order_id) is not str + or len(self.runtime_order_id) != len("bt-managed-v1:") + 64 + or not self.runtime_order_id.startswith("bt-managed-v1:") + or any(character not in "0123456789abcdef" for character in self.runtime_order_id[14:]) + ): + raise ManagedExecutionAdapterError("managed CTP dispatch runtime order ID is invalid") + if ( + type(self.order_ref) is not str + or len(self.order_ref) != 12 + or not self.order_ref.isascii() + or not self.order_ref.isdigit() + ): + raise ManagedExecutionAdapterError("managed CTP dispatch OrderRef is invalid") + _managed_sha256(self.request_payload_sha256, "request payload") + if _managed_payload_sha256(self.request_payload, "request payload") != ( + self.request_payload_sha256 + ): + raise ManagedExecutionAdapterError("managed CTP request payload digest differs") + if ( + _managed_payload_sha256(self.native_request_payload, "native request payload") + != self.native_request_payload_sha256 + ): + raise ManagedExecutionAdapterError("managed CTP native request payload digest differs") + if "OrderActionRef" in self.request_payload: + raise ManagedExecutionAdapterError( + "managed CTP logical request cannot contain native ActionRef" + ) + _managed_sha256(self.approval_digest, "approval") + _managed_sha256(self.session_binding_sha256, "session binding") + if _managed_payload_sha256(self.session_binding, "session binding") != ( + self.session_binding_sha256 + ): + raise ManagedExecutionAdapterError("managed CTP session binding digest differs") + object.__setattr__(self, "request_payload", _managed_freeze_json(self.request_payload)) + object.__setattr__( + self, "native_request_payload", _managed_freeze_json(self.native_request_payload) + ) + object.__setattr__(self, "session_binding", _managed_freeze_json(self.session_binding)) + _managed_identity_text(self.session_generation_id, "session_generation_id") + for value, name in ( + (self.dispatch_front_id, "dispatch FrontID"), + (self.dispatch_session_id, "dispatch SessionID"), + (self.native_request_id, "native RequestID"), + ): + if type(value) is not int or value <= 0 or value > 2_147_483_647: + raise ManagedExecutionAdapterError("managed CTP " + name + " is invalid") + if ( + type(self.local_queue_receipt_id) is not str + or len(self.local_queue_receipt_id) != 32 + or any(character not in "0123456789abcdef" for character in self.local_queue_receipt_id) + ): + raise ManagedExecutionAdapterError("managed CTP queue receipt ID is invalid") + if type(self.order_ref_reservation_created_at_ns) is not int or ( + self.order_ref_reservation_created_at_ns <= 0 + ): + raise ManagedExecutionAdapterError( + "managed CTP OrderRef reservation timestamp is invalid" + ) + if ( + self.local_queue_receipt_queued is not None + and type(self.local_queue_receipt_queued) is not bool + ): + raise ManagedExecutionAdapterError("managed CTP queue receipt disposition is invalid") + if "RequestID" in self.request_payload and ( + type(self.request_payload["RequestID"]) is not int + or self.request_payload["RequestID"] != self.native_request_id + ): + raise ManagedExecutionAdapterError( + "managed CTP logical request RequestID differs from its native identity" + ) + if self.operation == "submit": + if self.managed_action_id != self.managed_intent_id: + raise ManagedExecutionAdapterError("managed CTP submit action identity differs") + if self.managed_cancel_intent_id is not None: + raise ManagedExecutionAdapterError("managed CTP submit has cancel identity") + if self.native_action_ref is not None: + raise ManagedExecutionAdapterError("managed CTP submit cannot carry ActionRef") + if self.native_request_payload_sha256 != self.request_payload_sha256: + raise ManagedExecutionAdapterError( + "managed CTP submit native payload differs from logical payload" + ) + if self.cancel_target_order_ref is not None: + raise ManagedExecutionAdapterError( + "managed CTP submit cannot carry target OrderRef" + ) + if any( + value is not None + for value in ( + self.cancel_target_exchange_id, + self.cancel_target_order_sys_id, + self.cancel_target_front_id, + self.cancel_target_session_id, + ) + ): + raise ManagedExecutionAdapterError("managed CTP submit cannot carry cancel target") + else: + _managed_identity_text(self.managed_cancel_intent_id, "managed_cancel_intent_id") + if self.managed_action_id != self.managed_cancel_intent_id: + raise ManagedExecutionAdapterError("managed CTP cancel action identity differs") + if self.managed_action_id == self.managed_intent_id: + raise ManagedExecutionAdapterError("managed CTP cancel action must be distinct") + if ( + type(self.native_action_ref) is not int + or not 1 <= self.native_action_ref <= 2_147_483_647 + ): + raise ManagedExecutionAdapterError("managed CTP native ActionRef is invalid") + if ( + type(self.cancel_target_order_ref) is not str + or self.cancel_target_order_ref != self.order_ref + ): + raise ManagedExecutionAdapterError("managed CTP cancel target OrderRef differs") + _managed_identity_text(self.cancel_target_exchange_id, "cancel target ExchangeID") + _managed_identity_text(self.cancel_target_order_sys_id, "cancel target OrderSysID") + for value, name in ( + (self.cancel_target_front_id, "cancel target FrontID"), + (self.cancel_target_session_id, "cancel target SessionID"), + ): + if type(value) is not int or value <= 0 or value > 2_147_483_647: + raise ManagedExecutionAdapterError("managed CTP " + name + " is invalid") + expected_session = { + "session_generation_id": self.session_generation_id, + "dispatch_front_id": self.dispatch_front_id, + "dispatch_session_id": self.dispatch_session_id, + } + if any(self.session_binding.get(key) != value for key, value in expected_session.items()): + raise ManagedExecutionAdapterError("managed CTP session keys do not match binding") + if self.request_payload.get("OrderRef") != self.order_ref: + raise ManagedExecutionAdapterError("managed CTP request does not echo OrderRef") + if self.operation == "cancel": + native_request_payload = dict(self.request_payload) + native_request_payload["OrderActionRef"] = self.native_action_ref + if ( + _managed_payload_sha256(native_request_payload, "native request payload") + != self.native_request_payload_sha256 + ): + raise ManagedExecutionAdapterError( + "managed CTP cancel native payload differs from Store ActionRef" + ) + cancel_echoes = { + "ExchangeID": self.cancel_target_exchange_id, + "OrderSysID": self.cancel_target_order_sys_id, + "FrontID": self.cancel_target_front_id, + "SessionID": self.cancel_target_session_id, + } + if any( + self.request_payload.get(key) != value + or type(self.request_payload.get(key)) is not type(value) + for key, value in cancel_echoes.items() + ): + raise ManagedExecutionAdapterError( + "managed CTP cancel request does not echo its exact target binding" + ) + + def to_store_payload(self) -> dict[str, Any]: + return { + "version": self.version, + "operation": self.operation, + "command_id": self.command_id, + "account_key": self.account_key, + "scope_key": self.scope_key, + "trading_day": self.trading_day, + "managed_intent_id": self.managed_intent_id, + "runtime_order_id": self.runtime_order_id, + "managed_action_id": self.managed_action_id, + "order_ref": self.order_ref, + "cancel_target_order_ref": self.cancel_target_order_ref, + "request_payload_sha256": self.request_payload_sha256, + "request_payload": _managed_thaw_json(self.request_payload), + "native_request_payload_sha256": self.native_request_payload_sha256, + "native_request_payload": _managed_thaw_json(self.native_request_payload), + "approval_use_id": self.approval_use_id, + "approval_digest": self.approval_digest, + "session_binding_sha256": self.session_binding_sha256, + "session_binding": _managed_thaw_json(self.session_binding), + "session_generation_id": self.session_generation_id, + "dispatch_front_id": self.dispatch_front_id, + "dispatch_session_id": self.dispatch_session_id, + "native_request_id": self.native_request_id, + "native_action_ref": self.native_action_ref, + "local_queue_receipt_id": self.local_queue_receipt_id, + "order_ref_reservation_created_at_ns": self.order_ref_reservation_created_at_ns, + "local_queue_receipt_queued": self.local_queue_receipt_queued, + "managed_cancel_intent_id": self.managed_cancel_intent_id, + "cancel_target_exchange_id": self.cancel_target_exchange_id, + "cancel_target_order_sys_id": self.cancel_target_order_sys_id, + "cancel_target_front_id": self.cancel_target_front_id, + "cancel_target_session_id": self.cancel_target_session_id, + } + + @classmethod + def from_store_payload(cls, value: Any) -> "CtpManagedDispatchBinding": + fields = { + "version", + "operation", + "command_id", + "account_key", + "scope_key", + "trading_day", + "managed_intent_id", + "runtime_order_id", + "managed_action_id", + "order_ref", + "cancel_target_order_ref", + "request_payload_sha256", + "request_payload", + "native_request_payload_sha256", + "native_request_payload", + "approval_use_id", + "approval_digest", + "session_binding_sha256", + "session_binding", + "session_generation_id", + "dispatch_front_id", + "dispatch_session_id", + "native_request_id", + "native_action_ref", + "local_queue_receipt_id", + "order_ref_reservation_created_at_ns", + "local_queue_receipt_queued", + "managed_cancel_intent_id", + "cancel_target_exchange_id", + "cancel_target_order_sys_id", + "cancel_target_front_id", + "cancel_target_session_id", + } + if not isinstance(value, Mapping) or set(value) != fields: + raise ManagedExecutionAdapterError("managed CTP dispatch binding shape is invalid") + try: + return cls(**dict(value)) + except TypeError as error: + raise ManagedExecutionAdapterError("managed CTP dispatch binding is invalid") from error + + +@dataclass(frozen=True) +class CtpManagedExecutionProjection: + """One durable, non-provider projection returned by a managed CTP adapter. + + ``durable_projection_id`` names the ledger row the adapter claims to have + read. The Store can validate this value's shape and identity echoes, but + this Python type does not prove that a row was committed or recovered. + A future CTP adapter must read the projection from the single execution + ledger/outbox before returning it. This value intentionally has no provider + order ID, fill, ACK, or native receipt field. ``local_queue_receipt_id`` + correlates transport only; it is never evidence that the CTP front accepted + an action. ``PENDING`` and ``UNKNOWN`` remain nonterminal until a separate, + typed provider-callback observation is available. Until that durable outbox + read and callback evidence exist, this projection is only a nonauthorizing + structural contract. + """ + + operation: str + state: CtpManagedProjectionState + durable_projection_id: str + managed_intent_id: str + runtime_order_id: str + managed_cancel_intent_id: Optional[str] = None + local_queue_receipt_id: Optional[str] = None + error_code: Optional[str] = None + dispatch_binding: Optional[CtpManagedDispatchBinding] = None + version: int = 1 + + def __post_init__(self) -> None: + if type(self.version) is not int or self.version not in (1, 2): + raise ManagedExecutionAdapterError("managed CTP projection version is invalid") + if type(self.operation) is not str or self.operation not in ("submit", "cancel"): + raise ManagedExecutionAdapterError("managed CTP projection operation is invalid") + try: + state = CtpManagedProjectionState(self.state) + except (TypeError, ValueError) as error: + raise ManagedExecutionAdapterError("managed CTP projection state is invalid") from error + object.__setattr__(self, "state", state) + _managed_identity_text(self.durable_projection_id, "durable_projection_id") + _managed_identity_text(self.managed_intent_id, "managed_intent_id") + if ( + type(self.runtime_order_id) is not str + or len(self.runtime_order_id) != len("bt-managed-v1:") + 64 + or not self.runtime_order_id.startswith("bt-managed-v1:") + or any(character not in "0123456789abcdef" for character in self.runtime_order_id[14:]) + ): + raise ManagedExecutionAdapterError("managed CTP projection runtime_order_id is invalid") + if self.operation == "cancel": + _managed_identity_text(self.managed_cancel_intent_id, "managed_cancel_intent_id") + elif self.managed_cancel_intent_id is not None: + raise ManagedExecutionAdapterError( + "managed CTP submit projection cannot contain a cancel identity" + ) + if self.local_queue_receipt_id is not None and ( + type(self.local_queue_receipt_id) is not str + or len(self.local_queue_receipt_id) != 32 + or any(character not in "0123456789abcdef" for character in self.local_queue_receipt_id) + ): + raise ManagedExecutionAdapterError("managed CTP local queue receipt ID is invalid") + if self.error_code is not None: + _managed_identity_text(self.error_code, "projection error_code") + if self.version == 1 and self.dispatch_binding is not None: + raise ManagedExecutionAdapterError( + "managed CTP legacy projection cannot carry a v2 dispatch binding" + ) + if self.version == 2 and type(self.dispatch_binding) is not CtpManagedDispatchBinding: + raise ManagedExecutionAdapterError( + "managed CTP projection lacks its typed dispatch binding" + ) + if self.dispatch_binding is not None: + if ( + self.dispatch_binding.operation != self.operation + or self.dispatch_binding.command_id != self.durable_projection_id + or self.dispatch_binding.managed_intent_id != self.managed_intent_id + or self.dispatch_binding.runtime_order_id != self.runtime_order_id + or self.dispatch_binding.managed_cancel_intent_id != self.managed_cancel_intent_id + or self.dispatch_binding.local_queue_receipt_id != self.local_queue_receipt_id + ): + raise ManagedExecutionAdapterError("managed CTP projection binding does not match") + if ( + self.version == 2 + and state is CtpManagedProjectionState.PENDING + and (self.dispatch_binding.local_queue_receipt_queued is not True) + ): + raise ManagedExecutionAdapterError( + "managed CTP pending projection lacks queued receipt" + ) + if ( + self.version == 2 + and state is CtpManagedProjectionState.LOCAL_REJECTED + and (self.dispatch_binding.local_queue_receipt_queued is not False) + ): + raise ManagedExecutionAdapterError("managed CTP rejection lacks rejected queue receipt") + if state is CtpManagedProjectionState.LOCAL_REJECTED and not self.error_code: + raise ManagedExecutionAdapterError("managed CTP local rejection requires an error code") + + def to_store_response(self) -> dict[str, Any]: + """Return the fixed Store/Broker envelope for this durable projection.""" + + response = { + "kind": "managed_execution_projection", + "version": self.version, + "projection_id": self.durable_projection_id, + "operation": self.operation, + "state": self.state.value, + "managed_intent_id": self.managed_intent_id, + "runtime_order_id": self.runtime_order_id, + "managed_cancel_intent_id": self.managed_cancel_intent_id, + "local_queue_receipt_id": self.local_queue_receipt_id, + "status": ( + "local_rejected" + if self.state is CtpManagedProjectionState.LOCAL_REJECTED + else self.state.value.lower() + ), + "execution_unknown": self.state is CtpManagedProjectionState.UNKNOWN, + "error_code": self.error_code, + } + if self.dispatch_binding is not None: + response["dispatch_binding"] = self.dispatch_binding.to_store_payload() + return response + + +_CTP_MANAGED_PROJECTION_FIELDS = frozenset( + { + "kind", + "version", + "projection_id", + "operation", + "state", + "managed_intent_id", + "runtime_order_id", + "managed_cancel_intent_id", + "local_queue_receipt_id", + "status", + "execution_unknown", + "error_code", + } +) +_CTP_MANAGED_PROJECTION_V2_FIELDS = _CTP_MANAGED_PROJECTION_FIELDS | {"dispatch_binding"} + + +def parse_ctp_managed_execution_projection( + value: Any, *, expected_operation: Optional[str] = None +) -> Optional[CtpManagedExecutionProjection]: + """Parse the exact non-provider Store envelope, returning ``None`` otherwise.""" + + if not isinstance(value, Mapping) or value.get("kind") != "managed_execution_projection": + return None + version = value.get("version") + expected_fields = ( + _CTP_MANAGED_PROJECTION_FIELDS + if version == 1 + else _CTP_MANAGED_PROJECTION_V2_FIELDS if version == 2 else frozenset() + ) + if set(value) != expected_fields: + raise ManagedExecutionAdapterError("managed CTP projection envelope has an invalid shape") + projection = CtpManagedExecutionProjection( + operation=value["operation"], + state=value["state"], + durable_projection_id=value["projection_id"], + managed_intent_id=value["managed_intent_id"], + runtime_order_id=value["runtime_order_id"], + managed_cancel_intent_id=value["managed_cancel_intent_id"], + local_queue_receipt_id=value["local_queue_receipt_id"], + error_code=value["error_code"], + dispatch_binding=( + CtpManagedDispatchBinding.from_store_payload(value["dispatch_binding"]) + if version == 2 + else None + ), + version=value["version"], + ) + if expected_operation is not None and projection.operation != expected_operation: + raise ManagedExecutionAdapterError("managed CTP projection operation does not match") + expected_status = ( + "local_rejected" + if projection.state is CtpManagedProjectionState.LOCAL_REJECTED + else projection.state.value.lower() + ) + if ( + value["status"] != expected_status + or type(value["execution_unknown"]) is not bool + or value["execution_unknown"] is not (projection.state is CtpManagedProjectionState.UNKNOWN) + ): + raise ManagedExecutionAdapterError("managed CTP projection state echo is invalid") + return projection + + +def require_ctp_managed_execution_projection( + value: Any, + *, + operation: str, + managed_intent_id: str, + runtime_order_id: str, + managed_cancel_intent_id: Optional[str] = None, + local_queue_receipt: Any = None, +) -> CtpManagedExecutionProjection: + """Require the adapter result to be a matching durable projection. + + A raw ``command_receipt`` or a runtime queue classifier is deliberately + rejected here, even if it says ``UNKNOWN``. Only a durable projection may + leave the managed Store method as its final result. + """ + + if type(value) is not CtpManagedExecutionProjection: + raise ManagedExecutionAdapterError( + "managed CTP adapter must return a typed durable projection" + ) + if ( + value.operation != operation + or value.managed_intent_id != managed_intent_id + or value.runtime_order_id != runtime_order_id + or value.managed_cancel_intent_id != managed_cancel_intent_id + ): + raise ManagedExecutionAdapterError( + "managed CTP projection identity does not match dispatch" + ) + if local_queue_receipt is None: + return value + if not isinstance(local_queue_receipt, Mapping): + raise ManagedExecutionAdapterError("managed CTP Store queue receipt is malformed") + receipt_id = local_queue_receipt.get("receipt_id") + queued = local_queue_receipt.get("queued") + if ( + local_queue_receipt.get("kind") != "command_receipt" + or local_queue_receipt.get("command") != operation + or type(receipt_id) is not str + or len(receipt_id) != 32 + or any(character not in "0123456789abcdef" for character in receipt_id) + or type(queued) is not bool + or value.local_queue_receipt_id != receipt_id + ): + raise ManagedExecutionAdapterError( + "managed CTP projection does not echo its local queue receipt" + ) + if queued is True and value.state is CtpManagedProjectionState.LOCAL_REJECTED: + raise ManagedExecutionAdapterError( + "managed CTP queued command cannot be projected as locally rejected" + ) + if queued is False and value.state is not CtpManagedProjectionState.LOCAL_REJECTED: + raise ManagedExecutionAdapterError( + "managed CTP rejected queue cannot be projected as pending" + ) + return value + + +def _managed_identity_text(value: Any, name: str) -> str: + """Validate a bounded identity token before it crosses the Store boundary.""" + + if ( + type(value) is not str + or not value + or value != value.strip() + or len(value) > 128 + or not value.isascii() + or any(not (character.isalnum() or character in "._:-") for character in value) + ): + raise ManagedExecutionAdapterError("managed CTP " + name + " is invalid") + return value + + +@dataclass(frozen=True) +class CtpManagedOrderDispatch: + """Canonical CTP order plus runtime-owned durable identity. + + Code-owned runtime composition creates this value only from an admitted + managed intent. The Store accepts it at its SDK queue boundary and never + translates it to the legacy native CTP wrapper. + """ + + order: Any + managed_intent_id: str + runtime_order_id: str + hedge_flag: str + + def __post_init__(self) -> None: + _managed_identity_text(self.managed_intent_id, "managed_intent_id") + if ( + type(self.runtime_order_id) is not str + or len(self.runtime_order_id) != len("bt-managed-v1:") + 64 + or not self.runtime_order_id.startswith("bt-managed-v1:") + or any(character not in "0123456789abcdef" for character in self.runtime_order_id[14:]) + ): + raise ManagedExecutionAdapterError("managed CTP runtime_order_id is invalid") + if type(self.hedge_flag) is not str or self.hedge_flag not in ("1", "2", "3"): + raise ManagedExecutionAdapterError("managed CTP hedge_flag is invalid") + + +@dataclass(frozen=True) +class CtpManagedCancelDispatch: + """Durable CTP cancel identity resolved by the SDK from one order binding.""" + + managed_intent_id: str + runtime_order_id: str + managed_cancel_intent_id: str + + def __post_init__(self) -> None: + _managed_identity_text(self.managed_intent_id, "managed_intent_id") + if ( + type(self.runtime_order_id) is not str + or len(self.runtime_order_id) != len("bt-managed-v1:") + 64 + or not self.runtime_order_id.startswith("bt-managed-v1:") + or any(character not in "0123456789abcdef" for character in self.runtime_order_id[14:]) + ): + raise ManagedExecutionAdapterError("managed CTP runtime_order_id is invalid") + _managed_identity_text(self.managed_cancel_intent_id, "managed_cancel_intent_id") + + +class CtpRuntimeExecutionAdapter(Protocol): + """Typed, pre-authorized CTP adapter composed by code-owned runtime. + + Its dispatch callback accepts only the frozen contracts above. This keeps + managed CTP writes on the SDK queue and gives this Store no raw native + order/action identifier to pass to its historical CTP wrapper. + """ + + ctp_managed_execution_version: int + + def submit_order( + self, + order: Any, + sdk_dispatch: Callable[[CtpManagedOrderDispatch], Any], + ) -> Any: + """Admit a managed intent before projecting it to the SDK queue.""" + + def cancel_order( + self, + order_or_ref: Any, + dataname: Optional[str], + sdk_dispatch: Callable[[CtpManagedCancelDispatch], Any], + ) -> Any: + """Admit a managed cancellation before projecting it to the SDK queue.""" + + +def require_ctp_runtime_execution_adapter(value: Any) -> CtpRuntimeExecutionAdapter: + """Require the explicit typed adapter version used by managed CTP routes.""" + + version = getattr(value, "ctp_managed_execution_version", None) + if type(version) is not int or version != 1: + raise ManagedExecutionAdapterError( + "managed CTP execution requires the typed runtime adapter" + ) + if not callable(getattr(value, "submit_order", None)): + raise ManagedExecutionAdapterError("managed CTP adapter must implement submit_order") + if not callable(getattr(value, "cancel_order", None)): + raise ManagedExecutionAdapterError("managed CTP adapter must implement cancel_order") + return value + + +class ManagedExecutionAdapter(Protocol): + """The small explicit port accepted by :class:`BtApiStore`. + + The ``legacy_dispatch`` callables are private Store internals. An adapter + may call one once only after its own intent/journal/risk gates have made a + durable decision. It must not use an adapter failure as permission for + the Store to route the framework action directly. + """ + + def submit_order(self, order: Any, legacy_dispatch: Callable[[Any], Any]) -> Any: + """Return a normal Store response after a managed submission projection.""" + + def cancel_order( + self, + order_or_ref: Any, + dataname: Optional[str], + legacy_dispatch: Callable[[Any, Optional[str]], Any], + ) -> Any: + """Return a normal Store response after a managed cancellation projection.""" + + +def require_managed_execution_adapter(value: Any) -> Optional[ManagedExecutionAdapter]: + """Validate a deliberate managed adapter attachment without importing optional packages.""" + + if value is None: + return None + if not callable(getattr(value, "submit_order", None)): + raise ManagedExecutionAdapterError("managed adapter must implement submit_order") + return value + + +def require_managed_cancel( + adapter: ManagedExecutionAdapter, +) -> Callable[[Any, Optional[str], Any], Any]: + """Return the explicit cancellation port or fail closed before provider I/O.""" + + cancel = getattr(adapter, "cancel_order", None) + if not callable(cancel): + raise ManagedExecutionAdapterError("managed adapter does not implement cancellation") + return cancel diff --git a/backtrader_runtime/__init__.py b/backtrader_runtime/__init__.py new file mode 100644 index 00000000..0432bf11 --- /dev/null +++ b/backtrader_runtime/__init__.py @@ -0,0 +1,250 @@ +"""Configuration-first runtime contracts for Iteration 41. + +Importing this package is safe: it exposes schema, registry, and CLI helpers +without importing a runtime dispatcher or optional trading capabilities. +Dispatch helpers remain available through lazy public attributes so existing +callers retain their import surface while ``bootstrap`` and ``doctor`` start +from the smallest offline-only module graph. +""" + +from typing import Any + +from .config import CONFIG_FILENAME, CONFIG_SCHEMA_VERSION, RuntimeConfig, load_runtime_config +from .errors import ( + CONFIG_EXISTS, + CONFIG_REQUIRED, + CONFIG_SCHEMA_UNSUPPORTED, + ENVIRONMENT_MISMATCH, + MIGRATION_REVIEW_REQUIRED, + MODE_PRESET_MISMATCH, + PRESET_POLICY_VIOLATION, + RuntimeConfigError, +) +from .policy import PRESET_REGISTRY, PresetPolicy, preset_names +from .managed_execution import ( + ManagedExecutionBindingError, + ManagedExecutionBridge, + bind_managed_execution, + cancel_observation_from_store_response, + observation_from_store_response, + project_cancel_record_to_store_response, + project_record_to_store_response, + strict_cancel_intent_from_order, + strict_limit_intent_from_order, +) +from .inventory import ( + ITERATION41_007_CTP_REGISTRATION, + ITERATION41_007_CTP_RUNTIME_DIR, + ITERATION41_007_CTP_STRATEGY_ID, + ITERATION41_010_LIVE_EXAMPLES_REGISTRATION, + ITERATION41_010_LIVE_EXAMPLES_RUNTIME_DIR, + ITERATION41_010_LIVE_EXAMPLES_STRATEGY_ID, + ITERATION41_012_1_REGISTRATION, + ITERATION41_012_1_RUNTIME_DIR, + ITERATION41_012_1_STRATEGY_ID, + ITERATION41_012_2_REGISTRATION, + ITERATION41_012_2_RUNTIME_DIR, + ITERATION41_012_2_STRATEGY_ID, + ITERATION41_013_1_REGISTRATION, + ITERATION41_013_1_RUNTIME_DIR, + ITERATION41_013_1_STRATEGY_ID, + ITERATION41_013_2_REGISTRATION, + ITERATION41_013_2_RUNTIME_DIR, + ITERATION41_013_2_STRATEGY_ID, + ITERATION41_013_3_REGISTRATION, + ITERATION41_013_3_MANAGED_REPLAY_REGISTRATION, + ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_DIR, + ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_ID, + ITERATION41_013_3_RUNTIME_DIR, + ITERATION41_013_3_STRATEGY_ID, + ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION, + ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_DIR, + ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_ID, + ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_STRATEGY_ID, + ITERATION41_014_1_REGISTRATION, + ITERATION41_014_1_RUNTIME_DIR, + ITERATION41_014_1_STRATEGY_ID, + ITERATION41_014_2_REGISTRATION, + ITERATION41_014_2_RUNTIME_DIR, + ITERATION41_014_2_STRATEGY_ID, + ITERATION41_015_REGISTRATION, + ITERATION41_015_RUNTIME_DIR, + ITERATION41_015_STRATEGY_ID, + ITERATION41_REPLAY_RUNTIME_SET, + ITERATION41_MANAGED_REPLAY_RUNTIME_SET, + iteration41_runtime_registry, +) +from .registry import ( + BootstrapRuntimeSetItem, + BootstrapRuntimeSetResult, + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + RuntimeSet, + bootstrap_runtime_config, + bootstrap_runtime_set, + default_runtime_registry, + resolve_runtime_config, + select_bootstrap_preset, + validate_runtime_config, +) +from .provider_preflight import ( + ProviderSessionPreflightBinding, + ProviderSessionPreflightRegistration, + validate_provider_session_preflight_binding, +) +from .test_execution_profile import ( + RejectingTestExecutionProfileVerifier, + TestExecutionPreflightContext, + TestExecutionProfile, + TestExecutionProfileError, + TestExecutionProfileObservation, + TestExecutionProfileVerifier, + canonical_test_execution_profile, + parse_test_execution_profile, + test_execution_profile_sha256, + validate_test_execution_profile, +) +from .review_evidence import ( + ITERATION41_EVIDENCE_SCHEMA_VERSION, + REVIEW_REQUIRED, + Iteration41ReviewEvidenceBindings, + Iteration41ReviewEvidenceError, + Iteration41ReviewEvidenceObservation, + resolve_iteration41_review_evidence_bindings, + validate_iteration41_review_evidence, + validate_registered_iteration41_review_evidence, +) + + +_LAZY_RUNNER_EXPORTS = frozenset( + { + "dispatch_configured_runtime", + "dispatch_registered_runtime", + "resolve_runner_effective_config", + } +) + + +def __getattr__(name: str) -> Any: + """Load dispatch code only when a caller explicitly requests it. + + The public functions historically re-exported from this module remain + compatible with ``from backtrader_runtime import ...``. Leaving the + dispatcher out of normal package import keeps configuration inspection and + rejected startup paths free of runner-loading side effects. + """ + + if name in _LAZY_RUNNER_EXPORTS: + from . import runner + + value = getattr(runner, name) + globals()[name] = value + return value + raise AttributeError("module {0!r} has no attribute {1!r}".format(__name__, name)) + + +__all__ = [ + "CONFIG_EXISTS", + "CONFIG_FILENAME", + "CONFIG_REQUIRED", + "CONFIG_SCHEMA_UNSUPPORTED", + "CONFIG_SCHEMA_VERSION", + "BootstrapRuntimeSetItem", + "BootstrapRuntimeSetResult", + "ENVIRONMENT_MISMATCH", + "EffectiveRuntimeConfig", + "ITERATION41_007_CTP_REGISTRATION", + "ITERATION41_007_CTP_RUNTIME_DIR", + "ITERATION41_007_CTP_STRATEGY_ID", + "ITERATION41_010_LIVE_EXAMPLES_REGISTRATION", + "ITERATION41_010_LIVE_EXAMPLES_RUNTIME_DIR", + "ITERATION41_010_LIVE_EXAMPLES_STRATEGY_ID", + "ITERATION41_012_1_REGISTRATION", + "ITERATION41_012_1_RUNTIME_DIR", + "ITERATION41_012_1_STRATEGY_ID", + "ITERATION41_012_2_REGISTRATION", + "ITERATION41_012_2_RUNTIME_DIR", + "ITERATION41_012_2_STRATEGY_ID", + "ITERATION41_013_1_REGISTRATION", + "ITERATION41_013_1_RUNTIME_DIR", + "ITERATION41_013_1_STRATEGY_ID", + "ITERATION41_013_2_REGISTRATION", + "ITERATION41_013_2_RUNTIME_DIR", + "ITERATION41_013_2_STRATEGY_ID", + "ITERATION41_013_3_REGISTRATION", + "ITERATION41_013_3_MANAGED_REPLAY_REGISTRATION", + "ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_DIR", + "ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_ID", + "ITERATION41_013_3_RUNTIME_DIR", + "ITERATION41_013_3_STRATEGY_ID", + "ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION", + "ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_DIR", + "ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_ID", + "ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_STRATEGY_ID", + "ITERATION41_014_1_REGISTRATION", + "ITERATION41_014_1_RUNTIME_DIR", + "ITERATION41_014_1_STRATEGY_ID", + "ITERATION41_014_2_REGISTRATION", + "ITERATION41_014_2_RUNTIME_DIR", + "ITERATION41_014_2_STRATEGY_ID", + "ITERATION41_015_REGISTRATION", + "ITERATION41_015_RUNTIME_DIR", + "ITERATION41_015_STRATEGY_ID", + "ITERATION41_EVIDENCE_SCHEMA_VERSION", + "ITERATION41_REPLAY_RUNTIME_SET", + "ITERATION41_MANAGED_REPLAY_RUNTIME_SET", + "MIGRATION_REVIEW_REQUIRED", + "ManagedExecutionBindingError", + "ManagedExecutionBridge", + "MODE_PRESET_MISMATCH", + "PRESET_POLICY_VIOLATION", + "PRESET_REGISTRY", + "PresetPolicy", + "ProviderSessionPreflightBinding", + "ProviderSessionPreflightRegistration", + "RegisteredRuntime", + "RuntimeConfig", + "RuntimeConfigError", + "RuntimeProfile", + "RuntimeRegistry", + "RuntimeSet", + "RejectingTestExecutionProfileVerifier", + "REVIEW_REQUIRED", + "Iteration41ReviewEvidenceBindings", + "Iteration41ReviewEvidenceError", + "Iteration41ReviewEvidenceObservation", + "bootstrap_runtime_config", + "bootstrap_runtime_set", + "bind_managed_execution", + "cancel_observation_from_store_response", + "default_runtime_registry", + "dispatch_configured_runtime", + "dispatch_registered_runtime", + "load_runtime_config", + "observation_from_store_response", + "project_cancel_record_to_store_response", + "project_record_to_store_response", + "iteration41_runtime_registry", + "preset_names", + "resolve_runtime_config", + "resolve_runner_effective_config", + "resolve_iteration41_review_evidence_bindings", + "select_bootstrap_preset", + "strict_limit_intent_from_order", + "strict_cancel_intent_from_order", + "validate_runtime_config", + "TestExecutionPreflightContext", + "TestExecutionProfile", + "TestExecutionProfileError", + "TestExecutionProfileObservation", + "TestExecutionProfileVerifier", + "canonical_test_execution_profile", + "parse_test_execution_profile", + "test_execution_profile_sha256", + "validate_provider_session_preflight_binding", + "validate_test_execution_profile", + "validate_iteration41_review_evidence", + "validate_registered_iteration41_review_evidence", +] diff --git a/backtrader_runtime/__main__.py b/backtrader_runtime/__main__.py new file mode 100644 index 00000000..8e54763e --- /dev/null +++ b/backtrader_runtime/__main__.py @@ -0,0 +1,6 @@ +"""Module entry point for ``python -m backtrader_runtime``.""" + +from .cli import main + + +raise SystemExit(main()) diff --git a/backtrader_runtime/capability_imports.py b/backtrader_runtime/capability_imports.py new file mode 100644 index 00000000..f5b8738e --- /dev/null +++ b/backtrader_runtime/capability_imports.py @@ -0,0 +1,813 @@ +"""Origin-pinned imports for reviewed external capability packages. + +The Iteration 41 runner owns its entrypoint selection, so a configuration file +must not also get to choose where optional SDK code comes from. This module +keeps that rule at the Python import boundary: a reviewed registration lists +the exact top-level ``bt_api_*`` modules it may use, and each import is +resolved only from the process's non-CWD deployment search roots captured +before the runner starts. + +This is deliberately a source-origin fence, not a wheel-signature verifier. +Production deployment still needs an isolated environment plus independently +verified wheel/lock hashes. The fence prevents a same-name package placed in +the current working directory from winning normal Python import precedence and +rejects a cached package whose origin differs from the captured deployment +root. +""" + +from __future__ import annotations + +import importlib +import importlib.machinery +import os +import stat +import sys +import sysconfig +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Generator, Iterable, Optional, Sequence, Tuple + +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError + + +# Keep the standard finder selected before reviewed runner code can execute. +# A runner may not replace ``PathFinder.find_spec`` later and turn a child +# namespace import into an ambient/custom finder result. +_PATH_FINDER_FIND_SPEC = importlib.machinery.PathFinder.find_spec + + +def _capability_error(reason: str, message: str) -> RuntimeConfigError: + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + message, + field_path="runtime.capabilities", + reason=reason, + ) + + +def _is_link_or_reparse(stat_result: os.stat_result) -> bool: + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + attributes = getattr(stat_result, "st_file_attributes", 0) + return stat.S_ISLNK(stat_result.st_mode) or bool(attributes & reparse_point) + + +@dataclass(frozen=True) +class _PathIdentity: + """A leaf identity retained while a reviewed runner is active.""" + + device: int + inode: int + mode: int + + @classmethod + def directory(cls, path: Path) -> "_PathIdentity": + try: + result = os.lstat(str(path)) + except OSError: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package directory cannot be inspected", + ) from None + if _is_link_or_reparse(result) or not stat.S_ISDIR(result.st_mode): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package directory is not concrete", + ) + return cls(result.st_dev, result.st_ino, result.st_mode) + + @classmethod + def regular_file(cls, path: Path) -> "_PathIdentity": + try: + result = os.lstat(str(path)) + except OSError: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package initializer cannot be inspected", + ) from None + if _is_link_or_reparse(result) or not stat.S_ISREG(result.st_mode): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package initializer is not a regular file", + ) + return cls(result.st_dev, result.st_ino, result.st_mode) + + def matches_directory(self, path: Path) -> bool: + try: + result = os.lstat(str(path)) + except OSError: + return False + return ( + not _is_link_or_reparse(result) + and stat.S_ISDIR(result.st_mode) + and (result.st_dev, result.st_ino, result.st_mode) + == (self.device, self.inode, self.mode) + ) + + def matches_regular_file(self, path: Path) -> bool: + try: + result = os.lstat(str(path)) + except OSError: + return False + return ( + not _is_link_or_reparse(result) + and stat.S_ISREG(result.st_mode) + and (result.st_dev, result.st_ino, result.st_mode) + == (self.device, self.inode, self.mode) + ) + + +def _absolute_path(path: object) -> Path: + try: + return Path(os.path.normcase(os.path.normpath(os.path.abspath(str(path))))) + except (OSError, RuntimeError, TypeError, ValueError): + raise _capability_error( + "capability_origin_mismatch", "a capability import path is not usable" + ) from None + + +def _physical_path(path: object) -> Path: + """Normalise intermediate links before comparing import locations.""" + + try: + return Path( + os.path.normcase(os.path.normpath(os.path.realpath(os.path.abspath(str(path))))) + ) + except (OSError, RuntimeError, TypeError, ValueError): + raise _capability_error( + "capability_origin_mismatch", "a capability import path is not usable" + ) from None + + +def _path_is_within(path: object, root: Path) -> bool: + try: + candidate = str(_physical_path(path)) + trusted_root = str(_physical_path(root)) + return os.path.commonpath((candidate, trusted_root)) == trusted_root + except (OSError, RuntimeError, TypeError, ValueError): + return False + + +def _concrete_directory(path: Path) -> Optional[Path]: + """Return one usable import root without following links or CWD aliases.""" + + try: + result = os.lstat(str(path)) + except OSError: + return None + if _is_link_or_reparse(result) or not stat.S_ISDIR(result.st_mode): + return None + return path + + +def _interpreter_install_roots() -> frozenset[Path]: + """Return the exact stdlib-reported package roots for this interpreter. + + A clean virtual environment is often deliberately created under the + consumer's temporary working directory. Its ``site-packages`` directory + must remain an admissible deployment root; treating every descendant of + CWD as a shadow would otherwise reject the isolated wheel consumer before + a reviewed capability package can be loaded. This exception is deliberately + narrow: only the interpreter's own exact ``purelib``/``platlib`` roots are + accepted, never arbitrary CWD children supplied through ``PYTHONPATH``. + """ + + try: + paths = sysconfig.get_paths() + except (AttributeError, OSError, RuntimeError, TypeError, ValueError): + return frozenset() + roots = [] + for key in ("purelib", "platlib"): + raw_path = paths.get(key) + if isinstance(raw_path, (str, os.PathLike)) and str(raw_path): + roots.append(_absolute_path(raw_path)) + return frozenset(roots) + + +def _concrete_interpreter_install_roots() -> Tuple[Path, ...]: + """Return only concrete exact ``purelib``/``platlib`` roots for this interpreter. + + This deliberately does not inspect ``sys.path``. The ordinary capability + fence preserves its reviewed deployment-root behavior, including absolute + non-CWD paths. The stricter future private-account boundary instead needs + the current interpreter's two installation roots and nothing else. + """ + + roots = [] + seen = set() + for root in _interpreter_install_roots(): + concrete = _concrete_directory(root) + if concrete is None: + continue + key = os.path.normcase(str(concrete)) + if key in seen: + continue + seen.add(key) + roots.append(concrete) + return tuple(sorted(roots, key=lambda root: os.path.normcase(str(root)))) + + +def _captured_non_cwd_search_roots(source_root: Path) -> Tuple[Path, ...]: + """Snapshot import roots that cannot be supplied by the current directory. + + Normal Python import resolution puts ``cwd`` before ``PYTHONPATH`` and + installed packages. A reviewed runtime captures the latter once, before + source execution, and never consults a path added by the runner later. + Absolute roots explicitly configured by a deployment remain possible; + their release provenance is a separate wheel/environment acceptance gate. + """ + + cwd = _absolute_path(os.getcwd()) + interpreter_install_roots = _interpreter_install_roots() + candidates: list[Path] = [] + seen = set() + + # The fixed Backtrader source root is itself a reviewed source location. + # It normally contains no SDK packages, but including it permits an + # explicitly packaged deployment tree without consulting CWD. + raw_paths: Iterable[object] = (source_root,) + tuple(sys.path) + for raw_path in raw_paths: + if not isinstance(raw_path, (str, os.PathLike)) or not str(raw_path): + # Empty sys.path entries mean CWD and are intentionally excluded. + continue + candidate = _absolute_path(raw_path) + if _path_is_within(candidate, cwd) and candidate not in interpreter_install_roots: + continue + concrete = _concrete_directory(candidate) + if concrete is None: + continue + key = os.path.normcase(str(concrete)) + if key in seen: + continue + seen.add(key) + candidates.append(concrete) + return tuple(candidates) + + +@dataclass(frozen=True) +class _CapabilityOrigin: + """One top-level package resolved from a captured deployment root.""" + + module_name: str + search_root: Path + package_directory: Path + initializer: Path + package_identity: _PathIdentity + initializer_identity: _PathIdentity + + def assert_current(self) -> None: + if not self.package_identity.matches_directory(self.package_directory): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package directory changed during runner startup", + ) + if not self.initializer_identity.matches_regular_file(self.initializer): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package initializer changed during runner startup", + ) + + +class _CapabilityOriginResolver: + """Resolve only code-owned capability module names from captured roots.""" + + def __init__(self, modules: Sequence[str], source_root: Path) -> None: + self.modules = frozenset(modules) + self._initial_cwd = _absolute_path(os.getcwd()) + self.search_roots = _captured_non_cwd_search_roots(source_root) + # PEP 660 editable installations can expose a package solely through + # a meta-path finder. Keep the pre-run finder objects so a runner + # cannot add a new finder after this context starts, and never call + # PathFinder here: it would consult the live CWD/sys.path again. + self._deployment_meta_finders = tuple( + finder for finder in sys.meta_path if finder is not importlib.machinery.PathFinder + ) + self._origins: Dict[str, _CapabilityOrigin] = {} + + def origin_for(self, module_name: str) -> _CapabilityOrigin: + origin = self._origins.get(module_name) + if origin is not None: + origin.assert_current() + return origin + + for search_root in self.search_roots: + try: + specification = _PATH_FINDER_FIND_SPEC(module_name, (str(search_root),)) + except (ImportError, OSError, ValueError): + continue + if specification is None: + continue + origin = self._origin_from_specification(module_name, search_root, specification) + self._origins[module_name] = origin + return origin + + origin = self._origin_from_deployment_meta_finder(module_name) + if origin is not None: + self._origins[module_name] = origin + return origin + raise _capability_error( + "capability_origin_unavailable", + "a reviewed capability package is unavailable from the captured deployment roots", + ) + + def _origin_from_deployment_meta_finder(self, module_name: str) -> Optional[_CapabilityOrigin]: + """Resolve a concrete editable-package root without using CWD. + + ``PathFinder`` above already searched the captured deployment roots. + The remaining pre-existing finders cover editable package mappings. + A result is accepted only after it is reduced to a regular package + directory outside CWD and then re-resolved with ``PathFinder`` from + that directory's parent. Thus a finder cannot hand a runner an + opaque loader or a CWD package. + """ + + current_cwd = _absolute_path(os.getcwd()) + for finder in self._deployment_meta_finders: + find_spec = getattr(finder, "find_spec", None) + if not callable(find_spec): + continue + try: + specification = find_spec(module_name, None, None) + except (ImportError, OSError, TypeError, ValueError): + continue + if specification is None: + continue + locations = getattr(specification, "submodule_search_locations", None) + if locations is None: + continue + package_locations = tuple(locations) + if len(package_locations) != 1: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package must not be a namespace package", + ) + package_directory = _absolute_path(package_locations[0]) + search_root = package_directory.parent + if ( + _path_is_within(package_directory, self._initial_cwd) + or _path_is_within(package_directory, current_cwd) + or _concrete_directory(search_root) is None + ): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package must not resolve from the current directory", + ) + try: + concrete_specification = _PATH_FINDER_FIND_SPEC(module_name, (str(search_root),)) + except (ImportError, OSError, ValueError): + concrete_specification = None + if concrete_specification is None: + raise _capability_error( + "capability_origin_unavailable", + "a reviewed editable capability package has no concrete deployment root", + ) + return self._origin_from_specification(module_name, search_root, concrete_specification) + return None + + @staticmethod + def _origin_from_specification( + module_name: str, + search_root: Path, + specification: object, + ) -> _CapabilityOrigin: + locations = getattr(specification, "submodule_search_locations", None) + origin = getattr(specification, "origin", None) + if not isinstance(origin, str) or not origin or locations is None: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package must have one concrete package origin", + ) + package_locations = tuple(locations) + if len(package_locations) != 1: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package must not be a namespace package", + ) + package_directory = _absolute_path(package_locations[0]) + initializer = _absolute_path(origin) + if ( + not _path_is_within(package_directory, search_root) + or not _path_is_within(initializer, package_directory) + or initializer.name != "__init__.py" + ): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package does not resolve inside its captured root", + ) + return _CapabilityOrigin( + module_name=module_name, + search_root=search_root, + package_directory=package_directory, + initializer=initializer, + package_identity=_PathIdentity.directory(package_directory), + initializer_identity=_PathIdentity.regular_file(initializer), + ) + + def assert_module_origin(self, module_name: str, module: object) -> None: + origin = self.origin_for(module_name) + module_file = getattr(module, "__file__", None) + module_path = getattr(module, "__path__", None) + locations = [] + if isinstance(module_file, str) and module_file: + locations.append(module_file) + if module_path is not None: + try: + locations.extend(module_path) + except TypeError: + locations = [] + if not locations or any( + not _path_is_within(location, origin.package_directory) for location in locations + ): + raise _capability_error( + "capability_origin_mismatch", + "a cached capability package is outside the captured deployment root", + ) + + def assert_cached_origins(self) -> None: + for name, module in tuple(sys.modules.items()): + if not isinstance(name, str): + continue + top_level = name.split(".", 1)[0] + if not top_level.startswith("bt_api_"): + continue + # ``import`` returns an already cached module before consulting + # meta-path finders. Therefore a preloaded, unregistered SDK + # package would otherwise bypass ``_CapabilityImportFinder`` and + # let a reviewed runner expand its capability surface through an + # ambient/CWD import. Reject it before runner source is loaded. + if top_level not in self.modules: + raise _capability_error( + "capability_module_not_registered", + "a reviewed runtime started with an unregistered external capability package", + ) + if module is None: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package is partially initialized before runner startup", + ) + self.assert_module_origin(top_level, module) + + def assert_top_level_loaded(self, module_name: str) -> None: + module = sys.modules.get(module_name) + if module is None: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability package is not available after import selection", + ) + self.assert_module_origin(module_name, module) + + def trusted_submodule_spec(self, fullname: str, top_level: str) -> object: + """Resolve one capability child through its already-pinned package path. + + A top-level package's ``__path__`` is not enough by itself: a custom + meta finder may ignore that path and hand importlib an ambient child + module. Resolve the child with ``PathFinder`` here, before any later + finder can run, and reject namespace/link/non-concrete results before + their loaders execute. + """ + + origin = self.origin_for(top_level) + origin.assert_current() + parent_name, separator, _ = fullname.rpartition(".") + if not separator: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule name is invalid", + ) + parent = sys.modules.get(parent_name) + if parent is None: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule has no trusted parent package", + ) + parent_paths = getattr(parent, "__path__", None) + try: + search_paths = tuple(parent_paths) + except TypeError: + search_paths = () + if not search_paths or any( + not _path_is_within(path, origin.package_directory) + or _concrete_directory(_absolute_path(path)) is None + for path in search_paths + ): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule parent is outside its captured package root", + ) + try: + specification = _PATH_FINDER_FIND_SPEC(fullname, search_paths) + except (ImportError, OSError, ValueError): + specification = None + if specification is None: + raise _capability_error( + "capability_origin_unavailable", + "a reviewed capability submodule is unavailable from its captured package root", + ) + self._assert_submodule_specification(fullname, origin, specification) + return specification + + @staticmethod + def _assert_submodule_specification( + fullname: str, top_level_origin: _CapabilityOrigin, specification: object + ) -> None: + """Reject a child spec which can execute outside the pinned package. + + ``trusted_submodule_spec`` obtains this value directly from + :class:`~importlib.machinery.PathFinder`; accepting a namespace child + therefore does not hand control back to ambient meta-path finders. A + namespace child has no executable origin, but is safe to admit when + it has exactly one concrete directory beneath the already-pinned + top-level package. This narrow case is needed by SDK packages which + intentionally omit ``__init__.py`` from data-only grouping folders. + """ + + if not isinstance(specification, importlib.machinery.ModuleSpec): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule has an invalid import specification", + ) + if getattr(specification, "name", None) != fullname: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule has an unexpected import name", + ) + origin = getattr(specification, "origin", None) + locations = getattr(specification, "submodule_search_locations", None) + if origin is None: + # Only a standard no-code namespace package can have no origin. + # A loader, a missing package path, or a forged spec must not be + # treated as a benign namespace merely because it has no file. + if getattr(specification, "loader", None) is not None or locations is None: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability namespace submodule is not a no-code package", + ) + if isinstance(locations, (str, bytes)): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability namespace submodule has an invalid package path", + ) + try: + package_locations = tuple(locations) + except TypeError: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability namespace submodule has an invalid package path", + ) from None + if len(package_locations) != 1: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability namespace submodule must have one concrete path", + ) + package_directory = _absolute_path(package_locations[0]) + if not _path_is_within(package_directory, top_level_origin.package_directory): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability namespace submodule resolves outside its captured package root", + ) + # lstat keeps a directory link/reparse point from being accepted + # even where its resolved destination would fall below the same + # reviewed package root. + _PathIdentity.directory(package_directory) + return + if not isinstance(origin, str) or not origin: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule lacks a concrete file origin", + ) + initializer = _absolute_path(origin) + if not _path_is_within(initializer, top_level_origin.package_directory): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule resolves outside its captured package root", + ) + _PathIdentity.regular_file(initializer) + if locations is None: + return + package_locations = tuple(locations) + if len(package_locations) != 1: + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule must not be a namespace package", + ) + package_directory = _absolute_path(package_locations[0]) + if ( + not _path_is_within(package_directory, top_level_origin.package_directory) + or _concrete_directory(package_directory) is None + or initializer.name != "__init__.py" + or not _path_is_within(initializer, package_directory) + ): + raise _capability_error( + "capability_origin_mismatch", + "a reviewed capability submodule package is not concrete", + ) + + +class _InstalledCapabilityOriginResolver(_CapabilityOriginResolver): + """Resolve registered capabilities only from this interpreter's install roots. + + It intentionally has no source-root, ``sys.path``, or editable meta-finder + fallback. This is an origin fence only: it does not claim wheel hashes, + signatures, release provenance, or isolation from trusted in-process code. + """ + + def __init__(self, modules: Sequence[str]) -> None: + self.modules = frozenset(modules) + self._initial_cwd = _absolute_path(os.getcwd()) + self.search_roots = _concrete_interpreter_install_roots() + self._origins: Dict[str, _CapabilityOrigin] = {} + + def _origin_from_deployment_meta_finder(self, module_name: str) -> Optional[_CapabilityOrigin]: + """Never permit editable/meta-path resolution in the strict boundary.""" + + del module_name + return None + + +class _CapabilityImportFinder: + """A one-run meta finder that bypasses CWD for reviewed SDK packages.""" + + def __init__(self, resolver: _CapabilityOriginResolver) -> None: + self._resolver = resolver + + def find_spec( + self, + fullname: str, + path: Optional[Sequence[str]] = None, + target: object = None, + ) -> object: + del path, target + top_level = fullname.split(".", 1)[0] + if not top_level.startswith("bt_api_"): + return None + if top_level not in self._resolver.modules: + raise _capability_error( + "capability_module_not_registered", + "the reviewed runtime did not register this external capability package", + ) + if fullname == top_level: + origin = self._resolver.origin_for(top_level) + origin.assert_current() + try: + specification = _PATH_FINDER_FIND_SPEC(top_level, (str(origin.search_root),)) + except (ImportError, OSError, ValueError): + specification = None + if specification is None: + raise _capability_error( + "capability_origin_unavailable", + "a reviewed capability package disappeared during runner startup", + ) + # ``origin_for`` has already rejected namespace, link, and + # cross-root results. Revalidate before handing the loader back + # to importlib so a changed package is rejected before execution. + self._resolver._origin_from_specification(top_level, origin.search_root, specification) + return specification + self._resolver.assert_top_level_loaded(top_level) + return self._resolver.trusted_submodule_spec(fullname, top_level) + + +def _installed_capability_module_names(capability_modules: Sequence[str]) -> Tuple[str, ...]: + """Reject non-top-level or duplicate names before strict origin resolution.""" + + if isinstance(capability_modules, (str, bytes)): + raise _capability_error( + "capability_module_not_registered", + "installed capability imports require an exact sequence of top-level packages", + ) + try: + modules = tuple(capability_modules) + except TypeError: + raise _capability_error( + "capability_module_not_registered", + "installed capability imports require an exact sequence of top-level packages", + ) from None + if any( + type(module_name) is not str or not module_name.startswith("bt_api_") or "." in module_name + for module_name in modules + ): + raise _capability_error( + "capability_module_not_registered", + "installed capability imports require registered top-level bt_api packages", + ) + if len(set(modules)) != len(modules): + raise _capability_error( + "capability_module_not_registered", + "installed capability imports must not contain duplicate package names", + ) + return modules + + +@contextmanager +def trusted_capability_import_context( + source_root: Optional[Path], capability_modules: Sequence[str] +) -> Generator[None, None, None]: + """Pin reviewed SDK imports for one shipped runner execution. + + Test-only registries with no inventory source root retain the existing + runner-file loader behavior. A shipped runner with an empty allow-list + instead means that *no* external ``bt_api_*`` package is approved: its + cached and newly requested capability imports still fail closed. + """ + + modules = tuple(capability_modules) + if source_root is None: + yield + return + resolver = _CapabilityOriginResolver(modules, source_root) + resolver.assert_cached_origins() + finder = _CapabilityImportFinder(resolver) + sys.meta_path.insert(0, finder) + try: + yield + except Exception: + raise + else: + # Catch a package whose cached paths were altered by code that ran + # under the reviewed runner before its report becomes public. + resolver.assert_cached_origins() + finally: + try: + sys.meta_path.remove(finder) + except ValueError: + pass + + +def first_unavailable_trusted_capability_module( + source_root: Path, + capability_modules: Sequence[str], + required_modules: Sequence[str], +) -> Optional[str]: + """Return the first absent, declared package without importing its code. + + This is a narrow preflight for reviewed routes that must fail before their + runner starts. It uses the same non-CWD origin resolver as the later + import fence, so an ambient package cannot satisfy the check. Resolving a + package specification does not execute its initializer or import provider + code. + """ + + modules = _installed_capability_module_names(capability_modules) + required = _installed_capability_module_names(required_modules) + if any(module_name not in modules for module_name in required): + raise _capability_error( + "capability_module_not_registered", + "a required capability package is absent from the reviewed import allow-list", + ) + + resolver = _CapabilityOriginResolver(modules, source_root) + resolver.assert_cached_origins() + for module_name in required: + try: + resolver.origin_for(module_name) + except RuntimeConfigError as error: + if error.reason == "capability_origin_unavailable": + return module_name + raise + return None + + +@contextmanager +def trusted_installed_capability_import_context( + capability_modules: Sequence[str], +) -> Generator[None, None, None]: + """Pin registered SDK imports to this interpreter's exact install roots. + + Unlike :func:`trusted_capability_import_context`, this strict context does + not admit a source root, absolute ``PYTHONPATH`` entry, or editable + meta-path mapping. Each registered package is resolved before code under + the context can run, and cached top-level/submodule locations must remain + below that exact pinned installed package directory. + + This rejects origin substitution; it does not verify a wheel signature, + release provenance, dependency lock, or arbitrary trusted in-process + Python code. + """ + + modules = _installed_capability_module_names(capability_modules) + resolver = _InstalledCapabilityOriginResolver(modules) + # Resolve each name eagerly. If an absolute PYTHONPATH/cache package is + # the only available copy, fail before a future credential-backed factory + # can run. The resolver intentionally has no editable/meta-finder path. + for module_name in modules: + resolver.origin_for(module_name) + resolver.assert_cached_origins() + finder = _CapabilityImportFinder(resolver) + sys.meta_path.insert(0, finder) + try: + yield + except Exception: + raise + else: + resolver.assert_cached_origins() + finally: + try: + sys.meta_path.remove(finder) + except ValueError: + pass + + +__all__ = [ + "first_unavailable_trusted_capability_module", + "trusted_capability_import_context", + "trusted_installed_capability_import_context", +] diff --git a/backtrader_runtime/cli.py b/backtrader_runtime/cli.py new file mode 100644 index 00000000..aeb4dea4 --- /dev/null +++ b/backtrader_runtime/cli.py @@ -0,0 +1,1673 @@ +"""The deliberately small configuration-first ``bt-runtime`` CLI skeleton.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from pathlib import Path +from typing import Mapping, Optional, Sequence, TextIO + +from .config import load_runtime_config +from .evidence_bundle import collect_iteration41_evidence +from .errors import CONFIG_SCHEMA_UNSUPPORTED, PRESET_POLICY_VIOLATION, RuntimeConfigError +from .policy import get_preset_policy, preset_names +from .registry import ( + RuntimeRegistry, + bootstrap_runtime_config, + bootstrap_runtime_set, + default_runtime_registry, + select_bootstrap_preset, + validate_runtime_config, +) + + +_OVERRIDE_ENVIRONMENT_NAMES = ( + "BT_RUNTIME_MODE", + "BT_RUNTIME_PRESET", + "BACKTRADER_RUNTIME_MODE", + "BACKTRADER_RUNTIME_PRESET", +) +_DISALLOWED_RUN_ARGUMENTS = ("--mode", "--preset", "--config", "--runtime-mode", "--runtime-preset") +_REPORT_SUMMARY_FIELDS = ( + "status", + "admission_status", + "hft_status", + "candidate_id", + "scenario", + "actual_fills", + "external_network_requests", + "external_write_requests", + "external_request_counts", + "evidence_boundary", +) +_RUNTIME_REPORT_FIELDS = ( + "scope", + "evidence_boundary", + "allows_network", + "allows_external_writes", + "allows_production_writes", +) +_DOCTOR_LIVE_SAFE_RELOAD_REASONS = frozenset( + ( + "approval_receipt_missing", + "environment_override_not_allowed", + "parameter_not_registered", + "required_capability_not_declared", + "secrets_ref_not_registered", + ) +) +_CTP_SIMNOW_PREFLIGHT_SUPERVISOR_REASON = "ctp_simnow_preflight_supervisor_required" +_CTP_SIMNOW_PREFLIGHT_SUPERVISOR_ACTION = "preflight_requires_process_supervisor" +_CTP_SIMNOW_PREFLIGHT_SUPERVISOR_NOTE = ( + "ordinary bt-runtime preflight is disabled until native CTP startup and shutdown run under a " + "hard process supervisor; use doctor for offline config validation. This rejection happens " + "before credentials, SDK import, or provider/network I/O" +) + + +class _ParserError(Exception): + pass + + +class _RuntimeArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> None: + raise _ParserError(message) + + +def build_parser() -> argparse.ArgumentParser: + """Build the public CLI parser without importing runtime capabilities.""" + + parser = _RuntimeArgumentParser( + prog="bt-runtime", + description="Configuration-first Backtrader runtime launcher", + ) + commands = parser.add_subparsers(dest="command") + commands.required = True + + bootstrap = commands.add_parser( + "bootstrap", help="atomically create safe schema-v4 config files" + ) + bootstrap_target = bootstrap.add_mutually_exclusive_group(required=True) + bootstrap_target.add_argument("--strategy-dir", type=Path) + bootstrap_target.add_argument( + "--runtime-set", + help="reviewed code-owned runtime set to bootstrap; never a filesystem manifest path", + ) + bootstrap.add_argument( + "--preset", + default=None, + choices=preset_names(), + help="optional reviewed preset; defaults to this runtime's safest registered preset", + ) + + for command_name, command_help in ( + ( + "prepare-ctp-config", + "prepare the protected shared CTP config.yaml from explicit private source files", + ), + ("prepare-ctp-simnow-config", "legacy alias for prepare-ctp-config"), + ): + prepare_ctp = commands.add_parser(command_name, help=command_help) + prepare_ctp.add_argument( + "--source-env", + type=Path, + help=( + "absolute owner-only .env path; may combine with one YAML. If it supplies TD/MD " + "fields alongside YAML front_pairs, both must exactly match one listed candidate" + ), + ) + prepare_ctp.add_argument( + "--source-yaml", + type=Path, + help=( + "absolute owner-only YAML path with a canonical ctp mapping (or legacy " + "ctp_simnow mapping); direct md_front/td_front fields or explicit front_pairs; " + "never auto-discovered" + ), + ) + prepare_ctp.add_argument( + "--source-collector-yaml", + type=Path, + help="absolute path to an owner-only collector YAML with a ctp mapping; requires a second source for instrument/exchange/HedgeFlag", + ) + prepare_ctp.add_argument( + "--runtime-id", + choices=sorted(_ctp_private_readonly_runtime_ids()), + default=None, + help=( + "select one reviewed private CTP config target; defaults to the existing " + "013_3 target" + ), + ) + + commands.add_parser( + "collect-evidence", + help="collect code-owned local Iteration 41 metadata without runtime, provider, or network I/O", + ) + + for name, help_text in ( + ("validate", "validate config and its sealed runtime policy without provider I/O"), + ("doctor", "print redacted offline configuration diagnostics"), + ( + "check-ctp-fronts", + "probe configured CTP MD/TD TCP fronts from a sealed private sandbox config", + ), + ("run", "validate, then dispatch the runtime's code-owned entrypoint"), + ( + "preflight", + "private read-only preflight where available; registered 007/013_3 CTP routes are disabled pending a bounded supervisor", + ), + ): + command = commands.add_parser(name, help=help_text) + command.add_argument("--strategy-dir", required=True, type=Path) + if name == "run": + command.add_argument( + "--confirm-live", + action="store_true", + help="confirm an already-approved live contract; never changes its mode", + ) + command.add_argument( + "--full-report", + action="store_true", + help="emit the complete local runner report after safe dispatch", + ) + return parser + + +def _write_payload(stream: TextIO, payload: dict) -> None: + stream.write(json.dumps(payload, default=str, ensure_ascii=True, sort_keys=True) + "\n") + + +def _write_error(stream: TextIO, error: RuntimeConfigError) -> None: + _write_payload(stream, error.as_dict()) + + +def _profile_dispatch_unavailable() -> RuntimeConfigError: + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "profile-scoped runtime dispatch is not enabled", + field_path="runtime.preset", + reason="profile_dispatch_unavailable", + ) + + +def dispatch_registered_runtime( + effective: object, registry: RuntimeRegistry +) -> Mapping[str, object]: + """Import dispatch only after ``run`` passes the offline policy gates. + + Keeping this import here lets ``bootstrap``, ``validate``, and ``doctor`` + remain in the configuration-only surface. The wrapper retains the module + attribute used by reviewed programmatic callers while avoiding a runner + import merely to render offline diagnostics. + """ + + from .runner import dispatch_registered_runtime as dispatch + + return dispatch(effective, registry) + + +def dispatch_registered_ctp_simnow_readonly_preflight( + effective: object, registry: RuntimeRegistry +) -> object: + """Import the CTP composition only after the CLI policy gates succeed.""" + + from .ctp_simnow_operator import ( + dispatch_registered_ctp_simnow_readonly_preflight as dispatch, + ) + + return dispatch(effective, registry) + + +def dispatch_registered_ctp_front_check(effective: object, registry: RuntimeRegistry) -> object: + """Import the credential-free front diagnostic after offline policy gates.""" + + from .ctp_configured_front_check import check_configured_ctp_fronts + + return check_configured_ctp_fronts(effective, registry) + + +def _ctp_private_readonly_runtime_ids() -> frozenset: + """Return the exact registered CTP private read-only runtime identities.""" + + from .inventory import ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ) + + return frozenset( + ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ) + ) + + +def _doctor_write_boundary( + *, allows_external_writes: bool, allows_hypothetical_fills: bool, blocked: bool = False +) -> tuple: + """Return the operator-facing write and PnL boundaries for one policy.""" + + if blocked: + return ( + "blocked_before_external_writes", + "not_started_provider_reconciliation_required", + ) + if allows_external_writes: + return "managed_preflight_and_approval_required", "provider_reconciliation_required" + if allows_hypothetical_fills: + return "zero_external_writes_local_simulation", "local_hypothetical_only" + return "zero_external_writes", "not_applicable_without_external_fills" + + +def _doctor_destination(order_route: object, account_access: object) -> str: + """Name a route in terms an operator can distinguish without loading it.""" + + if account_access == "sandbox_private_read": + return "sandbox_private_account_read" + if not order_route: + return "local_runtime" + if account_access == "fake_provider": + return "offline_fake_provider_managed_execution" + if order_route == "managed_execution" and account_access: + return "managed_execution:{0}".format(account_access) + return str(order_route) + + +def _doctor_operator_summary( + *, + mode: str, + preset: str, + environment: str, + order_route: object, + account_access: object, + allows_external_writes: bool, + allows_hypothetical_fills: bool, + required_capabilities: Sequence[str], + requires_approval: bool, + blocked: bool = False, + profile_dispatch_available: bool = True, + profile_dispatch_unavailable_reason: Optional[str] = None, +) -> dict: + """Build a redacted operator summary from already-resolved policy data.""" + + write_boundary, pnl_source = _doctor_write_boundary( + allows_external_writes=allows_external_writes, + allows_hypothetical_fills=allows_hypothetical_fills, + blocked=blocked or not profile_dispatch_available, + ) + summary = { + "mode": mode, + "preset": preset, + "destination": ( + _doctor_destination(order_route, account_access) + if profile_dispatch_available + else "profile_dispatch_unavailable" + ), + "environment": environment, + "write_boundary": write_boundary, + "pnl_source": pnl_source, + "required_capabilities": list(required_capabilities), + "requires_approval": requires_approval, + } + if not profile_dispatch_available: + summary["profile_dispatch_available"] = False + if profile_dispatch_unavailable_reason is not None: + summary["profile_dispatch_unavailable_reason"] = profile_dispatch_unavailable_reason + if blocked or not profile_dispatch_available: + summary["admission_status"] = "blocked" + return summary + + +def _ctp_private_operator_actions( + *, + strategy_dir: Path, + mode: str, + preset: str, + profile_dispatch_available: bool, + profile_dispatch_unavailable_reason: Optional[str], + unavailable_live_profile_note: str = "The registered 013_3 production profile is unavailable.", +) -> dict: + """Give registered private CTP operators a redacted action/status matrix. + + The matrix reports only code-owned route status and the requested + mode/preset. It deliberately does not copy private CTP fields, configured + front addresses, or credential material from the sealed config. + """ + + live_requested = mode == "live" and preset == "managed_live_direct" + dispatch_reason = profile_dispatch_unavailable_reason or "profile_dispatch_unavailable" + return { + "mode": mode, + "preset": preset, + "doctor": {"status": "offline"}, + "check-ctp-fronts": ( + { + "status": "tcp_only", + "command": [ + "bt-runtime", + "check-ctp-fronts", + "--strategy-dir", + str(strategy_dir), + ], + "note": ( + "credential-free TCP reachability only; it does not test login or authorize " + "trading" + ), + } + if mode == "simulation" and preset == "sandbox" + else {"status": "unavailable", "reason": "ctp_front_check_profile_required"} + ), + "preflight": { + "status": "disabled", + "reason": ( + _CTP_SIMNOW_PREFLIGHT_SUPERVISOR_REASON + if mode == "simulation" and preset == "sandbox" + else "managed_live_direct_profile_unavailable" + ), + }, + "run": ( + {"status": "available"} + if profile_dispatch_available + else {"status": "unavailable", "reason": dispatch_reason} + ), + "live": { + "status": "unavailable", + "reason": "managed_live_direct_profile_unavailable", + "requested": live_requested, + "authorization": "not_granted", + "note": ( + "The config requests live/managed_live_direct; that request does not authorize " + "production trading." + if live_requested + else unavailable_live_profile_note + ), + }, + } + + +def _blocked_live_doctor_diagnostic( + error: RuntimeConfigError, + strategy_dir: Path, + registry: RuntimeRegistry, +) -> Optional[dict]: + """Explain a statically valid but unapproved live policy without probing it. + + The normal resolver deliberately fails before any live runtime dispatch. + Doctor still has enough information in the strict local config and sealed + registry to say *what is blocked*. This helper must stay local: it reads + no secret, imports no runner, and never starts provider preflight. + """ + + ctp_profile_unavailable_error = error.reason == "managed_live_direct_profile_unavailable" + if error.reason not in _DOCTOR_LIVE_SAFE_RELOAD_REASONS and not ctp_profile_unavailable_error: + return None + try: + config = load_runtime_config(strategy_dir, registry=registry) + registration = registry.require_runtime_dir(config.strategy_dir) + except RuntimeConfigError: + return None + policy = get_preset_policy(config.preset) + profile = registration.profile_for(config.mode, config.preset) + if config.mode != "live" or policy is None or config.strategy_id != registration.strategy_id: + return None + from .inventory import ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ) + + ctp_private_live_unavailable = ( + registration.runtime_id + in (ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID) + and config.preset == "managed_live_direct" + and profile is None + and error.reason == "managed_live_direct_profile_unavailable" + and any( + item.mode == "live" + and item.preset == "managed_live_direct" + and item.reason == "managed_live_direct_profile_unavailable" + for item in registration.unavailable_mode_profiles + ) + ) + if ctp_profile_unavailable_error and not ctp_private_live_unavailable: + return None + if not ctp_private_live_unavailable and ( + (registration.profiles and profile is None) + or (not registration.profiles and config.preset not in registration.allowed_presets) + ): + return None + if ctp_private_live_unavailable: + next_actions = _next_actions_for_error(error, strategy_dir, registry=registry) + unavailable_live_profile_note = ( + "The registered 007 CTP live profile is unavailable." + if registration.runtime_id == ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + else "The registered 013_3 production profile is unavailable." + ) + return { + "offline": True, + "provider_preflight_started": False, + "operator_summary": _doctor_operator_summary( + mode=config.mode, + preset=config.preset, + environment=policy.environment, + order_route=policy.order_route, + account_access=policy.account_access, + allows_external_writes=policy.allows_external_writes, + allows_hypothetical_fills=policy.allows_hypothetical_fills, + required_capabilities=policy.required_capabilities, + requires_approval=policy.requires_approval, + blocked=True, + profile_dispatch_available=False, + profile_dispatch_unavailable_reason="managed_live_direct_profile_unavailable", + ), + "blockers": [_error_blocker(error)], + "next_actions": next_actions, + "profile_dispatch_available": False, + "profile_dispatch_unavailable_reason": "managed_live_direct_profile_unavailable", + "operator_actions": _ctp_private_operator_actions( + strategy_dir=strategy_dir, + mode=config.mode, + preset=config.preset, + profile_dispatch_available=False, + profile_dispatch_unavailable_reason="managed_live_direct_profile_unavailable", + unavailable_live_profile_note=unavailable_live_profile_note, + ), + } + policy_values = registration if profile is None else profile + + related_errors = [error] + if policy.requires_approval and policy_values.approval_receipt_digest is None: + related_errors.append( + RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered write-capable profile has no trusted approval receipt", + field_path="runtime.preset", + reason="approval_receipt_missing", + ) + ) + missing_capabilities = tuple( + capability + for capability in policy.required_capabilities + if capability not in policy_values.available_capabilities + ) + if missing_capabilities: + related_errors.append( + RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered profile lacks a required managed capability", + field_path="runtime.preset", + reason="required_capability_not_declared", + ) + ) + + blockers = [] + next_actions = [] + profile_dispatch_available = profile is None + if not profile_dispatch_available: + next_actions.append( + { + "action": "review_profile_dispatch", + "field_path": "runtime.preset", + "note": "profile validation is offline; profile-scoped dispatch is not enabled", + } + ) + live_action = { + "action": "review_live_contract", + "note": ( + "live remains blocked. A reviewed deployment must bind the required " + "capabilities and a trusted approval receipt; live confirmation cannot " + "bypass this block." + ), + } + for related_error in related_errors: + blocker = _error_blocker(related_error) + if blocker not in blockers: + blockers.append(blocker) + if related_error.reason in ( + "approval_receipt_missing", + "required_capability_not_declared", + ): + if live_action not in next_actions: + next_actions.append(live_action) + continue + for action in _next_actions_for_error(related_error, strategy_dir, registry=registry): + if action not in next_actions: + next_actions.append(action) + + return { + "offline": True, + "provider_preflight_started": False, + "operator_summary": _doctor_operator_summary( + mode=config.mode, + preset=config.preset, + environment=policy.environment, + order_route=policy.order_route, + account_access=policy.account_access, + allows_external_writes=policy.allows_external_writes, + allows_hypothetical_fills=policy.allows_hypothetical_fills, + required_capabilities=policy.required_capabilities, + requires_approval=policy.requires_approval, + blocked=True, + profile_dispatch_available=profile_dispatch_available, + profile_dispatch_unavailable_reason=( + None if profile_dispatch_available else "profile_dispatch_unavailable" + ), + ), + "blockers": blockers, + "next_actions": next_actions, + "profile_dispatch_available": profile_dispatch_available, + "profile_dispatch_unavailable_reason": ( + None if profile_dispatch_available else "profile_dispatch_unavailable" + ), + } + + +def _error_blocker(error: RuntimeConfigError) -> dict: + """Project a safe error into the compact diagnostic blocker vocabulary.""" + + return { + "field_path": error.field_path or "config.yaml", + "reason": error.reason or "runtime_validation_failed", + "message": error.message, + } + + +def _next_actions_for_error( + error: RuntimeConfigError, + strategy_dir: Optional[Path], + *, + registry: Optional[RuntimeRegistry] = None, + rejected_arguments: Sequence[str] = (), +) -> list: + """Return one safe, operator-actionable response to a rejection. + + The action never supplies an alternate mode, preset, secret, route, or + runner. It either points to the only safe bootstrap command or tells the + operator which reviewed record needs attention. Keeping this mapping in + the CLI means ``bootstrap``, ``validate``, ``doctor``, and failed ``run`` + share one vocabulary without importing a runtime. + """ + + reason = error.reason or "" + if reason == _CTP_SIMNOW_PREFLIGHT_SUPERVISOR_REASON: + return [ + { + "action": _CTP_SIMNOW_PREFLIGHT_SUPERVISOR_ACTION, + "field_path": error.field_path or "command", + "note": _CTP_SIMNOW_PREFLIGHT_SUPERVISOR_NOTE, + } + ] + if reason in ("provider_dependency_version_unreviewed", "provider_dependency_unavailable"): + return [ + { + "action": "install_reviewed_public_shadow_dependencies", + "field_path": error.field_path or "runtime.dependencies", + "note": ( + "from the Backtrader repository root, install the reviewed public OKX " + "shadow dependency pair in the same Python environment as bt-runtime; " + "this does not grant account or order access" + ), + "command": ["python", "-m", "pip", "install", "-e", ".[okx-public-shadow]"], + } + ] + if reason in ( + "public_market_startup_incomplete", + "public_market_read_failed", + "public_market_observation_empty", + "provider_shutdown_incomplete", + "provider_hard_timeout", + "provider_hard_timeout_loop_unavailable", + ): + return [ + { + "action": "inspect_public_market_session", + "field_path": error.field_path or "runtime.runner", + "note": ( + "the bounded public-market run did not complete; inspect provider/network " + "availability and the reviewed dependency pair before retrying this same config" + ), + } + ] + if reason == "ctp_private_source_incomplete": + return [ + { + "action": "complete_explicit_ctp_source", + "field_path": error.field_path or "ctp", + "note": ( + error.message + + "; fill these names in the explicitly selected local .env or YAML source, " + "then rerun prepare-ctp-config. No config was written" + ), + } + ] + if reason.startswith("private_source_"): + return [ + { + "action": "protect_explicit_source", + "field_path": "source", + "note": ( + "restrict the explicitly named local .env or YAML source to the current user " + "(Windows protected owner-only ACL or POSIX owner-only permissions), then " + "rerun the same command. The source content is not read before this check" + ), + } + ] + if reason.startswith("private_target_"): + return [ + { + "action": "repair_private_target", + "field_path": error.field_path or "config.yaml", + "note": ( + "check the reserved runtime-ctp-private directory and its current-user " + "ownership; the helper will restrict its permissions only after all source " + "fields pass validation" + ), + } + ] + if reason.startswith(("source_", "private_config_")): + return [ + { + "action": "review_private_config_source", + "field_path": error.field_path or "source", + "note": ( + "check the explicitly selected local source syntax and required schema fields; " + "the helper never interpolates values, selects endpoints, or overwrites config.yaml" + ), + } + ] + if reason == "missing_config" and strategy_dir is not None: + if registry is not None: + try: + from .ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding + + registration = registry.require_runtime_dir(strategy_dir) + binding = registry.require_ctp_simnow_readonly_binding(registration.runtime_id) + if type(binding) is CtpSimNowConfigReadOnlyBinding: + return [ + { + "action": "prepare_ctp_private_config", + "field_path": "config.yaml", + "note": ( + "prepare the single protected, Git-ignored runtime-ctp-private/config.yaml " + "with the canonical top-level ctp mapping, using one or two " + "explicit owner-only local sources that together contain either a " + "direct md_front/td_front pair or an explicit front_pairs list, plus " + "instrument_id, exchange_id, hedge_flag and all five CTP " + "authentication fields. Overlapping fields must agree. The setup " + "command does not read process environment variables, infer endpoints, " + "or authorize provider access; the registered route remains " + "simulation/sandbox read-only. Bootstrap cannot create this private " + "configuration, and changing mode/preset alone does not register a " + "production route" + ), + "command_examples": [ + [ + "bt-runtime", + "prepare-ctp-config", + "--source-env", + "", + ], + [ + "bt-runtime", + "prepare-ctp-config", + "--source-yaml", + "", + ], + [ + "bt-runtime", + "prepare-ctp-config", + "--source-collector-yaml", + "", + "--source-yaml", + "", + ], + ], + } + ] + except RuntimeConfigError: + pass + return [ + { + "action": "bootstrap", + "command": ["bt-runtime", "bootstrap", "--strategy-dir", str(strategy_dir)], + "note": "creates only the reviewed safe config; it never overwrites an existing file", + } + ] + if reason == "ctp_simnow_preflight_front_policy_required": + return [ + { + "action": "review_registration", + "field_path": "runtime.preset", + "note": ( + "replace the legacy static SimNow profile binding with a reviewed config-driven " + "front-pair policy" + ), + } + ] + if reason in ( + "ctp_simnow_preflight_route_unregistered", + "runtime_not_registered", + "invalid_runtime_directory", + "runtime_directory_unavailable", + "runtime_set_not_registered", + "registry_not_trusted", + ): + return [ + { + "action": "review_registration", + "field_path": error.field_path or "strategy_dir", + "note": ( + "use a reviewed registered runtime or runtime set; the CLI never discovers " + "a route from the current directory, an environment variable, or AI output" + ), + } + ] + if reason in ("config_exists", "existing_config_differs"): + action = { + "action": "review_existing_config", + "field_path": "config.yaml", + "note": "the existing file is preserved; inspect it with offline doctor before any reviewed edit", + } + if strategy_dir is not None: + action["command"] = ["bt-runtime", "doctor", "--strategy-dir", str(strategy_dir)] + return [action] + if reason in ( + "inline_secret", + "invalid_secrets_ref", + "secrets_not_allowed_for_preset", + "secrets_ref_not_registered", + "offline_managed_execution_forbids_secrets", + ): + return [ + { + "action": "review_secret_reference", + "field_path": error.field_path or "secrets_ref", + "note": ( + "ordinary runtimes use only a reviewed opaque secret reference; the " + "CTP SimNow simulation/sandbox private config_yaml profile is a separate " + "strictly validated local exception. Review this runtime's registered " + "secret source, then run doctor again" + ), + } + ] + if reason == "ctp_simnow_private_config_required": + return [ + { + "action": "prepare_ctp_private_config", + "field_path": "config.yaml", + "note": ( + "bootstrap cannot create CTP account and front fields; see " + "examples/013_3_sa_midfreq_simnow/runtime-ctp-private/README.md for the " + "source schema, then run bt-runtime prepare-ctp-config with an explicit " + "owner-only source to create the protected, Git-ignored config.yaml" + ), + } + ] + if reason.startswith(("private_config_", "config_yaml_windows_")): + return [ + { + "action": "protect_ctp_private_config", + "field_path": "config.yaml", + "note": ( + "keep the private config untracked; restrict the runtime " + "directory and config file to the current user and trusted system " + "administrators, then run doctor again" + ), + } + ] + if reason in ( + "ctp_simnow_preflight_front_rejected", + "ctp_simnow_preflight_front_probe_rejected", + ): + return [ + { + "action": "check_configured_ctp_fronts", + "field_path": "ctp.front_pairs", + "note": ( + "check the exact MD/TD addresses in config.yaml and their network " + "reachability; only configured pairs can be selected" + ), + } + ] + if reason in ( + "ctp_simnow_preflight_capability_origin_rejected", + "ctp_simnow_preflight_capability_provenance_rejected", + ): + return [ + { + "action": "install_reviewed_ctp_sdk_wheels", + "field_path": "runtime.capabilities", + "note": ( + "install the independently reviewed and code-pinned bt_api_base and " + "bt_api_ctp wheels in this Python environment; editable or source " + "installs cannot pass private-account preflight" + ), + } + ] + if reason == "managed_live_direct_profile_unavailable": + return [ + { + "action": "review_live_enablement", + "field_path": "runtime.preset", + "note": ( + "this same protected config.yaml is the future input to one shared CTP execution runner for simulation and live mode, " + "shared-runner live dispatch and production-specific admission are not enabled, and editing mode or parameters alone does not enable trading; " + "keep the zero-write sandbox profile until the reviewed live route is available" + ), + } + ] + if ( + reason == "profile_dispatch_unavailable" + and registry is not None + and strategy_dir is not None + ): + try: + registration = registry.require_runtime_dir(strategy_dir) + from .ctp_sandbox_readonly_admission import ( + require_ctp_sandbox_profile_registration, + ) + + require_ctp_sandbox_profile_registration(registration, registry) + except Exception: + pass + else: + return [ + { + "action": "preflight", + "command": [ + "bt-runtime", + "preflight", + "--strategy-dir", + str(strategy_dir), + ], + "note": ( + "this profile has no run entrypoint for strategy execution; the exact CTP " + "read-only preflight remains available and grants no write authority" + ), + } + ] + if reason in ( + "unsupported_mode", + "unsupported_preset", + "mode_preset_mismatch", + "preset_not_registered", + "strategy_id_not_registered", + "parameter_not_registered", + "runtime_control_field_not_allowed", + "environment_interpolation_not_allowed", + ): + return [ + { + "action": "review_configuration", + "field_path": error.field_path or "config.yaml", + "note": ( + "make this field match the reviewed registration instead of passing an override, " + "then run doctor again" + ), + } + ] + if reason == "environment_override_not_allowed": + return [ + { + "action": "remove_environment_override", + "field_path": error.field_path or "environment", + "note": ( + "remove this variable; environment values cannot choose mode or preset. " + "The reviewed config.yaml remains authoritative" + ), + } + ] + if reason == "cli_override_not_allowed": + action = { + "action": "remove_cli_override", + "field_path": error.field_path or "argv", + "note": ( + "remove mode, preset, config, or runtime override flags; they cannot change the " + "reviewed config.yaml" + ), + } + if rejected_arguments: + action["rejected_arguments"] = list(rejected_arguments) + return [action] + if reason == "cli_argument_not_allowed": + return [ + { + "action": "use_explicit_registered_runtime", + "field_path": error.field_path or "argv", + "note": ( + "provide one supported command and its explicit --strategy-dir; the CLI never " + "uses CWD, arbitrary config paths, or AI-produced arguments as a runtime" + ), + } + ] + if reason in ( + "approval_receipt_missing", + "required_capability_not_declared", + "live_confirmation_required", + "live_confirmation_not_applicable", + ): + return [ + { + "action": "review_live_contract", + "field_path": error.field_path or "runtime.preset", + "note": ( + "live remains blocked until a reviewed deployment binds the required capabilities " + "and trusted approval; confirmation cannot change mode, account, or scope" + ), + } + ] + if reason in ( + "runner_not_registered", + "runner_entrypoint_invalid", + "runner_report_invalid", + "ctp_simnow_preflight_route_unregistered", + ): + if reason == "runner_not_registered" and strategy_dir is not None and registry is not None: + try: + registration = registry.require_runtime_dir(strategy_dir) + _require_config_driven_ctp_binding(registry, registration.runtime_id) + except RuntimeConfigError: + pass + else: + return [ + { + "action": "preflight", + "field_path": error.field_path or "runner", + "command": [ + "bt-runtime", + "preflight", + "--strategy-dir", + str(strategy_dir), + ], + "note": ( + "this registered CTP private-read runtime has no run entrypoint; use " + "preflight for its bounded read-only checks, which grant no write authority" + ), + } + ] + return [ + { + "action": "review_registration", + "field_path": error.field_path or "runner", + "note": ( + "a code-owned registered route is required; no path, module, scope, or " + "plugin override is accepted" + ), + } + ] + if reason == "capability_dependency_missing": + action = { + "action": "install_runtime_dependency", + "field_path": error.field_path or "runtime.capabilities", + "note": ( + "install the reviewed local SDK package that provides this capability in the same " + "Python environment used by bt-runtime, then rerun the registered runtime" + ), + } + if strategy_dir is not None: + action["command"] = [ + "bt-runtime", + "run", + "--strategy-dir", + str(strategy_dir), + ] + return [action] + return [ + { + "action": "review_configuration", + "field_path": error.field_path or "config.yaml", + "note": "correct the reported field in the registered local config, then run doctor again", + } + ] + + +def _operator_error_payload( + error: RuntimeConfigError, + *, + command: Optional[str], + strategy_dir: Optional[Path], + registry: RuntimeRegistry, + additional_errors: Sequence[RuntimeConfigError] = (), + rejected_arguments: Sequence[str] = (), + dispatch_started: bool = False, +) -> dict: + """Add offline next steps to every CLI rejection without loosening a gate. + + A primary loader/policy error remains the top-level stable error. When + independently observable environment override attempts are also present, + they are returned together as blockers so an operator can clear them in one + pass. The strict loader intentionally remains first-error for malformed + YAML because parsing arbitrary invalid source twice would risk exposing or + normalising untrusted configuration text. + """ + + payload = error.as_dict() + blocked_live = None + if command == "doctor" and strategy_dir is not None: + blocked_live = _blocked_live_doctor_diagnostic(error, strategy_dir, registry) + if blocked_live is None: + # These dependency/origin errors are raised before a client is + # constructed. Other dispatch failures may have crossed the I/O + # boundary, so the CLI must not label them offline by default. + provider_io_may_have_started = dispatch_started and error.reason not in ( + "capability_dependency_missing", + "provider_dependency_version_unreviewed", + "provider_dependency_unavailable", + "provider_origin_untrusted", + "strategy_callback_dependency_unavailable", + "strategy_callback_initialization_failed", + # The config-driven CTP binding can reject malformed front + # selection before any transport probe or provider access. A + # failed TCP probe is not offline and is intentionally omitted. + "ctp_simnow_preflight_front_rejected", + ) + diagnostic = { + "offline": not provider_io_may_have_started, + "provider_preflight_started": provider_io_may_have_started and command == "preflight", + "next_actions": _next_actions_for_error( + error, + strategy_dir, + registry=registry, + rejected_arguments=rejected_arguments, + ), + } + if provider_io_may_have_started: + diagnostic["provider_io_may_have_started"] = True + else: + diagnostic = blocked_live + + if additional_errors: + blockers = diagnostic.get("blockers") + if blockers is None: + blockers = [_error_blocker(error)] + diagnostic["blockers"] = blockers + existing = {(item.get("field_path"), item.get("reason")) for item in blockers} + actions = diagnostic["next_actions"] + for additional in additional_errors: + blocker = _error_blocker(additional) + key = (blocker["field_path"], blocker["reason"]) + if key in existing: + continue + blockers.append(blocker) + actions.extend(_next_actions_for_error(additional, strategy_dir, registry=registry)) + existing.add(key) + + payload["diagnostic"] = diagnostic + return payload + + +def _doctor_error_payload( + error: RuntimeConfigError, strategy_dir: Path, registry: RuntimeRegistry +) -> dict: + """Backward-compatible doctor helper for callers outside :func:`main`.""" + + return _operator_error_payload( + error, + command="doctor", + strategy_dir=strategy_dir, + registry=registry, + ) + + +def _disallowed_run_arguments(argv: Sequence[str]) -> tuple: + """Return every rejected override flag without retaining user supplied values.""" + + if not argv or argv[0] not in ( + "validate", + "doctor", + "run", + "preflight", + "check-ctp-fronts", + ): + return () + rejected = [] + for token in argv[1:]: + for disallowed in _DISALLOWED_RUN_ARGUMENTS: + if token == disallowed or token.startswith(disallowed + "="): + if disallowed not in rejected: + rejected.append(disallowed) + return tuple(rejected) + + +def _contains_disallowed_run_argument(argv: Sequence[str]) -> Optional[str]: + """Return the first rejected override for compatibility with older callers.""" + + rejected = _disallowed_run_arguments(argv) + return rejected[0] if rejected else None + + +def _environment_override_errors(environ: Mapping[str, str]) -> tuple: + """Collect static mode/preset override attempts without applying any of them.""" + + errors = [] + for name in _OVERRIDE_ENVIRONMENT_NAMES: + value = environ.get(name) + if value: + errors.append( + RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "environment variables cannot override runtime.mode or runtime.preset", + field_path="environment.{0}".format(name), + reason="environment_override_not_allowed", + ) + ) + return tuple(errors) + + +def _reject_environment_overrides(environ: Mapping[str, str]) -> None: + errors = _environment_override_errors(environ) + if errors: + raise errors[0] + + +def _success_payload(effective: object, status: str, **extra: object) -> dict: + payload = {"status": status} + payload.update(effective.as_public_dict()) + payload.update(extra) + return payload + + +def _require_config_driven_ctp_binding(registry: RuntimeRegistry, runtime_id: str) -> object: + """Reject legacy static routes before loading a private CTP config.""" + + from .ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding + + binding = registry.require_ctp_simnow_readonly_binding(runtime_id) + if type(binding) is not CtpSimNowConfigReadOnlyBinding: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "CTP SimNow preflight requires a config-driven exact front-pair binding", + field_path="runtime.preset", + reason="ctp_simnow_preflight_front_policy_required", + ) + return binding + + +def _report_projection(report: Mapping[str, object], *, full_report: bool) -> dict: + """Keep the common ``run`` output short while retaining an explicit detail mode.""" + + canonical = json.dumps( + report, default=str, ensure_ascii=True, separators=(",", ":"), sort_keys=True + ) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + if full_report: + return {"detail": "full", "digest": digest, "result": dict(report)} + summary = {name: report[name] for name in _REPORT_SUMMARY_FIELDS if name in report} + runtime_config = report.get("runtime_config") + if isinstance(runtime_config, Mapping): + runtime_summary = { + name: runtime_config[name] for name in _RUNTIME_REPORT_FIELDS if name in runtime_config + } + if runtime_summary: + summary["runtime"] = runtime_summary + return {"detail": "summary", "digest": digest, "result": summary} + + +def _doctor_projection(effective: object, strategy_dir: Path, registry: RuntimeRegistry) -> dict: + """Describe the resolved route in operator language without capability I/O.""" + + public = effective.as_public_dict() + # New operator configs use the canonical shared CTP block. Keep the + # legacy SimNow alias visible only for already-loaded compatibility files. + private_simnow = effective.config.ctp or effective.config.ctp_simnow + read_only_preflight_available = False + read_only_preflight_unavailable_reason = None + from .inventory import ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ) + + requires_preflight_supervisor = ( + effective.registration.runtime_id + in (ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID) + and effective.mode == "simulation" + and effective.preset == "sandbox" + and effective.account_access == "sandbox_private_read" + ) + if ( + effective.mode == "simulation" + and effective.preset == "sandbox" + and effective.account_access == "sandbox_private_read" + ): + if requires_preflight_supervisor: + read_only_preflight_unavailable_reason = ( + _CTP_SIMNOW_PREFLIGHT_SUPERVISOR_REASON + ) + else: + try: + if effective.profile is not None: + from .ctp_sandbox_readonly_admission import ( + require_ctp_sandbox_profile_runtime, + ) + + require_ctp_sandbox_profile_runtime(effective, registry) + _require_config_driven_ctp_binding(registry, effective.registration.runtime_id) + except RuntimeConfigError: + pass + except Exception: + pass + else: + read_only_preflight_available = True + configured_simnow_fronts = None + if effective.account_access == "sandbox_private_read" and private_simnow is not None: + # Front addresses are non-secret operator facts. Keep credentials, + # account identity, and contract selectors out of the diagnostic. + if private_simnow.md_front is not None and private_simnow.td_front is not None: + configured_simnow_fronts = { + "md_front": private_simnow.md_front, + "td_front": private_simnow.td_front, + } + else: + configured_simnow_fronts = { + "front_pairs": [ + {"md_front": pair["md_front"], "td_front": pair["td_front"]} + for pair in private_simnow.front_pairs + ] + } + next_action = { + "action": "run", + "command": ["bt-runtime", "run", "--strategy-dir", str(strategy_dir)], + "note": "run repeats schema and sealed-policy validation before any registered runner import", + } + if public["requires_live_confirmation"]: + next_action["command"].append("--confirm-live") + if requires_preflight_supervisor: + next_action = { + "action": _CTP_SIMNOW_PREFLIGHT_SUPERVISOR_ACTION, + "field_path": "command", + "note": _CTP_SIMNOW_PREFLIGHT_SUPERVISOR_NOTE, + } + elif read_only_preflight_available: + next_action = { + "action": "preflight", + "command": ["bt-runtime", "preflight", "--strategy-dir", str(strategy_dir)], + "note": ( + "runs the code-bound CTP read-only checks for this sandbox config; " + "does not enable strategy execution or write authority" + if effective.profile is not None + else ( + "probes only configured MD/TD pairs, then opens bounded TD account " + "and MD market-data read-only checks; grants no write authority" + ) + ), + } + elif not public["profile_dispatch_available"]: + next_action = { + "action": "review_profile_dispatch", + "field_path": "runtime.preset", + "note": "profile validation is offline; profile-scoped dispatch is not enabled", + } + elif effective.account_access == "sandbox_private_read": + try: + _require_config_driven_ctp_binding(registry, effective.registration.runtime_id) + except RuntimeConfigError: + next_action = { + "action": "review_registration", + "field_path": "runtime.preset", + "note": ("register a config-driven CTP front-pair policy before provider access"), + } + else: + next_action = { + "action": "preflight", + "command": ["bt-runtime", "preflight", "--strategy-dir", str(strategy_dir)], + "note": ( + "probes only configured MD/TD pairs, then opens bounded TD account " + "and MD market-data read-only checks; grants no write authority" + ), + } + diagnostic = { + "offline": True, + "provider_preflight_started": False, + "operator_summary": _doctor_operator_summary( + mode=public["mode"], + preset=public["preset"], + environment=public["environment"], + order_route=public["order_route"], + account_access=public["account_access"], + allows_external_writes=bool(public["allows_external_writes"]), + allows_hypothetical_fills=bool(public["allows_hypothetical_fills"]), + required_capabilities=public["required_capabilities"], + requires_approval=bool(public["requires_approval"]), + profile_dispatch_available=bool(public["profile_dispatch_available"]), + profile_dispatch_unavailable_reason=public["profile_dispatch_unavailable_reason"], + ), + "next_actions": [next_action], + "profile_dispatch_available": public["profile_dispatch_available"], + "profile_dispatch_unavailable_reason": public["profile_dispatch_unavailable_reason"], + "read_only_preflight_dispatch_available": read_only_preflight_available, + "read_only_preflight_dispatch_unavailable_reason": ( + None + if read_only_preflight_available + else ( + read_only_preflight_unavailable_reason + or ("profile_dispatch_unavailable" if effective.profile is not None else None) + ) + ), + } + if effective.registration.runtime_id in ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ): + diagnostic["operator_actions"] = _ctp_private_operator_actions( + strategy_dir=strategy_dir, + mode=public["mode"], + preset=public["preset"], + profile_dispatch_available=bool(public["profile_dispatch_available"]), + profile_dispatch_unavailable_reason=public["profile_dispatch_unavailable_reason"], + unavailable_live_profile_note=( + "The registered 007 CTP live profile is unavailable." + if effective.registration.runtime_id == ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + else "The registered 013_3 production profile is unavailable." + ), + ) + if configured_simnow_fronts is not None: + diagnostic["configured_simnow_fronts"] = configured_simnow_fronts + return diagnostic + + +def main( + argv: Optional[Sequence[str]] = None, + *, + registry: Optional[RuntimeRegistry] = None, + environ: Optional[Mapping[str, str]] = None, + stdout: Optional[TextIO] = None, + stderr: Optional[TextIO] = None, +) -> int: + """Run the CLI and return an exit status after safe runtime dispatch. + + ``registry`` injection exists for reviewed application code and unit tests. + The installed command has no registry/mode/preset/config override flag and + uses the package's reviewed runtime inventory. Directories absent from + that inventory fail closed. + """ + + arguments = list(sys.argv[1:] if argv is None else argv) + output = stdout or sys.stdout + errors = stderr or sys.stderr + selected_registry = registry or default_runtime_registry() + selected_environ = os.environ if environ is None else environ + environment_errors = _environment_override_errors(selected_environ) + + if arguments and arguments[0] == "prepare-ctp-production-config": + _write_payload( + errors, + _operator_error_payload( + RuntimeConfigError( + CONFIG_SCHEMA_UNSUPPORTED, + "Iteration 41 uses the shared config.yaml under " + "examples/013_3_sa_midfreq_simnow/runtime-ctp-private; " + "the separate production-config command is retired. " + "A future live route still requires independent implementation and review.", + field_path="command", + reason="separate_production_config_not_supported", + ), + command=arguments[0], + strategy_dir=None, + registry=selected_registry, + ), + ) + return 2 + command_uses_runtime_policy = bool(arguments) and arguments[0] in ( + "validate", + "doctor", + "run", + "preflight", + "check-ctp-fronts", + ) + + disallowed = _disallowed_run_arguments(arguments) + if disallowed: + _write_payload( + errors, + _operator_error_payload( + RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "command-line arguments cannot override runtime.mode or runtime.preset", + field_path="argv", + reason="cli_override_not_allowed", + ), + command=arguments[0] if arguments else None, + strategy_dir=None, + registry=selected_registry, + additional_errors=environment_errors if command_uses_runtime_policy else (), + rejected_arguments=disallowed, + ), + ) + return 2 + + parser = build_parser() + dispatch_started = False + try: + args = parser.parse_args(arguments) + except _ParserError: + _write_payload( + errors, + _operator_error_payload( + RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "command-line arguments are not permitted for this runtime command", + field_path="argv", + reason="cli_argument_not_allowed", + ), + command=arguments[0] if arguments else None, + strategy_dir=None, + registry=selected_registry, + additional_errors=environment_errors if command_uses_runtime_policy else (), + ), + ) + return 2 + except SystemExit as exc: + # --help remains conventional; no runtime code has been loaded. + return int(exc.code) + + try: + if args.command == "collect-evidence": + bundle = collect_iteration41_evidence() + succeeded = bundle["status"] == "PASS" + _write_payload(output if succeeded else errors, bundle) + return 0 if succeeded else 2 + + if args.command in ("prepare-ctp-config", "prepare-ctp-simnow-config"): + from .ctp_private_config_setup import ( + prepare_ctp_simnow_config, + prepare_ctp_simnow_config_sources, + ) + + source_specs = [] + if args.source_env is not None: + source_specs.append((args.source_env, "env")) + if args.source_yaml is not None: + source_specs.append((args.source_yaml, "yaml")) + if args.source_collector_yaml is not None: + source_specs.append((args.source_collector_yaml, "collector_yaml")) + if not source_specs: + raise RuntimeConfigError( + CONFIG_SCHEMA_UNSUPPORTED, + "provide an explicit --source-env, --source-yaml, or --source-collector-yaml path", + field_path="source", + reason="source_required", + ) + target_kwargs = ( + {"runtime_id": args.runtime_id} if args.runtime_id is not None else {} + ) + if len(source_specs) == 1: + source_path, source_format = source_specs[0] + prepared = prepare_ctp_simnow_config( + source_path, source_format=source_format, **target_kwargs + ) + else: + prepared = prepare_ctp_simnow_config_sources(source_specs, **target_kwargs) + _write_payload(output, prepared.as_public_dict()) + return 0 + + if args.command == "bootstrap": + if args.runtime_set is not None: + if args.preset is not None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "batch bootstrap always chooses each runtime's safest reviewed preset", + field_path="--preset", + reason="batch_bootstrap_preset_not_allowed", + ) + batch = bootstrap_runtime_set(args.runtime_set, selected_registry) + _write_payload(output if batch.succeeded else errors, batch.as_public_dict()) + return 0 if batch.succeeded else 2 + preset = args.preset or select_bootstrap_preset( + selected_registry.require_runtime_dir(args.strategy_dir) + ) + config = bootstrap_runtime_config(args.strategy_dir, selected_registry, preset) + _write_payload( + output, + { + "status": "bootstrapped", + "strategy_id": config.strategy_id, + "mode": config.mode, + "preset": config.preset, + "config_digest": config.config_digest, + }, + ) + return 0 + + if args.command in ("preflight", "check-ctp-fronts"): + # A CTP private config can contain credentials. Establish the + # exact code-owned runtime and its config-driven read-only binding + # before the config loader can inspect any bytes. + registration = selected_registry.require_runtime_dir(args.strategy_dir) + if args.command == "preflight": + if registration.runtime_id in _ctp_private_readonly_runtime_ids(): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "ordinary CTP private preflight is disabled until native SDK startup " + "and shutdown run under a hard process supervisor; no config, " + "credentials, SDK, or provider/network access was started", + field_path="command", + reason=_CTP_SIMNOW_PREFLIGHT_SUPERVISOR_REASON, + ) + if args.command == "check-ctp-fronts": + if registration.runtime_id not in _ctp_private_readonly_runtime_ids(): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "configured CTP front checks are limited to registered private read-only runtimes", + field_path="strategy_dir", + reason="ctp_front_check_runtime_required", + ) + if registration.profiles: + from .ctp_sandbox_readonly_admission import ( + CtpSandboxReadOnlyAdmissionError, + require_ctp_sandbox_profile_registration, + ) + + try: + require_ctp_sandbox_profile_registration(registration, selected_registry) + except CtpSandboxReadOnlyAdmissionError: + raise _profile_dispatch_unavailable() from None + _require_config_driven_ctp_binding(selected_registry, registration.runtime_id) + + # Once the command's code-owned route is established, schema and + # registry policy take precedence over environment override diagnostics. + # A missing config for that registered route reports CONFIG_REQUIRED. + effective = validate_runtime_config(args.strategy_dir, selected_registry) + if args.command == "run" and not effective.profile_dispatch_available: + raise _profile_dispatch_unavailable() + if args.command == "check-ctp-fronts" and ( + effective.mode != "simulation" or effective.preset != "sandbox" + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "configured CTP front checks require the sealed simulation/sandbox profile", + field_path="runtime.preset", + reason="ctp_front_check_profile_required", + ) + if environment_errors: + raise environment_errors[0] + + if args.command == "validate": + _write_payload(output, _success_payload(effective, "valid")) + return 0 + if args.command == "doctor": + _write_payload( + output, + _success_payload( + effective, + "diagnostic", + diagnostic=_doctor_projection(effective, args.strategy_dir, selected_registry), + ), + ) + return 0 + + if args.command == "preflight": + dispatch_started = True + observation = dispatch_registered_ctp_simnow_readonly_preflight( + effective, selected_registry + ) + _write_payload( + output, + _success_payload( + effective, + "read_only_observation", + read_only_preflight_started=True, + result=observation.as_public_dict(), + ), + ) + return 0 + + if args.command == "check-ctp-fronts": + checked = dispatch_registered_ctp_front_check(effective, selected_registry) + payload = checked.as_public_dict() + succeeded = checked.succeeded is True + _write_payload(output if succeeded else errors, payload) + return 0 if succeeded else 2 + + if effective.requires_live_confirmation and not args.confirm_live: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "live mode requires --confirm-live for this approved effective configuration", + field_path="--confirm-live", + reason="live_confirmation_required", + ) + if args.confirm_live and not effective.requires_live_confirmation: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "--confirm-live cannot upgrade a non-live runtime", + field_path="--confirm-live", + reason="live_confirmation_not_applicable", + ) + + dispatch_started = True + report = dispatch_registered_runtime(effective, selected_registry) + _write_payload( + output, + _success_payload( + effective, + "completed", + started=True, + runner_dispatch="code_owned", + report=_report_projection(report, full_report=args.full_report), + ), + ) + return 0 + except RuntimeConfigError as error: + command = getattr(args, "command", None) + strategy_dir = getattr(args, "strategy_dir", None) + additional_errors = () + if command in ("validate", "doctor", "run", "preflight", "check-ctp-fronts"): + additional_errors = tuple(item for item in environment_errors if item is not error) + _write_payload( + errors, + _operator_error_payload( + error, + command=command, + strategy_dir=strategy_dir, + registry=selected_registry, + additional_errors=additional_errors, + dispatch_started=dispatch_started, + ), + ) + return 2 + + +if __name__ == "__main__": # pragma: no cover - covered via __main__ entry point + raise SystemExit(main()) diff --git a/backtrader_runtime/config.py b/backtrader_runtime/config.py new file mode 100644 index 00000000..39634d92 --- /dev/null +++ b/backtrader_runtime/config.py @@ -0,0 +1,1723 @@ +"""Strict, side-effect-free schema-v4 runtime configuration loading. + +Only filesystem and YAML parsing occur in this module. It intentionally does +not import Backtrader strategy modules or any optional live-trading package, +which makes invalid configuration a proven zero-provider-I/O path. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import stat +import weakref +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any, Dict, Iterable, Optional, Tuple, Union +from urllib.parse import urlsplit + +from .errors import ( + CONFIG_EXISTS, + CONFIG_REQUIRED, + CONFIG_SCHEMA_UNSUPPORTED, + MODE_PRESET_MISMATCH, + PRESET_POLICY_VIOLATION, + RuntimeConfigError, +) +from .policy import get_preset_policy + + +CONFIG_FILENAME = "config.yaml" +CONFIG_SCHEMA_VERSION = 4 +MAX_CONFIG_BYTES = 1024 * 1024 +CTP_PRODUCTION_RUNTIME_DIR = ( + Path(__file__).resolve().parent.parent + / "examples" + / "007_ctp" + / "runtime-production" +) + +_STRATEGY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_OS_SECRET_REF_RE = re.compile(r"^os_secret_store:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_ROOT_FIELDS = frozenset( + ( + "config_schema_version", + "strategy", + "runtime", + "parameters", + "secrets_ref", + "ctp", + "ctp_simnow", + "ctp_production", + ) +) +_CTP_SIMNOW_FIELDS = frozenset( + ( + "front_pairs", + "md_front", + "td_front", + "instrument_id", + "exchange_id", + "hedge_flag", + "broker_id", + "user_id", + "password", + "app_id", + "auth_code", + ) +) +_CTP_SIMNOW_FRONT_PAIR_FIELDS = frozenset(("md_front", "td_front")) +_MAX_CTP_SIMNOW_FRONT_PAIRS = 8 +_CTP_PRODUCTION_FIELDS = frozenset( + ( + "front_pairs", + "md_front", + "td_front", + "instrument_id", + "exchange_id", + "hedge_flag", + "broker_id", + "user_id", + "password", + "app_id", + "auth_code", + ) +) +_CTP_PRODUCTION_FRONT_PAIR_FIELDS = frozenset(("md_front", "td_front")) +_MAX_CTP_PRODUCTION_FRONT_PAIRS = 8 +_CTP_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_CTP_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_CTP_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_CTP_CALENDAR_PATH_PART_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_STRATEGY_FIELDS = frozenset(("id",)) +_RUNTIME_FIELDS = frozenset(("mode", "preset")) +_INLINE_SECRET_NAMES = frozenset( + ( + "api_key", + "apikey", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "authorization", + "cookie", + "credential", + "credentials", + "password", + "passphrase", + "private_key", + "secret", + "token", + ) +) +_FORBIDDEN_CONTROL_FIELDS = frozenset( + ( + "account_access", + "approval", + "approval_ref", + "capabilities", + "capability", + "capability_module", + "capability_modules", + "connection", + "enabled", + "environment", + "mode", + "modules", + "order_route", + "plugins", + "preset", + "provider", + "receipt", + "route", + "runtime", + "write_policy", + "zmq", + ) +) + + +class _DuplicateYamlKeyError(ValueError): + """Raised internally when a strict YAML mapping repeats a key.""" + + def __init__(self, key: object) -> None: + self.key = str(key) + super().__init__(self.key) + + +class _YamlAliasError(ValueError): + """Raised internally for aliases/anchors, which are not part of v4.""" + + +@dataclass(frozen=True) +class CtpSimNowPrivateConfig: + """Validated CTP SimNow settings retained only for the private resolver. + + Every field is excluded from the generated representation because even + account identifiers and connection settings belong to the private block. + Generic dataclass serialization such as ``dataclasses.asdict`` still reads + these fields; diagnostics must use ``RuntimeConfig.as_public_dict``. + """ + + # The legacy single-front selector fields are retained for source + # compatibility. They are None for a multi-pair config so consumers cannot + # accidentally treat the first pair as an implicit selection. + md_front: Optional[str] = field(repr=False) + td_front: Optional[str] = field(repr=False) + front_pairs: Tuple[Mapping[str, str], ...] = field(repr=False) + instrument_id: str = field(repr=False) + exchange_id: str = field(repr=False) + hedge_flag: str = field(repr=False) + broker_id: str = field(repr=False) + user_id: str = field(repr=False) + password: str = field(repr=False) + app_id: str = field(repr=False) + auth_code: str = field(repr=False) + + def __repr__(self) -> str: + return "CtpSimNowPrivateConfig()" + + def _private_facts(self) -> Tuple[Any, ...]: + """Return internal facts for the loader provenance seal only.""" + + return ( + self.md_front, + self.td_front, + tuple((pair["md_front"], pair["td_front"]) for pair in self.front_pairs), + self.instrument_id, + self.exchange_id, + self.hedge_flag, + self.broker_id, + self.user_id, + self.password, + self.app_id, + self.auth_code, + ) + + +# The canonical mode-neutral schema has the same private value shape as the +# historical SimNow parser. Keeping one exact class preserves existing +# consumers that deliberately check ``type(value) is CtpSimNowPrivateConfig``. +CtpPrivateConfig = CtpSimNowPrivateConfig + + +@dataclass(frozen=True) +class CtpProductionPrivateConfig: + """Private CTP production endpoint selectors and credentials from protected YAML. + + Explicitly configured fronts are retained as parser-only candidate + metadata. A front_pairs list is never selected here. Parsing grants no + route, trusted environment, receipt, approval, or provider access. + """ + + # Multi-pair input stays unresolved at this parser boundary. The scalar + # compatibility fields are None whenever front_pairs was supplied, so a + # later consumer cannot accidentally treat the first configured pair as + # an operator-approved selection. + md_front: Optional[str] = field(repr=False) + td_front: Optional[str] = field(repr=False) + front_pairs: Tuple[Mapping[str, str], ...] = field(repr=False) + instrument_id: str = field(repr=False) + exchange_id: str = field(repr=False) + hedge_flag: str = field(repr=False) + broker_id: str = field(repr=False) + user_id: str = field(repr=False) + password: str = field(repr=False) + app_id: str = field(repr=False) + auth_code: str = field(repr=False) + + def __repr__(self) -> str: + return "CtpProductionPrivateConfig()" + + def _private_facts(self) -> Tuple[Any, ...]: + """Return private fields only for the in-process loader seal.""" + + return ( + self.md_front, + self.td_front, + tuple((pair["md_front"], pair["td_front"]) for pair in self.front_pairs), + self.instrument_id, + self.exchange_id, + self.hedge_flag, + self.broker_id, + self.user_id, + self.password, + self.app_id, + self.auth_code, + ) + + +@dataclass(frozen=True) +class RuntimeConfig: + """An immutable, parsed user configuration before registry resolution. + + Use :meth:`as_public_dict` for diagnostics. Generic dataclass serializers + can expose the private CTP configuration fields. + """ + + strategy_dir: Path + source_path: Path + strategy_id: str + mode: str + preset: str + parameters: Mapping[str, Any] + secrets_ref: str + config_digest: str + ctp: Optional[CtpPrivateConfig] = field(default=None, repr=False) + ctp_simnow: Optional[CtpSimNowPrivateConfig] = field(default=None, repr=False) + ctp_production: Optional[CtpProductionPrivateConfig] = field(default=None, repr=False) + _source_file_identity: Optional[Tuple[int, int]] = field( + default=None, repr=False, compare=False + ) + + def as_public_dict(self) -> Dict[str, Any]: + """Return a redacted summary safe for diagnostics and audit events.""" + + return { + "strategy": {"id": self.strategy_id}, + "runtime": {"mode": self.mode, "preset": self.preset}, + "parameter_keys": tuple(sorted(self.parameters)), + "secrets_ref": "none" if self.secrets_ref == "none" else "configured", + "config_digest": self.config_digest, + } + + def parameter_dict(self) -> Dict[str, Any]: + """Return a detached, JSON-safe copy of registered strategy parameters.""" + + return _thaw_value(self.parameters) + + +@dataclass(frozen=True) +class _LoadedRuntimeConfigSeal: + """Private identity/snapshot retained only for loader-produced configs.""" + + registry: Optional[Any] + strategy_dir: Path + source_path: Path + strategy_id: str + mode: str + preset: str + parameters: Mapping[str, Any] + secrets_ref: str + config_digest: str + ctp: Optional[CtpPrivateConfig] + ctp_private_digest: Optional[str] + ctp_simnow: Optional[CtpSimNowPrivateConfig] + ctp_simnow_private_digest: Optional[str] + ctp_production: Optional[CtpProductionPrivateConfig] + ctp_production_private_digest: Optional[str] + source_file_identity: Optional[Tuple[int, int]] + + +# ``RuntimeConfig`` intentionally remains a public, ergonomic frozen data +# class. A public field cannot prove it came from config.yaml, so loader +# provenance lives in this private identity registry rather than on the data +# object. The weak reference prevents stale id entries from authorising a +# later object which happens to reuse the same memory address. +_LOADED_RUNTIME_CONFIG_SEALS: Dict[int, Tuple[Any, _LoadedRuntimeConfigSeal]] = {} + + +def _seal_loaded_runtime_config( + config: RuntimeConfig, registry: Optional[Any] = None +) -> RuntimeConfig: + """Register a config that was parsed from one verified config.yaml FD.""" + + identifier = id(config) + + def discard(reference: Any) -> None: + current = _LOADED_RUNTIME_CONFIG_SEALS.get(identifier) + if current is not None and current[0] is reference: + _LOADED_RUNTIME_CONFIG_SEALS.pop(identifier, None) + + reference = weakref.ref(config, discard) + _LOADED_RUNTIME_CONFIG_SEALS[identifier] = ( + reference, + _LoadedRuntimeConfigSeal( + registry=registry, + strategy_dir=config.strategy_dir, + source_path=config.source_path, + strategy_id=config.strategy_id, + mode=config.mode, + preset=config.preset, + parameters=config.parameters, + secrets_ref=config.secrets_ref, + config_digest=config.config_digest, + ctp=config.ctp, + ctp_private_digest=_digest_private_ctp_config(config.ctp), + ctp_simnow=config.ctp_simnow, + ctp_simnow_private_digest=_digest_private_ctp_config(config.ctp_simnow), + ctp_production=config.ctp_production, + ctp_production_private_digest=_digest_private_ctp_production_config( + config.ctp_production + ), + source_file_identity=config._source_file_identity, + ), + ) + return config + + +def require_loaded_runtime_config_seal( + config: RuntimeConfig, registry: Optional[Any] = None +) -> None: + """Reject public/manual or cross-registry config objects before execution.""" + + entry = _LOADED_RUNTIME_CONFIG_SEALS.get(id(config)) + if entry is None or entry[0]() is not config: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime configuration was not loaded from a verified config.yaml", + field_path="runtime.config", + reason="config_provenance_invalid", + ) + seal = entry[1] + if ( + (seal.registry is not None and seal.registry is not registry) + or config.strategy_dir != seal.strategy_dir + or config.source_path != seal.source_path + or config.strategy_id != seal.strategy_id + or config.mode != seal.mode + or config.preset != seal.preset + or config.parameters is not seal.parameters + or config.secrets_ref != seal.secrets_ref + or config.config_digest != seal.config_digest + or config.ctp is not seal.ctp + or _digest_private_ctp_config(config.ctp) != seal.ctp_private_digest + or config.ctp_simnow is not seal.ctp_simnow + or _digest_private_ctp_config(config.ctp_simnow) != seal.ctp_simnow_private_digest + or config.ctp_production is not seal.ctp_production + or _digest_private_ctp_production_config(config.ctp_production) + != seal.ctp_production_private_digest + or config._source_file_identity != seal.source_file_identity + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime configuration no longer matches its verified config.yaml", + field_path="runtime.config", + reason="config_provenance_invalid", + ) + + +def _config_error(message: str, field_path: Optional[str], reason: str) -> RuntimeConfigError: + return RuntimeConfigError( + CONFIG_SCHEMA_UNSUPPORTED, + message, + field_path=field_path, + reason=reason, + ) + + +def _normalise_runtime_dir(strategy_dir: Union[str, os.PathLike]) -> Path: + try: + return Path(strategy_dir).expanduser().resolve(strict=False) + except (OSError, RuntimeError, TypeError, ValueError): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "strategy runtime directory is not usable", + field_path="strategy_dir", + reason="invalid_runtime_directory", + ) from None + + +def _is_ctp_production_runtime_dir(strategy_dir: Union[str, os.PathLike]) -> bool: + """Match only the reserved production runtime's canonical directory.""" + + try: + candidate = _normalise_runtime_dir(strategy_dir) + expected = _normalise_runtime_dir(CTP_PRODUCTION_RUNTIME_DIR) + except RuntimeConfigError: + return False + return os.path.normcase(os.fspath(candidate)) == os.path.normcase(os.fspath(expected)) + + +def _read_config_text( + strategy_dir: Path, + *, + directory_fd: Optional[int] = None, + identity_out: Optional[list] = None, + require_private_config_security: bool = False, + directory_identity: Optional[Any] = None, +) -> Tuple[Path, str]: + """Read one regular config file through a verified file descriptor. + + The config path is an authorization boundary. Checking ``Path.is_file`` + and then reopening the pathname lets a concurrent replacement change the + bytes which are actually parsed. Keep the descriptor that was checked, + parse only its bytes, and verify its identity against ``lstat`` before and + after the read. ``O_NOFOLLOW`` closes the symlink race on platforms that + expose it; the identity checks retain a fail-closed fallback elsewhere. + """ + + config_path = strategy_dir / CONFIG_FILENAME + config_target: Union[str, Path] = CONFIG_FILENAME if directory_fd is not None else config_path + try: + if directory_fd is None: + path_stat = os.lstat(str(config_target)) + else: + path_stat = os.lstat(str(config_target), dir_fd=directory_fd) + except FileNotFoundError: + raise RuntimeConfigError( + CONFIG_REQUIRED, + "required config.yaml is missing from the registered runtime directory", + field_path=CONFIG_FILENAME, + reason="missing_config", + ) from None + except OSError: + raise RuntimeConfigError( + CONFIG_REQUIRED, + "config.yaml cannot be opened", + field_path=CONFIG_FILENAME, + reason="config_unreadable", + ) from None + + if stat.S_ISLNK(path_stat.st_mode): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config.yaml must not be a symbolic link", + field_path=CONFIG_FILENAME, + reason="config_symlink_not_allowed", + ) + if not stat.S_ISREG(path_stat.st_mode): + raise RuntimeConfigError( + CONFIG_REQUIRED, + "config.yaml must be a regular file", + field_path=CONFIG_FILENAME, + reason="config_not_regular_file", + ) + + flags = os.O_RDONLY + flags |= getattr(os, "O_BINARY", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + if directory_fd is None: + descriptor = os.open(str(config_target), flags) + else: + descriptor = os.open(str(config_target), flags, dir_fd=directory_fd) + except FileNotFoundError: + raise RuntimeConfigError( + CONFIG_REQUIRED, + "required config.yaml is missing from the registered runtime directory", + field_path=CONFIG_FILENAME, + reason="missing_config", + ) from None + except OSError: + raise RuntimeConfigError( + CONFIG_REQUIRED, + "config.yaml cannot be opened", + field_path=CONFIG_FILENAME, + reason="config_unreadable", + ) from None + + try: + descriptor_stat = os.fstat(descriptor) + if not stat.S_ISREG(descriptor_stat.st_mode): + raise RuntimeConfigError( + CONFIG_REQUIRED, + "config.yaml must be a regular file", + field_path=CONFIG_FILENAME, + reason="config_not_regular_file", + ) + if (path_stat.st_dev, path_stat.st_ino) != (descriptor_stat.st_dev, descriptor_stat.st_ino): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config.yaml changed while it was being opened", + field_path=CONFIG_FILENAME, + reason="config_identity_changed", + ) + + if require_private_config_security: + _require_private_config_security( + strategy_dir, + descriptor, + descriptor_stat, + directory_fd=directory_fd, + directory_identity=directory_identity, + ) + + chunks = [] + remaining = MAX_CONFIG_BYTES + 1 + while remaining: + chunk = os.read(descriptor, remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + if require_private_config_security: + final_descriptor_stat = os.fstat(descriptor) + if (final_descriptor_stat.st_dev, final_descriptor_stat.st_ino) != ( + descriptor_stat.st_dev, + descriptor_stat.st_ino, + ): + raise _private_config_security_error("private_config_identity_changed") + _require_single_config_link(getattr(final_descriptor_stat, "st_nlink", None)) + _require_private_config_security( + strategy_dir, + descriptor, + final_descriptor_stat, + directory_fd=directory_fd, + directory_identity=directory_identity, + ) + except RuntimeConfigError: + raise + except OSError: + raise RuntimeConfigError( + CONFIG_REQUIRED, + "config.yaml cannot be opened", + field_path=CONFIG_FILENAME, + reason="config_unreadable", + ) from None + finally: + try: + os.close(descriptor) + except OSError: + pass + + try: + if directory_fd is None: + final_path_stat = os.lstat(str(config_target)) + else: + final_path_stat = os.lstat(str(config_target), dir_fd=directory_fd) + except OSError: + final_path_stat = None + if ( + final_path_stat is None + or stat.S_ISLNK(final_path_stat.st_mode) + or not stat.S_ISREG(final_path_stat.st_mode) + or (final_path_stat.st_dev, final_path_stat.st_ino) + != (descriptor_stat.st_dev, descriptor_stat.st_ino) + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config.yaml changed while it was being read", + field_path=CONFIG_FILENAME, + reason="config_identity_changed", + ) + if require_private_config_security: + _require_single_config_link(getattr(final_path_stat, "st_nlink", None)) + + if identity_out is not None: + identity_out.append((descriptor_stat.st_dev, descriptor_stat.st_ino)) + + if len(raw) > MAX_CONFIG_BYTES: + raise _config_error( + "config.yaml exceeds the maximum supported size", + CONFIG_FILENAME, + "config_too_large", + ) + try: + # The canonical runtime directory was resolved before registry lookup. + # Avoid resolving the leaf pathname again: that would reopen the race + # which the descriptor and identity checks above just closed. + return config_path, raw.decode("utf-8") + except UnicodeDecodeError: + raise _config_error( + "config.yaml must be UTF-8 text", + CONFIG_FILENAME, + "invalid_encoding", + ) from None + except (OSError, RuntimeError): + raise RuntimeConfigError( + CONFIG_REQUIRED, + "config.yaml cannot be resolved", + field_path=CONFIG_FILENAME, + reason="config_unreadable", + ) from None + + +def _private_config_security_error(reason: str) -> RuntimeConfigError: + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config.yaml permissions do not protect private CTP credentials", + field_path=CONFIG_FILENAME, + reason=reason, + ) + + +def _require_private_config_security( + strategy_dir: Path, + config_descriptor: int, + config_stat: os.stat_result, + *, + directory_fd: Optional[int], + directory_identity: Optional[Any], +) -> None: + """Check private config access before reading any config bytes. + + This gate is selected from the code-owned registration's allowed secret + refs, before the YAML is parsed. POSIX uses the already-open directory and + config descriptors. Windows checks the already-open config handle and a + separate directory metadata handle while the registry lease is held. + """ + + if directory_identity is None: + raise _private_config_security_error("private_config_identity_unavailable") + _require_single_config_link(getattr(config_stat, "st_nlink", None)) + + if os.name == "posix": + if directory_fd is None or not hasattr(os, "geteuid"): + raise _private_config_security_error("private_config_posix_security_unavailable") + directory_stat = os.fstat(directory_fd) + uid = os.geteuid() + directory_mode = stat.S_IMODE(directory_stat.st_mode) + file_mode = stat.S_IMODE(config_stat.st_mode) + if ( + not directory_identity.matches(directory_stat) + or directory_stat.st_uid != uid + or directory_mode & ~0o700 + or directory_mode & 0o500 != 0o500 + ): + raise _private_config_security_error("private_config_directory_unsafe") + if ( + not stat.S_ISREG(config_stat.st_mode) + or config_stat.st_uid != uid + or file_mode & ~0o600 + or file_mode & stat.S_IRUSR == 0 + ): + raise _private_config_security_error("private_config_file_unsafe") + return + + if os.name == "nt": + try: + from .credential_resolver import ( + CredentialResolutionError, + _open_windows_metadata_handle, + _windows_acl_for_handle, + _windows_os_handle, + ) + + directory_path = strategy_dir + directory_fd_for_acl = _open_windows_metadata_handle(directory_path, is_directory=True) + try: + opened_directory = os.fstat(directory_fd_for_acl) + if not directory_identity.matches(opened_directory): + raise _private_config_security_error( + "private_config_directory_identity_changed" + ) + _windows_acl_for_handle(_windows_os_handle(directory_fd_for_acl)) + finally: + os.close(directory_fd_for_acl) + _windows_acl_for_handle(_windows_os_handle(config_descriptor)) + return + except CredentialResolutionError as error: + raise _private_config_security_error( + "private_config_windows_acl_invalid" + if error.reason and "invalid" in error.reason + else "private_config_windows_acl_unavailable" + ) from None + except RuntimeConfigError as error: + raise _private_config_security_error( + error.reason or "private_config_windows_acl_invalid" + ) from None + except Exception: + raise _private_config_security_error("private_config_windows_acl_unavailable") from None + + raise _private_config_security_error("private_config_platform_unsupported") + + +def _require_single_config_link(link_count: Any) -> None: + """Reject private config leaves that have another hard-link name.""" + + if type(link_count) is not int or link_count != 1: + raise _private_config_security_error("private_config_hardlink_not_allowed") + + +def _load_strict_yaml(text: str) -> Any: + """Load YAML without aliases, dynamic tags, or duplicate mapping keys.""" + + try: + import yaml + except ImportError: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the required YAML parser is not installed", + field_path=CONFIG_FILENAME, + reason="yaml_dependency_missing", + ) from None + + try: + for event in yaml.parse(text, Loader=yaml.SafeLoader): + if isinstance(event, yaml.events.AliasEvent) or getattr(event, "anchor", None): + raise _YamlAliasError() + + def validate_mapping_nodes(node: Any, field_path: str) -> None: + if isinstance(node, yaml.nodes.MappingNode): + seen = set() + for key_node, value_node in node.value: + if ( + not isinstance(key_node, yaml.nodes.ScalarNode) + or key_node.tag != "tag:yaml.org,2002:str" + ): + raise _config_error( + "configuration mapping keys must be strings", + field_path or CONFIG_FILENAME, + "non_string_mapping_key", + ) + key = key_node.value + child_path = key if not field_path else "{0}.{1}".format(field_path, key) + if key in seen: + raise _DuplicateYamlKeyError(child_path) + seen.add(key) + validate_mapping_nodes(value_node, child_path) + elif isinstance(node, yaml.nodes.SequenceNode): + for index, item in enumerate(node.value): + validate_mapping_nodes(item, "{0}[{1}]".format(field_path, index)) + + document = yaml.compose(text, Loader=yaml.SafeLoader) + if document is not None: + validate_mapping_nodes(document, "") + + class StrictSafeLoader(yaml.SafeLoader): + pass + + def construct_mapping(loader: Any, node: Any, deep: bool = False) -> Dict[str, Any]: + mapping: Dict[str, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if not isinstance(key, str): + raise _config_error( + "configuration mapping keys must be strings", + CONFIG_FILENAME, + "non_string_mapping_key", + ) + if key in mapping: + raise _DuplicateYamlKeyError(key) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + StrictSafeLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + construct_mapping, + ) + return yaml.load(text, Loader=StrictSafeLoader) + except RuntimeConfigError: + raise + except _DuplicateYamlKeyError as exc: + field_path = ( + "ctp" + if exc.key == "ctp" or exc.key.startswith("ctp.") + else "ctp_simnow" + if exc.key == "ctp_simnow" or exc.key.startswith("ctp_simnow.") + else "ctp_production" + if exc.key == "ctp_production" or exc.key.startswith("ctp_production.") + else exc.key + ) + raise _config_error( + "config.yaml contains a duplicate field", + field_path, + "duplicate_field", + ) from None + except _YamlAliasError: + raise _config_error( + "config.yaml must not use YAML aliases or anchors", + CONFIG_FILENAME, + "yaml_alias_not_allowed", + ) from None + except Exception: + # Parser errors can reproduce raw source lines. Do not expose them in + # operator output because a malformed file could contain a secret. + raise _config_error( + "config.yaml is not a supported strict YAML document", + CONFIG_FILENAME, + "invalid_yaml", + ) from None + + +def _require_mapping(value: Any, field_path: str) -> Dict[str, Any]: + if not isinstance(value, dict): + raise _config_error( + "{0} must be a mapping".format(field_path), + field_path, + "expected_mapping", + ) + return value + + +def _reject_unknown_fields( + mapping: Dict[str, Any], allowed: Iterable[str], field_path: str +) -> None: + allowed_fields = frozenset(allowed) + for key in mapping: + if key not in allowed_fields: + path = key if not field_path else "{0}.{1}".format(field_path, key) + if field_path in ("ctp", "ctp_simnow", "ctp_production"): + path = field_path + raise _config_error( + "configuration field is not allowed by schema v4", + path, + "field_not_allowed", + ) + + +def _require_field(mapping: Dict[str, Any], key: str, field_path: str) -> Any: + if key not in mapping: + path = key if not field_path else "{0}.{1}".format(field_path, key) + raise _config_error( + "required configuration field is missing", + path, + "required_field_missing", + ) + return mapping[key] + + +def _normalise_value(value: Any, field_path: str) -> Any: + """Convert YAML values into a finite JSON-compatible tree.""" + + if value is None or isinstance(value, (bool, str)): + if isinstance(value, str) and "${" in value: + raise _config_error( + "environment interpolation is not supported in config.yaml", + field_path, + "environment_interpolation_not_allowed", + ) + return value + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise _config_error( + "configuration numbers must be finite", + field_path, + "non_finite_number", + ) + return value + if isinstance(value, list): + return [ + _normalise_value(item, "{0}[{1}]".format(field_path, index)) + for index, item in enumerate(value) + ] + if isinstance(value, dict): + normalised: Dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise _config_error( + "configuration mapping keys must be strings", + field_path, + "non_string_mapping_key", + ) + child_path = "{0}.{1}".format(field_path, key) + normalised[key] = _normalise_value(item, child_path) + return normalised + raise _config_error( + "configuration values must be JSON-compatible scalars, lists, or mappings", + field_path, + "unsupported_value_type", + ) + + +def _forbidden_name(name: str) -> bool: + normalised = name.casefold().replace("-", "_") + if normalised in _INLINE_SECRET_NAMES or normalised in _FORBIDDEN_CONTROL_FIELDS: + return True + return normalised.endswith(("_token", "_password", "_passphrase", "_private_key", "_secret")) + + +def _reject_inline_secrets_and_controls(value: Any, field_path: str) -> None: + if not isinstance(value, dict): + if isinstance(value, list): + for index, item in enumerate(value): + _reject_inline_secrets_and_controls(item, "{0}[{1}]".format(field_path, index)) + return + + for key, item in value.items(): + child_path = "{0}.{1}".format(field_path, key) + normalised = key.casefold().replace("-", "_") + if normalised in _INLINE_SECRET_NAMES or normalised.endswith( + ("_token", "_password", "_passphrase", "_private_key", "_secret") + ): + raise _config_error( + "inline credentials are not allowed in config.yaml", + child_path, + "inline_secret", + ) + if normalised in _FORBIDDEN_CONTROL_FIELDS: + raise _config_error( + "runtime control fields are not allowed in parameters", + child_path, + "runtime_control_field_not_allowed", + ) + _reject_inline_secrets_and_controls(item, child_path) + + +def _digest_private_ctp_config(value: Optional[CtpSimNowPrivateConfig]) -> Optional[str]: + """Fingerprint private fields for the in-process config provenance seal. + + This digest is never included in ``config_digest`` or a public projection. + Keeping it in the identity seal lets later gates detect illicit mutation of + a frozen credential object without publishing a low-entropy password hash. + """ + + if value is None: + return None + serialized = json.dumps(value._private_facts(), ensure_ascii=True, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _digest_private_ctp_production_config( + value: Optional[CtpProductionPrivateConfig], +) -> Optional[str]: + """Fingerprint private production fields for the local provenance seal.""" + + if value is None: + return None + serialized = json.dumps(value._private_facts(), ensure_ascii=True, separators=(",", ":")) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _required_private_string( + mapping: Dict[str, Any], + key: str, + *, + secret: bool = False, + field_prefix: str = "ctp_simnow", +) -> str: + reason_prefix = field_prefix.split(".", 1)[0] + value = _require_field(mapping, key, field_prefix) + if type(value) is not str or not value or value != value.strip() or "\x00" in value: + raise _config_error( + "{0}.{1} must be a non-empty string".format(field_prefix, key), + "{0}.{1}".format(field_prefix, key), + "invalid_{0}_value".format(reason_prefix), + ) + try: + size = len(value.encode("utf-8")) + except UnicodeEncodeError: + size = MAX_CONFIG_BYTES + 1 + if size > (2048 if secret else 128): + raise _config_error( + "{0}.{1} exceeds the supported size".format(field_prefix, key), + "{0}.{1}".format(field_prefix, key), + "invalid_{0}_value".format(reason_prefix), + ) + return value + + +def _required_ctp_front( + mapping: Dict[str, Any], key: str, *, field_prefix: str = "ctp_simnow" +) -> str: + """Parse one bounded, explicit TCP front without importing the SDK.""" + + reason_prefix = field_prefix.split(".", 1)[0] + value = _required_private_string(mapping, key, field_prefix=field_prefix) + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + port = None + parsed = None + if ( + parsed is None + or parsed.scheme != "tcp" + or not parsed.hostname + or port is None + or not 1 <= port <= 65535 + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or any(character.isspace() or ord(character) < 0x20 for character in value) + or value != "tcp://{0}:{1}".format(parsed.hostname, port) + ): + raise _config_error( + "{0}.{1} must be a canonical tcp://host:port front".format(field_prefix, key), + "{0}.{1}".format(field_prefix, key), + "invalid_{0}_front".format(reason_prefix), + ) + return value + + +def _parse_ctp_private_config( + value: Any, *, field_prefix: str = "ctp" +) -> CtpPrivateConfig: + """Parse the shared private CTP fields without selecting a front pair.""" + + block_name = field_prefix + mapping = _require_mapping(value, block_name) + _reject_unknown_fields(mapping, _CTP_SIMNOW_FIELDS, block_name) + normalized = _normalise_value(mapping, block_name) + # Required lookups and type/format checks intentionally use stable field + # paths and never echo malformed values into CLI errors. + has_front_pairs = "front_pairs" in normalized + has_legacy_fronts = "md_front" in normalized or "td_front" in normalized + if has_front_pairs and has_legacy_fronts: + raise _config_error( + "{0} must use either front_pairs or the legacy md_front/td_front pair".format( + block_name + ), + "{0}.front_pairs".format(block_name), + "mixed_{0}_front_forms".format(block_name), + ) + if has_front_pairs: + raw_pairs = normalized["front_pairs"] + if type(raw_pairs) is not list or not 1 <= len(raw_pairs) <= _MAX_CTP_SIMNOW_FRONT_PAIRS: + raise _config_error( + "{0}.front_pairs must contain between 1 and {1} pairs".format( + block_name, _MAX_CTP_SIMNOW_FRONT_PAIRS + ), + "{0}.front_pairs".format(block_name), + "invalid_{0}_front_pairs".format(block_name), + ) + parsed_pairs = [] + seen_pairs = set() + for index, raw_pair in enumerate(raw_pairs): + pair_path = "{0}.front_pairs[{1}]".format(block_name, index) + pair_mapping = _require_mapping(raw_pair, pair_path) + _reject_unknown_fields(pair_mapping, _CTP_SIMNOW_FRONT_PAIR_FIELDS, pair_path) + md_front_value = _required_ctp_front( + pair_mapping, "md_front", field_prefix=pair_path + ) + td_front_value = _required_ctp_front( + pair_mapping, "td_front", field_prefix=pair_path + ) + pair_key = (md_front_value, td_front_value) + if pair_key in seen_pairs: + raise _config_error( + "{0}.front_pairs must not repeat an exact pair".format(block_name), + pair_path, + "duplicate_{0}_front_pair".format(block_name), + ) + seen_pairs.add(pair_key) + parsed_pairs.append( + MappingProxyType( + {"md_front": md_front_value, "td_front": td_front_value} + ) + ) + md_front = None + td_front = None + front_pairs = tuple(parsed_pairs) + else: + md_front = _required_ctp_front( + normalized, "md_front", field_prefix=block_name + ) + td_front = _required_ctp_front( + normalized, "td_front", field_prefix=block_name + ) + front_pairs = (MappingProxyType({"md_front": md_front, "td_front": td_front}),) + def required(key: str, *, secret: bool = False) -> str: + return _required_private_string( + normalized, key, secret=secret, field_prefix=block_name + ) + + instrument_id = required("instrument_id") + exchange_id = required("exchange_id") + hedge_flag = required("hedge_flag") + broker_id = required("broker_id", secret=True) + user_id = required("user_id", secret=True) + password = required("password", secret=True) + app_id = required("app_id", secret=True) + auth_code = required("auth_code", secret=True) + + if not _CTP_INSTRUMENT_RE.fullmatch(instrument_id): + raise _config_error( + "{0}.instrument_id is not a supported identifier".format(block_name), + "{0}.instrument_id".format(block_name), + "invalid_{0}_value".format(block_name), + ) + if not _CTP_EXCHANGE_RE.fullmatch(exchange_id): + raise _config_error( + "{0}.exchange_id is not a supported identifier".format(block_name), + "{0}.exchange_id".format(block_name), + "invalid_{0}_value".format(block_name), + ) + if hedge_flag not in ("1", "2", "3"): + raise _config_error( + "{0}.hedge_flag must be 1, 2, or 3".format(block_name), + "{0}.hedge_flag".format(block_name), + "invalid_{0}_value".format(block_name), + ) + # The credential strings are authenticated by the private seal; this + # additional identifier check keeps malformed account selectors bounded. + for key, identifier in (("broker_id", broker_id), ("user_id", user_id)): + if not _CTP_IDENTIFIER_RE.fullmatch(identifier): + raise _config_error( + "{0}.{1} is not a supported identifier".format(block_name, key), + "{0}.{1}".format(block_name, key), + "invalid_{0}_value".format(block_name), + ) + return CtpSimNowPrivateConfig( + md_front=md_front, + td_front=td_front, + front_pairs=front_pairs, + instrument_id=instrument_id, + exchange_id=exchange_id, + hedge_flag=hedge_flag, + broker_id=broker_id, + user_id=user_id, + password=password, + app_id=app_id, + auth_code=auth_code, + ) + + +def _parse_ctp_simnow_private_config(value: Any) -> CtpSimNowPrivateConfig: + """Keep the historical SimNow entry point and its error paths stable.""" + + return _parse_ctp_private_config(value, field_prefix="ctp_simnow") + + +def _parse_ctp_production_private_config(value: Any) -> CtpProductionPrivateConfig: + """Parse private CTP values without selecting from a configured front list.""" + + mapping = _require_mapping(value, "ctp_production") + _reject_unknown_fields(mapping, _CTP_PRODUCTION_FIELDS, "ctp_production") + normalized = _normalise_value(mapping, "ctp_production") + + def required(key: str, *, secret: bool = False) -> str: + return _required_private_string( + normalized, + key, + secret=secret, + field_prefix="ctp_production", + ) + + has_front_pairs = "front_pairs" in normalized + has_legacy_fronts = "md_front" in normalized or "td_front" in normalized + if has_front_pairs and has_legacy_fronts: + raise _config_error( + "ctp_production must use either front_pairs or the legacy md_front/td_front pair", + "ctp_production.front_pairs", + "mixed_ctp_production_front_forms", + ) + if has_front_pairs: + raw_pairs = normalized["front_pairs"] + if ( + type(raw_pairs) is not list + or not 1 <= len(raw_pairs) <= _MAX_CTP_PRODUCTION_FRONT_PAIRS + ): + raise _config_error( + "ctp_production.front_pairs must contain between 1 and {0} pairs".format( + _MAX_CTP_PRODUCTION_FRONT_PAIRS + ), + "ctp_production.front_pairs", + "invalid_ctp_production_front_pairs", + ) + parsed_pairs = [] + seen_pairs = set() + for index, raw_pair in enumerate(raw_pairs): + pair_path = "ctp_production.front_pairs[{0}]".format(index) + pair_mapping = _require_mapping(raw_pair, pair_path) + _reject_unknown_fields( + pair_mapping, _CTP_PRODUCTION_FRONT_PAIR_FIELDS, pair_path + ) + md_front_value = _required_ctp_front( + pair_mapping, "md_front", field_prefix=pair_path + ) + td_front_value = _required_ctp_front( + pair_mapping, "td_front", field_prefix=pair_path + ) + pair_key = (md_front_value, td_front_value) + if pair_key in seen_pairs: + raise _config_error( + "ctp_production.front_pairs must not repeat an exact pair", + pair_path, + "duplicate_ctp_production_front_pair", + ) + seen_pairs.add(pair_key) + parsed_pairs.append( + MappingProxyType( + {"md_front": md_front_value, "td_front": td_front_value} + ) + ) + # Deliberately do not populate the legacy scalars with the first pair. + md_front = None + td_front = None + front_pairs = tuple(parsed_pairs) + else: + md_front = _required_ctp_front( + normalized, "md_front", field_prefix="ctp_production" + ) + td_front = _required_ctp_front( + normalized, "td_front", field_prefix="ctp_production" + ) + front_pairs = (MappingProxyType({"md_front": md_front, "td_front": td_front}),) + instrument_id = required("instrument_id") + exchange_id = required("exchange_id") + hedge_flag = required("hedge_flag") + broker_id = required("broker_id", secret=True) + user_id = required("user_id", secret=True) + password = required("password", secret=True) + app_id = required("app_id", secret=True) + auth_code = required("auth_code", secret=True) + + for key, identifier, pattern in ( + ("instrument_id", instrument_id, _CTP_INSTRUMENT_RE), + ("exchange_id", exchange_id, _CTP_EXCHANGE_RE), + ("broker_id", broker_id, _CTP_IDENTIFIER_RE), + ("user_id", user_id, _CTP_IDENTIFIER_RE), + ): + if not pattern.fullmatch(identifier): + raise _config_error( + "ctp_production.{0} is not a supported identifier".format(key), + "ctp_production.{0}".format(key), + "invalid_ctp_production_value", + ) + if hedge_flag not in ("1", "2", "3"): + raise _config_error( + "ctp_production.hedge_flag must be 1, 2, or 3", + "ctp_production.hedge_flag", + "invalid_ctp_production_value", + ) + return CtpProductionPrivateConfig( + md_front=md_front, + td_front=td_front, + front_pairs=front_pairs, + instrument_id=instrument_id, + exchange_id=exchange_id, + hedge_flag=hedge_flag, + broker_id=broker_id, + user_id=user_id, + password=password, + app_id=app_id, + auth_code=auth_code, + ) + + +def _freeze_value(value: Any) -> Any: + if isinstance(value, dict): + return MappingProxyType({key: _freeze_value(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze_value(item) for item in value) + return value + + +def _thaw_value(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _thaw_value(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw_value(item) for item in value] + return value + + +def _digest_config( + strategy_id: str, + mode: str, + preset: str, + parameters: Dict[str, Any], + secrets_ref: str, + ctp_simnow: Optional[CtpSimNowPrivateConfig] = None, + ctp_production: Optional[CtpProductionPrivateConfig] = None, + ctp: Optional[CtpPrivateConfig] = None, +) -> str: + canonical = { + "config_schema_version": CONFIG_SCHEMA_VERSION, + "parameters": parameters, + "runtime": {"mode": mode, "preset": preset}, + "secrets_ref": secrets_ref, + "strategy": {"id": strategy_id}, + } + if ctp_simnow is not None: + # Front addresses and contract selectors are public route metadata; + # account identifiers and authentication values remain private. + simnow_scope: Dict[str, Any] = { + "instrument_id": ctp_simnow.instrument_id, + "exchange_id": ctp_simnow.exchange_id, + "hedge_flag": ctp_simnow.hedge_flag, + } + if ctp_simnow.md_front is not None and ctp_simnow.td_front is not None: + # Preserve the canonical digest of existing single-pair configs. + simnow_scope["md_front"] = ctp_simnow.md_front + simnow_scope["td_front"] = ctp_simnow.td_front + else: + simnow_scope["front_pairs"] = [ + {"md_front": pair["md_front"], "td_front": pair["td_front"]} + for pair in ctp_simnow.front_pairs + ] + canonical["ctp_simnow"] = simnow_scope + if ctp_production is not None: + # Configured fronts are bound into the digest so an endpoint change + # changes config identity. A front_pairs list stays unresolved and is + # never projected as a selected front. The code-owned production pin + # remains a separate admission check; accounts and credentials stay + # private. + production_scope: Dict[str, Any] = { + "instrument_id": ctp_production.instrument_id, + "exchange_id": ctp_production.exchange_id, + "hedge_flag": ctp_production.hedge_flag, + } + if ctp_production.md_front is not None and ctp_production.td_front is not None: + # Preserve the canonical digest for existing explicit single-pair configs. + production_scope["md_front"] = ctp_production.md_front + production_scope["td_front"] = ctp_production.td_front + else: + production_scope["front_pairs"] = [ + {"md_front": pair["md_front"], "td_front": pair["td_front"]} + for pair in ctp_production.front_pairs + ] + canonical["ctp_production"] = production_scope + if ctp is not None: + # The canonical mode-neutral block binds its configured front set and + # contract scope while keeping account selectors and credentials out + # of the public digest. + ctp_scope: Dict[str, Any] = { + "instrument_id": ctp.instrument_id, + "exchange_id": ctp.exchange_id, + "hedge_flag": ctp.hedge_flag, + } + if ctp.md_front is not None and ctp.td_front is not None: + ctp_scope["md_front"] = ctp.md_front + ctp_scope["td_front"] = ctp.td_front + else: + ctp_scope["front_pairs"] = [ + {"md_front": pair["md_front"], "td_front": pair["td_front"]} + for pair in ctp.front_pairs + ] + canonical["ctp"] = ctp_scope + serialized = json.dumps(canonical, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _validate_schema( + data: Any, + strategy_dir: Path, + source_path: Path, + source_file_identity: Optional[Tuple[int, int]] = None, +) -> RuntimeConfig: + if data is None: + raise _config_error("config.yaml must not be empty", CONFIG_FILENAME, "empty_config") + root = _require_mapping(data, CONFIG_FILENAME) + _reject_unknown_fields(root, _ROOT_FIELDS, "") + + version = _require_field(root, "config_schema_version", "") + if ( + isinstance(version, bool) + or not isinstance(version, int) + or version != CONFIG_SCHEMA_VERSION + ): + raise _config_error( + "only config schema version 4 is supported", + "config_schema_version", + "unsupported_schema_version", + ) + + strategy = _require_mapping(_require_field(root, "strategy", ""), "strategy") + _reject_unknown_fields(strategy, _STRATEGY_FIELDS, "strategy") + strategy_id = _require_field(strategy, "id", "strategy") + if not isinstance(strategy_id, str) or not _STRATEGY_ID_RE.fullmatch(strategy_id): + raise _config_error( + "strategy.id must be a non-empty stable identifier", + "strategy.id", + "invalid_strategy_id", + ) + + runtime = _require_mapping(_require_field(root, "runtime", ""), "runtime") + _reject_unknown_fields(runtime, _RUNTIME_FIELDS, "runtime") + mode = _require_field(runtime, "mode", "runtime") + preset = _require_field(runtime, "preset", "runtime") + if not isinstance(mode, str) or mode not in ("backtest", "simulation", "live"): + raise RuntimeConfigError( + MODE_PRESET_MISMATCH, + "runtime.mode is not one of the supported modes", + field_path="runtime.mode", + reason="unsupported_mode", + ) + if not isinstance(preset, str) or get_preset_policy(preset) is None: + raise RuntimeConfigError( + MODE_PRESET_MISMATCH, + "runtime.preset is not a registered preset name", + field_path="runtime.preset", + reason="unsupported_preset", + ) + policy = get_preset_policy(preset) + if policy is None or policy.mode != mode: + raise RuntimeConfigError( + MODE_PRESET_MISMATCH, + "runtime.mode and runtime.preset are not a permitted pair", + field_path="runtime.preset", + reason="mode_preset_mismatch", + ) + + has_ctp = "ctp" in root + has_ctp_simnow = "ctp_simnow" in root + has_ctp_production = "ctp_production" in root + if has_ctp and (has_ctp_simnow or has_ctp_production): + raise _config_error( + "canonical and legacy CTP private blocks are mutually exclusive", + "ctp", + "ctp_private_blocks_mutually_exclusive", + ) + if has_ctp_simnow and has_ctp_production: + raise _config_error( + "CTP SimNow and production private blocks are mutually exclusive", + "ctp_production", + "ctp_private_blocks_mutually_exclusive", + ) + + ctp: Optional[CtpPrivateConfig] = None + if has_ctp: + if (mode, preset) not in ( + ("simulation", "sandbox"), + ("live", "managed_live_direct"), + ): + raise _config_error( + "ctp is allowed only for simulation/sandbox or live/managed_live_direct", + "ctp", + "ctp_scope_not_allowed", + ) + ctp = _parse_ctp_private_config(root["ctp"]) + + ctp_simnow: Optional[CtpSimNowPrivateConfig] = None + if "ctp_simnow" in root: + if mode != "simulation" or preset != "sandbox": + raise _config_error( + "ctp_simnow is allowed only for simulation/sandbox", + "ctp_simnow", + "ctp_simnow_scope_not_allowed", + ) + ctp_simnow = _parse_ctp_simnow_private_config(root["ctp_simnow"]) + + ctp_production: Optional[CtpProductionPrivateConfig] = None + if has_ctp_production: + if not _is_ctp_production_runtime_dir(strategy_dir): + raise _config_error( + "ctp_production is allowed only in the reserved production runtime directory", + "ctp_production", + "ctp_production_runtime_path_mismatch", + ) + if mode != "live" or preset != "managed_live_direct": + raise _config_error( + "ctp_production is allowed only for live/managed_live_direct", + "ctp_production", + "ctp_production_scope_not_allowed", + ) + ctp_production = _parse_ctp_production_private_config(root["ctp_production"]) + + parameters_raw = root.get("parameters", {}) + parameters = _normalise_value(_require_mapping(parameters_raw, "parameters"), "parameters") + _reject_inline_secrets_and_controls(parameters, "parameters") + + secrets_ref = root.get("secrets_ref", "none") + if not isinstance(secrets_ref, str) or not ( + secrets_ref in ("none", "runtime_secrets", "config_yaml") + or _OS_SECRET_REF_RE.fullmatch(secrets_ref) + ): + raise _config_error( + "secrets_ref must be none, runtime_secrets, or an opaque OS secret-store reference", + "secrets_ref", + "invalid_secrets_ref", + ) + if "${" in secrets_ref: + raise _config_error( + "environment interpolation is not supported in config.yaml", + "secrets_ref", + "environment_interpolation_not_allowed", + ) + + # The old SimNow attribute remains an exact-type compatibility view for a + # canonical sandbox block; both names refer to one object and one set of + # credentials. The canonical live block has no legacy alias. + if ctp is not None and mode == "simulation": + ctp_simnow = ctp + + has_private_ctp_block = ( + ctp is not None or ctp_simnow is not None or ctp_production is not None + ) + if has_private_ctp_block and secrets_ref != "config_yaml": + block_name = ( + "ctp" + if ctp is not None + else "ctp_production" + if ctp_production is not None + else "ctp_simnow" + ) + raise _config_error( + "secrets_ref config_yaml is required for the private {0} block".format(block_name), + block_name, + "ctp_secret_source_mismatch" + if ctp is not None + else "ctp_production_secret_source_mismatch" + if ctp_production is not None + else "ctp_simnow_secret_source_mismatch", + ) + if not has_private_ctp_block and secrets_ref == "config_yaml": + raise _config_error( + "secrets_ref config_yaml requires a private CTP block", + "secrets_ref", + "ctp_simnow_secret_source_mismatch", + ) + + digest = _digest_config( + strategy_id, + mode, + preset, + parameters, + secrets_ref, + ctp_simnow if has_ctp_simnow else None, + ctp_production, + ctp, + ) + return RuntimeConfig( + strategy_dir=strategy_dir, + source_path=source_path, + strategy_id=strategy_id, + mode=mode, + preset=preset, + parameters=_freeze_value(parameters), + secrets_ref=secrets_ref, + config_digest=digest, + ctp=ctp, + ctp_simnow=ctp_simnow, + ctp_production=ctp_production, + _source_file_identity=source_file_identity, + ) + + +def _parse_verified_runtime_config_text( + text: str, + strategy_dir: Path, + source_path: Path, + *, + registry: Optional[Any] = None, + source_file_identity: Optional[Tuple[int, int]] = None, +) -> RuntimeConfig: + """Parse bytes already read through a verified config descriptor. + + Bootstrap-set preflight uses this to compare and validate the *same* sealed + bytes. It must not re-open config.yaml after deciding that the bytes match + the reviewed canonical bootstrap content. + """ + + return _seal_loaded_runtime_config( + _validate_schema(_load_strict_yaml(text), strategy_dir, source_path, source_file_identity), + registry, + ) + + +def load_runtime_config( + strategy_dir: Union[str, os.PathLike], + *, + registry: Optional[Any] = None, +) -> RuntimeConfig: + """Read and strictly validate ``/config.yaml``. + + Passing a registry binds the parsed config to that exact trusted registry + and checks its directory before opening the file. Omitting it supports + safe two-stage inspection; later resolution still checks the chosen + registry and runtime identity. The function deliberately performs no + network, secret-store, plugin, provider, or strategy import. + """ + + resolved_dir = _normalise_runtime_dir(strategy_dir) + if registry is None: + if _is_ctp_production_runtime_dir(resolved_dir): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "ctp_production requires a registered runtime before private config can be read", + field_path="ctp_production", + reason="private_config_registration_required", + ) + identity_out = [] + source_path, text = _read_config_text(resolved_dir, identity_out=identity_out) + config = _parse_verified_runtime_config_text( + text, + resolved_dir, + source_path, + source_file_identity=identity_out[0], + ) + if config.ctp is not None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "ctp requires a code-owned runtime registration before private config can be returned or used", + field_path="ctp", + reason="private_config_registration_required", + ) + if config.ctp_simnow is not None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "ctp_simnow requires a code-owned runtime registration before private config can be returned or used", + field_path="ctp_simnow", + reason="private_config_registration_required", + ) + if config.ctp_production is not None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "ctp_production requires a code-owned runtime registration before private config can be returned or used", + field_path="ctp_production", + reason="private_config_registration_required", + ) + return config + + # After lookup, use only the canonical directory held by the trusted + # registration. In particular, never reopen the caller's spelling of a + # path after it has been authorised. + registration = registry.require_runtime_dir(resolved_dir) + with registry.verified_runtime_directory(registration) as directory_fd: + identity_out = [] + source_path, text = _read_config_text( + registration.runtime_dir, + directory_fd=directory_fd, + identity_out=identity_out, + require_private_config_security=( + "config_yaml" in registration.allowed_secrets_refs + or any( + "config_yaml" in profile.allowed_secrets_refs + for profile in registration.profiles + ) + ), + directory_identity=registration.directory_identity, + ) + config = _parse_verified_runtime_config_text( + text, + registration.runtime_dir, + source_path, + registry=registry, + source_file_identity=identity_out[0], + ) + return config + + +def write_bootstrap_config( + path: Union[str, os.PathLike], content: str, *, directory_fd: Optional[int] = None +) -> Path: + """Atomically create a new local config file without overwriting one. + + This low-level writer is used by the registry-aware bootstrap helper. It + does not create parents, merge files, or write secrets. + """ + + target = Path(path) + target_name = CONFIG_FILENAME if directory_fd is not None else str(target) + try: + if directory_fd is None: + descriptor = os.open(target_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + else: + descriptor = os.open( + target_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=directory_fd, + ) + except FileExistsError: + raise RuntimeConfigError( + CONFIG_EXISTS, + "config.yaml already exists and will not be overwritten", + field_path=CONFIG_FILENAME, + reason="config_exists", + ) from None + except OSError: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config.yaml cannot be created in the registered runtime directory", + field_path=CONFIG_FILENAME, + reason="config_create_failed", + ) from None + + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + except Exception: + try: + if directory_fd is None: + target.unlink() + else: + os.unlink(CONFIG_FILENAME, dir_fd=directory_fd) + except OSError: + pass + raise + return target diff --git a/backtrader_runtime/credential_resolver.py b/backtrader_runtime/credential_resolver.py new file mode 100644 index 00000000..694da666 --- /dev/null +++ b/backtrader_runtime/credential_resolver.py @@ -0,0 +1,1727 @@ +"""Fail-closed, local credential resolution for future provider factories. + +This module deliberately resolves only a narrowly scoped authentication +payload. It does *not* import a provider SDK, open a network connection, +prove that an account exists, start preflight, or authorize any external +write. A future provider factory must revalidate the sealed result before it +uses a value and must perform its own provider-side identity checks. + +The matching credential source is selected by a code-owned +:class:`RuntimeCredentialScope`; a secret payload cannot provide or override a +provider, front, endpoint, environment, route, account fingerprint, mode, or +preset. The exact CTP ``simulation/sandbox`` route may use a loader-sealed +``ctp_simnow`` block in ``config.yaml``. That source requires a protected +runtime directory and config file on POSIX, or a current-user-owned protected +DACL on Windows, both when the registered loader reads the file and again +before credentials are released. + +Two sources are intentionally narrow: + +* ``runtime_secrets`` reads exactly ``/secrets.yaml`` + through the registry's verified-directory lease. It is supported only on + POSIX, where the standard library can verify owner and restrictive mode bits + on both the directory and file. Windows rejects this source because Python's + standard library cannot prove a file DACL/owner safely enough. +* ``os_secret_store:`` is supported only on Windows via Credential + Manager ``CredReadW``. It reads the exact code-owned ```` target as a + Generic credential, copies a bounded UTF-8 JSON blob, and calls ``CredFree`` + on every allocated result. No environment variable, CWD, parent-directory, + file fallback, or provider import exists. + +Credential values are necessarily available to trusted in-process factory +code. Python cannot make an in-process string a secret boundary, so this +module instead prevents accidental disclosure through public projections, +reprs, errors, and default resolution paths. +""" + +from __future__ import annotations + +import ctypes +import json +import os +import re +import stat +import weakref +from ctypes import wintypes +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any, Dict, Mapping, Optional, Tuple + +from .config import CtpSimNowPrivateConfig, _load_strict_yaml +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from .policy import get_preset_policy +from .registry import EffectiveRuntimeConfig, RuntimeRegistry, require_effective_runtime_config_seal + + +RUNTIME_SECRETS_FILENAME = "secrets.yaml" +RUNTIME_SECRETS_REF = "runtime_secrets" +CONFIG_YAML_REF = "config_yaml" +OS_SECRET_STORE_PREFIX = "os_secret_store:" +MAX_RUNTIME_SECRETS_BYTES = 64 * 1024 +# ``CRED_MAX_CREDENTIAL_BLOB_SIZE`` is 5 * 512 bytes. Enforce it ourselves +# before decoding even if a platform returns a malformed credential record. +MAX_OS_SECRET_STORE_BLOB_BYTES = 5 * 512 +MAX_CREDENTIAL_VALUE_BYTES = 2048 + +# Future CTP factories can import this code-owned tuple instead of accepting a +# caller-selected credential schema. Every key is required when this tuple is +# used; a provider with a different reviewed authentication scheme must declare +# a separate, explicit tuple in its own composition root. +CTP_AUTHENTICATION_CREDENTIAL_KEYS = ( + "broker_id", + "user_id", + "password", + "app_id", + "auth_code", +) + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") +_ENVIRONMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,127}$") +_CREDENTIAL_KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_OS_SECRET_REF_RE = re.compile(r"^os_secret_store:([A-Za-z0-9][A-Za-z0-9._-]{0,127})$") +_CTP_SIMNOW_ENVIRONMENT = "simnow" + +# These fields control a route or identity rather than authenticate. The +# secrets payload is not allowed to smuggle any of them into a later factory. +_CONTROL_CREDENTIAL_KEYS = frozenset( + ( + "account", + "account_access", + "account_fingerprint", + "account_fingerprint_sha256", + "account_id", + "approval", + "approval_receipt_digest", + "capability", + "capability_digest", + "config_digest", + "effective_config_digest", + "endpoint", + "environment", + "front", + "gateway", + "host", + "md_front", + "mode", + "order_route", + "policy", + "preset", + "production", + "provider", + "provider_environment", + "registration", + "registration_digest", + "route", + "runtime", + "runtime_id", + "strategy", + "strategy_id", + "td_front", + "url", + ) +) + + +class CredentialResolutionError(RuntimeConfigError): + """A stable, redacted failure while locating a provider credential. + + This keeps the existing Iteration 41 error vocabulary while giving future + factories a distinct exception type and a reason that never interpolates a + secret value, credential-manager target, or filesystem path. + """ + + def __init__(self, reason: str, message: str) -> None: + super().__init__( + PRESET_POLICY_VIOLATION, + message, + field_path="runtime.credentials", + reason=reason, + ) + + +def _reject(reason: str, message: str) -> None: + raise CredentialResolutionError(reason, message) + + +def _platform_name() -> str: + """Small seam for platform-specific unit tests; never reads environment.""" + + return os.name + + +def _strict_identifier(value: Any, field_name: str) -> str: + if ( + type(value) is not str + or len(value) > 128 + or value != value.strip() + or not _IDENTIFIER_RE.fullmatch(value) + ): + raise ValueError("{0} must be a non-empty code-owned identifier".format(field_name)) + return value + + +def _strict_provider(value: Any) -> str: + if ( + type(value) is not str + or len(value) > 64 + or value != value.strip() + or not _PROVIDER_RE.fullmatch(value) + ): + raise ValueError("provider must be a lower-case code-owned identifier") + return value + + +def _strict_environment(value: Any) -> str: + if ( + type(value) is not str + or len(value) > 128 + or value != value.strip() + or not _ENVIRONMENT_RE.fullmatch(value) + ): + raise ValueError("provider_environment must be a code-owned environment identifier") + return value + + +def _strict_digest(value: Any, field_name: str) -> str: + if type(value) is not str or len(value) != 64 or not _SHA256_RE.fullmatch(value): + raise ValueError("{0} must be a lower-case SHA-256 digest".format(field_name)) + return value + + +def _strict_secret_ref(value: Any) -> str: + if type(value) is str and value in (RUNTIME_SECRETS_REF, CONFIG_YAML_REF): + return value + if type(value) is str and _OS_SECRET_REF_RE.fullmatch(value): + return value + raise ValueError( + "secrets_ref must select config_yaml, runtime_secrets, or a code-owned OS secret-store name" + ) + + +def _strict_credential_keys(value: Any) -> Tuple[str, ...]: + if type(value) is not tuple or not value: + raise ValueError("credential_keys must be a non-empty exact tuple") + if len(value) > 16: + raise ValueError("credential_keys contains too many values") + normalized = [] + for key in value: + if ( + type(key) is not str + or len(key) > 64 + or not _CREDENTIAL_KEY_RE.fullmatch(key) + or key in _CONTROL_CREDENTIAL_KEYS + ): + raise ValueError("credential_keys contains an unsafe credential field name") + normalized.append(key) + if len(set(normalized)) != len(normalized): + raise ValueError("credential_keys contains duplicates") + return tuple(normalized) + + +@dataclass(frozen=True) +class RuntimeCredentialScope: + """Code-owned authentication scope for exactly one sealed runtime route. + + The scope is deliberately constructed in provider composition code, never + parsed from YAML. ``provider_environment`` is a code-owned label; for CTP + sandbox credentials it is exactly ``simnow`` and independent of front + addresses, which are bound separately by the selected route. The + ``policy_environment`` must + match the Iteration 41 preset policy and prevents a sandbox config from + resolving a production-labelled source. + """ + + runtime_id: str + strategy_id: str + provider: str + provider_environment: str + policy_environment: str + mode: str + preset: str + account_access: str + account_fingerprint_sha256: str = field(repr=False) + secrets_ref: str = field(repr=False) + credential_keys: Tuple[str, ...] + effective_config_digest: str = field(repr=False) + registration_digest: str = field(repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "runtime_id", _strict_identifier(self.runtime_id, "runtime_id")) + object.__setattr__(self, "strategy_id", _strict_identifier(self.strategy_id, "strategy_id")) + object.__setattr__(self, "provider", _strict_provider(self.provider)) + object.__setattr__( + self, "provider_environment", _strict_environment(self.provider_environment) + ) + if type(self.policy_environment) is not str or self.policy_environment not in ( + "sandbox", + "production", + ): + raise ValueError("policy_environment must be sandbox or production") + if type(self.mode) is not str or type(self.preset) is not str: + raise ValueError("mode and preset must be strings") + policy = get_preset_policy(self.preset) + if policy is None or policy.mode != self.mode: + raise ValueError("mode and preset must match a reviewed policy") + if policy.environment != self.policy_environment: + raise ValueError("policy_environment must match the reviewed preset policy") + if self.provider == "ctp": + if ( + self.policy_environment == "sandbox" + and self.provider_environment != _CTP_SIMNOW_ENVIRONMENT + ): + raise ValueError( + "a CTP sandbox credential scope must use the code-owned SimNow environment" + ) + if ( + self.policy_environment == "production" + and self.provider_environment != "production" + ): + raise ValueError("a CTP production credential scope must name exact production") + elif self.policy_environment == "sandbox" and self.provider_environment.casefold() in { + "production", + "prod", + "live", + }: + raise ValueError("a sandbox credential scope cannot name a production environment") + if ( + type(self.account_access) is not str + or len(self.account_access) > 128 + or not _IDENTIFIER_RE.fullmatch(self.account_access) + ): + raise ValueError("account_access must be a code-owned route identifier") + object.__setattr__( + self, + "account_fingerprint_sha256", + _strict_digest(self.account_fingerprint_sha256, "account_fingerprint_sha256"), + ) + object.__setattr__(self, "secrets_ref", _strict_secret_ref(self.secrets_ref)) + credential_keys = _strict_credential_keys(self.credential_keys) + if self.provider == "ctp" and credential_keys != CTP_AUTHENTICATION_CREDENTIAL_KEYS: + raise ValueError("a CTP credential scope requires the full fixed CTP schema") + object.__setattr__(self, "credential_keys", credential_keys) + object.__setattr__( + self, + "effective_config_digest", + _strict_digest(self.effective_config_digest, "effective_config_digest"), + ) + object.__setattr__( + self, + "registration_digest", + _strict_digest(self.registration_digest, "registration_digest"), + ) + + def __repr__(self) -> str: + return ( + "RuntimeCredentialScope(runtime_id={!r}, strategy_id={!r}, provider={!r}, " + "provider_environment={!r}, policy_environment={!r}, mode={!r}, preset={!r}, " + "account_access={!r}, credential_keys={!r})" + ).format( + self.runtime_id, + self.strategy_id, + self.provider, + self.provider_environment, + self.policy_environment, + self.mode, + self.preset, + self.account_access, + self.credential_keys, + ) + + def as_public_dict(self) -> Dict[str, Any]: + """Return only code-owned route metadata, never refs or account IDs.""" + + return { + "runtime_id": self.runtime_id, + "strategy_id": self.strategy_id, + "provider": self.provider, + "provider_environment": self.provider_environment, + "policy_environment": self.policy_environment, + "mode": self.mode, + "preset": self.preset, + "account_access": self.account_access, + "credential_count": len(self.credential_keys), + "effective_config_digest": self.effective_config_digest, + "registration_digest": self.registration_digest, + } + + +def _snapshot_scope(scope: RuntimeCredentialScope) -> RuntimeCredentialScope: + """Copy validated scope facts after rejecting subclasses and mutation.""" + + if type(scope) is not RuntimeCredentialScope: + _reject( + "credential_scope_invalid", + "credential scope must be a code-owned RuntimeCredentialScope", + ) + try: + return RuntimeCredentialScope( + runtime_id=scope.runtime_id, + strategy_id=scope.strategy_id, + provider=scope.provider, + provider_environment=scope.provider_environment, + policy_environment=scope.policy_environment, + mode=scope.mode, + preset=scope.preset, + account_access=scope.account_access, + account_fingerprint_sha256=scope.account_fingerprint_sha256, + secrets_ref=scope.secrets_ref, + credential_keys=scope.credential_keys, + effective_config_digest=scope.effective_config_digest, + registration_digest=scope.registration_digest, + ) + except (AttributeError, TypeError, ValueError): + _reject("credential_scope_invalid", "credential scope is not a valid code-owned binding") + raise AssertionError("unreachable") + + +def _scope_facts(scope: RuntimeCredentialScope) -> Tuple[Any, ...]: + return ( + scope.runtime_id, + scope.strategy_id, + scope.provider, + scope.provider_environment, + scope.policy_environment, + scope.mode, + scope.preset, + scope.account_access, + scope.account_fingerprint_sha256, + scope.secrets_ref, + scope.credential_keys, + scope.effective_config_digest, + scope.registration_digest, + ) + + +def _require_matching_scope( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + scope: RuntimeCredentialScope, +) -> RuntimeCredentialScope: + """Bind code-owned credential scope to one sealed config and registration.""" + + require_effective_runtime_config_seal(effective, registry) + if effective.profile is not None: + from .ctp_sandbox_readonly_admission import ( + CtpSandboxReadOnlyAdmissionError, + require_ctp_sandbox_profile_runtime, + ) + + try: + require_ctp_sandbox_profile_runtime(effective, registry) + except CtpSandboxReadOnlyAdmissionError: + _reject( + "profile_scoped_credentials_unavailable", + "profile credentials require the exact zero-write CTP sandbox route", + ) + snapshot = _snapshot_scope(scope) + registration = effective.registration + + if ( + snapshot.runtime_id != registration.runtime_id + or snapshot.strategy_id != effective.strategy_id + or snapshot.mode != effective.mode + or snapshot.preset != effective.preset + or snapshot.account_access != effective.account_access + ): + _reject( + "credential_scope_runtime_mismatch", + "credential scope does not match the sealed runtime route", + ) + if snapshot.policy_environment != effective.policy.environment: + _reject( + "credential_scope_environment_mismatch", + "credential scope does not match the sealed runtime environment", + ) + if snapshot.secrets_ref != effective.config.secrets_ref: + _reject( + "credential_scope_secret_ref_mismatch", + "credential scope does not match the sealed secret reference", + ) + if snapshot.effective_config_digest != effective.effective_digest: + _reject( + "credential_scope_config_mismatch", + "credential scope does not match the sealed effective configuration", + ) + if snapshot.registration_digest != registration.digest: + _reject( + "credential_scope_registration_mismatch", + "credential scope does not match the reviewed runtime registration", + ) + if not effective.policy.accepts_provider_secrets: + _reject( + "credential_scope_secrets_not_allowed", + "the sealed runtime policy does not accept provider credentials", + ) + if snapshot.policy_environment == "sandbox": + if effective.allows_production_writes or ( + snapshot.provider != "ctp" + and snapshot.provider_environment.casefold() in {"production", "prod", "live"} + ): + _reject( + "credential_scope_sandbox_production_mismatch", + "a sandbox credential scope cannot resolve a production route", + ) + elif snapshot.policy_environment == "production": + if not effective.allows_production_writes: + _reject( + "credential_scope_production_route_mismatch", + "a production credential scope requires the sealed production route", + ) + else: # Defensive in case a frozen scope was mutated after validation. + _reject( + "credential_scope_environment_mismatch", + "credential scope has an unsupported environment", + ) + return snapshot + + +def _is_link_or_reparse(result: os.stat_result) -> bool: + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + attributes = getattr(result, "st_file_attributes", 0) + return stat.S_ISLNK(result.st_mode) or bool(attributes & reparse_point) + + +def _require_posix_directory_security(directory_fd: int) -> None: + try: + result = os.fstat(directory_fd) + effective_uid = os.geteuid() + except (AttributeError, OSError): + _reject( + "runtime_secrets_platform_acl_unavailable", + "runtime_secrets requires verified POSIX owner and permission checks", + ) + if not stat.S_ISDIR(result.st_mode) or result.st_uid != effective_uid or result.st_mode & 0o022: + _reject( + "runtime_secrets_directory_unsafe", + "the registered runtime directory is not safe for credential resolution", + ) + + +def _require_posix_secret_security(result: os.stat_result) -> None: + try: + effective_uid = os.geteuid() + except AttributeError: + _reject( + "runtime_secrets_platform_acl_unavailable", + "runtime_secrets requires verified POSIX owner and permission checks", + ) + if result.st_uid != effective_uid or result.st_mode & 0o077 or result.st_nlink != 1: + _reject( + "runtime_secrets_file_unsafe", + "secrets.yaml must be an owner-only unlinked regular file", + ) + + +def _read_runtime_secrets_text(runtime_dir: Path, directory_fd: Optional[int]) -> str: + """Read exactly one owner-protected secret file through a verified FD.""" + + del runtime_dir # A caller must not reopen the pathname outside the lease. + if _platform_name() != "posix" or directory_fd is None: + _reject( + "runtime_secrets_platform_acl_unavailable", + "runtime_secrets is unavailable until this platform can prove secret-file ownership and ACLs", + ) + _require_posix_directory_security(directory_fd) + try: + path_stat = os.lstat(RUNTIME_SECRETS_FILENAME, dir_fd=directory_fd) + except FileNotFoundError: + _reject( + "runtime_secrets_missing", + "required secrets.yaml is missing from the registered runtime directory", + ) + except OSError: + _reject("runtime_secrets_unreadable", "secrets.yaml cannot be opened safely") + if _is_link_or_reparse(path_stat): + _reject("runtime_secrets_symlink_not_allowed", "secrets.yaml must not be a symbolic link") + if not stat.S_ISREG(path_stat.st_mode): + _reject("runtime_secrets_not_regular_file", "secrets.yaml must be a regular file") + _require_posix_secret_security(path_stat) + + # ``lstat`` alone cannot close the replacement race between inspection and + # open. Do not claim the POSIX file source is safe on an implementation + # which cannot request a non-following open of the final leaf. + no_follow = getattr(os, "O_NOFOLLOW", None) + if type(no_follow) is not int or no_follow == 0: + _reject( + "runtime_secrets_platform_acl_unavailable", + "runtime_secrets requires non-following descriptor-relative file opens", + ) + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | no_follow + try: + descriptor = os.open(RUNTIME_SECRETS_FILENAME, flags, dir_fd=directory_fd) + except FileNotFoundError: + _reject( + "runtime_secrets_missing", + "required secrets.yaml is missing from the registered runtime directory", + ) + except OSError: + _reject("runtime_secrets_unreadable", "secrets.yaml cannot be opened safely") + + descriptor_stat: Optional[os.stat_result] = None + try: + descriptor_stat = os.fstat(descriptor) + if not stat.S_ISREG(descriptor_stat.st_mode): + _reject("runtime_secrets_not_regular_file", "secrets.yaml must be a regular file") + if (path_stat.st_dev, path_stat.st_ino) != (descriptor_stat.st_dev, descriptor_stat.st_ino): + _reject( + "runtime_secrets_identity_changed", + "secrets.yaml changed while it was being opened", + ) + _require_posix_secret_security(descriptor_stat) + chunks = [] + remaining = MAX_RUNTIME_SECRETS_BYTES + 1 + while remaining: + chunk = os.read(descriptor, remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + except CredentialResolutionError: + raise + except OSError: + _reject("runtime_secrets_unreadable", "secrets.yaml cannot be opened safely") + finally: + try: + os.close(descriptor) + except OSError: + pass + + try: + final_stat = os.lstat(RUNTIME_SECRETS_FILENAME, dir_fd=directory_fd) + except OSError: + final_stat = None + if ( + descriptor_stat is None + or final_stat is None + or _is_link_or_reparse(final_stat) + or not stat.S_ISREG(final_stat.st_mode) + or (final_stat.st_dev, final_stat.st_ino) + != (descriptor_stat.st_dev, descriptor_stat.st_ino) + ): + _reject( + "runtime_secrets_identity_changed", + "secrets.yaml changed while it was being read", + ) + _require_posix_secret_security(final_stat) + if len(raw) > MAX_RUNTIME_SECRETS_BYTES: + _reject("runtime_secrets_too_large", "secrets.yaml exceeds the supported size") + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + _reject("runtime_secrets_invalid_encoding", "secrets.yaml must be UTF-8 text") + raise AssertionError("unreachable") + + +class _WindowsFileTime(ctypes.Structure): + _fields_ = (("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)) + + +class _WindowsCredentialW(ctypes.Structure): + """The documented ``CREDENTIALW`` layout used only with CredReadW.""" + + _fields_ = ( + ("Flags", wintypes.DWORD), + ("Type", wintypes.DWORD), + ("TargetName", wintypes.LPWSTR), + ("Comment", wintypes.LPWSTR), + ("LastWritten", _WindowsFileTime), + ("CredentialBlobSize", wintypes.DWORD), + ("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)), + ("Persist", wintypes.DWORD), + ("AttributeCount", wintypes.DWORD), + ("Attributes", ctypes.c_void_p), + ("TargetAlias", wintypes.LPWSTR), + ("UserName", wintypes.LPWSTR), + ) + + +@dataclass(frozen=True) +class _WindowsCredentialApi: + cred_read: Any + cred_free: Any + generic_type: int = 1 + + +def _validate_windows_acl_entries( + owner_sid: str, entries: Tuple[Tuple[int, int, str], ...] +) -> None: + """Accept only explicit allow/deny ACEs for owner, SYSTEM, or admins. + + Unknown trustees, inherited ACEs, and complex/object ACEs fail closed. + This is intentionally stricter than Windows' full ACL inheritance model. + """ + + broad_sids = { + "S-1-1-0", # Everyone + "S-1-5-7", # Anonymous + "S-1-5-11", # Authenticated Users + "S-1-5-32-545", # Builtin Users + "S-1-5-32-546", # Builtin Guests + } + if owner_sid in broad_sids: + _reject( + "config_yaml_windows_acl_invalid", + "config_yaml requires an explicit owner-only Windows ACL", + ) + allowed_sids = {owner_sid, "S-1-5-18", "S-1-5-32-544"} + for ace_type, ace_flags, trustee_sid in entries: + if ace_type not in (0, 1) or ace_flags & 0x10 or trustee_sid not in allowed_sids: + _reject( + "config_yaml_windows_acl_invalid", + "config_yaml requires an explicit owner-only Windows ACL", + ) + + +def _validate_windows_acl_owner(owner_sid: str, current_user_sid: str) -> None: + """Require the metadata object's owner to be this process's user SID.""" + + broad_or_group_sids = { + "S-1-1-0", # Everyone + "S-1-5-7", # Anonymous + "S-1-5-11", # Authenticated Users + "S-1-5-32-544", # Builtin Administrators is a group, not a user owner + "S-1-5-32-545", # Builtin Users + "S-1-5-32-546", # Builtin Guests + } + if owner_sid != current_user_sid or owner_sid in broad_or_group_sids: + _reject( + "config_yaml_windows_acl_invalid", + "config.yaml owner must match the current process user", + ) + + +def _validate_windows_dacl_control(is_protected: bool) -> None: + """Require a protected DACL so parent ACL edits cannot widen access.""" + + if not is_protected: + _reject( + "config_yaml_windows_acl_invalid", + "config_yaml requires a protected Windows DACL", + ) + + +class _WindowsAclSizeInformation(ctypes.Structure): + _fields_ = ( + ("AceCount", wintypes.DWORD), + ("AclBytesInUse", wintypes.DWORD), + ("AclBytesFree", wintypes.DWORD), + ) + + +def _current_windows_user_sid(advapi32: Any, kernel32: Any) -> str: + """Return the current process token's user SID for owner binding.""" + + open_process_token = advapi32.OpenProcessToken + open_process_token.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + ctypes.POINTER(wintypes.HANDLE), + ) + open_process_token.restype = wintypes.BOOL + get_current_process = kernel32.GetCurrentProcess + get_current_process.argtypes = () + get_current_process.restype = wintypes.HANDLE + close_handle = kernel32.CloseHandle + close_handle.argtypes = (wintypes.HANDLE,) + close_handle.restype = wintypes.BOOL + + token = wintypes.HANDLE() + if not open_process_token(get_current_process(), 0x0008, ctypes.byref(token)): + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config owner", + ) + try: + get_token_information = advapi32.GetTokenInformation + get_token_information.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ) + get_token_information.restype = wintypes.BOOL + token_user = ctypes.create_string_buffer(64 * 1024) + required = wintypes.DWORD() + if not get_token_information( + token, 1, token_user, ctypes.sizeof(token_user), ctypes.byref(required) + ): + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config owner", + ) + sid = ctypes.cast(token_user, ctypes.POINTER(ctypes.c_void_p))[0] + if not sid: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config owner", + ) + return _windows_sid_to_string(advapi32, kernel32, sid) + finally: + close_handle(token) + + +def _windows_sid_to_string(advapi32: Any, kernel32: Any, sid: ctypes.c_void_p) -> str: + convert_sid = advapi32.ConvertSidToStringSidW + convert_sid.argtypes = (ctypes.c_void_p, ctypes.POINTER(wintypes.LPWSTR)) + convert_sid.restype = wintypes.BOOL + sid_text = wintypes.LPWSTR() + if not convert_sid(sid, ctypes.byref(sid_text)) or not sid_text: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + try: + return sid_text.value + finally: + local_free = kernel32.LocalFree + local_free.argtypes = (ctypes.c_void_p,) + local_free.restype = ctypes.c_void_p + local_free(ctypes.cast(sid_text, ctypes.c_void_p)) + + +def _windows_acl_for_handle(handle: int) -> None: + """Inspect a Win32 handle's owner and DACL without opening file contents.""" + + try: + advapi32 = ctypes.WinDLL("Advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + get_security_info = advapi32.GetSecurityInfo + get_security_info.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ) + get_security_info.restype = wintypes.DWORD + owner_sid = ctypes.c_void_p() + group_sid = ctypes.c_void_p() + dacl = ctypes.c_void_p() + sacl = ctypes.c_void_p() + descriptor = ctypes.c_void_p() + result = get_security_info( + wintypes.HANDLE(handle), + 1, # SE_FILE_OBJECT + 0x00000001 | 0x00000004, # OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION + ctypes.byref(owner_sid), + ctypes.byref(group_sid), + ctypes.byref(dacl), + ctypes.byref(sacl), + ctypes.byref(descriptor), + ) + if result != 0 or not descriptor: + if descriptor: + local_free = kernel32.LocalFree + local_free.argtypes = (ctypes.c_void_p,) + local_free.restype = ctypes.c_void_p + local_free(descriptor) + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + + try: + get_control = advapi32.GetSecurityDescriptorControl + get_control.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.WORD), + ctypes.POINTER(wintypes.DWORD), + ) + get_control.restype = wintypes.BOOL + descriptor_control = wintypes.WORD() + descriptor_revision = wintypes.DWORD() + if not get_control( + descriptor, + ctypes.byref(descriptor_control), + ctypes.byref(descriptor_revision), + ): + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config DACL", + ) + _validate_windows_dacl_control(bool(descriptor_control.value & 0x1000)) + + get_owner = advapi32.GetSecurityDescriptorOwner + get_owner.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.BOOL), + ) + get_owner.restype = wintypes.BOOL + owner_defaulted = wintypes.BOOL() + if not get_owner(descriptor, ctypes.byref(owner_sid), ctypes.byref(owner_defaulted)): + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + is_valid_sid = advapi32.IsValidSid + is_valid_sid.argtypes = (ctypes.c_void_p,) + is_valid_sid.restype = wintypes.BOOL + if not owner_sid or not is_valid_sid(owner_sid): + _reject( + "config_yaml_windows_acl_invalid", + "config_yaml requires an explicit owner-only Windows ACL", + ) + owner_sid_text = _windows_sid_to_string(advapi32, kernel32, owner_sid) + current_user_sid = _current_windows_user_sid(advapi32, kernel32) + _validate_windows_acl_owner(owner_sid_text, current_user_sid) + + get_dacl = advapi32.GetSecurityDescriptorDacl + get_dacl.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.BOOL), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.BOOL), + ) + get_dacl.restype = wintypes.BOOL + dacl_present = wintypes.BOOL() + dacl_defaulted = wintypes.BOOL() + if ( + not get_dacl( + descriptor, + ctypes.byref(dacl_present), + ctypes.byref(dacl), + ctypes.byref(dacl_defaulted), + ) + or not dacl_present.value + or dacl_defaulted.value + or not dacl + ): + _reject( + "config_yaml_windows_acl_invalid", + "config_yaml requires an explicit owner-only Windows ACL", + ) + + get_acl_information = advapi32.GetAclInformation + get_acl_information.argtypes = ( + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ) + get_acl_information.restype = wintypes.BOOL + size_info = _WindowsAclSizeInformation() + if not get_acl_information( + dacl, + ctypes.byref(size_info), + ctypes.sizeof(size_info), + 2, # AclSizeInformation + ): + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + + get_ace = advapi32.GetAce + get_ace.argtypes = ( + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ) + get_ace.restype = wintypes.BOOL + get_length_sid = advapi32.GetLengthSid + get_length_sid.argtypes = (ctypes.c_void_p,) + get_length_sid.restype = wintypes.DWORD + entries = [] + for index in range(int(size_info.AceCount)): + ace_pointer = ctypes.c_void_p() + if not get_ace(dacl, index, ctypes.byref(ace_pointer)) or not ace_pointer: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + header = ctypes.string_at(ace_pointer, 4) + ace_type = header[0] + ace_flags = header[1] + ace_size = int.from_bytes(header[2:4], byteorder="little") + if ace_size < 12 or ace_size > int(size_info.AclBytesInUse): + _reject( + "config_yaml_windows_acl_invalid", + "config_yaml requires an explicit owner-only Windows ACL", + ) + trustee = ctypes.c_void_p(ace_pointer.value + 8) + if not is_valid_sid(trustee) or get_length_sid(trustee) > ace_size - 8: + _reject( + "config_yaml_windows_acl_invalid", + "config_yaml requires an explicit owner-only Windows ACL", + ) + trustee_sid = _windows_sid_to_string(advapi32, kernel32, trustee) + entries.append((ace_type, ace_flags, trustee_sid)) + _validate_windows_acl_entries(owner_sid_text, tuple(entries)) + finally: + kernel32.LocalFree(descriptor) + except CredentialResolutionError: + raise + except Exception: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + + +def _open_windows_metadata_handle(path: Path, *, is_directory: bool) -> int: + """Open a path for READ_CONTROL without granting or reading file data.""" + + try: + import msvcrt + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + create_file.restype = wintypes.HANDLE + close_handle = kernel32.CloseHandle + close_handle.argtypes = (wintypes.HANDLE,) + close_handle.restype = wintypes.BOOL + read_control = 0x00020000 + file_read_attributes = 0x00000080 + share_read_write = 0x00000001 | 0x00000002 + open_existing = 3 + open_reparse_point = 0x00200000 + backup_semantics = 0x02000000 if is_directory else 0 + invalid_handle = ctypes.c_void_p(-1).value + handle = create_file( + str(path), + read_control | file_read_attributes, + share_read_write, + None, + open_existing, + open_reparse_point | backup_semantics, + None, + ) + if handle == invalid_handle or handle == -1: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not open the private config metadata safely", + ) + try: + return msvcrt.open_osfhandle( + handle, + os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0), + ) + except (OSError, ValueError): + close_handle(handle) + raise + except CredentialResolutionError: + raise + except Exception: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not open the private config metadata safely", + ) + raise AssertionError("unreachable") + + +def _require_windows_private_config_acl( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> None: + """Verify runtime-directory and config-file ACLs under a sealed directory lease.""" + + registration = effective.registration + directory = registration.runtime_dir + config_path = effective.config.source_path + expected_file_identity = effective.config._source_file_identity + if ( + expected_file_identity is None + or config_path.parent != directory + or config_path.name != "config.yaml" + ): + _reject( + "config_yaml_windows_identity_unavailable", + "the private config file identity could not be verified", + ) + try: + with registry.verified_runtime_directory(registration): + directory_lstat = os.lstat(str(directory)) + if _is_link_or_reparse(directory_lstat) or not stat.S_ISDIR(directory_lstat.st_mode): + _reject( + "config_yaml_windows_identity_invalid", + "the private runtime directory is not a regular directory", + ) + directory_fd = _open_windows_metadata_handle(directory, is_directory=True) + try: + directory_stat = os.fstat(directory_fd) + if not registration.directory_identity.matches(directory_stat): + _reject( + "config_yaml_windows_identity_invalid", + "the private runtime directory identity changed", + ) + _windows_acl_for_handle(_windows_os_handle(directory_fd)) + if not registration.directory_identity.matches(os.lstat(str(directory))): + _reject( + "config_yaml_windows_identity_invalid", + "the private runtime directory identity changed", + ) + finally: + os.close(directory_fd) + + config_lstat = os.lstat(str(config_path)) + if _is_link_or_reparse(config_lstat) or not stat.S_ISREG(config_lstat.st_mode): + _reject( + "config_yaml_windows_identity_invalid", + "the private config file is not a regular file", + ) + config_fd = _open_windows_metadata_handle(config_path, is_directory=False) + try: + config_stat = os.fstat(config_fd) + if (config_stat.st_dev, config_stat.st_ino) != expected_file_identity: + _reject( + "config_yaml_windows_identity_invalid", + "the private config file identity changed", + ) + _require_private_config_single_link(config_stat.st_nlink) + _windows_acl_for_handle(_windows_os_handle(config_fd)) + final_config_lstat = os.lstat(str(config_path)) + if _is_link_or_reparse(final_config_lstat) or ( + config_stat.st_dev, + config_stat.st_ino, + ) != (final_config_lstat.st_dev, final_config_lstat.st_ino): + _reject( + "config_yaml_windows_identity_invalid", + "the private config file identity changed", + ) + _require_private_config_single_link(final_config_lstat.st_nlink) + finally: + os.close(config_fd) + except CredentialResolutionError: + raise + except Exception: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + + +def _require_private_config_single_link(link_count: Any) -> None: + if type(link_count) is not int or link_count != 1: + _reject( + "config_yaml_identity_invalid", + "config.yaml must have exactly one filesystem link", + ) + + +def _require_posix_private_config_acl( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> None: + """Recheck private config ownership, modes and identity without reading bytes.""" + + registration = effective.registration + directory = registration.runtime_dir + config_path = effective.config.source_path + expected_file_identity = effective.config._source_file_identity + if ( + expected_file_identity is None + or config_path.parent != directory + or config_path.name != "config.yaml" + ): + _reject( + "config_yaml_identity_unavailable", + "the private config file identity could not be verified", + ) + + no_follow = getattr(os, "O_NOFOLLOW", None) + if _platform_name() != "posix" or type(no_follow) is not int or no_follow == 0: + _reject( + "config_yaml_posix_security_unavailable", + "config_yaml requires non-following POSIX descriptor checks", + ) + + try: + with registry.verified_runtime_directory(registration) as directory_fd: + if directory_fd is None: + _reject( + "config_yaml_posix_security_unavailable", + "config_yaml requires a verified runtime directory descriptor", + ) + directory_stat = os.fstat(directory_fd) + try: + effective_uid = os.geteuid() + except (AttributeError, OSError): + _reject( + "config_yaml_posix_security_unavailable", + "config_yaml requires verified POSIX ownership checks", + ) + directory_mode = stat.S_IMODE(directory_stat.st_mode) + if ( + not registration.directory_identity.matches(directory_stat) + or not stat.S_ISDIR(directory_stat.st_mode) + or directory_stat.st_uid != effective_uid + or directory_mode & ~0o700 + or directory_mode & 0o500 != 0o500 + ): + _reject( + "config_yaml_directory_unsafe", + "the private runtime directory is not owner-only", + ) + + try: + path_stat = os.lstat("config.yaml", dir_fd=directory_fd) + except OSError: + _reject( + "config_yaml_identity_invalid", + "the private config file is unavailable", + ) + if _is_link_or_reparse(path_stat) or not stat.S_ISREG(path_stat.st_mode): + _reject( + "config_yaml_identity_invalid", + "the private config file must be a regular non-link file", + ) + descriptor = os.open( + "config.yaml", + os.O_RDONLY | getattr(os, "O_BINARY", 0) | no_follow, + dir_fd=directory_fd, + ) + try: + config_stat = os.fstat(descriptor) + if ( + not stat.S_ISREG(config_stat.st_mode) + or (config_stat.st_dev, config_stat.st_ino) != expected_file_identity + or (path_stat.st_dev, path_stat.st_ino) + != (config_stat.st_dev, config_stat.st_ino) + ): + _reject( + "config_yaml_identity_invalid", + "the private config file identity changed", + ) + _require_private_config_single_link(config_stat.st_nlink) + _require_posix_private_config_stat(config_stat, effective_uid) + final_stat = os.lstat("config.yaml", dir_fd=directory_fd) + final_fd_stat = os.fstat(descriptor) + if ( + _is_link_or_reparse(final_stat) + or (final_stat.st_dev, final_stat.st_ino) != expected_file_identity + or (final_fd_stat.st_dev, final_fd_stat.st_ino) != expected_file_identity + ): + _reject( + "config_yaml_identity_invalid", + "the private config file identity changed", + ) + _require_private_config_single_link(final_fd_stat.st_nlink) + _require_private_config_single_link(final_stat.st_nlink) + _require_posix_private_config_stat(final_fd_stat, effective_uid) + finally: + os.close(descriptor) + except CredentialResolutionError: + raise + except Exception: + _reject( + "config_yaml_posix_security_unavailable", + "POSIX could not verify the private config permissions", + ) + + +def _require_posix_private_config_stat(result: os.stat_result, effective_uid: int) -> None: + file_mode = stat.S_IMODE(result.st_mode) + if result.st_uid != effective_uid or file_mode & ~0o600 or file_mode & stat.S_IRUSR == 0: + _reject( + "config_yaml_file_unsafe", + "config.yaml must be readable only by its owner", + ) + + +def _windows_os_handle(descriptor: int) -> int: + try: + import msvcrt + + return int(msvcrt.get_osfhandle(descriptor)) + except Exception: + _reject( + "config_yaml_windows_acl_unavailable", + "Windows could not verify the private config ACL", + ) + raise AssertionError("unreachable") + + +def _load_windows_credential_api() -> _WindowsCredentialApi: + """Load just the documented Credential Manager functions, lazily.""" + + if _platform_name() != "nt": + _reject( + "os_secret_store_platform_unsupported", + "the configured OS secret store is unavailable on this platform", + ) + try: + library = ctypes.WinDLL("Advapi32", use_last_error=True) + cred_read = library.CredReadW + cred_read.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.POINTER(_WindowsCredentialW)), + ) + cred_read.restype = wintypes.BOOL + cred_free = library.CredFree + cred_free.argtypes = (ctypes.c_void_p,) + cred_free.restype = None + except Exception: + # A ctypes loader failure may embed system details. Keep the public + # result deterministic and avoid propagating a platform message. + _reject( + "os_secret_store_api_unavailable", + "the required Windows Credential Manager API is unavailable", + ) + return _WindowsCredentialApi(cred_read=cred_read, cred_free=cred_free) + + +def _read_windows_credential_blob( + target: str, api: Optional[_WindowsCredentialApi] = None +) -> bytes: + """Copy an exact Generic credential blob and always free the OS allocation. + + ``api`` is a private test seam. Production calls always use the lazily + loaded Win32 functions; no test or normal path enumerates credentials. + """ + + if api is None: + api = _load_windows_credential_api() + if type(api) is not _WindowsCredentialApi: + _reject("os_secret_store_api_unavailable", "the OS secret-store API is unavailable") + credential_pointer = ctypes.POINTER(_WindowsCredentialW)() + allocation_received = False + result: Optional[bytes] = None + error: Optional[CredentialResolutionError] = None + try: + success = api.cred_read(target, api.generic_type, 0, ctypes.byref(credential_pointer)) + allocation_received = bool(credential_pointer) + if not success: + _reject( + "os_secret_store_credential_missing", + "the code-owned OS secret-store credential could not be read", + ) + if not allocation_received: + _reject( + "os_secret_store_credential_invalid", + "the OS secret-store credential returned no readable payload", + ) + credential = credential_pointer.contents + if credential.Type != api.generic_type or credential.TargetName != target: + _reject( + "os_secret_store_credential_scope_mismatch", + "the OS secret-store credential does not match the code-owned scope", + ) + if ( + credential.Flags != 0 + or credential.Comment + or credential.AttributeCount != 0 + or credential.UserName + or credential.TargetAlias + ): + _reject( + "os_secret_store_credential_metadata_invalid", + "the OS secret-store credential has unsupported metadata", + ) + size = int(credential.CredentialBlobSize) + if size <= 0 or size > MAX_OS_SECRET_STORE_BLOB_BYTES or not credential.CredentialBlob: + _reject( + "os_secret_store_blob_invalid", + "the OS secret-store credential has an invalid bounded payload", + ) + result = ctypes.string_at(credential.CredentialBlob, size) + except CredentialResolutionError as caught: + error = caught + except Exception: + # A foreign ctypes callback or a mocked WinAPI can surface arbitrary + # exception text. It must never carry a Credential Manager payload + # into a caller-visible error. + error = CredentialResolutionError( + "os_secret_store_credential_invalid", + "the OS secret-store credential could not be read safely", + ) + finally: + # A malformed/mock API can raise after assigning the output pointer. + # Test the pointer again so every allocation that reached Python gets + # released, including failure paths before ``allocation_received`` was + # updated. + if allocation_received or bool(credential_pointer): + try: + api.cred_free(ctypes.cast(credential_pointer, ctypes.c_void_p)) + except Exception: + if error is None: + error = CredentialResolutionError( + "os_secret_store_free_failed", + "the OS secret-store credential could not be released safely", + ) + if error is not None: + raise error + if result is None: + _reject( + "os_secret_store_credential_invalid", + "the OS secret-store credential returned no readable payload", + ) + return result + + +class _DuplicateJsonFieldError(ValueError): + pass + + +def _load_secret_store_json(raw: bytes) -> Any: + if type(raw) is not bytes or not raw or len(raw) > MAX_OS_SECRET_STORE_BLOB_BYTES: + _reject( + "os_secret_store_blob_invalid", "the OS secret-store credential has an invalid payload" + ) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + _reject("os_secret_store_blob_invalid", "the OS secret-store payload must be UTF-8 JSON") + + def object_pairs(pairs: Any) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise _DuplicateJsonFieldError() + result[key] = value + return result + + def reject_constant(value: str) -> None: + del value + raise ValueError("JSON constants are not allowed") + + try: + return json.loads(text, object_pairs_hook=object_pairs, parse_constant=reject_constant) + except Exception: + # JSON parser diagnostics can quote source text, which may contain a + # credential. Collapse every parser failure to one redacted reason. + _reject("os_secret_store_blob_invalid", "the OS secret-store payload is not supported JSON") + raise AssertionError("unreachable") + + +def _load_runtime_secrets_yaml(text: str) -> Any: + try: + return _load_strict_yaml(text) + except RuntimeConfigError: + _reject( + "runtime_secrets_invalid_yaml", "secrets.yaml is not a supported strict YAML document" + ) + raise AssertionError("unreachable") + + +def _validate_credential_value(value: Any) -> str: + if type(value) is not str or not value or "\x00" in value: + _reject("credential_value_invalid", "a required credential value is missing or invalid") + try: + encoded = value.encode("utf-8") + except UnicodeEncodeError: + _reject("credential_value_invalid", "a required credential value is missing or invalid") + if len(encoded) > MAX_CREDENTIAL_VALUE_BYTES: + _reject("credential_value_too_large", "a credential value exceeds the supported size") + return value + + +def _credential_document_values(document: Any, scope: RuntimeCredentialScope) -> Mapping[str, str]: + """Accept only ``{credentials: {: string}}``.""" + + if type(document) is not dict: + _reject("credential_document_invalid", "credential payload must be a mapping") + if set(document) != {"credentials"}: + _reject( + "credential_document_controls_forbidden", + "credential payload may contain only the credentials mapping", + ) + credentials = document.get("credentials") + if type(credentials) is not dict: + _reject("credential_document_invalid", "credentials must be a mapping") + keys = tuple(credentials.keys()) + if any(type(key) is not str for key in keys): + _reject("credential_document_invalid", "credential field names must be strings") + if any(key in _CONTROL_CREDENTIAL_KEYS for key in keys): + _reject( + "credential_document_controls_forbidden", + "credential payload may not contain runtime control fields", + ) + if set(keys) != set(scope.credential_keys) or len(keys) != len(scope.credential_keys): + _reject( + "credential_schema_mismatch", + "credential payload does not match the code-owned authentication schema", + ) + result: Dict[str, str] = {} + for key in scope.credential_keys: + result[key] = _validate_credential_value(credentials[key]) + return MappingProxyType(result) + + +@dataclass(frozen=True) +class ResolvedRuntimeCredentials: + """Private credential material plus explicit non-authority status fields. + + Raw values are held in an internal attribute, never appear in a repr or + public projection, and are not process-isolated from trusted in-process + factory code. A provider factory must call + :func:`require_resolved_runtime_credentials_seal` before using + :meth:`require_credential`; resolving a value does not prove account + identity or grant preflight, connection, or execution authority. + """ + + scope: RuntimeCredentialScope + source: str + _values: Mapping[str, str] = field(repr=False, compare=False) + credentials_resolved: bool = True + provider_connected: bool = False + account_identity_verified: bool = False + preflight_authorized: bool = False + execution_authorized: bool = False + external_writes_authorized: bool = False + + def __post_init__(self) -> None: + snapshot = _snapshot_scope(self.scope) + if self.source not in (CONFIG_YAML_REF, RUNTIME_SECRETS_REF, "os_secret_store"): + raise ValueError("credential source is unsupported") + if type(self._values) not in (dict, MappingProxyType): + raise ValueError("credential values must be a private mapping") + values = _credential_document_values({"credentials": dict(self._values)}, snapshot) + object.__setattr__(self, "scope", snapshot) + object.__setattr__(self, "_values", values) + if ( + self.credentials_resolved is not True + or self.provider_connected is not False + or self.account_identity_verified is not False + or self.preflight_authorized is not False + or self.execution_authorized is not False + or self.external_writes_authorized is not False + ): + raise ValueError("credential resolution cannot grant provider or execution authority") + + def __bool__(self) -> bool: + raise TypeError( + "ResolvedRuntimeCredentials is not an account, preflight, or execution authorization; " + "do not use it as a boolean" + ) + + def __repr__(self) -> str: + return ( + "ResolvedRuntimeCredentials(scope={!r}, source={!r}, credentials_resolved=True, " + "provider_connected=False, account_identity_verified=False, preflight_authorized=False, " + "execution_authorized=False, external_writes_authorized=False)" + ).format(self.scope, self.source) + + @property + def credential_names(self) -> Tuple[str, ...]: + """Return only the reviewed schema names, never authentication values.""" + + return self.scope.credential_keys + + def require_credential(self, key: str) -> str: + """Return one value to a trusted factory after seal verification.""" + + if type(key) is not str or key not in self.scope.credential_keys: + _reject("credential_name_not_available", "the requested credential is not available") + try: + return self._values[key] + except (KeyError, TypeError): + _reject( + "credential_resolution_invalid", "resolved credential material is no longer valid" + ) + raise AssertionError("unreachable") + + def as_public_dict(self) -> Dict[str, Any]: + """Return a safe state projection that cannot be treated as admission.""" + + return { + "runtime_id": self.scope.runtime_id, + "strategy_id": self.scope.strategy_id, + "provider": self.scope.provider, + "provider_environment": self.scope.provider_environment, + "policy_environment": self.scope.policy_environment, + "mode": self.scope.mode, + "preset": self.scope.preset, + "account_access": self.scope.account_access, + "credential_count": len(self.scope.credential_keys), + "credentials_resolved": self.credentials_resolved, + "provider_connected": self.provider_connected, + "account_identity_verified": self.account_identity_verified, + "preflight_authorized": self.preflight_authorized, + "execution_authorized": self.execution_authorized, + "external_writes_authorized": self.external_writes_authorized, + } + + +@dataclass(frozen=True) +class _ResolvedCredentialSeal: + registry: RuntimeRegistry + effective: EffectiveRuntimeConfig + scope_facts: Tuple[Any, ...] + source: str + values: Mapping[str, str] + + +_RESOLVED_CREDENTIAL_SEALS: Dict[int, Tuple[Any, _ResolvedCredentialSeal]] = {} + + +def _seal_resolved_credentials( + resolved: ResolvedRuntimeCredentials, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, +) -> ResolvedRuntimeCredentials: + identifier = id(resolved) + + def discard(reference: Any) -> None: + current = _RESOLVED_CREDENTIAL_SEALS.get(identifier) + if current is not None and current[0] is reference: + _RESOLVED_CREDENTIAL_SEALS.pop(identifier, None) + + reference = weakref.ref(resolved, discard) + _RESOLVED_CREDENTIAL_SEALS[identifier] = ( + reference, + _ResolvedCredentialSeal( + registry=registry, + effective=effective, + scope_facts=_scope_facts(resolved.scope), + source=resolved.source, + values=resolved._values, + ), + ) + return resolved + + +def require_resolved_runtime_credentials_seal( + resolved: ResolvedRuntimeCredentials, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + scope: RuntimeCredentialScope, +) -> None: + """Reject forged, cross-route, or mutated credential-resolution results. + + This is intentionally a separate factory-facing gate. It verifies the + sealed effective config again, then confirms that the resolution came from + this module for the exact code-owned credential scope supplied to the + future provider factory. + """ + + # Repeat the complete route binding, not just the effective-config object + # seal. Frozen dataclasses can still be mutated through hostile Python + # code, and a registration digest change after resolution must invalidate + # every later credential access. + expected_scope = _require_matching_scope(effective, registry, scope) + if type(resolved) is not ResolvedRuntimeCredentials: + _reject( + "credential_resolution_provenance_invalid", + "credential resolution was not produced by the trusted resolver", + ) + entry = _RESOLVED_CREDENTIAL_SEALS.get(id(resolved)) + if entry is None or entry[0]() is not resolved: + _reject( + "credential_resolution_provenance_invalid", + "credential resolution was not produced by the trusted resolver", + ) + seal = entry[1] + if ( + seal.registry is not registry + or seal.effective is not effective + or seal.scope_facts != _scope_facts(expected_scope) + or _scope_facts(resolved.scope) != seal.scope_facts + or resolved.source != seal.source + or resolved._values is not seal.values + or resolved.credentials_resolved is not True + or resolved.provider_connected is not False + or resolved.account_identity_verified is not False + or resolved.preflight_authorized is not False + or resolved.execution_authorized is not False + or resolved.external_writes_authorized is not False + ): + _reject( + "credential_resolution_provenance_invalid", + "credential resolution no longer matches the sealed runtime scope", + ) + + +def resolve_runtime_credentials( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + scope: RuntimeCredentialScope, +) -> ResolvedRuntimeCredentials: + """Resolve one exact credential schema without provider activity. + + A successful return only establishes that a local credential source matched + the caller's code-owned scope. It is neither a connection, a provider + account check, a receipt check, nor an execution permit. + """ + + matched_scope = _require_matching_scope(effective, registry, scope) + if matched_scope.secrets_ref == CONFIG_YAML_REF: + # ``_require_matching_scope`` has already revalidated registry, + # effective-config, and loader provenance seals. Read the sealed + # private object directly; never reopen config.yaml or consult env. + private = effective.config.ctp_simnow + if ( + matched_scope.provider != "ctp" + or matched_scope.policy_environment != "sandbox" + or matched_scope.mode != "simulation" + or matched_scope.preset != "sandbox" + or type(private) is not CtpSimNowPrivateConfig + ): + _reject( + "credential_scope_secret_ref_mismatch", + "config_yaml credentials require a sealed CTP sandbox configuration", + ) + if _platform_name() == "nt": + _require_windows_private_config_acl(effective, registry) + elif _platform_name() == "posix": + _require_posix_private_config_acl(effective, registry) + else: + _reject( + "config_yaml_platform_unsupported", + "config_yaml credentials require verified local file permissions", + ) + document = { + "credentials": { + key: getattr(private, key) for key in CTP_AUTHENTICATION_CREDENTIAL_KEYS + } + } + source = CONFIG_YAML_REF + elif matched_scope.secrets_ref == RUNTIME_SECRETS_REF: + with registry.verified_runtime_directory(effective.registration) as directory_fd: + text = _read_runtime_secrets_text(effective.registration.runtime_dir, directory_fd) + document = _load_runtime_secrets_yaml(text) + source = RUNTIME_SECRETS_REF + else: + match = _OS_SECRET_REF_RE.fullmatch(matched_scope.secrets_ref) + if match is None: + _reject( + "credential_scope_secret_ref_mismatch", + "credential scope has an invalid secret source", + ) + if _platform_name() != "nt": + _reject( + "os_secret_store_platform_unsupported", + "the configured OS secret store is unavailable on this platform", + ) + blob = _read_windows_credential_blob(match.group(1)) + document = _load_secret_store_json(blob) + source = "os_secret_store" + values = _credential_document_values(document, matched_scope) + return _seal_resolved_credentials( + ResolvedRuntimeCredentials(scope=matched_scope, source=source, _values=values), + effective, + registry, + ) + + +__all__ = [ + "CTP_AUTHENTICATION_CREDENTIAL_KEYS", + "CONFIG_YAML_REF", + "CredentialResolutionError", + "MAX_OS_SECRET_STORE_BLOB_BYTES", + "MAX_RUNTIME_SECRETS_BYTES", + "OS_SECRET_STORE_PREFIX", + "RUNTIME_SECRETS_FILENAME", + "RUNTIME_SECRETS_REF", + "ResolvedRuntimeCredentials", + "RuntimeCredentialScope", + "require_resolved_runtime_credentials_seal", + "resolve_runtime_credentials", +] diff --git a/backtrader_runtime/ctp_artifact_provenance.py b/backtrader_runtime/ctp_artifact_provenance.py new file mode 100644 index 00000000..96641379 --- /dev/null +++ b/backtrader_runtime/ctp_artifact_provenance.py @@ -0,0 +1,1124 @@ +"""Code-owned artifact and endpoint checks for the private CTP read path. + +The installed-root import boundary proves where ``bt_api_*`` came from. It +does not identify which wheel was installed or whether its files still match +that wheel. This module adds that missing check before the CTP composition +root reads credentials. + +The reviewed base/CTP pair below is scoped to the registered SimNow sandbox +read-only preflight. Managed writes additionally require a separately pinned +``bt_api_py`` parent distribution, which is not yet approved. A pin binds both +the PEP 610 wheel digest and the exact installed ``RECORD``; the latter is +checked against every listed file. This is local artifact identity evidence, +not a publisher signature or a general SDK release approval. +""" + +from __future__ import annotations + +import base64 +import binascii +import csv +import hashlib +import importlib +import importlib.util +import importlib.metadata +import io +import json +import marshal +import os +import re +import stat +import sysconfig +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from types import CodeType, MappingProxyType +from typing import Mapping, Sequence, Tuple +from urllib.parse import unquote, urlsplit + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_DIST_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +# Legacy profile-compatibility table, checked on 2026-09-23 against the SimNow +# product page: https://www.simnow.com.cn/product.action . The config-front- +# pair path uses verify_ctp_sdk_artifact_provenance_for_fronts instead and does +# not select or allow-list endpoints through this table. +_ALLOWED_SIMNOW_PROFILE_FRONTS = MappingProxyType( + { + "set1_group1": ( + "tcp://180.168.146.187:10201", + "tcp://180.168.146.187:10211", + ), + "set1_group2": ( + "tcp://180.168.146.187:10202", + "tcp://180.168.146.187:10212", + ), + "set2_7x24": ( + "tcp://180.168.146.187:10130", + "tcp://180.168.146.187:10131", + ), + } +) + + +class CtpArtifactProvenanceError(ValueError): + """Redacted fail-closed artifact or SimNow profile rejection.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__("the installed CTP SDK artifact or profile was rejected") + + +def _reject(reason: str) -> None: + raise CtpArtifactProvenanceError(reason) + + +@dataclass(frozen=True) +class CtpSdkArtifactPin: + """One code-reviewed wheel identity and its installed-file manifest.""" + + distribution: str + module: str + version: str + wheel_filename: str + wheel_sha256: str + record_sha256: str + + def __post_init__(self) -> None: + if ( + type(self.distribution) is not str + or not _DIST_NAME_RE.fullmatch(self.distribution) + or type(self.module) is not str + or not re.fullmatch(r"bt_api_[a-z0-9_]+", self.module) + or type(self.version) is not str + or not self.version + or type(self.wheel_filename) is not str + or not self.wheel_filename.endswith(".whl") + or Path(self.wheel_filename).name != self.wheel_filename + or type(self.wheel_sha256) is not str + or not _SHA256_RE.fullmatch(self.wheel_sha256) + or type(self.record_sha256) is not str + or not _SHA256_RE.fullmatch(self.record_sha256) + ): + raise ValueError("invalid CTP SDK artifact pin") + + +# Candidate I2 is pinned only for the registered SimNow sandbox read-only +# preflight. Its clean source commits are base 73e860e2 and CTP 28157ce3; +# the CTP wheel carries H2's bounded native shutdown receipt and scoped query +# certificate, plus fixed, value-free MD login callback diagnostics. A present +# but blank rate ExchangeID remains unverified; a missing field rejects. +# The parent distribution is +# deliberately absent: managed SimNow requires all three modules and remains +# closed. These local wheel identities do not approve a general SDK release +# or a production route. +# Runtime configuration and caller input cannot add or replace pins. +CTP_SDK_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CtpSdkArtifactPin( + distribution="bt_api_base", + module="bt_api_base", + version="0.15.5", + wheel_filename="bt_api_base-0.15.5-py3-none-any.whl", + wheel_sha256="1c1129444d8659f4dfe7b72f716872a63dddf13c1c935865e1d2568800d0d64d", + record_sha256="47994f991fee3266e62fb368fe167dc1f188eceecfddac6ccc06dad2604e9762", + ), + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i2", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i2-cp311-cp311-win_amd64.whl", + wheel_sha256="988c52a91a12d3256caadf27df2ae1f1eb45e54fc6c4f63b7abba0363f34c4ff", + record_sha256="4953ef5cb13468a300693fd3c37b7eb59c4afd0727e3d6dbcf8a15de5abebe08", + ), + } +) + +# The I3 one-shot MD diagnostic candidate is pinned in its own table so its +# experimental wheel cannot silently replace the accepted I2 identity. The +# wheel was built from clean source SHA ce3127ade8cef00f02b9a6cee6545fb147cda8b9. +# This SDK wheel contains trading APIs; these hashes establish artifact +# identity only. Read-only behavior remains a property of the diagnostic +# caller's constrained call chain, not of the artifact itself. This pin is +# not registered and must not be used for managed execution or general SDK +# acceptance. +CTP_I3_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CTP_SDK_ARTIFACT_PINS["bt_api_base"], + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i3", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i3-cp311-cp311-win_amd64.whl", + wheel_sha256="c1ead607c9b6758b7850b8517996cc1654514cdd8fa81ef10fbf2858e1d914d8", + record_sha256="df7644d667a1adeff151c98e58f788e2637cf77473b7cf37f6928cfffda17cb8", + ), + } +) + +# The I4 one-shot MD diagnostic is an independent, unregistered candidate +# built from clean CTP source commit 809239fdc0b7982d3512f4289e3e8dbcbd43a523. +# Its base wheel bytes and CTP wheel remain the reviewed I2/I4 artifacts. The +# separately pinned base RECORD hash captures I4's audited no-bytecode install +# and leaves the I2 registered-route pin untouched. These identities grant no +# execution or general provider authority. +CTP_I4_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CtpSdkArtifactPin( + distribution="bt_api_base", + module="bt_api_base", + version="0.15.5", + wheel_filename="bt_api_base-0.15.5-py3-none-any.whl", + wheel_sha256="1c1129444d8659f4dfe7b72f716872a63dddf13c1c935865e1d2568800d0d64d", + record_sha256="aa91bfa982d473eb2b8ce59192196f87a84e7c9c19aafd8e961c73e5ea87ce90", + ), + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i4", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i4-cp311-cp311-win_amd64.whl", + wheel_sha256="96f8c874871b6f571e3abb14bf25b32a4ccb133ca09e51767584e5c03682283e", + record_sha256="327998c95de9c3a69ac9cb40444361822c8602e30975067705a750c782ffbd6f", + ), + } +) + +# The unregistered I5 MD-only diagnostic is built from clean CTP source commit +# a101590f5f29070439c13b6062b3487abee936cc. Its BrokerID response-shape +# classification is value-free metadata and does not change login acceptance. +# The base wheel and its installed RECORD match the separately reviewed I4 +# install. This table is diagnostic-only and grants no write or live route. +CTP_I5_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CTP_I4_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS["bt_api_base"], + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i5", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i5-cp311-cp311-win_amd64.whl", + wheel_sha256="552dc8711523aef930864b7441a2a93ed2f4cb9541acc15435ac8fe9a3ca98dc", + record_sha256="3320042e1b0d4706cda2c9272806c91aa5ef7efe329f85178db45f3ed2b36026", + ), + } +) + +# The unregistered I6 MD-only diagnostic is built from clean CTP source commit +# d85cd1571000c63d38bb9417a4942ec2692c5ad6. Its response-field shapes +# are value-free metadata and do not change login acceptance. This independent +# pin grants no managed write or live authority. +CTP_I6_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CTP_I4_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS["bt_api_base"], + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i6", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i6-cp311-cp311-win_amd64.whl", + wheel_sha256="3788bf75019eb8fa685b828be9dae66b2f17d41422e770441870a105e02c1ded", + record_sha256="e5ab9889853f1d01683c2754156ca4c2875cad2ceafdea05baadf5d2420a9bb6", + ), + } +) + +# The unregistered I7 MD-only diagnostic adds the native callback storage +# shape enums. Its artifact identity is independent of the earlier diagnostic +# wheels and grants no write or live authority. +CTP_I7_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CTP_I6_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS["bt_api_base"], + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i7", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i7-cp311-cp311-win_amd64.whl", + wheel_sha256="22bc34140233785abcad61e7c3bc4dbb85c9d97b171692d5e6b44cf89eda94b4", + record_sha256="03b4d23a6a647c4b29c392304e56dcd3be8b776e9c3eef695af0adcfbbdc45b6", + ), + } +) + +# The unregistered I8 MD diagnostic pins this retained wheel pair. The I8 CTP +# wheel is reported as built from source commit +# a7d9b04d5520373d80ed395f76d2939ca5206e4b with MSVC /Brepro and a fixed +# SOURCE_DATE_EPOCH. Same-path separated-time builds were reported identical; +# independent reproducibility review is pending. The RECORD pins below are +# hashes of the audited installed distributions (the verifier checks those +# files), not the RECORD entries embedded in the wheel archives. These hashes +# establish artifact identity only and do not register the diagnostic, approve +# a provider session, or grant write capability. +CTP_I8_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CtpSdkArtifactPin( + distribution="bt_api_base", + module="bt_api_base", + version="0.15.5", + wheel_filename="bt_api_base-0.15.5-py3-none-any.whl", + wheel_sha256="2f413f7e914c4bbd1dcd47b3b95a3fb36e224dda2c4db97bdda35bf61797ad68", + record_sha256="40052081b6ddff201e059818d310e83c012f7e428ba2e76dc64af0417e68de4d", + ), + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i8", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i8-cp311-cp311-win_amd64.whl", + wheel_sha256="f354327f092993cce7954339deca3b5cc32ab953f5fd330ba5a6b3a7ea94f715", + record_sha256="1b2ce3ad778712e735d9e76f44e81748c8af323ece5bc0a0e96e51148882c48a", + ), + } +) + +# Historical I9 source/build evidence only. Two clean-clone builds reproduced +# this wheel and its embedded RECORD, but the installed RECORD differs by +# pip-generated direct_url.json: targets A/B hash to 1021d0... and 0866e6.... +# CtpSdkArtifactPin.record_sha256 is checked against the installed RECORD, so +# the embedded hash is not a valid installed pin and neither target-specific +# hash is a portable pin. Keep the diagnostic table empty so its verifier fails +# closed until an exact installation scope is deliberately reviewed. +CTP_I9_REPRODUCED_WHEEL_SHA256 = "aa094c039788a41adf975cfeee839fa3baf1bcef3878ae53d10eefb44410a4a3" +CTP_I9_EMBEDDED_WHEEL_RECORD_SHA256 = ( + "d030ccf23d59a5f77230b490df52aa48c4cbecd67b2a78b7e782600126565841" +) +CTP_I9_INSTALLED_RECORD_SHA256_BY_REPRO_TARGET: Mapping[str, str] = MappingProxyType( + { + "a": "1021d0edf7e4aa1f19b86dd5b0140634b19b589773e0aa7b55153c438367d466", + "b": "0866e6150a881f0eb227fa80dfc215fbab65bb3b6f216be6b10d12c354226089", + } +) +CTP_I9_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType({}) + +# I10 was built from clean source commit a6253a58b1ebca11f58c8836fbed757d0daf7582; +# two clean-clone wheel builds matched byte-for-byte, including this embedded +# RECORD. pip's installed RECORD also covers direct_url.json, so it depends on +# the local wheel source path: the two reproduction installs yielded distinct +# installed RECORD hashes. Keep those reproduction hashes as inert evidence; +# the verifier pin below uses the installed RECORD from the controlled +# no-index/no-deps CPython 3.11 venv installed from reproduction wheel A. This +# diagnostic table is independent of the registered I2 and fail-closed I9 +# tables. I10 contains native CTP/trading APIs and its artifact identity grants +# no provider session, write, or general SDK-release authority. +CTP_I10_REPRODUCED_WHEEL_SHA256 = "e81bd7fcba8f0aaf823af9efcca565622a55842ed3bce970994f483f4f3188c4" +CTP_I10_EMBEDDED_WHEEL_RECORD_SHA256 = ( + "0f7f5724ed45f98a491f8f6bcc767f9825551a8e0753f0911a40e993a9c23911" +) +CTP_I10_INSTALLED_RECORD_SHA256_BY_REPRO_TARGET: Mapping[str, str] = MappingProxyType( + { + "a": "c0cd1f19a6af3f2bab0fc98042b5042e620565b67751bae362bf5d697649d673", + "b": "cb768050b6f1a15c300591f3eae51e1ed9b4a7301a311817f88b285d85c40be2", + } +) +CTP_I10_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CtpSdkArtifactPin( + distribution="bt_api_base", + module="bt_api_base", + version="0.15.5", + wheel_filename="bt_api_base-0.15.5-py3-none-any.whl", + wheel_sha256="1c1129444d8659f4dfe7b72f716872a63dddf13c1c935865e1d2568800d0d64d", + record_sha256="47994f991fee3266e62fb368fe167dc1f188eceecfddac6ccc06dad2604e9762", + ), + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i10", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i10-cp311-cp311-win_amd64.whl", + wheel_sha256=CTP_I10_REPRODUCED_WHEEL_SHA256, + record_sha256=CTP_I10_INSTALLED_RECORD_SHA256_BY_REPRO_TARGET["a"], + ), + } +) + +# Independent code-owned pin for the unregistered I12 TD-only read-only +# candidate. The wheel bytes and controlled install source are the same as +# I10, but the MD-only pin above does not authorize or verify this TD call +# path. Keep a separate installed-RECORD pin and verifier so either diagnostic +# can be reviewed, changed, or closed without inheriting the other's scope. +# These are the exact base/CTP installed records from the fixed I10 isolated +# CPython 3.11 environment; the CTP record includes its pip direct_url.json. +CTP_I12_TD_ONLY_READONLY_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CtpSdkArtifactPin( + distribution="bt_api_base", + module="bt_api_base", + version="0.15.5", + wheel_filename="bt_api_base-0.15.5-py3-none-any.whl", + wheel_sha256="1c1129444d8659f4dfe7b72f716872a63dddf13c1c935865e1d2568800d0d64d", + record_sha256="47994f991fee3266e62fb368fe167dc1f188eceecfddac6ccc06dad2604e9762", + ), + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i10", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i10-cp311-cp311-win_amd64.whl", + wheel_sha256="e81bd7fcba8f0aaf823af9efcca565622a55842ed3bce970994f483f4f3188c4", + record_sha256="c0cd1f19a6af3f2bab0fc98042b5042e620565b67751bae362bf5d697649d673", + ), + } +) + +# Candidate-private I13 MD diagnostic pin. This is tied to the isolated +# CPython 3.11.5 I13 venv whose direct_url and installed RECORD were audited +# alongside the two-clone wheel receipt. The base RECORD differs from the I10 +# environment because this installation came from its own retained base wheel +# path. These identities are diagnostic-only and grant no provider or write +# authority. +CTP_I13_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS: Mapping[str, CtpSdkArtifactPin] = MappingProxyType( + { + "bt_api_base": CtpSdkArtifactPin( + distribution="bt_api_base", + module="bt_api_base", + version="0.15.5", + wheel_filename="bt_api_base-0.15.5-py3-none-any.whl", + wheel_sha256="2f413f7e914c4bbd1dcd47b3b95a3fb36e224dda2c4db97bdda35bf61797ad68", + record_sha256="6f73003cdba8f15468faa0264eb99c81578e6648c00ebf5de4c7161c676f7113", + ), + "bt_api_ctp": CtpSdkArtifactPin( + distribution="bt_api_ctp", + module="bt_api_ctp", + version="2.0.3+iteration41.i13", + wheel_filename="bt_api_ctp-2.0.3+iteration41.i13-cp311-cp311-win_amd64.whl", + wheel_sha256="c6eb83c1389b8f0e96edf9f727c20901b2411961489e19abb4ee1aef6ec2ce5d", + record_sha256="d21876f577927651a585ad7273c5bdcf508d65766f2a05e2f5a06c8a29798217", + ), + } +) + +_READONLY_CTP_MODULES = ("bt_api_base", "bt_api_ctp") +_MANAGED_SIMNOW_MODULES = (*_READONLY_CTP_MODULES, "bt_api_py") + + +def _distribution_key(value: str) -> str: + return re.sub(r"[-_.]+", "_", value).lower() + + +def _is_link_or_reparse(result: os.stat_result) -> bool: + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + attributes = getattr(result, "st_file_attributes", 0) + return stat.S_ISLNK(result.st_mode) or bool(attributes & reparse_point) + + +def _regular_file(path: Path) -> bytes: + try: + result = os.lstat(str(path)) + except OSError: + _reject("artifact_file_unavailable") + if _is_link_or_reparse(result) or not stat.S_ISREG(result.st_mode): + _reject("artifact_file_invalid") + try: + return path.read_bytes() + except OSError: + _reject("artifact_file_unavailable") + raise AssertionError("unreachable") + + +def _concrete_directory(path: Path) -> Path: + try: + result = os.lstat(str(path)) + except OSError: + _reject("artifact_install_root_invalid") + if _is_link_or_reparse(result) or not stat.S_ISDIR(result.st_mode): + _reject("artifact_install_root_invalid") + try: + physical = path.resolve(strict=True) + except (OSError, RuntimeError): + _reject("artifact_install_root_invalid") + if os.path.normcase(str(path.absolute())) != os.path.normcase(str(physical)): + _reject("artifact_install_root_invalid") + return physical + + +def _interpreter_install_roots() -> Tuple[Path, ...]: + try: + reported = sysconfig.get_paths() + except (AttributeError, OSError, RuntimeError, TypeError, ValueError): + _reject("artifact_install_root_invalid") + roots = [] + for key in ("purelib", "platlib"): + value = reported.get(key) + if not isinstance(value, (str, os.PathLike)) or not str(value): + continue + root = _concrete_directory(Path(value)) + if all(os.path.normcase(str(root)) != os.path.normcase(str(item)) for item in roots): + roots.append(root) + if not roots: + _reject("artifact_install_root_invalid") + return tuple(roots) + + +def _relative_record_path(value: str) -> Tuple[str, ...]: + if type(value) is not str or not value or "\\" in value or "\x00" in value: + _reject("artifact_record_invalid") + path = PurePosixPath(value) + if ( + path.is_absolute() + or not path.parts + or any(part in ("", ".", "..") or ":" in part for part in path.parts) + ): + _reject("artifact_record_invalid") + return tuple(path.parts) + + +def _file_digest_matches(path: Path, expected: str, expected_size: str) -> None: + if not expected.startswith("sha256="): + _reject("artifact_record_invalid") + digest_text = expected[len("sha256=") :] + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=" + if not digest_text or any(character not in alphabet for character in digest_text): + _reject("artifact_record_invalid") + try: + padding = "=" * ((4 - len(digest_text) % 4) % 4) + expected_digest = base64.urlsafe_b64decode(digest_text + padding) + except (ValueError, binascii.Error): + _reject("artifact_record_invalid") + if len(expected_digest) != hashlib.sha256().digest_size: + _reject("artifact_record_invalid") + if not expected_size or not expected_size.isdecimal(): + _reject("artifact_record_invalid") + contents = _regular_file(path) + if len(contents) != int(expected_size) or hashlib.sha256(contents).digest() != expected_digest: + _reject("artifact_file_hash_mismatch") + + +def _same_code(left: CodeType, right: CodeType) -> bool: + """Compare executable code fields while ignoring only trace-position tables.""" + + fields = ( + "co_argcount", + "co_posonlyargcount", + "co_kwonlyargcount", + "co_nlocals", + "co_stacksize", + "co_flags", + "co_code", + "co_names", + "co_varnames", + "co_freevars", + "co_cellvars", + "co_filename", + "co_firstlineno", + "co_exceptiontable", + ) + if any(getattr(left, field, None) != getattr(right, field, None) for field in fields): + return False + if len(left.co_consts) != len(right.co_consts): + return False + for left_value, right_value in zip(left.co_consts, right.co_consts): + if isinstance(left_value, CodeType) or isinstance(right_value, CodeType): + if not isinstance(left_value, CodeType) or not isinstance(right_value, CodeType): + return False + if not _same_code(left_value, right_value): + return False + elif type(left_value) is not type(right_value) or left_value != right_value: + return False + return True + + +def _validate_pyc_cache( + path: Path, package_root: Path, install_root: Path, recorded: set[str] +) -> None: + """Accept only bytecode whose executable code is derived from pinned source.""" + + if path.parent.name != "__pycache__" or path.suffix != ".pyc": + _reject("artifact_bytecode_invalid") + try: + source_path = Path(importlib.util.source_from_cache(str(path))) + source_relative = source_path.relative_to(install_root).as_posix() + source_path.relative_to(package_root) + except (NotImplementedError, OSError, ValueError): + _reject("artifact_bytecode_invalid") + if source_relative not in recorded: + _reject("artifact_bytecode_invalid") + source_bytes = _regular_file(source_path) + pyc_bytes = _regular_file(path) + if len(pyc_bytes) < 16 or pyc_bytes[:4] != importlib.util.MAGIC_NUMBER: + _reject("artifact_bytecode_invalid") + + flags = int.from_bytes(pyc_bytes[4:8], "little") + if flags & ~3: + _reject("artifact_bytecode_invalid") + if flags & 1: + if pyc_bytes[8:16] != importlib.util.source_hash(source_bytes): + _reject("artifact_bytecode_invalid") + else: + try: + source_stat = os.stat(str(source_path)) + except OSError: + _reject("artifact_bytecode_invalid") + timestamp = int(source_stat.st_mtime) & 0xFFFFFFFF + source_size = source_stat.st_size & 0xFFFFFFFF + if ( + int.from_bytes(pyc_bytes[8:12], "little") != timestamp + or int.from_bytes(pyc_bytes[12:16], "little") != source_size + ): + _reject("artifact_bytecode_invalid") + + try: + cached_code = marshal.loads(pyc_bytes[16:]) + except (EOFError, ValueError, TypeError): + _reject("artifact_bytecode_invalid") + if not isinstance(cached_code, CodeType): + _reject("artifact_bytecode_invalid") + optimisation_match = re.search(r"\.opt-([12])$", path.stem) + optimisation = int(optimisation_match.group(1)) if optimisation_match else 0 + try: + source_code = compile( + source_bytes, + cached_code.co_filename, + "exec", + dont_inherit=True, + optimize=optimisation, + ) + except (SyntaxError, TypeError, ValueError): + _reject("artifact_bytecode_invalid") + if not _same_code(cached_code, source_code): + _reject("artifact_bytecode_invalid") + + +def _record_relative_path(files: Sequence[object]) -> str: + candidates = [] + for item in files: + value = str(item) + if value.endswith(".dist-info/RECORD"): + candidates.append(value) + if len(candidates) != 1: + _reject("artifact_record_invalid") + return candidates[0] + + +def _validate_package_inventory(package_root: Path, install_root: Path, recorded: set[str]) -> None: + for directory, child_directories, filenames in os.walk(str(package_root), followlinks=False): + current = Path(directory) + kept_directories = [] + for name in child_directories: + child = current / name + try: + result = os.lstat(str(child)) + except OSError: + _reject("artifact_file_invalid") + if _is_link_or_reparse(result) or not stat.S_ISDIR(result.st_mode): + _reject("artifact_file_invalid") + kept_directories.append(name) + child_directories[:] = kept_directories + for name in filenames: + path = current / name + if name.endswith(".pyc"): + _validate_pyc_cache(path, package_root, install_root, recorded) + continue + if current.name == "__pycache__": + _reject("artifact_bytecode_invalid") + try: + result = os.lstat(str(path)) + except OSError: + _reject("artifact_file_invalid") + if _is_link_or_reparse(result) or not stat.S_ISREG(result.st_mode): + _reject("artifact_file_invalid") + try: + relative = path.relative_to(install_root).as_posix() + except ValueError: + _reject("artifact_install_root_invalid") + if relative not in recorded: + _reject("artifact_record_mismatch") + + +def _validate_installed_distribution(pin: CtpSdkArtifactPin) -> Path: + try: + distribution = importlib.metadata.distribution(pin.distribution) + except importlib.metadata.PackageNotFoundError: + _reject("artifact_unavailable") + except Exception: + _reject("artifact_metadata_invalid") + + metadata = getattr(distribution, "metadata", None) + name = metadata.get("Name") if metadata is not None else None + version = metadata.get("Version") if metadata is not None else None + if ( + type(name) is not str + or _distribution_key(name) != _distribution_key(pin.distribution) + or type(version) is not str + or version != pin.version + ): + _reject("artifact_identity_mismatch") + + try: + install_root = _concrete_directory(Path(distribution.locate_file(""))) + allowed_roots = _interpreter_install_roots() + except CtpArtifactProvenanceError: + raise + except Exception: + _reject("artifact_install_root_invalid") + if all( + os.path.normcase(str(install_root)) != os.path.normcase(str(allowed)) + for allowed in allowed_roots + ): + _reject("artifact_install_root_invalid") + + files = getattr(distribution, "files", None) + if not files: + _reject("artifact_record_invalid") + try: + file_entries = tuple(files) + except (TypeError, ValueError): + _reject("artifact_record_invalid") + record_relative = _record_relative_path(file_entries) + record_parts = _relative_record_path(record_relative) + record_path = install_root.joinpath(*record_parts) + record_bytes = _regular_file(record_path) + if hashlib.sha256(record_bytes).hexdigest() != pin.record_sha256: + _reject("artifact_record_pin_mismatch") + try: + record_text = record_bytes.decode("utf-8") + rows = tuple(csv.reader(io.StringIO(record_text, newline=""))) + except (UnicodeDecodeError, csv.Error): + _reject("artifact_record_invalid") + if not rows: + _reject("artifact_record_invalid") + + recorded = set() + pyc_entries = [] + record_self_rows = 0 + for row in rows: + if len(row) != 3: + _reject("artifact_record_invalid") + relative = row[0] + parts = _relative_record_path(relative) + if relative in recorded: + _reject("artifact_record_invalid") + recorded.add(relative) + path = install_root.joinpath(*parts) + if relative == record_relative: + record_self_rows += 1 + if row[1] or row[2]: + _reject("artifact_record_invalid") + continue + if path.suffix == ".pyc": + if row[1] or row[2]: + _file_digest_matches(path, row[1], row[2]) + elif "__pycache__" in parts: + pyc_entries.append(path) + else: + _reject("artifact_record_invalid") + continue + _file_digest_matches(path, row[1], row[2]) + distribution_paths = {str(item) for item in file_entries} + if record_self_rows != 1 or recorded != distribution_paths: + _reject("artifact_record_mismatch") + + package_root = _concrete_directory(install_root / pin.module) + for path in pyc_entries: + _validate_pyc_cache(path, package_root, install_root, recorded) + _validate_package_inventory(package_root, install_root, recorded) + + try: + direct_url_text = distribution.read_text("direct_url.json") + direct_url = json.loads(direct_url_text) if direct_url_text is not None else None + except (OSError, TypeError, ValueError, json.JSONDecodeError): + _reject("artifact_provenance_invalid") + if type(direct_url) is not dict or "dir_info" in direct_url: + _reject("artifact_provenance_invalid") + archive_info = direct_url.get("archive_info") + url = direct_url.get("url") + if type(archive_info) is not dict or type(url) is not str: + _reject("artifact_provenance_invalid") + archive_hash = archive_info.get("hash") + archive_hashes = archive_info.get("hashes") + if archive_hash is not None and archive_hash != "sha256=" + pin.wheel_sha256: + _reject("artifact_wheel_hash_mismatch") + if archive_hashes is not None and archive_hashes != {"sha256": pin.wheel_sha256}: + _reject("artifact_wheel_hash_mismatch") + if archive_hash is None and archive_hashes is None: + _reject("artifact_provenance_invalid") + try: + parsed_url = urlsplit(url) + wheel_name = PurePosixPath(unquote(parsed_url.path)).name + except (TypeError, ValueError): + _reject("artifact_provenance_invalid") + if ( + parsed_url.scheme != "file" + or parsed_url.netloc not in ("", "localhost") + or parsed_url.query + or parsed_url.fragment + or wheel_name != pin.wheel_filename + ): + _reject("artifact_provenance_invalid") + return package_root + + +def _verify_package_import_root(module_name: str, package_root: Path) -> None: + """Require Python's resolved package path to be the pinned installation.""" + + try: + spec = importlib.util.find_spec(module_name) + origin = getattr(spec, "origin", None) + locations = getattr(spec, "submodule_search_locations", None) + if type(origin) is not str or locations is None: + _reject("artifact_import_rejected") + resolved_origin = Path(origin).resolve(strict=True) + expected_init = (package_root / "__init__.py").resolve(strict=True) + resolved_locations = tuple(Path(location).resolve(strict=True) for location in locations) + except CtpArtifactProvenanceError: + raise + except Exception: + _reject("artifact_import_rejected") + if resolved_origin != expected_init or resolved_locations != ( + package_root.resolve(strict=True), + ): + _reject("artifact_import_rejected") + + +def _verified_official_fronts(profile: str, package_root: Path) -> Tuple[str, str]: + try: + module = importlib.import_module("bt_api_ctp.ctp_env_selector") + except Exception: + _reject("artifact_import_rejected") + module_file = getattr(module, "__file__", None) + if type(module_file) is not str: + _reject("artifact_import_rejected") + expected_module_file = package_root / "ctp_env_selector.py" + try: + if Path(module_file).resolve(strict=True) != expected_module_file.resolve(strict=True): + _reject("artifact_import_rejected") + except (OSError, RuntimeError): + _reject("artifact_import_rejected") + official_fronts = getattr(module, "official_simnow_fronts", None) + code = getattr(official_fronts, "__code__", None) + code_filename = getattr(code, "co_filename", None) + if not callable(official_fronts) or type(code_filename) is not str: + _reject("artifact_import_rejected") + try: + if Path(code_filename).resolve(strict=True) != expected_module_file.resolve(strict=True): + _reject("artifact_import_rejected") + value = official_fronts(profile) + except CtpArtifactProvenanceError: + raise + except Exception: + _reject("profile_front_mismatch") + if ( + type(value) is not tuple + or len(value) != 2 + or type(value[0]) is not str + or type(value[1]) is not str + ): + _reject("profile_front_mismatch") + return value + + +def simnow_profile_for_fronts(*, td_front: str, md_front: str) -> str: + """Return the reviewed SDK profile for one exact TD/MD front pair. + + Runtime config may carry the official front strings directly, but it may + not introduce an endpoint, mix the TD front from one profile with the MD + front from another, or rely on URL parser normalization. The returned + profile is code-derived metadata for SDK policy only; callers should keep + and pass the original configured strings to any provider API that accepts + them. + """ + + try: + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + except CtpArtifactProvenanceError as error: + if error.reason == "front_pair_syntax_invalid": + _reject("profile_front_mismatch") + raise + for profile, (reviewed_td_front, reviewed_md_front) in _ALLOWED_SIMNOW_PROFILE_FRONTS.items(): + if td_front == reviewed_td_front and md_front == reviewed_md_front: + return profile + _reject("profile_front_mismatch") + raise AssertionError("unreachable") + + +def _validate_exact_tcp_front_pair(*, td_front: str, md_front: str) -> Tuple[str, str]: + """Validate canonical CTP TCP front strings without selecting a profile.""" + + if type(td_front) is not str or type(md_front) is not str: + _reject("front_pair_syntax_invalid") + if any( + not value + or value != value.strip() + or len(value) > 128 + or any(character.isspace() or ord(character) < 0x20 for character in value) + for value in (td_front, md_front) + ): + _reject("front_pair_syntax_invalid") + for value in (td_front, md_front): + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + _reject("front_pair_syntax_invalid") + if ( + parsed.scheme != "tcp" + or not parsed.hostname + or port is None + or not 1 <= port <= 65535 + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or value != "tcp://{0}:{1}".format(parsed.hostname, port) + ): + _reject("front_pair_syntax_invalid") + return td_front, md_front + + +def simnow_fronts_for_profile(profile: str) -> Tuple[str, str]: + """Return the code-owned TD/MD pair for a reviewed SDK profile.""" + + if type(profile) is not str or profile not in _ALLOWED_SIMNOW_PROFILE_FRONTS: + _reject("profile_not_pinned") + return _ALLOWED_SIMNOW_PROFILE_FRONTS[profile] + + +def _verify_pinned_sdk_distributions( + required_modules: Sequence[str], + *, + pins: Mapping[str, CtpSdkArtifactPin], +) -> Mapping[str, Path]: + """Validate each required pinned distribution, RECORD, and import root.""" + + package_roots = {} + for module_name in required_modules: + pin = pins.get(module_name) + if ( + type(pin) is not CtpSdkArtifactPin + or pin.module != module_name + or _distribution_key(pin.distribution) != module_name + ): + _reject("artifact_pin_unavailable") + package_roots[module_name] = _validate_installed_distribution(pin) + for module_name, package_root in package_roots.items(): + _verify_package_import_root(module_name, package_root) + return package_roots + + +def verify_ctp_sdk_artifact_provenance_for_fronts(*, td_front: str, md_front: str) -> None: + """Verify pinned SDK artifacts for an already selected, explicit CTP pair. + + The caller must validate its configuration seal before passing these + strings. This function requires canonical TCP syntax and verifies the + installed distributions, but it does not select a SimNow profile or + require static address allowlist membership. The exact input strings are + never rewritten or returned as normalized endpoint values. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions(_READONLY_CTP_MODULES, pins=CTP_SDK_ARTIFACT_PINS) + + +def verify_ctp_i3_oneshot_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the isolated I3 one-shot diagnostic's exact base and CTP wheels. + + The configured pair is checked for canonical TCP syntax only; no endpoint + is selected or allow-listed here. This opt-in verifier is for an + unregistered diagnostic call chain. The pinned CTP wheel includes trading + APIs, so this check does not make the artifact read-only or authorize any + provider action. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I3_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i4_oneshot_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the exact I2 base and I4 CTP wheels for the unregistered probe. + + Only canonical endpoint syntax and installed artifact identity are + checked. This call selects no endpoint and confers no provider authority. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I4_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i5_oneshot_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the isolated I5 SDK wheel pair for one explicit configured pair. + + This unregistered, read-only diagnostic gate checks syntax and installed + artifact identity only. It does not select an endpoint or authorize writes. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I5_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i6_oneshot_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the isolated I6 SDK wheel pair for one configured read-only probe. + + This unregistered diagnostic gate checks exact endpoint syntax and installed + artifact identity only; it neither selects an endpoint nor authorizes writes. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I6_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i7_oneshot_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Fail closed until I7's exact diagnostic wheel identities are reviewed.""" + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I7_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i8_oneshot_md_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the retained installed I8 wheel pair for one exact front.""" + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I8_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i9_oneshot_md_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Fail closed because I9 currently has source/build evidence, not an installed pin.""" + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I9_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i10_oneshot_md_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the exact I10 wheel pair for one explicit, unregistered MD diagnostic. + + This gate checks canonical endpoint syntax and installed artifact identity + only. The CTP artifact includes trading APIs; verification neither selects + a provider endpoint nor authorizes a session or write. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I10_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_i12_td_only_readonly_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the separately reviewed installed artifacts for I12's TD path. + + This checks exact wheel and installed-RECORD identity only. TD-only scope + comes from the caller's reviewed call graph and session contract; artifact + identity grants no provider session, settlement, or write authority. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I12_TD_ONLY_READONLY_ARTIFACT_PINS, + ) + + +def verify_ctp_i13_oneshot_md_diagnostic_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify the isolated I13 base/CTP wheels for its unregistered MD probe.""" + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, + pins=CTP_I13_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS, + ) + + +def verify_ctp_simnow_managed_artifact_provenance_for_fronts( + *, td_front: str, md_front: str +) -> None: + """Verify exact wheel provenance for managed SimNow's three SDK packages. + + The managed CTP write contract imports authorization and credential-binding + types from the separate ``bt_api_py`` distribution. Keep the read-only + verifier independent of that package, and require all three reviewed wheels + before a managed caller extracts credentials or imports/constructs an SDK + client. + """ + + _validate_exact_tcp_front_pair(td_front=td_front, md_front=md_front) + _verify_pinned_sdk_distributions(_MANAGED_SIMNOW_MODULES, pins=CTP_SDK_ARTIFACT_PINS) + + +def verify_ctp_sdk_artifact_provenance(sdk_profile: str) -> None: + """Verify SDK pins and the package's exact code-owned SimNow front map. + + This compatibility API remains profile-bound. New explicit-front + compositions should use verify_ctp_sdk_artifact_provenance_for_fronts. + """ + + if type(sdk_profile) is not str or sdk_profile not in _ALLOWED_SIMNOW_PROFILE_FRONTS: + _reject("profile_not_pinned") + package_roots = _verify_pinned_sdk_distributions( + _READONLY_CTP_MODULES, pins=CTP_SDK_ARTIFACT_PINS + ) + + actual_fronts = _verified_official_fronts(sdk_profile, package_roots["bt_api_ctp"]) + if actual_fronts != _ALLOWED_SIMNOW_PROFILE_FRONTS[sdk_profile]: + _reject("profile_front_mismatch") + + +__all__ = [ + "CTP_I3_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I4_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I5_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I6_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I7_ONESHOT_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I8_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I9_EMBEDDED_WHEEL_RECORD_SHA256", + "CTP_I9_INSTALLED_RECORD_SHA256_BY_REPRO_TARGET", + "CTP_I9_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I9_REPRODUCED_WHEEL_SHA256", + "CTP_I10_EMBEDDED_WHEEL_RECORD_SHA256", + "CTP_I10_INSTALLED_RECORD_SHA256_BY_REPRO_TARGET", + "CTP_I10_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_I10_REPRODUCED_WHEEL_SHA256", + "CTP_I12_TD_ONLY_READONLY_ARTIFACT_PINS", + "CTP_I13_ONESHOT_MD_DIAGNOSTIC_ARTIFACT_PINS", + "CTP_SDK_ARTIFACT_PINS", + "CtpArtifactProvenanceError", + "CtpSdkArtifactPin", + "simnow_fronts_for_profile", + "simnow_profile_for_fronts", + "verify_ctp_simnow_managed_artifact_provenance_for_fronts", + "verify_ctp_sdk_artifact_provenance", + "verify_ctp_sdk_artifact_provenance_for_fronts", + "verify_ctp_i3_oneshot_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i4_oneshot_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i5_oneshot_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i6_oneshot_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i7_oneshot_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i8_oneshot_md_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i9_oneshot_md_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i10_oneshot_md_diagnostic_artifact_provenance_for_fronts", + "verify_ctp_i12_td_only_readonly_artifact_provenance_for_fronts", + "verify_ctp_i13_oneshot_md_diagnostic_artifact_provenance_for_fronts", +] diff --git a/backtrader_runtime/ctp_configured_front_check.py b/backtrader_runtime/ctp_configured_front_check.py new file mode 100644 index 00000000..19dbe970 --- /dev/null +++ b/backtrader_runtime/ctp_configured_front_check.py @@ -0,0 +1,404 @@ +"""Bounded, credential-free TCP diagnostics for sealed configured CTP fronts. + +This is an operator diagnostic only. It consumes an exact sealed +simulation/sandbox configuration from the code-owned 013_3 or 007 private +runtime, opens TCP sockets only to the configured MD/TD candidates, and +projects the result to pair indexes and sample counts. +Schema validation parses the same protected config bytes, which include CTP +authentication fields, but this module never accesses those field values, +invokes the credential resolver, imports an SDK, performs a login, or starts a +trading route. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Mapping, Optional + +from .ctp_front_pair_probe import ( + CtpConfiguredFrontPair, + CtpFrontEndpointEvidence, + CtpFrontPairEvidence, + CtpFrontPairProbeError, + CtpFrontPairSelection, + CtpFrontProbeSample, + select_ctp_front_pair, +) +from .ctp_sandbox_readonly_admission import require_ctp_sandbox_profile_runtime +from .ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from .inventory import ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, +) +from .registry import EffectiveRuntimeConfig, RuntimeRegistry + + +_PROBE_TIMEOUT_SECONDS = 3.0 +_PROBE_MAX_PAIRS = 8 +_PROBE_REPEATED_SAMPLES = 3 +_PAIR_STATUS_VALUES = frozenset({"reachable", "partial", "unreachable", "unavailable"}) +_RESULT_STATUS_VALUES = frozenset({"selected", "no_pair_reachable", "rejected"}) +_RESULT_REASON_VALUES = frozenset( + { + "selected_configured_pair", + "no_configured_pair_reachable", + "front_probe_rejected", + "runtime_profile_rejected", + } +) +_SUPPORTED_PRIVATE_RUNTIME_IDENTITIES = frozenset( + { + (ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR), + (ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR), + } +) + + +@dataclass(frozen=True) +class CtpConfiguredFrontPairCheck: + config_index: int + md_connected_count: int + md_sample_count: int + td_connected_count: int + td_sample_count: int + status: str + + def __post_init__(self) -> None: + if ( + type(self.config_index) is not int + or not 0 <= self.config_index < _PROBE_MAX_PAIRS + or type(self.md_connected_count) is not int + or type(self.md_sample_count) is not int + or type(self.td_connected_count) is not int + or type(self.td_sample_count) is not int + or not 0 <= self.md_connected_count <= self.md_sample_count <= _PROBE_REPEATED_SAMPLES + or not 0 <= self.td_connected_count <= self.td_sample_count <= _PROBE_REPEATED_SAMPLES + or self.status not in _PAIR_STATUS_VALUES + ): + raise ValueError("front_check_pair_result_invalid") + + def as_public_dict(self) -> dict[str, object]: + return { + "config_index": self.config_index, + "md_connected_count": self.md_connected_count, + "md_sample_count": self.md_sample_count, + "status": self.status, + "td_connected_count": self.td_connected_count, + "td_sample_count": self.td_sample_count, + } + + +@dataclass(frozen=True) +class CtpConfiguredFrontCheckResult: + status: str + reason: str + configured_pair_count: int + selected_config_index: Optional[int] + pairs: tuple[CtpConfiguredFrontPairCheck, ...] + + def __post_init__(self) -> None: + if ( + self.status not in _RESULT_STATUS_VALUES + or self.reason not in _RESULT_REASON_VALUES + or type(self.configured_pair_count) is not int + or not 1 <= self.configured_pair_count <= _PROBE_MAX_PAIRS + or type(self.pairs) is not tuple + or len(self.pairs) != self.configured_pair_count + or tuple(item.config_index for item in self.pairs) + != tuple(range(self.configured_pair_count)) + or ( + self.selected_config_index is not None + and ( + type(self.selected_config_index) is not int + or not 0 <= self.selected_config_index < self.configured_pair_count + ) + ) + or (self.status == "selected") != (self.selected_config_index is not None) + ): + raise ValueError("front_check_result_invalid") + + @property + def succeeded(self) -> bool: + return self.status == "selected" + + def as_public_dict(self) -> dict[str, object]: + return { + "authentication_attempted": False, + "configured_pair_count": self.configured_pair_count, + "credential_resolver_invoked": False, + "order_submission_authorized": False, + "pairs": [item.as_public_dict() for item in self.pairs], + "provider_login_started": False, + "reason": self.reason, + "sdk_imported": False, + "selected_config_index": self.selected_config_index, + "settlement_writes": 0, + "status": self.status, + "tcp_probe_only": True, + "trading_writes": 0, + } + + +def _front_pair(front_pair: object) -> tuple[str, str]: + if not isinstance(front_pair, Mapping): + _reject("front_config_rejected") + td_front = front_pair.get("td_front") + md_front = front_pair.get("md_front") + if type(td_front) is not str or type(md_front) is not str: + _reject("front_config_rejected") + return td_front, md_front + + +def _endpoint_counts(value: object) -> tuple[int, int]: + if type(value) is not CtpFrontEndpointEvidence or type(value.samples) is not tuple: + _reject("front_probe_rejected") + samples = value.samples + if len(samples) != _PROBE_REPEATED_SAMPLES: + _reject("front_probe_rejected") + for sample in samples: + if type(sample) is not CtpFrontProbeSample or type(sample.connected) is not bool: + _reject("front_probe_rejected") + if sample.connected and ( + type(sample.latency_ms) not in (int, float) + or not math.isfinite(sample.latency_ms) + or sample.latency_ms < 0 + ): + _reject("front_probe_rejected") + return sum(sample.connected is True for sample in samples), len(samples) + + +def _pair_status(md_connected: int, md_count: int, td_connected: int, td_count: int) -> str: + if ( + md_count > 0 + and td_count > 0 + and md_connected >= md_count // 2 + 1 + and td_connected >= td_count // 2 + 1 + ): + return "reachable" + if md_connected or td_connected: + return "partial" + return "unreachable" + + +def _project_evidence( + evidence: object, *, front_pairs: tuple[Any, ...] +) -> tuple[CtpConfiguredFrontPairCheck, ...]: + configured_pair_count = len(front_pairs) + if type(evidence) is not tuple or len(evidence) > configured_pair_count: + _reject("front_probe_rejected") + by_index: dict[int, CtpFrontPairEvidence] = {} + for item in evidence: + if ( + type(item) is not CtpFrontPairEvidence + or type(item.config_index) is not int + or not 0 <= item.config_index < configured_pair_count + or item.config_index in by_index + or type(item.pair) is not CtpConfiguredFrontPair + ): + _reject("front_probe_rejected") + td_front, md_front = _front_pair(front_pairs[item.config_index]) + if ( + (item.pair.td_front, item.pair.md_front) != (td_front, md_front) + or type(item.md) is not CtpFrontEndpointEvidence + or type(item.td) is not CtpFrontEndpointEvidence + or item.md.front != md_front + or item.td.front != td_front + ): + _reject("front_probe_rejected") + by_index[item.config_index] = item + + projected = [] + for index in range(configured_pair_count): + item = by_index.get(index) + if item is None: + projected.append(CtpConfiguredFrontPairCheck(index, 0, 0, 0, 0, "unavailable")) + continue + md_connected, md_count = _endpoint_counts(item.md) + td_connected, td_count = _endpoint_counts(item.td) + status = _pair_status(md_connected, md_count, td_connected, td_count) + if type(item.reachable) is not bool or item.reachable is not (status == "reachable"): + _reject("front_probe_rejected") + md_median = item.md.median_latency_ms + td_median = item.td.median_latency_ms + expected_score = ( + max(md_median, td_median) + if status == "reachable" and md_median is not None and td_median is not None + else None + ) + if item.latency_score_ms != expected_score: + _reject("front_probe_rejected") + projected.append( + CtpConfiguredFrontPairCheck( + index, + md_connected, + md_count, + td_connected, + td_count, + status, + ) + ) + return tuple(projected) + + +def _validate_selection( + selection: object, + *, + front_pairs: tuple[Any, ...], + projected: tuple[CtpConfiguredFrontPairCheck, ...], +) -> int: + if ( + type(selection) is not CtpFrontPairSelection + or type(selection.config_index) is not int + or not 0 <= selection.config_index < len(front_pairs) + or type(selection.pair) is not CtpConfiguredFrontPair + or projected[selection.config_index].status != "reachable" + or selection.repeated_samples != _PROBE_REPEATED_SAMPLES + or type(selection.evidence) is not tuple + or len(selection.evidence) != len(front_pairs) + or any( + type(item) is not CtpFrontPairEvidence or item.config_index != index + for index, item in enumerate(selection.evidence) + ) + ): + _reject("front_probe_rejected") + td_front, md_front = _front_pair(front_pairs[selection.config_index]) + if (selection.pair.td_front, selection.pair.md_front) != (td_front, md_front): + _reject("front_probe_rejected") + eligible = tuple(item for item in selection.evidence if item.reachable) + if ( + not eligible + or selection.latency_score_ms != selection.evidence[selection.config_index].latency_score_ms + or min(eligible, key=lambda item: (item.latency_score_ms, item.config_index)).config_index + != selection.config_index + ): + _reject("front_probe_rejected") + return selection.config_index + + +def check_configured_ctp_fronts( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, +) -> CtpConfiguredFrontCheckResult: + """Probe only sealed front candidates for an explicitly registered private runtime.""" + + try: + profile = require_ctp_sandbox_profile_runtime(effective, registry) + except Exception: + _reject("runtime_profile_rejected") + if effective.mode != "simulation" or effective.preset != "sandbox": + _reject("runtime_profile_rejected") + try: + registration = registry.require_runtime_dir(effective.config.strategy_dir) + runtime_identity = (registration.runtime_id, registration.runtime_dir) + if ( + registration is not effective.registration + or runtime_identity not in _SUPPORTED_PRIVATE_RUNTIME_IDENTITIES + or effective.profile is not profile + ): + _reject("runtime_profile_rejected") + binding = registry.require_ctp_simnow_readonly_binding(registration.runtime_id) + if ( + type(binding) is not CtpSimNowConfigReadOnlyBinding + or binding.runtime_id != registration.runtime_id + ): + _reject("runtime_profile_rejected") + private, bound_registration, front_pairs = binding._sealed_private_config( + effective, registry + ) + except RuntimeConfigError: + raise + except Exception: + _reject("runtime_profile_rejected") + if ( + bound_registration is not registration + or type(front_pairs) is not tuple + or not 1 <= len(front_pairs) <= _PROBE_MAX_PAIRS + or any(_front_pair(pair) is None for pair in front_pairs) + or type(getattr(private, "instrument_id", None)) is not str + or type(getattr(private, "exchange_id", None)) is not str + or type(getattr(private, "hedge_flag", None)) is not str + ): + _reject("front_config_rejected") + + try: + selection = select_ctp_front_pair( + front_pairs, + timeout_seconds=_PROBE_TIMEOUT_SECONDS, + max_pairs=_PROBE_MAX_PAIRS, + repeated_samples=_PROBE_REPEATED_SAMPLES, + ) + except CtpFrontPairProbeError as error: + try: + pairs = _project_evidence(error.evidence, front_pairs=front_pairs) + except RuntimeConfigError: + return CtpConfiguredFrontCheckResult( + "rejected", + "front_probe_rejected", + len(front_pairs), + None, + tuple( + CtpConfiguredFrontPairCheck(index, 0, 0, 0, 0, "unavailable") + for index in range(len(front_pairs)) + ), + ) + if error.reason == "no_configured_front_pair_reachable": + return CtpConfiguredFrontCheckResult( + "no_pair_reachable", + "no_configured_pair_reachable", + len(front_pairs), + None, + pairs, + ) + return CtpConfiguredFrontCheckResult( + "rejected", "front_probe_rejected", len(front_pairs), None, pairs + ) + except Exception: + return CtpConfiguredFrontCheckResult( + "rejected", + "front_probe_rejected", + len(front_pairs), + None, + tuple( + CtpConfiguredFrontPairCheck(index, 0, 0, 0, 0, "unavailable") + for index in range(len(front_pairs)) + ), + ) + + try: + pairs = _project_evidence(selection.evidence, front_pairs=front_pairs) + selected_index = _validate_selection( + selection, + front_pairs=front_pairs, + projected=pairs, + ) + except RuntimeConfigError: + _reject("front_probe_rejected") + return CtpConfiguredFrontCheckResult( + "selected", "selected_configured_pair", len(front_pairs), selected_index, pairs + ) + + +def _reject(reason: str) -> None: + reasons = { + "front_config_rejected", + "front_probe_rejected", + "runtime_profile_rejected", + } + safe_reason = reason if reason in reasons else "runtime_profile_rejected" + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "configured CTP front check was rejected by the sealed zero-write policy", + field_path="runtime.preset", + reason=safe_reason, + ) + + +__all__ = [ + "CtpConfiguredFrontCheckResult", + "CtpConfiguredFrontPairCheck", + "check_configured_ctp_fronts", +] diff --git a/backtrader_runtime/ctp_f14_external_admission.py b/backtrader_runtime/ctp_f14_external_admission.py new file mode 100644 index 00000000..3b85f2e0 --- /dev/null +++ b/backtrader_runtime/ctp_f14_external_admission.py @@ -0,0 +1,376 @@ +"""Typed seam for an external F14 account fence and coherent snapshot authority. + +This module defines a contract only. It contains no network client, key, +provider access, local-lock fallback, or write-route integration. A future +code-owned composition must supply an independently authenticated authority +that atomically claims one action, fences every writer for the account, and +attests a complete account snapshot captured under that same fence/version. +Independent CTP query terminal replies cannot satisfy that snapshot contract. + +The returned admission handle contains no caller-supplied proof parameter: +the authority is asked for a claim from the exact request and its response is +checked against that request. The authority remains a critical external +dependency; a fake or caller-controlled implementation is useful for local +contract tests only and does not provide authorization. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from dataclasses import dataclass +from typing import Protocol + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_REQUIRED_SNAPSHOT_COVERAGE = ( + "account_funds", + "open_orders", + "positions", + "trades", +) +_VALID_ROUTE_PAIRS = frozenset( + (("simnow", "simulation", "sandbox"), ("production", "live", "managed_live_direct")) +) + + +class CtpF14AdmissionError(ValueError): + """Redacted, fail-closed rejection from the F14 contract seam.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason.replace("_", " ")) + + +def _reject(reason: str) -> None: + raise CtpF14AdmissionError(reason) + + +def _require_id(value: object, field: str) -> str: + if type(value) is not str or not _ID_RE.fullmatch(value): + _reject("invalid_" + field) + return value + + +def _require_digest(value: object, field: str) -> str: + if type(value) is not str or not _SHA256_RE.fullmatch(value): + _reject("invalid_" + field) + return value + + +def _canonical_digest(value: object) -> str: + encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("ascii")).hexdigest() + + +@dataclass(frozen=True) +class CtpF14SessionBinding: + """Exact, non-secret native session identity supplied by the composition.""" + + session_id: str + trading_day: str + connection_generation: int + identity_digest: str + + def __post_init__(self) -> None: + _require_id(self.session_id, "session_id") + if type(self.trading_day) is not str or not re.fullmatch(r"[0-9]{8}", self.trading_day): + _reject("invalid_session_trading_day") + if type(self.connection_generation) is not int or self.connection_generation <= 0: + _reject("invalid_session_connection_generation") + _require_digest(self.identity_digest, "session_identity_digest") + + +@dataclass(frozen=True) +class CtpF14ActionRequest: + """All immutable identity needed to authorize one submit or cancel action. + + ``artifact_set_digest`` must cover the exact approved base, CTP and parent + artifacts plus their installed origins. ``approval_digest`` names the + fresh one-action approval. ``target_digest`` is required for cancellation + and forbidden for submit; it is a digest of the exact provider target. + """ + + runtime_id: str + environment: str + mode: str + preset: str + account_fingerprint_sha256: str + config_digest: str + effective_digest: str + registration_digest: str + artifact_set_digest: str + session: CtpF14SessionBinding + action_kind: str + action_id: str + action_digest: str + approval_digest: str + target_digest: str | None = None + + def __post_init__(self) -> None: + _require_id(self.runtime_id, "runtime_id") + if ( + type(self.environment) is not str + or type(self.mode) is not str + or type(self.preset) is not str + or (self.environment, self.mode, self.preset) not in _VALID_ROUTE_PAIRS + ): + _reject("f14_route_scope_invalid") + for name in ( + "account_fingerprint_sha256", + "config_digest", + "effective_digest", + "registration_digest", + "artifact_set_digest", + "action_digest", + "approval_digest", + ): + _require_digest(getattr(self, name), name) + if type(self.session) is not CtpF14SessionBinding: + _reject("f14_session_binding_required") + if type(self.action_kind) is not str or self.action_kind not in ("SUBMIT", "CANCEL"): + _reject("f14_action_kind_invalid") + _require_id(self.action_id, "action_id") + if self.action_kind == "CANCEL": + _require_digest(self.target_digest, "target_digest") + elif self.target_digest is not None: + _reject("submit_target_digest_forbidden") + + @property + def scope_digest(self) -> str: + """Digest of route, account, config, artifacts and exact session.""" + + return _canonical_digest( + { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "artifact_set_digest": self.artifact_set_digest, + "config_digest": self.config_digest, + "effective_digest": self.effective_digest, + "environment": self.environment, + "mode": self.mode, + "preset": self.preset, + "registration_digest": self.registration_digest, + "runtime_id": self.runtime_id, + "session": { + "connection_generation": self.session.connection_generation, + "identity_digest": self.session.identity_digest, + "session_id": self.session.session_id, + "trading_day": self.session.trading_day, + }, + } + ) + + @property + def request_digest(self) -> str: + """Digest of the exact action and its bound scope.""" + + return _canonical_digest( + { + "action_digest": self.action_digest, + "action_id": self.action_id, + "action_kind": self.action_kind, + "approval_digest": self.approval_digest, + "scope_digest": self.scope_digest, + "target_digest": self.target_digest, + } + ) + + +@dataclass(frozen=True) +class CtpF14ExternalAdmissionClaim: + """Typed response from an independently authenticated external authority. + + These fields are not self-authenticating. Only a code-owned authority + adapter that validates its remote trust root may construct/return one for + admission. The snapshot must be account-wide and complete at a single + authoritative version while the returned fence epoch is held. + """ + + binding: CtpF14ActionRequest + authority_id: str + claim_id: str + fence_id: str + fence_epoch: int + fence_scope_digest: str + snapshot_id: str + snapshot_version: str + snapshot_digest: str + snapshot_coverage_digest: str + snapshot_account_fingerprint_sha256: str + snapshot_fence_id: str + snapshot_fence_epoch: int + snapshot_coverage: tuple[str, ...] + account_writer_exclusive: bool + snapshot_complete: bool + snapshot_consistent: bool + issued_at_utc: float + expires_at_utc: float + authority_receipt_digest: str + + def __post_init__(self) -> None: + if type(self.binding) is not CtpF14ActionRequest: + _reject("external_claim_binding_invalid") + for name in ("authority_id", "claim_id", "fence_id", "snapshot_id", "snapshot_version"): + _require_id(getattr(self, name), name) + for name in ( + "fence_scope_digest", + "snapshot_digest", + "snapshot_coverage_digest", + "snapshot_account_fingerprint_sha256", + "authority_receipt_digest", + ): + _require_digest(getattr(self, name), name) + if type(self.fence_epoch) is not int or self.fence_epoch <= 0: + _reject("external_claim_fence_epoch_invalid") + if type(self.snapshot_fence_epoch) is not int or self.snapshot_fence_epoch <= 0: + _reject("external_claim_snapshot_fence_epoch_invalid") + _require_id(self.snapshot_fence_id, "snapshot_fence_id") + if type(self.snapshot_coverage) is not tuple or any( + type(item) is not str for item in self.snapshot_coverage + ): + _reject("external_claim_snapshot_coverage_invalid") + if type(self.account_writer_exclusive) is not bool: + _reject("external_claim_fence_assertion_invalid") + if type(self.snapshot_complete) is not bool or type(self.snapshot_consistent) is not bool: + _reject("external_claim_snapshot_assertion_invalid") + if ( + type(self.issued_at_utc) not in (int, float) + or type(self.expires_at_utc) not in (int, float) + or not math.isfinite(float(self.issued_at_utc)) + or not math.isfinite(float(self.expires_at_utc)) + or self.expires_at_utc <= self.issued_at_utc + ): + _reject("external_claim_time_bounds_invalid") + + +class CtpF14ExternalAdmissionAuthority(Protocol): + """External, independently authenticated account control service. + + ``claim_and_verify`` must atomically: establish exclusive control over all + account writers (including other hosts, users and direct SDK clients); + read/verify a complete account-wide snapshot with one authoritative + version under that fence; and consume exactly one current action grant + bound to ``request``. It must authenticate its source and reject stale, + replayed or revoked state before returning. Returning seven separately + terminal CTP query results is insufficient. + + ``assert_active`` must freshly confirm that the one-action claim remains + live, unrevoked and fenced before each native dispatch. The method must + return literal ``True`` only while all those conditions still hold. + """ + + def claim_and_verify(self, request: CtpF14ActionRequest) -> CtpF14ExternalAdmissionClaim: + ... + + def assert_active( + self, claim: CtpF14ExternalAdmissionClaim, *, request: CtpF14ActionRequest + ) -> bool: + ... + + +def _validate_claim(request: CtpF14ActionRequest, claim: object) -> CtpF14ExternalAdmissionClaim: + if type(claim) is not CtpF14ExternalAdmissionClaim: + _reject("external_admission_claim_invalid") + if claim.binding != request or claim.fence_scope_digest != request.scope_digest: + _reject("external_admission_scope_mismatch") + if ( + claim.account_writer_exclusive is not True + or claim.snapshot_complete is not True + or claim.snapshot_consistent is not True + ): + _reject("external_admission_evidence_incomplete") + if claim.snapshot_coverage != _REQUIRED_SNAPSHOT_COVERAGE: + _reject("external_admission_snapshot_coverage_incomplete") + if ( + claim.snapshot_account_fingerprint_sha256 != request.account_fingerprint_sha256 + or claim.snapshot_fence_id != claim.fence_id + or claim.snapshot_fence_epoch != claim.fence_epoch + ): + _reject("external_admission_snapshot_scope_mismatch") + return claim + + +class CtpF14ActionAdmission: + """Ephemeral claim handle; call :meth:`assert_active` before native use.""" + + __slots__ = ("_authority", "_request", "_claim") + + def __init__( + self, + authority: CtpF14ExternalAdmissionAuthority, + request: CtpF14ActionRequest, + claim: CtpF14ExternalAdmissionClaim, + *, + _construction_key: object, + ) -> None: + if _construction_key is not _ADMISSION_CONSTRUCTION_KEY: + _reject("f14_admission_factory_required") + self._authority = authority + self._request = request + self._claim = claim + + @property + def request_digest(self) -> str: + return self._request.request_digest + + @property + def scope_digest(self) -> str: + return self._request.scope_digest + + def assert_active(self) -> None: + """Recheck external fence and one-action claim; any uncertainty rejects.""" + + try: + result = self._authority.assert_active(self._claim, request=self._request) + except Exception: + _reject("external_admission_recheck_unavailable") + if result is not True: + _reject("external_admission_no_longer_active") + + +_ADMISSION_CONSTRUCTION_KEY = object() + + +def claim_f14_action( + authority: CtpF14ExternalAdmissionAuthority, + request: CtpF14ActionRequest, +) -> CtpF14ActionAdmission: + """Ask the external authority to claim one exact action and validate its response. + + No receipt/evidence argument is accepted. This function is an offline + integration seam only; it grants no runtime capability until a reviewed, + code-owned composition binds a pinned authority implementation and calls + :meth:`CtpF14ActionAdmission.assert_active` immediately before dispatch. + """ + + if type(request) is not CtpF14ActionRequest: + _reject("f14_action_request_required") + claim_method = getattr(authority, "claim_and_verify", None) + active_method = getattr(authority, "assert_active", None) + if not callable(claim_method) or not callable(active_method): + _reject("external_admission_authority_required") + try: + claim = claim_method(request) + except Exception: + _reject("external_admission_authority_unavailable") + validated = _validate_claim(request, claim) + return CtpF14ActionAdmission( + authority, + request, + validated, + _construction_key=_ADMISSION_CONSTRUCTION_KEY, + ) + + +__all__ = [ + "CtpF14ActionAdmission", + "CtpF14ActionRequest", + "CtpF14AdmissionError", + "CtpF14ExternalAdmissionAuthority", + "CtpF14ExternalAdmissionClaim", + "CtpF14SessionBinding", + "claim_f14_action", +] diff --git a/backtrader_runtime/ctp_front_pair_probe.py b/backtrader_runtime/ctp_front_pair_probe.py new file mode 100644 index 00000000..1fe987df --- /dev/null +++ b/backtrader_runtime/ctp_front_pair_probe.py @@ -0,0 +1,620 @@ +"""Credential-free, config-only CTP MD/TD front reachability selection. + +This module probes only the exact TCP endpoints supplied by the caller. It +does not consult SDK profile tables, infer endpoints from time, discover other +fronts, read credentials, log in, or authorize trading. It is a small network +diagnostic that returns the selected configured pair and detached timing +evidence. Callers must perform their separate config and execution admission. + +Each endpoint must connect in a strict majority of its bounded samples. The +latency score is the larger of the successful-sample medians for MD and TD, +so a fast market-data front cannot hide a slow trading front. Ties retain +config order. Numeric IPv4 addresses use direct timed sockets. Hostname +resolution and connect run in a child Python process; the reported connection +latency includes DNS plus TCP connect time but excludes interpreter startup. +The parent imposes a wall-clock timeout and kills a hung worker. Callers may +also provide an absolute monotonic deadline to cap each connect by the time +remaining to a larger operation budget. This is fail-closed deadline accounting, +not a hard whole-command deadline: process creation, socket operations, close, +and worker cleanup can outlast it. Independent configured endpoints run in a +bounded pool, while repeated samples for each endpoint remain sequential. +""" + +from __future__ import annotations + +import ipaddress +import math +import os +import socket +import subprocess +import sys +import time +from collections.abc import Mapping, Sequence +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from statistics import median +from typing import Any, Callable, Optional +from urllib.parse import urlsplit + + +_MAX_PAIRS = 8 +_MAX_REPEATED_SAMPLES = 5 +_MAX_TIMEOUT_SECONDS = 10.0 +_MAX_WORST_CASE_SECONDS = 180.0 +_MAX_CONCURRENT_ENDPOINTS = 8 +_WORKER_TERMINATION_GRACE_SECONDS = 0.25 +_WORKER_TERMINATION_ATTEMPTS = 2 +_HOSTNAME_WORKER_SCRIPT = ( + "import socket, sys, time\n" + "host = sys.argv[1]\n" + "port = int(sys.argv[2])\n" + "timeout = float(sys.argv[3])\n" + "dns_started = time.perf_counter()\n" + "addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)\n" + "dns_elapsed = time.perf_counter() - dns_started\n" + "for family, socktype, proto, _, sockaddr in addresses:\n" + " connection = socket.socket(family, socktype, proto)\n" + " try:\n" + " connection.settimeout(timeout)\n" + " connect_started = time.perf_counter()\n" + " connection.connect(sockaddr)\n" + " connect_elapsed = time.perf_counter() - connect_started\n" + " connection.close()\n" + " sys.stdout.write('%.9f,%.9f' % (dns_elapsed, connect_elapsed))\n" + " break\n" + " except OSError:\n" + " connection.close()\n" + "else:\n" + " sys.exit(1)\n" +) + + +class CtpFrontPairProbeError(ValueError): + """The configured front set cannot be selected safely.""" + + def __init__(self, reason: str, evidence: tuple["CtpFrontPairEvidence", ...] = ()) -> None: + self.reason = reason + self.evidence = evidence + super().__init__(reason) + + +@dataclass(frozen=True) +class CtpConfiguredFrontPair: + """One exact MD/TD address pair supplied by runtime configuration.""" + + md_front: str + td_front: str + + +@dataclass(frozen=True) +class CtpFrontProbeSample: + """One credential-free TCP connect observation.""" + + connected: bool + # DNS resolution plus TCP connect time; worker startup is excluded. + latency_ms: Optional[float] + failure: Optional[str] = None + dns_resolution_ms: Optional[float] = None + tcp_connect_ms: Optional[float] = None + + +@dataclass(frozen=True) +class CtpFrontEndpointEvidence: + """Repeated measurements for one configured endpoint.""" + + front: str + samples: tuple[CtpFrontProbeSample, ...] + + @property + def median_latency_ms(self) -> Optional[float]: + connected = tuple( + sample.latency_ms + for sample in self.samples + if sample.connected and sample.latency_ms is not None + ) + if len(connected) < len(self.samples) // 2 + 1: + return None + return float(median(connected)) + + +@dataclass(frozen=True) +class CtpFrontPairEvidence: + """Detached latency and reachability evidence for a candidate pair.""" + + config_index: int + pair: CtpConfiguredFrontPair + md: CtpFrontEndpointEvidence + td: CtpFrontEndpointEvidence + reachable: bool + latency_score_ms: Optional[float] + + +@dataclass(frozen=True) +class CtpFrontPairSelection: + """Selected configured pair; this object carries no execution authority.""" + + pair: CtpConfiguredFrontPair + config_index: int + latency_score_ms: float + evidence: tuple[CtpFrontPairEvidence, ...] + timeout_seconds: float + repeated_samples: int + + +@dataclass(frozen=True) +class _ParsedFront: + original: str + host: str + port: int + ip_literal: bool + + +@dataclass(frozen=True) +class _RemoteProbeConnection: + """Marker for a TCP socket already closed in the isolated worker.""" + + latency_ms: float + dns_resolution_ms: float + tcp_connect_ms: float + + def close(self) -> None: + return None + + +def _parse_front(value: Any) -> _ParsedFront: + if ( + type(value) is not str + or not value + or value != value.strip() + or any(ord(character) < 0x21 or ord(character) == 0x7F for character in value) + ): + raise CtpFrontPairProbeError("front_address_invalid") + try: + parsed = urlsplit(value) + host = parsed.hostname + port = parsed.port + except ValueError as error: + raise CtpFrontPairProbeError("front_address_invalid") from error + if ( + parsed.scheme != "tcp" + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or host is None + or not host + or port is None + or not 1 <= port <= 65535 + or parsed.path + or parsed.query + or parsed.fragment + or "@" in parsed.netloc + or value != "tcp://{0}:{1}".format(host, port) + ): + raise CtpFrontPairProbeError("front_address_must_be_plain_tcp_host_port") + try: + address = ipaddress.ip_address(host) + except ValueError: + if ":" in host: + raise CtpFrontPairProbeError("front_address_host_invalid") from None + try: + ascii_host = host.encode("idna").decode("ascii").lower() + except (UnicodeError, ValueError) as error: + raise CtpFrontPairProbeError("front_address_host_invalid") from error + labels = ascii_host.rstrip(".").split(".") + if ( + not ascii_host + or len(ascii_host) > 254 + or any( + not label + or len(label) > 63 + or not label[0].isalnum() + or not label[-1].isalnum() + or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label) + for label in labels + ) + ): + raise CtpFrontPairProbeError("front_address_host_invalid") from None + return _ParsedFront(value, ascii_host, port, False) + if address.version == 6: + # The runtime config parser currently canonicalizes fronts as + # tcp://host:port and does not accept bracketed IPv6 authorities. + raise CtpFrontPairProbeError("front_address_ipv6_not_supported_by_config") + return _ParsedFront(value, host, port, True) + + +def _configured_pairs(value: Any, *, max_pairs: int) -> tuple[tuple[CtpConfiguredFrontPair, _ParsedFront, _ParsedFront], ...]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence) or not value: + raise CtpFrontPairProbeError("front_pairs_required") + if len(value) > max_pairs: + raise CtpFrontPairProbeError("front_pair_count_exceeds_limit") + parsed_pairs = [] + seen = set() + for item in value: + if not isinstance(item, Mapping) or set(item) != {"md_front", "td_front"}: + raise CtpFrontPairProbeError("front_pair_fields_invalid") + pair = CtpConfiguredFrontPair(md_front=item["md_front"], td_front=item["td_front"]) + md = _parse_front(pair.md_front) + td = _parse_front(pair.td_front) + key = (pair.md_front, pair.td_front) + if key in seen: + raise CtpFrontPairProbeError("duplicate_front_pair") + seen.add(key) + parsed_pairs.append((pair, md, td)) + return tuple(parsed_pairs) + + +def _default_connect(host: str, port: int, timeout_seconds: float) -> Any: + """Open a TCP socket directly; no CTP SDK, login, or protocol request.""" + + address = ipaddress.ip_address(host) + connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + connection.settimeout(timeout_seconds) + connection.connect((str(address), port)) + return connection + except Exception: + connection.close() + raise + + +def _deadline_connect( + host: str, + port: int, + timeout_seconds: float, + *, + deadline_monotonic: float, + deadline_clock: Callable[[], float], +) -> Any: + """Connect to an IP literal while rechecking the shared operation deadline.""" + + address = ipaddress.ip_address(host) + connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + remaining = deadline_monotonic - deadline_clock() + if remaining <= 0: + raise TimeoutError("probe_global_deadline_exceeded") + connection.settimeout(min(timeout_seconds, remaining)) + if deadline_clock() >= deadline_monotonic: + raise TimeoutError("probe_global_deadline_exceeded") + connection.connect((str(address), port)) + return connection + except Exception: + connection.close() + raise + + +def _default_process_factory(command: list[str], **kwargs: Any) -> Any: + options = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.PIPE, + "stderr": subprocess.DEVNULL, + "shell": False, + "close_fds": True, + } + if os.name == "nt": + options["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) + options.update(kwargs) + return subprocess.Popen(command, **options) + + +def _bounded_hostname_connect( + host: str, + port: int, + timeout_seconds: float, + *, + process_factory: Callable[..., Any], + deadline_clock: Callable[[], float], + deadline_monotonic: Optional[float] = None, +) -> _RemoteProbeConnection: + """Resolve and connect in an isolated, killable child process.""" + + command = [ + sys.executable, + "-I", + "-S", + "-c", + _HOSTNAME_WORKER_SCRIPT, + host, + str(port), + str(timeout_seconds), + ] + deadline = deadline_clock() + timeout_seconds + if deadline_monotonic is not None: + deadline = min(deadline, deadline_monotonic) + if deadline <= deadline_clock(): + raise TimeoutError("probe_global_deadline_exceeded") + worker = process_factory(command) + remaining = max(0.0, deadline - deadline_clock()) + try: + output, _ = worker.communicate(timeout=remaining) + except subprocess.TimeoutExpired as error: + for _ in range(_WORKER_TERMINATION_ATTEMPTS): + try: + worker.kill() + except Exception: + pass + try: + worker.communicate(timeout=_WORKER_TERMINATION_GRACE_SECONDS) + break + except subprocess.TimeoutExpired: + continue + raise TimeoutError("hostname_probe_deadline_exceeded") from error + if worker.returncode != 0 or not isinstance(output, bytes): + raise ConnectionError("hostname_probe_failed") + try: + dns_seconds, connect_seconds = ( + float(component) for component in output.decode("ascii").split(",") + ) + except (UnicodeError, ValueError) as error: + raise ConnectionError("hostname_probe_latency_invalid") from error + if ( + not math.isfinite(dns_seconds) + or not math.isfinite(connect_seconds) + or dns_seconds < 0 + or not 0 <= connect_seconds <= timeout_seconds + or dns_seconds + connect_seconds > timeout_seconds + ): + raise ConnectionError("hostname_probe_latency_invalid") + return _RemoteProbeConnection( + latency_ms=round((dns_seconds + connect_seconds) * 1000.0, 6), + dns_resolution_ms=round(dns_seconds * 1000.0, 6), + tcp_connect_ms=round(connect_seconds * 1000.0, 6), + ) + + +def _probe_one( + front: _ParsedFront, + *, + timeout_seconds: float, + connector: Callable[[str, int, float], Any], + clock: Callable[[], float], + deadline_monotonic: Optional[float] = None, +) -> CtpFrontProbeSample: + connection = None + try: + started = clock() + if deadline_monotonic is not None: + remaining = deadline_monotonic - started + if remaining <= 0: + return CtpFrontProbeSample(False, None, "probe_global_deadline_exceeded") + connect_timeout = min(timeout_seconds, remaining) + else: + connect_timeout = timeout_seconds + connection = connector(front.host, front.port, connect_timeout) + finished = clock() + if deadline_monotonic is not None and finished >= deadline_monotonic: + return CtpFrontProbeSample(False, None, "probe_global_deadline_exceeded") + elapsed = finished - started + if not math.isfinite(elapsed) or elapsed < 0 or elapsed > connect_timeout: + return CtpFrontProbeSample(False, None, "connect_timeout_or_clock_invalid") + measured_latency = getattr(connection, "latency_ms", None) + dns_latency = getattr(connection, "dns_resolution_ms", 0.0) + tcp_latency = getattr(connection, "tcp_connect_ms", None) + if measured_latency is not None: + if ( + isinstance(measured_latency, bool) + or type(measured_latency) not in (int, float) + or not math.isfinite(measured_latency) + or measured_latency < 0 + or measured_latency > connect_timeout * 1000.0 + ): + return CtpFrontProbeSample(False, None, "worker_latency_invalid") + elapsed_ms = float(measured_latency) + if ( + isinstance(dns_latency, bool) + or type(dns_latency) not in (int, float) + or not math.isfinite(dns_latency) + or dns_latency < 0 + or tcp_latency is None + or isinstance(tcp_latency, bool) + or type(tcp_latency) not in (int, float) + or not math.isfinite(tcp_latency) + or tcp_latency < 0 + or abs(elapsed_ms - dns_latency - tcp_latency) > 0.00001 + ): + return CtpFrontProbeSample(False, None, "worker_latency_invalid") + else: + elapsed_ms = elapsed * 1000.0 + dns_latency = 0.0 + tcp_latency = elapsed_ms + close = getattr(connection, "close", None) + if not callable(close): + return CtpFrontProbeSample(False, None, "connection_handle_invalid") + try: + close() + except Exception as error: + return CtpFrontProbeSample(False, None, type(error).__name__[:64]) + connection = None + return CtpFrontProbeSample( + True, + round(elapsed_ms, 6), + dns_resolution_ms=round(float(dns_latency), 6), + tcp_connect_ms=round(float(tcp_latency), 6), + ) + except Exception as error: + return CtpFrontProbeSample(False, None, type(error).__name__[:64]) + finally: + if connection is not None: + try: + connection.close() + except Exception: + pass + + +def select_ctp_front_pair( + front_pairs: Sequence[Mapping[str, str]], + *, + timeout_seconds: float, + max_pairs: int, + repeated_samples: int, + connector: Optional[Callable[[str, int, float], Any]] = None, + process_factory: Optional[Callable[..., Any]] = None, + clock: Optional[Callable[[], float]] = None, + deadline_monotonic: Optional[float] = None, +) -> CtpFrontPairSelection: + """Pick the lowest-latency reachable pair from explicit config entries. + + All addresses are parsed before any socket opens. ``max_pairs`` is a hard + bound: excess config entries fail before probing instead of being silently + truncated. Every candidate receives the same number of MD and TD samples; + each endpoint needs a strict majority of successful samples. If every + configured pair is ineligible, a typed error contains failure evidence + and no pair is chosen. + + Use a timeout of at least 3 seconds for typical CTP fronts. The maximum + aggregate probe budget, including a bounded worker termination grace, is + 180 seconds across all configured endpoints. The default runtime path runs + at most eight endpoint jobs at once; injected connector, worker-process + factory or clock dependencies run serially for deterministic offline tests. + ``deadline_monotonic`` is an optional absolute deadline in the same clock + domain as ``clock`` (or ``time.perf_counter`` when no clock is injected). + Each connect receives at most the remaining time, and no connect is started + after the deadline is observed. This cannot bound a blocking OS call, + connector, process factory, socket close, or worker cleanup as a whole. + """ + + if ( + isinstance(timeout_seconds, bool) + or type(timeout_seconds) not in (int, float) + or not math.isfinite(float(timeout_seconds)) + or not 0 < float(timeout_seconds) <= _MAX_TIMEOUT_SECONDS + ): + raise CtpFrontPairProbeError("probe_timeout_out_of_bounds") + if type(max_pairs) is not int or not 1 <= max_pairs <= _MAX_PAIRS: + raise CtpFrontPairProbeError("max_pairs_out_of_bounds") + if type(repeated_samples) is not int or not 1 <= repeated_samples <= _MAX_REPEATED_SAMPLES: + raise CtpFrontPairProbeError("repeated_samples_out_of_bounds") + if deadline_monotonic is None: + deadline = None + else: + if isinstance(deadline_monotonic, bool) or type(deadline_monotonic) not in (int, float): + raise CtpFrontPairProbeError("probe_deadline_invalid") + try: + deadline = float(deadline_monotonic) + except (OverflowError, ValueError) as error: + raise CtpFrontPairProbeError("probe_deadline_invalid") from error + if not math.isfinite(deadline): + raise CtpFrontPairProbeError("probe_deadline_invalid") + timeout = float(timeout_seconds) + candidates = _configured_pairs(front_pairs, max_pairs=max_pairs) + worst_case = len(candidates) * 2 * repeated_samples * ( + timeout + _WORKER_TERMINATION_ATTEMPTS * _WORKER_TERMINATION_GRACE_SECONDS + ) + if worst_case > _MAX_WORST_CASE_SECONDS: + raise CtpFrontPairProbeError("probe_budget_exceeds_limit") + # Keep injected dependencies deterministic for offline callers and tests. + # The runtime path has independent endpoint jobs, so probe those in a + # bounded pool while keeping each endpoint's repeated samples sequential. + use_parallel_probes = connector is None and process_factory is None and clock is None + process_factory = process_factory or _default_process_factory + now = clock or time.perf_counter + + def probe_endpoint(front: _ParsedFront) -> tuple[CtpFrontProbeSample, ...]: + def hostname_connector(host: str, port: int, timeout_value: float) -> Any: + return _bounded_hostname_connect( + host, + port, + timeout_value, + process_factory=process_factory, + deadline_clock=now, + deadline_monotonic=deadline, + ) + + def deadline_connector(host: str, port: int, timeout_value: float) -> Any: + assert deadline is not None + return _deadline_connect( + host, + port, + timeout_value, + deadline_monotonic=deadline, + deadline_clock=now, + ) + + if front.ip_literal: + if connector is not None: + endpoint_connector = connector + elif deadline is None: + endpoint_connector = _default_connect + else: + endpoint_connector = deadline_connector + else: + endpoint_connector = hostname_connector + samples = [] + for _ in range(repeated_samples): + samples.append( + _probe_one( + front, + timeout_seconds=timeout, + connector=endpoint_connector, + clock=now, + deadline_monotonic=deadline, + ) + ) + return tuple(samples) + + endpoints = tuple( + (index, side, front) + for index, (_, md_front, td_front) in enumerate(candidates) + for side, front in (("md", md_front), ("td", td_front)) + ) + if use_parallel_probes and len(endpoints) > 1: + worker_count = min(_MAX_CONCURRENT_ENDPOINTS, len(endpoints)) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [executor.submit(probe_endpoint, front) for _, _, front in endpoints] + endpoint_samples = [future.result() for future in futures] + else: + endpoint_samples = [probe_endpoint(front) for _, _, front in endpoints] + + samples_by_endpoint = { + (index, side): samples + for (index, side, _), samples in zip(endpoints, endpoint_samples) + } + evidence = [] + for index, (pair, _, _) in enumerate(candidates): + md_samples = samples_by_endpoint[(index, "md")] + td_samples = samples_by_endpoint[(index, "td")] + md = CtpFrontEndpointEvidence(pair.md_front, md_samples) + td = CtpFrontEndpointEvidence(pair.td_front, td_samples) + md_median = md.median_latency_ms + td_median = td.median_latency_ms + reachable = md_median is not None and td_median is not None + score = max(md_median, td_median) if reachable else None + evidence.append( + CtpFrontPairEvidence( + config_index=index, + pair=pair, + md=md, + td=td, + reachable=reachable, + latency_score_ms=score, + ) + ) + + reachable_pairs = tuple(item for item in evidence if item.reachable) + if not reachable_pairs: + if deadline is not None and now() >= deadline: + raise CtpFrontPairProbeError("probe_global_deadline_exceeded", tuple(evidence)) + raise CtpFrontPairProbeError("no_configured_front_pair_reachable", tuple(evidence)) + selected = min(reachable_pairs, key=lambda item: (item.latency_score_ms, item.config_index)) + selection = CtpFrontPairSelection( + pair=selected.pair, + config_index=selected.config_index, + latency_score_ms=selected.latency_score_ms, + evidence=tuple(evidence), + timeout_seconds=timeout, + repeated_samples=repeated_samples, + ) + if deadline is not None and now() >= deadline: + raise CtpFrontPairProbeError("probe_global_deadline_exceeded", tuple(evidence)) + return selection + + +__all__ = [ + "CtpConfiguredFrontPair", + "CtpFrontEndpointEvidence", + "CtpFrontPairEvidence", + "CtpFrontPairProbeError", + "CtpFrontPairSelection", + "CtpFrontProbeSample", + "select_ctp_front_pair", +] diff --git a/backtrader_runtime/ctp_guardian_request_journal.py b/backtrader_runtime/ctp_guardian_request_journal.py new file mode 100644 index 00000000..ca998397 --- /dev/null +++ b/backtrader_runtime/ctp_guardian_request_journal.py @@ -0,0 +1,1000 @@ +"""Independent local journal for fixed read-only guardian requests. + +This storage seam is intended for a future pre-start service. It has no CLI, +SDK, provider, worker, credential, or trading integration. The service must +call :meth:`GuardianRequestJournal.accept_request` after receiving a request +and may start its read-only worker only after that method returns: the method +commits and reads back the ``RUNNING`` row first. + +The journal records a fixed operation name and an opaque request ID; it stores +no request payload or credentials. ``RUNNING``, ``OBSERVED``, and ``UNKNOWN`` +are accounting states only. None of them grants trading authority. An +``UNKNOWN`` row is terminal in this API and cannot be upgraded by a late +callback or caller-supplied digest. The observed digest must come from a +separate trusted verifier supplied by a future service; this module does not +provide or authenticate such a verifier. + +SQLite uses rollback-journal mode with ``synchronous=FULL`` and a bounded busy +timeout. That makes successful method returns wait for SQLite's configured +durability boundary, but it cannot make arbitrary filesystem, kernel, or +device stalls hard real-time bounded. A request deadline starts from a +code-owned monotonic timestamp before the durable accept transaction. It is +generation-local and is never reused after restart. +""" + +from __future__ import annotations + +import ctypes +import hashlib +import json +import ntpath +import os +import re +import sqlite3 +import stat +import threading +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator, Optional + + +GUARDIAN_REQUEST_SCHEMA_VERSION = 1 +GUARDIAN_REQUEST_DATABASE_NAME = "guardian-request-journal.sqlite3" +GUARDIAN_REQUEST_BUDGET_SECONDS = 300 +GUARDIAN_REQUEST_BUDGET_NS = GUARDIAN_REQUEST_BUDGET_SECONDS * 1_000_000_000 +GUARDIAN_REQUEST_BUSY_TIMEOUT_SECONDS = 0.25 +GUARDIAN_FIXED_OPERATION = "ctp_readonly_preflight" +GUARDIAN_READ_ONLY_OPERATIONS = frozenset((GUARDIAN_FIXED_OPERATION,)) +GUARDIAN_REQUEST_WRITE_AUTHORIZED = False +GUARDIAN_REQUEST_TRADING_CAPABILITIES: tuple[()] = () + +_IDENTITY_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z") +_REQUEST_ID_RE = re.compile(r"\A[0-9a-f]{32}\Z") +_SHA256_RE = re.compile(r"\A[0-9a-f]{64}\Z") +_UNKNOWN_REASONS = frozenset( + ( + "deadline_expired", + "provider_uncertain", + "restart_recovery", + "server_cancelled", + "transport_uncertain", + ) +) +_APPLICATION_TABLES = frozenset(("guardian_request_meta", "guardian_requests")) +_WINDOWS_FIXED_DRIVE = 3 + + +class GuardianRequestJournalError(RuntimeError): + """Redacted fail-closed journal error with a stable reason code.""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +@dataclass(frozen=True) +class GuardianRequestRecord: + """Immutable local receipt for one fixed read-only request.""" + + request_id: str + identity: str + operation: str + state: str + accepted_generation: str + accepted_monotonic_ns: int + deadline_monotonic_ns: int + terminal_generation: Optional[str] + terminal_monotonic_ns: Optional[int] + receipt_digest: Optional[str] + terminal_reason: Optional[str] + + +class GuardianRequestJournal: + """Durable, identity-bound state for a single pre-start service journal. + + ``state_dir`` must already exist and be private. On POSIX it must be owned + by the current user and have no group/world permissions. On Windows it + must be on a fixed local volume, contain no reparse-point path component, + and have a protected owner-only ACL. The database is a fixed child of that + directory so callers cannot redirect the journal to an arbitrary path. + + Opening an existing journal creates a new process generation and changes + every old ``RUNNING`` row to terminal ``UNKNOWN`` in one durable + transaction. A shared database opened by a second process similarly + fences the first process by changing the generation; the older instance + then fails closed. + """ + + LOCAL_ONLY = True + NO_WRITE = True + ORDER_SUBMISSION_AUTHORIZED = False + TRADING_CAPABILITIES: tuple[()] = () + + _META_TABLE = "guardian_request_meta" + _REQUEST_TABLE = "guardian_requests" + _BUSY_TIMEOUT_MS = int(GUARDIAN_REQUEST_BUSY_TIMEOUT_SECONDS * 1000) + + def __init__(self, state_dir: Path, identity: str) -> None: + if ( + not isinstance(state_dir, Path) + or not state_dir.is_absolute() + or ".." in state_dir.parts + ): + raise GuardianRequestJournalError("guardian_journal_config_invalid") + if type(identity) is not str or _IDENTITY_RE.fullmatch(identity) is None: + raise GuardianRequestJournalError("guardian_journal_identity_invalid") + + self._state_dir = Path(os.path.abspath(os.fspath(state_dir))) + self._path = self._state_dir / GUARDIAN_REQUEST_DATABASE_NAME + self._identity = identity + self._generation = uuid.uuid4().hex + self._lock = threading.RLock() + self._closed = False + self._directory_identity = self._verify_state_directory() + self._database_identity = self._prepare_database_file() + self._initialize_and_recover() + + @property + def identity(self) -> str: + """The fixed non-secret identity bound to this database.""" + + return self._identity + + @property + def generation(self) -> str: + """The current process generation; old monotonic deadlines are invalid.""" + + return self._generation + + def persist_accepted( + self, + request_id: str, + *, + service_t0_monotonic_ns: int, + deadline_monotonic_ns: int, + ) -> bool: + """Persist service acceptance and verify it through a fresh read. + + This method matches the future service's ``persist_accepted`` seam. + Call it only after the server receives and authenticates its fixed + request. The service must sample T0 first and may start its worker only + when this method returns literal ``True``. The only accepted operation + is :data:`GUARDIAN_FIXED_OPERATION`, and D must equal the code-owned + ``T0 + GUARDIAN_REQUEST_BUDGET_NS``. + """ + + self._ensure_open() + self._validate_request_id(request_id) + if ( + type(service_t0_monotonic_ns) is not int + or service_t0_monotonic_ns < 0 + or type(deadline_monotonic_ns) is not int + or deadline_monotonic_ns != service_t0_monotonic_ns + GUARDIAN_REQUEST_BUDGET_NS + ): + raise GuardianRequestJournalError("guardian_request_deadline_invalid") + try: + with self._transaction(write=True) as connection: + self._assert_current_generation(connection) + duplicate = connection.execute( + "SELECT 1 FROM " + self._REQUEST_TABLE + " WHERE request_id = ?", + (request_id,), + ).fetchone() + if duplicate is not None: + raise GuardianRequestJournalError("guardian_request_id_duplicate") + blocker = connection.execute( + "SELECT state FROM " + + self._REQUEST_TABLE + + " WHERE state IN ('RUNNING', 'UNKNOWN') LIMIT 1" + ).fetchone() + if blocker is not None: + reason = ( + "guardian_request_unknown_blocks" + if blocker[0] == "UNKNOWN" + else "guardian_request_in_flight" + ) + raise GuardianRequestJournalError(reason) + if time.monotonic_ns() >= deadline_monotonic_ns: + raise GuardianRequestJournalError("guardian_request_deadline_expired") + connection.execute( + "INSERT INTO " + + self._REQUEST_TABLE + + " (request_id, operation, state, accepted_generation, " + "accepted_monotonic_ns, deadline_monotonic_ns) " + "VALUES (?, ?, 'RUNNING', ?, ?, ?)", + ( + request_id, + GUARDIAN_FIXED_OPERATION, + self._generation, + service_t0_monotonic_ns, + deadline_monotonic_ns, + ), + ) + except GuardianRequestJournalError: + raise + + # A second connection is the read-back gate for any future worker. + # No provider request is issued by this module. + record = self.get_request(request_id) + if ( + record is None + or record.state != "RUNNING" + or record.accepted_generation != self._generation + or record.accepted_monotonic_ns != service_t0_monotonic_ns + or record.deadline_monotonic_ns != deadline_monotonic_ns + ): + raise GuardianRequestJournalError("guardian_request_accept_readback_failed") + if time.monotonic_ns() >= deadline_monotonic_ns: + self.mark_unknown(request_id, "deadline_expired") + return False + return True + + def record_observed(self, request_id: str, receipt_digest: str) -> GuardianRequestRecord: + """Persist a verified observation before its fixed monotonic deadline. + + The digest is only a content identifier. This module cannot authenticate + the receipt or mint trusted evidence. In particular, no call can move + a terminal ``UNKNOWN`` row to ``OBSERVED``. + """ + + self._ensure_open() + self._validate_request_id(request_id) + if type(receipt_digest) is not str or _SHA256_RE.fullmatch(receipt_digest) is None: + raise GuardianRequestJournalError("guardian_request_receipt_digest_invalid") + + now_ns = time.monotonic_ns() + deadline_expired = False + try: + with self._transaction(write=True) as connection: + self._assert_current_generation(connection) + row = self._select_request(connection, request_id) + if row is None: + raise GuardianRequestJournalError("guardian_request_not_found") + record = self._record_from_row(row) + if record.state == "UNKNOWN": + raise GuardianRequestJournalError("guardian_request_unknown_terminal") + if record.state == "OBSERVED": + if record.receipt_digest != receipt_digest: + raise GuardianRequestJournalError( + "guardian_request_terminal_digest_conflict" + ) + return record + if record.accepted_generation != self._generation: + raise GuardianRequestJournalError("guardian_request_generation_lost") + if now_ns >= record.deadline_monotonic_ns: + self._write_unknown( + connection, + record, + reason="deadline_expired", + terminal_ns=now_ns, + ) + deadline_expired = True + else: + connection.execute( + "UPDATE " + + self._REQUEST_TABLE + + " SET state = 'OBSERVED', terminal_generation = ?, " + "terminal_monotonic_ns = ?, receipt_digest = ?, terminal_reason = NULL " + "WHERE request_id = ? AND state = 'RUNNING'", + (self._generation, now_ns, receipt_digest, request_id), + ) + except GuardianRequestJournalError: + raise + + if deadline_expired: + raise GuardianRequestJournalError("guardian_request_deadline_expired") + + record = self.get_request(request_id) + if record is None or record.state != "OBSERVED" or record.receipt_digest != receipt_digest: + raise GuardianRequestJournalError("guardian_request_observation_readback_failed") + # A slow commit/read-back that crossed D is conservatively downgraded. + # SQLite FULL cannot bound arbitrary device or kernel stalls, so this + # guard is best effort and is not a hard real-time guarantee. + if time.monotonic_ns() >= record.deadline_monotonic_ns: + self._downgrade_late_observation(record) + raise GuardianRequestJournalError("guardian_request_deadline_expired") + return record + + def mark_unknown(self, request_id: str, reason: str) -> GuardianRequestRecord: + """Make an in-flight request terminally uncertain; never clears UNKNOWN.""" + + self._ensure_open() + self._validate_request_id(request_id) + if type(reason) is not str or reason not in _UNKNOWN_REASONS: + raise GuardianRequestJournalError("guardian_request_unknown_reason_invalid") + now_ns = time.monotonic_ns() + try: + with self._transaction(write=True) as connection: + self._assert_current_generation(connection) + row = self._select_request(connection, request_id) + if row is None: + raise GuardianRequestJournalError("guardian_request_not_found") + record = self._record_from_row(row) + if record.state == "UNKNOWN": + return record + if record.state == "OBSERVED": + raise GuardianRequestJournalError("guardian_request_terminal_state") + if record.accepted_generation != self._generation: + raise GuardianRequestJournalError("guardian_request_generation_lost") + self._write_unknown(connection, record, reason=reason, terminal_ns=now_ns) + except GuardianRequestJournalError: + raise + record = self.get_request(request_id) + if record is None or record.state != "UNKNOWN": + raise GuardianRequestJournalError("guardian_request_unknown_readback_failed") + return record + + def get_request(self, request_id: str) -> Optional[GuardianRequestRecord]: + """Read one local request record, raising if durable state is unavailable.""" + + self._ensure_open() + self._validate_request_id(request_id) + try: + with self._transaction(write=False) as connection: + row = self._select_request(connection, request_id) + return None if row is None else self._record_from_row(row) + except GuardianRequestJournalError: + raise + + def has_blocker(self) -> bool: + """Return whether any in-flight or unknown row blocks further admission.""" + + self._ensure_open() + try: + with self._transaction(write=False) as connection: + row = connection.execute( + "SELECT 1 FROM " + + self._REQUEST_TABLE + + " WHERE state IN ('RUNNING', 'UNKNOWN') LIMIT 1" + ).fetchone() + return row is not None + except GuardianRequestJournalError: + raise + + def close(self) -> None: + """Close this handle; the class holds no long-lived SQLite connection.""" + + self._closed = True + + def __enter__(self) -> "GuardianRequestJournal": + self._ensure_open() + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.close() + + def _initialize_and_recover(self) -> None: + try: + with self._transaction(write=True, initializing=True) as connection: + version = connection.execute("PRAGMA user_version").fetchone() + if version not in ((0,), (GUARDIAN_REQUEST_SCHEMA_VERSION,)): + raise GuardianRequestJournalError("guardian_journal_schema_invalid") + if version == (0,): + names = frozenset( + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%'" + ).fetchall() + ) + if names: + raise GuardianRequestJournalError("guardian_journal_schema_invalid") + self._create_schema(connection) + connection.execute( + "INSERT INTO " + + self._META_TABLE + + " (singleton, schema_version, identity, generation) VALUES (1, ?, ?, ?)", + (GUARDIAN_REQUEST_SCHEMA_VERSION, self._identity, self._generation), + ) + connection.execute( + "PRAGMA user_version = " + str(GUARDIAN_REQUEST_SCHEMA_VERSION) + ) + return + + self._validate_schema(connection) + meta = connection.execute( + "SELECT schema_version, identity FROM " + + self._META_TABLE + + " WHERE singleton = 1" + ).fetchone() + if meta != (GUARDIAN_REQUEST_SCHEMA_VERSION, self._identity): + raise GuardianRequestJournalError("guardian_journal_identity_mismatch") + + now_ns = time.monotonic_ns() + rows = connection.execute( + "SELECT request_id, operation, accepted_generation, accepted_monotonic_ns, " + "deadline_monotonic_ns, terminal_generation, terminal_monotonic_ns, " + "receipt_digest, terminal_reason " + "FROM " + self._REQUEST_TABLE + " WHERE state = 'RUNNING'" + ).fetchall() + for row in rows: + record = self._record_from_row((row[0], row[1], "RUNNING") + tuple(row[2:])) + self._write_unknown( + connection, + record, + reason="restart_recovery", + terminal_ns=now_ns, + ) + connection.execute( + "UPDATE " + self._META_TABLE + " SET generation = ? WHERE singleton = 1", + (self._generation,), + ) + except GuardianRequestJournalError: + raise + + def _create_schema(self, connection: sqlite3.Connection) -> None: + connection.execute( + "CREATE TABLE " + + self._META_TABLE + + " (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), " + "schema_version INTEGER NOT NULL, identity TEXT NOT NULL, generation TEXT NOT NULL)" + ) + connection.execute( + "CREATE TABLE " + + self._REQUEST_TABLE + + " (request_id TEXT PRIMARY KEY, operation TEXT NOT NULL, " + "state TEXT NOT NULL CHECK (state IN ('RUNNING', 'OBSERVED', 'UNKNOWN')), " + "accepted_generation TEXT NOT NULL, accepted_monotonic_ns INTEGER NOT NULL, " + "deadline_monotonic_ns INTEGER NOT NULL, terminal_generation TEXT, " + "terminal_monotonic_ns INTEGER, receipt_digest TEXT, terminal_reason TEXT, " + "CHECK (deadline_monotonic_ns > accepted_monotonic_ns), " + "CHECK ((state = 'RUNNING' AND terminal_generation IS NULL " + "AND terminal_monotonic_ns IS NULL AND receipt_digest IS NULL " + "AND terminal_reason IS NULL) OR (state IN ('OBSERVED', 'UNKNOWN') " + "AND terminal_generation IS NOT NULL AND terminal_monotonic_ns IS NOT NULL " + "AND receipt_digest IS NOT NULL)))" + ) + + def _validate_schema(self, connection: sqlite3.Connection) -> None: + names = frozenset( + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%'" + ).fetchall() + ) + if names != _APPLICATION_TABLES: + raise GuardianRequestJournalError("guardian_journal_schema_invalid") + meta_columns = tuple( + row[1] for row in connection.execute("PRAGMA table_info(" + self._META_TABLE + ")") + ) + request_columns = tuple( + row[1] for row in connection.execute("PRAGMA table_info(" + self._REQUEST_TABLE + ")") + ) + if meta_columns != ("singleton", "schema_version", "identity", "generation"): + raise GuardianRequestJournalError("guardian_journal_schema_invalid") + if request_columns != ( + "request_id", + "operation", + "state", + "accepted_generation", + "accepted_monotonic_ns", + "deadline_monotonic_ns", + "terminal_generation", + "terminal_monotonic_ns", + "receipt_digest", + "terminal_reason", + ): + raise GuardianRequestJournalError("guardian_journal_schema_invalid") + + @contextmanager + def _transaction( + self, *, write: bool, initializing: bool = False + ) -> Iterator[sqlite3.Connection]: + with self._lock: + self._ensure_open() + connection: Optional[sqlite3.Connection] = None + try: + self._verify_database_identity() + connection = self._connect() + connection.execute("BEGIN IMMEDIATE" if write else "BEGIN") + if not initializing: + self._assert_identity(connection) + if write: + self._assert_current_generation(connection) + yield connection + self._verify_database_identity() + connection.execute("COMMIT") + self._verify_database_identity() + except GuardianRequestJournalError: + if connection is not None and connection.in_transaction: + try: + connection.execute("ROLLBACK") + except sqlite3.Error: + pass + raise + except (OSError, sqlite3.Error, RuntimeError, ValueError) as exc: + if connection is not None and connection.in_transaction: + try: + connection.execute("ROLLBACK") + except sqlite3.Error: + pass + raise GuardianRequestJournalError("guardian_journal_unavailable") from exc + finally: + if connection is not None: + connection.close() + + def _connect(self) -> sqlite3.Connection: + connection: Optional[sqlite3.Connection] = None + try: + connection = sqlite3.connect( + str(self._path), + timeout=GUARDIAN_REQUEST_BUSY_TIMEOUT_SECONDS, + isolation_level=None, + ) + connection.execute("PRAGMA busy_timeout = " + str(self._BUSY_TIMEOUT_MS)) + connection.execute("PRAGMA foreign_keys = ON") + if connection.execute("PRAGMA foreign_keys").fetchone() != (1,): + raise GuardianRequestJournalError("guardian_journal_database_invalid") + if connection.execute("PRAGMA journal_mode = DELETE").fetchone() != ("delete",): + raise GuardianRequestJournalError("guardian_journal_database_invalid") + connection.execute("PRAGMA synchronous = FULL") + if connection.execute("PRAGMA synchronous").fetchone() != (2,): + raise GuardianRequestJournalError("guardian_journal_database_invalid") + check = connection.execute("PRAGMA quick_check").fetchall() + if check != [("ok",)]: + raise GuardianRequestJournalError("guardian_journal_database_corrupt") + self._verify_database_identity() + return connection + except BaseException: + if connection is not None: + connection.close() + raise + + def _assert_identity(self, connection: sqlite3.Connection) -> None: + version = connection.execute("PRAGMA user_version").fetchone() + meta = connection.execute( + "SELECT schema_version, identity FROM " + self._META_TABLE + " WHERE singleton = 1" + ).fetchone() + if version != (GUARDIAN_REQUEST_SCHEMA_VERSION,) or meta != ( + GUARDIAN_REQUEST_SCHEMA_VERSION, + self._identity, + ): + raise GuardianRequestJournalError("guardian_journal_identity_mismatch") + + def _assert_current_generation(self, connection: sqlite3.Connection) -> None: + row = connection.execute( + "SELECT generation FROM " + self._META_TABLE + " WHERE singleton = 1" + ).fetchone() + if row != (self._generation,): + raise GuardianRequestJournalError("guardian_request_generation_lost") + + def _select_request( + self, connection: sqlite3.Connection, request_id: str + ) -> Optional[tuple[object, ...]]: + return connection.execute( + "SELECT request_id, operation, state, accepted_generation, " + "accepted_monotonic_ns, deadline_monotonic_ns, terminal_generation, " + "terminal_monotonic_ns, receipt_digest, terminal_reason " + "FROM " + self._REQUEST_TABLE + " WHERE request_id = ?", + (request_id,), + ).fetchone() + + def _record_from_row(self, row: tuple[object, ...]) -> GuardianRequestRecord: + return GuardianRequestRecord( + request_id=str(row[0]), + identity=self._identity, + operation=str(row[1]), + state=str(row[2]), + accepted_generation=str(row[3]), + accepted_monotonic_ns=int(row[4]), + deadline_monotonic_ns=int(row[5]), + terminal_generation=None if row[6] is None else str(row[6]), + terminal_monotonic_ns=None if row[7] is None else int(row[7]), + receipt_digest=None if row[8] is None else str(row[8]), + terminal_reason=None if row[9] is None else str(row[9]), + ) + + def _public_record(self, row: tuple[object, ...]) -> GuardianRequestRecord: + record = self._record_from_row(row) + return GuardianRequestRecord( + request_id=record.request_id, + identity=self._identity, + operation=record.operation, + state=record.state, + accepted_generation=record.accepted_generation, + accepted_monotonic_ns=record.accepted_monotonic_ns, + deadline_monotonic_ns=record.deadline_monotonic_ns, + terminal_generation=record.terminal_generation, + terminal_monotonic_ns=record.terminal_monotonic_ns, + receipt_digest=record.receipt_digest, + terminal_reason=record.terminal_reason, + ) + + def _write_unknown( + self, + connection: sqlite3.Connection, + record: GuardianRequestRecord, + *, + reason: str, + terminal_ns: int, + ) -> None: + digest = _unknown_receipt_digest( + identity=self._identity, + request_id=record.request_id, + operation=record.operation, + accepted_generation=record.accepted_generation, + terminal_generation=self._generation, + reason=reason, + terminal_ns=terminal_ns, + ) + cursor = connection.execute( + "UPDATE " + + self._REQUEST_TABLE + + " SET state = 'UNKNOWN', terminal_generation = ?, terminal_monotonic_ns = ?, " + "receipt_digest = ?, terminal_reason = ? " + "WHERE request_id = ? AND state = 'RUNNING'", + (self._generation, terminal_ns, digest, reason, record.request_id), + ) + if cursor.rowcount != 1: + raise GuardianRequestJournalError("guardian_request_transition_conflict") + + def _downgrade_late_observation(self, record: GuardianRequestRecord) -> None: + now_ns = time.monotonic_ns() + try: + with self._transaction(write=True) as connection: + row = self._select_request(connection, record.request_id) + if row is None: + raise GuardianRequestJournalError("guardian_request_not_found") + current = self._public_record(row) + if current.state == "UNKNOWN": + return + if ( + current.state != "OBSERVED" + or current.receipt_digest != record.receipt_digest + or current.accepted_generation != self._generation + or current.terminal_generation != self._generation + ): + raise GuardianRequestJournalError("guardian_request_transition_conflict") + digest = _unknown_receipt_digest( + identity=self._identity, + request_id=current.request_id, + operation=current.operation, + accepted_generation=current.accepted_generation, + terminal_generation=self._generation, + reason="deadline_expired", + terminal_ns=now_ns, + ) + cursor = connection.execute( + "UPDATE " + + self._REQUEST_TABLE + + " SET state = 'UNKNOWN', terminal_generation = ?, " + "terminal_monotonic_ns = ?, receipt_digest = ?, terminal_reason = ? " + "WHERE request_id = ? AND state = 'OBSERVED' " + "AND accepted_generation = ? AND terminal_generation = ? " + "AND receipt_digest = ?", + ( + self._generation, + now_ns, + digest, + "deadline_expired", + current.request_id, + self._generation, + self._generation, + record.receipt_digest, + ), + ) + if cursor.rowcount != 1: + raise GuardianRequestJournalError("guardian_request_transition_conflict") + except GuardianRequestJournalError: + raise + + def _verify_state_directory(self) -> tuple[tuple[str, int, int], ...]: + absolute = self._state_dir + if not absolute.is_absolute() or not absolute.name: + raise GuardianRequestJournalError("guardian_journal_config_invalid") + if os.name == "nt": + self._require_windows_fixed_volume(absolute) + + current = Path(absolute.anchor) + paths = [current] + for part in absolute.parts: + if part == absolute.anchor: + continue + current = current / part + paths.append(current) + + identities = [] + for directory in paths: + try: + entry = os.lstat(str(directory)) + except OSError as exc: + raise GuardianRequestJournalError("guardian_journal_config_invalid") from exc + if _is_link_or_reparse(entry) or not stat.S_ISDIR(entry.st_mode): + raise GuardianRequestJournalError("guardian_journal_config_invalid") + identities.append((os.path.normcase(str(directory)),) + _file_identity(entry)) + + state_entry = os.lstat(str(absolute)) + if os.name != "nt": + mode = stat.S_IMODE(state_entry.st_mode) + if ( + state_entry.st_uid != os.geteuid() + or mode & 0o077 + or mode & stat.S_IRUSR == 0 + or mode & stat.S_IWUSR == 0 + or mode & stat.S_IXUSR == 0 + ): + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") + else: + self._verify_windows_private_directory(absolute) + return tuple(identities) + + def _prepare_database_file(self) -> tuple[int, int]: + created = False + try: + entry = os.lstat(str(self._path)) + except FileNotFoundError: + flags = os.O_CREAT | os.O_EXCL | os.O_RDWR + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(str(self._path), flags, 0o600) + except FileExistsError: + descriptor = None + if descriptor is not None: + created = True + try: + if hasattr(os, "fchmod"): + os.fchmod(descriptor, 0o600) + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise GuardianRequestJournalError("guardian_journal_path_invalid") + finally: + os.close(descriptor) + try: + entry = os.lstat(str(self._path)) + except OSError as exc: + raise GuardianRequestJournalError("guardian_journal_path_invalid") from exc + except OSError as exc: + raise GuardianRequestJournalError("guardian_journal_path_invalid") from exc + + if _is_link_or_reparse(entry) or not stat.S_ISREG(entry.st_mode): + raise GuardianRequestJournalError("guardian_journal_path_invalid") + if getattr(entry, "st_nlink", 1) != 1: + raise GuardianRequestJournalError("guardian_journal_path_invalid") + if os.name != "nt": + mode = stat.S_IMODE(entry.st_mode) + if ( + entry.st_uid != os.geteuid() + or mode & 0o077 + or mode & stat.S_IRUSR == 0 + or mode & stat.S_IWUSR == 0 + ): + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") + else: + if created: + self._secure_windows_database(entry) + else: + self._verify_windows_private_database(entry) + return _file_identity(entry) + + def _verify_database_identity(self) -> None: + if self._verify_state_directory() != self._directory_identity: + raise GuardianRequestJournalError("guardian_journal_path_changed") + try: + entry = os.lstat(str(self._path)) + except OSError as exc: + raise GuardianRequestJournalError("guardian_journal_path_unavailable") from exc + if ( + _is_link_or_reparse(entry) + or not stat.S_ISREG(entry.st_mode) + or getattr(entry, "st_nlink", 1) != 1 + or _file_identity(entry) != self._database_identity + ): + raise GuardianRequestJournalError("guardian_journal_path_changed") + if os.name != "nt": + mode = stat.S_IMODE(entry.st_mode) + if ( + entry.st_uid != os.geteuid() + or mode & 0o077 + or mode & stat.S_IRUSR == 0 + or mode & stat.S_IWUSR == 0 + ): + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") + else: + self._verify_windows_private_database(entry) + self._verify_sidecars() + + def _secure_windows_database(self, entry: os.stat_result) -> None: + """Harden a new SQLite file and verify its own handle ACL.""" + + try: + from .ctp_simnow_signed_review import _protect_windows_path_acl + + _protect_windows_path_acl(self._path, is_directory=False) + after = os.lstat(str(self._path)) + if _is_link_or_reparse(after) or _file_identity(after) != _file_identity(entry): + raise GuardianRequestJournalError("guardian_journal_path_changed") + self._verify_windows_private_database(after) + except GuardianRequestJournalError: + raise + except Exception as exc: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") from exc + + def _verify_windows_private_database(self, entry: os.stat_result) -> None: + """Verify the database file's own protected owner-only ACL by handle.""" + + try: + from .credential_resolver import _open_windows_metadata_handle, _windows_acl_for_handle + from .credential_resolver import _windows_os_handle + + descriptor = _open_windows_metadata_handle(self._path, is_directory=False) + try: + opened = os.fstat(descriptor) + if _file_identity(opened) != _file_identity(entry): + raise GuardianRequestJournalError("guardian_journal_path_changed") + _windows_acl_for_handle(_windows_os_handle(descriptor)) + after = os.lstat(str(self._path)) + if _is_link_or_reparse(after) or _file_identity(after) != _file_identity(opened): + raise GuardianRequestJournalError("guardian_journal_path_changed") + finally: + os.close(descriptor) + except GuardianRequestJournalError: + raise + except Exception as exc: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") from exc + + def _verify_sidecars(self) -> None: + for suffix in ("-wal", "-shm", "-journal"): + sidecar = Path(str(self._path) + suffix) + try: + entry = os.lstat(str(sidecar)) + except FileNotFoundError: + continue + except OSError as exc: + raise GuardianRequestJournalError("guardian_journal_sidecar_invalid") from exc + if ( + _is_link_or_reparse(entry) + or not stat.S_ISREG(entry.st_mode) + or getattr(entry, "st_nlink", 1) != 1 + ): + raise GuardianRequestJournalError("guardian_journal_sidecar_invalid") + if os.name != "nt" and stat.S_IMODE(entry.st_mode) & 0o077: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") + if os.name == "nt": + self._verify_windows_private_sidecar(sidecar, entry) + if suffix in ("-wal", "-shm"): + raise GuardianRequestJournalError("guardian_journal_wal_unsupported") + + def _verify_windows_private_sidecar(self, path: Path, entry: os.stat_result) -> None: + """Check rollback-journal owner and every ACE on the opened handle. + + A rollback journal inherits the already-verified private directory ACL. + Windows may mark its DACL inherited rather than protected, so this + accepts inherited ACEs only for the current user, SYSTEM, and local + Administrators. Broad trustees fail closed. + """ + + try: + from .credential_resolver import _open_windows_metadata_handle, _windows_os_handle + from .ctp_simnow_signed_review import _windows_acl_details_for_handle + + descriptor = _open_windows_metadata_handle(path, is_directory=False) + try: + opened = os.fstat(descriptor) + if _file_identity(opened) != _file_identity(entry): + raise GuardianRequestJournalError("guardian_journal_path_changed") + owner, current_user, _protected, entries = _windows_acl_details_for_handle( + _windows_os_handle(descriptor) + ) + _validate_windows_sqlite_sidecar_acl(owner, current_user, entries) + after = os.lstat(str(path)) + if _is_link_or_reparse(after) or _file_identity(after) != _file_identity(opened): + raise GuardianRequestJournalError("guardian_journal_path_changed") + finally: + os.close(descriptor) + except GuardianRequestJournalError: + raise + except Exception as exc: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") from exc + + def _verify_windows_private_directory(self, directory: Path) -> None: + try: + from .credential_resolver import _open_windows_metadata_handle, _windows_acl_for_handle + from .credential_resolver import _windows_os_handle + + descriptor = _open_windows_metadata_handle(directory, is_directory=True) + try: + _windows_acl_for_handle(_windows_os_handle(descriptor)) + if _is_link_or_reparse(os.lstat(str(directory))): + raise GuardianRequestJournalError("guardian_journal_config_invalid") + finally: + os.close(descriptor) + except GuardianRequestJournalError: + raise + except Exception as exc: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") from exc + + @staticmethod + def _require_windows_fixed_volume(path: Path) -> None: + drive, _tail = ntpath.splitdrive(os.fspath(path)) + if not re.fullmatch(r"[A-Za-z]:", drive): + raise GuardianRequestJournalError("guardian_journal_local_volume_required") + try: + get_drive_type = ctypes.WinDLL("Kernel32", use_last_error=True).GetDriveTypeW + get_drive_type.argtypes = (ctypes.c_wchar_p,) + get_drive_type.restype = ctypes.c_uint + drive_type = int(get_drive_type(drive + "\\")) + except Exception as exc: + raise GuardianRequestJournalError("guardian_journal_local_volume_required") from exc + if drive_type != _WINDOWS_FIXED_DRIVE: + raise GuardianRequestJournalError("guardian_journal_local_volume_required") + + @staticmethod + def _validate_request_id(request_id: str) -> None: + if type(request_id) is not str or _REQUEST_ID_RE.fullmatch(request_id) is None: + raise GuardianRequestJournalError("guardian_request_id_invalid") + + def _ensure_open(self) -> None: + if self._closed: + raise GuardianRequestJournalError("guardian_journal_closed") + + +def _is_link_or_reparse(result: os.stat_result) -> bool: + attributes = getattr(result, "st_file_attributes", 0) + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return stat.S_ISLNK(result.st_mode) or bool(attributes & reparse_point) + + +def _file_identity(result: os.stat_result) -> tuple[int, int]: + return int(result.st_dev), int(result.st_ino) + + +def _unknown_receipt_digest( + *, + identity: str, + request_id: str, + operation: str, + accepted_generation: str, + terminal_generation: str, + reason: str, + terminal_ns: int, +) -> str: + """Hash a local UNKNOWN transition marker; this is not provider evidence.""" + + payload = json.dumps( + { + "accepted_generation": accepted_generation, + "identity": identity, + "operation": operation, + "reason": reason, + "request_id": request_id, + "terminal_generation": terminal_generation, + "terminal_monotonic_ns": terminal_ns, + "version": 1, + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("ascii") + return hashlib.sha256(b"guardian-request-unknown-v1\0" + payload).hexdigest() + + +def _validate_windows_sqlite_sidecar_acl( + owner_sid: str, + current_user_sid: str, + entries: tuple[tuple[int, int, int, str], ...], +) -> None: + """Require a rollback sidecar ACL inherited only from the private root.""" + + allowed_sids = {current_user_sid, "S-1-5-18", "S-1-5-32-544"} + if owner_sid != current_user_sid or not entries: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") + for ace_type, ace_flags, _mask, trustee_sid in entries: + if ace_type not in (0, 1) or ace_flags & ~0x1F or trustee_sid not in allowed_sids: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") + + +__all__ = [ + "GUARDIAN_READ_ONLY_OPERATIONS", + "GUARDIAN_REQUEST_BUDGET_NS", + "GUARDIAN_REQUEST_BUDGET_SECONDS", + "GUARDIAN_REQUEST_DATABASE_NAME", + "GUARDIAN_REQUEST_SCHEMA_VERSION", + "GUARDIAN_REQUEST_TRADING_CAPABILITIES", + "GUARDIAN_REQUEST_WRITE_AUTHORIZED", + "GuardianRequestJournal", + "GuardianRequestJournalError", + "GuardianRequestRecord", +] diff --git a/backtrader_runtime/ctp_guardian_service.py b/backtrader_runtime/ctp_guardian_service.py new file mode 100644 index 00000000..69ad3a15 --- /dev/null +++ b/backtrader_runtime/ctp_guardian_service.py @@ -0,0 +1,584 @@ +"""Fixed-operation guardian request protocol (unregistered service core). + +This module contains the bounded, pathless request protocol and its service +state machine. It does not create a Windows service or named pipe, authenticate +an operating-system peer, load CTP configuration, or grant provider/write +authority. A deployment must supply a trusted peer verifier, durable acceptance +recorder, fixed operation runner, and transport adapter that verifies both +OS identities over a protected endpoint. The runner must use an OS process +supervisor and return its separately observed Job evidence; this protocol does +not turn caller-constructed evidence into provider or process authority. + +The module is intentionally not wired into the default CLI. On Windows, a +production port still needs a reviewed named-pipe ACL, client-token/SID +verification, service lifetime watchdog, and independent Win32 acceptance. +""" + +from __future__ import annotations + +import json +import math +import re +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Callable, Optional + + +REQUEST_SCHEMA = "backtrader_ctp_readonly_guardian_request.v1" +RESPONSE_SCHEMA = "backtrader_ctp_readonly_guardian_response.v1" +FIXED_OPERATION = "ctp_readonly_preflight" +SERVICE_OPERATION_BUDGET_SECONDS = 300 +MAX_REQUEST_BYTES = 1024 +MAX_RESPONSE_BYTES = 2048 + +_REQUEST_ID = re.compile(r"\A[0-9a-f]{32}\Z") +_NS_PER_SECOND = 1_000_000_000 +_OPERATION_REASON_CODES = frozenset( + { + "observation_complete", + "observation_missing", + "operation_unverified", + "worker_failed", + "sdk_session_failed", + "sdk_session_close_unverified", + "receipt_write_unverified", + "job_cleanup_unverified", + "deadline_expired", + } +) +_RESPONSE_REASON_CODES = _OPERATION_REASON_CODES | frozenset( + { + "accepted_record_unverified", + "client_identity_unverified", + "client_timeout", + "guardian_client_start_failed", + "guardian_response_unavailable", + "guardian_response_unverified", + "guardian_service_unavailable", + "guardian_transport_unverified", + "job_empty_unverified", + "native_close_unverified", + "operation_not_observed", + "operation_result_invalid", + "operation_runner_failed", + "operation_unverified", + "protocol_encoding_failed", + "readonly_observation_complete", + "receipt_durability_unverified", + "request_binding_invalid", + "request_duplicate_key", + "request_fields_invalid", + "request_invalid", + "request_not_canonical", + "request_size_invalid", + "response_binding_invalid", + "response_fields_invalid", + "response_invalid", + "response_not_canonical", + "response_size_invalid", + "service_clock_invalid", + "service_clock_unavailable", + "service_deadline_exceeded", + } +) + + +class GuardianServiceError(ValueError): + """A malformed or untrusted guardian protocol value.""" + + +@dataclass(frozen=True) +class OperationResult: + """Value-free result returned by the service-owned fixed operation. + + The configured runner must derive these facts from its own validated + read-only evidence. Job-empty, native close, and durable receipt evidence + remain separate because none alone proves the requested observation. + """ + + operation_observed: bool + job_empty_observed: Optional[bool] + native_close_observed: Optional[bool] + receipt_durable: Optional[bool] + reason: str + + def __post_init__(self) -> None: + if type(self.operation_observed) is not bool: + raise GuardianServiceError("operation_result_invalid") + for field_value in ( + self.job_empty_observed, + self.native_close_observed, + self.receipt_durable, + ): + if field_value is not None and type(field_value) is not bool: + raise GuardianServiceError("operation_result_invalid") + if type(self.reason) is not str or self.reason not in _OPERATION_REASON_CODES: + raise GuardianServiceError("operation_result_invalid") + + +@dataclass(frozen=True) +class GuardianResult: + """Redacted client-visible result; it never grants execution authority.""" + + state: str + reason: str + request_id: Optional[str] + service_t0_monotonic_ns: Optional[int] + deadline_monotonic_ns: Optional[int] + operation_observed: bool + job_empty_observed: Optional[bool] + native_close_observed: Optional[bool] = None + receipt_durable: Optional[bool] = None + + +@dataclass(frozen=True) +class _Request: + request_id: str + + +def _reject_duplicate_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise GuardianServiceError("request_duplicate_key") + result[key] = value + return result + + +def _canonical_json(value: object) -> bytes: + try: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeError): + raise GuardianServiceError("protocol_encoding_failed") from None + + +def _decode_request(raw: bytes) -> _Request: + if type(raw) is not bytes or not raw or len(raw) > MAX_REQUEST_BYTES: + raise GuardianServiceError("request_size_invalid") + try: + value = json.loads(raw.decode("ascii"), object_pairs_hook=_reject_duplicate_pairs) + except GuardianServiceError: + raise + except (UnicodeError, ValueError, TypeError): + raise GuardianServiceError("request_invalid") from None + if type(value) is not dict or set(value) != { + "schema", + "operation", + "request_id", + }: + raise GuardianServiceError("request_fields_invalid") + request_id = value["request_id"] + if ( + value["schema"] != REQUEST_SCHEMA + or value["operation"] != FIXED_OPERATION + or type(request_id) is not str + or _REQUEST_ID.fullmatch(request_id) is None + ): + raise GuardianServiceError("request_binding_invalid") + if _canonical_json(value) != raw: + raise GuardianServiceError("request_not_canonical") + return _Request(request_id=request_id) + + +def _encode_response(result: GuardianResult) -> bytes: + if result.reason not in _RESPONSE_REASON_CODES: + raise GuardianServiceError("response_reason_invalid") + body = { + "schema": RESPONSE_SCHEMA, + "state": result.state, + "reason": result.reason, + "request_id": result.request_id, + "service_t0_monotonic_ns": result.service_t0_monotonic_ns, + "deadline_monotonic_ns": result.deadline_monotonic_ns, + "operation_observed": result.operation_observed, + "job_empty_observed": result.job_empty_observed, + "native_close_observed": result.native_close_observed, + "receipt_durable": result.receipt_durable, + } + encoded = _canonical_json(body) + if len(encoded) > MAX_RESPONSE_BYTES: + raise GuardianServiceError("response_size_invalid") + return encoded + + +def _decode_response(raw: bytes, *, expected_request_id: str) -> GuardianResult: + if type(raw) is not bytes or not raw or len(raw) > MAX_RESPONSE_BYTES: + raise GuardianServiceError("response_size_invalid") + try: + value = json.loads(raw.decode("ascii"), object_pairs_hook=_reject_duplicate_pairs) + except GuardianServiceError: + raise + except (UnicodeError, ValueError, TypeError): + raise GuardianServiceError("response_invalid") from None + expected = { + "schema", + "state", + "reason", + "request_id", + "service_t0_monotonic_ns", + "deadline_monotonic_ns", + "operation_observed", + "job_empty_observed", + "native_close_observed", + "receipt_durable", + } + if type(value) is not dict or set(value) != expected: + raise GuardianServiceError("response_fields_invalid") + t0_ns = value["service_t0_monotonic_ns"] + deadline_ns = value["deadline_monotonic_ns"] + job_empty = value["job_empty_observed"] + native_close = value["native_close_observed"] + receipt_durable = value["receipt_durable"] + if ( + value["schema"] != RESPONSE_SCHEMA + or value["state"] not in ("observed", "unknown") + or type(value["reason"]) is not str + or value["reason"] not in _RESPONSE_REASON_CODES + or value["request_id"] != expected_request_id + or (t0_ns is not None and (type(t0_ns) is not int or t0_ns < 0)) + or (deadline_ns is not None and (type(deadline_ns) is not int or deadline_ns <= 0)) + or (t0_ns is None) != (deadline_ns is None) + or (t0_ns is not None and deadline_ns <= t0_ns) + or type(value["operation_observed"]) is not bool + or (job_empty is not None and type(job_empty) is not bool) + or (native_close is not None and type(native_close) is not bool) + or (receipt_durable is not None and type(receipt_durable) is not bool) + or ( + value["state"] == "observed" + and ( + value["operation_observed"] is not True + or job_empty is not True + or native_close is not True + or receipt_durable is not True + or t0_ns is None + or value["reason"] != "readonly_observation_complete" + ) + ) + ): + raise GuardianServiceError("response_binding_invalid") + if _canonical_json(value) != raw: + raise GuardianServiceError("response_not_canonical") + return GuardianResult( + state=value["state"], + reason=value["reason"], + request_id=value["request_id"], + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=deadline_ns, + operation_observed=value["operation_observed"], + job_empty_observed=job_empty, + native_close_observed=native_close, + receipt_durable=receipt_durable, + ) + + +class GuardianService: + """Handle one fixed request after a transport authenticates its peer. + + ``verify_peer`` must validate transport-bound operating-system identity, + not a request field. It returns the literal ``True`` only for the pinned + client identity. ``persist_accepted`` must return the literal ``True`` only + after the server-stamped request identity, T0, and fixed deadline are + durably committed. ``run_fixed_operation`` is service-owned and receives + only the fixed operation name and that same absolute deadline. + """ + + def __init__( + self, + verify_peer: Callable[[object], bool], + persist_accepted: Callable[..., bool], + run_fixed_operation: Callable[..., OperationResult], + *, + monotonic_ns: Callable[[], int] = time.monotonic_ns, + ) -> None: + if ( + not callable(verify_peer) + or not callable(persist_accepted) + or not callable(run_fixed_operation) + ): + raise TypeError("guardian_service_bindings_required") + if not callable(monotonic_ns): + raise TypeError("guardian_clock_required") + self._verify_peer = verify_peer + self._persist_accepted = persist_accepted + self._run_fixed_operation = run_fixed_operation + self._monotonic_ns = monotonic_ns + + def handle(self, raw_request: bytes, peer_context: object) -> bytes: + """Validate, authenticate, stamp T0, and run the single readonly route.""" + + try: + request = _decode_request(raw_request) + except GuardianServiceError as error: + return _encode_response( + GuardianResult("unknown", str(error), None, None, None, False, None) + ) + try: + authenticated = self._verify_peer(peer_context) + except BaseException: + authenticated = False + if authenticated is not True: + return _encode_response( + GuardianResult( + "unknown", + "client_identity_unverified", + request.request_id, + None, + None, + False, + None, + ) + ) + + # The service-owned clock starts only after request shape and transport + # identity have both been checked. Clients cannot supply T0 or a path. + try: + t0_ns = self._monotonic_ns() + except BaseException: + return _encode_response( + GuardianResult( + "unknown", + "service_clock_unavailable", + request.request_id, + None, + None, + False, + None, + ) + ) + if type(t0_ns) is not int or t0_ns < 0: + return _encode_response( + GuardianResult( + "unknown", "service_clock_invalid", request.request_id, None, None, False, None + ) + ) + deadline_ns = t0_ns + SERVICE_OPERATION_BUDGET_SECONDS * _NS_PER_SECOND + try: + accepted = self._persist_accepted( + request.request_id, + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=deadline_ns, + ) + except BaseException: + accepted = False + if accepted is not True: + return _encode_response( + GuardianResult( + "unknown", + "accepted_record_unverified", + request.request_id, + t0_ns, + deadline_ns, + False, + None, + ) + ) + try: + accepted_ns = self._monotonic_ns() + except BaseException: + accepted_ns = deadline_ns + if type(accepted_ns) is not int or accepted_ns < t0_ns: + return _encode_response( + GuardianResult( + "unknown", + "service_clock_invalid", + request.request_id, + t0_ns, + deadline_ns, + False, + None, + ) + ) + if accepted_ns >= deadline_ns: + return _encode_response( + GuardianResult( + "unknown", + "service_deadline_exceeded", + request.request_id, + t0_ns, + deadline_ns, + False, + None, + ) + ) + operation_observed = False + job_empty_observed: Optional[bool] = None + native_close_observed: Optional[bool] = None + receipt_durable: Optional[bool] = None + reason = "operation_unverified" + try: + outcome = self._run_fixed_operation(FIXED_OPERATION, deadline_monotonic_ns=deadline_ns) + if type(outcome) is not OperationResult: + raise GuardianServiceError("operation_result_invalid") + if outcome.reason not in _OPERATION_REASON_CODES: + raise GuardianServiceError("operation_result_invalid") + operation_observed = outcome.operation_observed + job_empty_observed = outcome.job_empty_observed + native_close_observed = outcome.native_close_observed + receipt_durable = outcome.receipt_durable + reason = outcome.reason + except BaseException: + reason = "operation_runner_failed" + + try: + completed_ns = self._monotonic_ns() + except BaseException: + completed_ns = deadline_ns + deadline_expired = ( + type(completed_ns) is not int or completed_ns < t0_ns or completed_ns >= deadline_ns + ) + if deadline_expired: + # A late worker result is diagnostic only; it cannot turn the + # final service outcome into observed. + return _encode_response( + GuardianResult( + "unknown", + "service_deadline_exceeded", + request.request_id, + t0_ns, + deadline_ns, + False, + job_empty_observed, + native_close_observed, + receipt_durable, + ) + ) + if job_empty_observed is not True: + reason = "job_empty_unverified" + elif native_close_observed is not True: + reason = "native_close_unverified" + elif receipt_durable is not True: + reason = "receipt_durability_unverified" + elif not operation_observed: + reason = "operation_not_observed" if reason == "operation_unverified" else reason + state = ( + "observed" + if ( + operation_observed is True + and job_empty_observed is True + and native_close_observed is True + and receipt_durable is True + ) + else "unknown" + ) + return _encode_response( + GuardianResult( + state, + "readonly_observation_complete" if state == "observed" else reason, + request.request_id, + t0_ns, + deadline_ns, + operation_observed, + job_empty_observed, + native_close_observed, + receipt_durable, + ) + ) + + +def _make_request() -> tuple[str, bytes]: + request_id = uuid.uuid4().hex + raw = _canonical_json( + { + "schema": REQUEST_SCHEMA, + "operation": FIXED_OPERATION, + "request_id": request_id, + } + ) + if len(raw) > MAX_REQUEST_BYTES: + raise GuardianServiceError("request_size_invalid") + return request_id, raw + + +def request_readonly_preflight( + transport: Optional[object], + *, + client_timeout_seconds: float = 30.0, +) -> GuardianResult: + """Send the fixed request and return UNKNOWN when service evidence is absent. + + ``transport`` is a deployment-owned object with ``exchange(bytes) -> + bytes`` and ``verify_server_identity() -> True``. That verifier must bind + the response channel to the pinned guardian service OS identity over a + protected endpoint. An object that implements only ``exchange`` is + rejected. The client wait timeout is independent of the server operation + deadline. On timeout this function returns an immutable UNKNOWN result; + a late transport response is discarded and cannot upgrade it. No concrete + Windows transport is provided here, so this function is not a production + connection path. + """ + + if ( + type(client_timeout_seconds) not in (int, float) + or not math.isfinite(float(client_timeout_seconds)) + or client_timeout_seconds <= 0 + or client_timeout_seconds > SERVICE_OPERATION_BUDGET_SECONDS + ): + raise ValueError("guardian_client_timeout_out_of_range") + request_id, raw_request = _make_request() + if transport is None: + return GuardianResult( + "unknown", "guardian_service_unavailable", request_id, None, None, False, None + ) + verify_server_identity = getattr(transport, "verify_server_identity", None) + exchange_request = getattr(transport, "exchange", None) + if not callable(verify_server_identity) or not callable(exchange_request): + return GuardianResult( + "unknown", "guardian_transport_unverified", request_id, None, None, False, None + ) + + completed = threading.Event() + response_holder: list[object] = [] + + def exchange() -> None: + try: + if verify_server_identity() is not True: + response_holder.append("transport_unverified") + return + response_holder.append(exchange_request(raw_request)) + except BaseException: + response_holder.append(None) + finally: + completed.set() + + try: + worker = threading.Thread(target=exchange, name="ctp-guardian-request", daemon=True) + worker.start() + except BaseException: + return GuardianResult( + "unknown", "guardian_client_start_failed", request_id, None, None, False, None + ) + if not completed.wait(float(client_timeout_seconds)): + return GuardianResult("unknown", "client_timeout", request_id, None, None, False, None) + if response_holder and response_holder[0] == "transport_unverified": + return GuardianResult( + "unknown", "guardian_transport_unverified", request_id, None, None, False, None + ) + if not response_holder or type(response_holder[0]) is not bytes: + return GuardianResult( + "unknown", "guardian_response_unavailable", request_id, None, None, False, None + ) + try: + return _decode_response(response_holder[0], expected_request_id=request_id) + except GuardianServiceError: + return GuardianResult( + "unknown", "guardian_response_unverified", request_id, None, None, False, None + ) + + +__all__ = [ + "FIXED_OPERATION", + "GuardianResult", + "GuardianService", + "GuardianServiceError", + "OperationResult", + "SERVICE_OPERATION_BUDGET_SECONDS", + "request_readonly_preflight", +] diff --git a/backtrader_runtime/ctp_i10_attempt_latch.py b/backtrader_runtime/ctp_i10_attempt_latch.py new file mode 100644 index 00000000..a77296c7 --- /dev/null +++ b/backtrader_runtime/ctp_i10_attempt_latch.py @@ -0,0 +1,75 @@ +"""Independent persistent latch for the supported I10 MD-only operator entry. + +This module records a one-shot attempt reservation for the code-owned supervisor. +It does not attest against a same-user caller deliberately invoking the SDK or +private Python functions outside that entry. It has no launcher, +provider adapter, runtime registration, credential lookup, or trading route. +Importing it and constructing a latch are inert; filesystem access starts only +when a caller invokes a latch method with an explicitly supplied path. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from .ctp_i8_oneshot_md_diagnostic import _PersistentI8NoRetryLatch +from .inventory import ( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + iteration41_runtime_registry, +) + + +I10_SOURCE_COMMIT = "a6253a58b1ebca11f58c8836fbed757d0daf7582" +I10_LATCH_CONTENT = b"i10-readonly-md-attempted-v1\n" +I10_LATCH_PATH = ( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR + / "state" + / "i10-readonly-md-supervisor-no-retry.latch" +) +I10_READ_ONLY = True +I10_ORDER_SUBMISSION_AUTHORIZED = False +I10_TRADING_CAPABILITIES: tuple[()] = () + + +class PersistentI10OneShotAttemptLatch(_PersistentI8NoRetryLatch): + """I10-only marker for the supported supervisor's first attempt. + + A caller must provide the path explicitly. The I10 marker has its own path + and content and never reads or changes the I8 or I9 markers. + """ + + _latch_content = I10_LATCH_CONTENT + + def __init__(self, path: Path) -> None: + super().__init__(path) + + def _verify_ancestor_chain(self) -> None: + # Windows path spelling is not an identity check. In particular, + # state/../state names the canonical marker but must not skip its + # registered-runtime check. The inherited scan still checks every + # original path component for a reparse point or symlink. + path_key = os.path.normcase(os.path.normpath(os.fspath(self._path))) + canonical_key = os.path.normcase(os.path.normpath(os.fspath(I10_LATCH_PATH))) + if path_key != canonical_key: + super()._verify_ancestor_chain() + return + + registry = iteration41_runtime_registry() + registration = registry.require_runtime_dir(ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR) + if registration.runtime_id != ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID: + raise OSError("latch_runtime_registration_invalid") + with registry.verified_runtime_directory(registration): + super()._verify_ancestor_chain() + + +__all__ = [ + "I10_LATCH_CONTENT", + "I10_LATCH_PATH", + "I10_ORDER_SUBMISSION_AUTHORIZED", + "I10_READ_ONLY", + "I10_SOURCE_COMMIT", + "I10_TRADING_CAPABILITIES", + "PersistentI10OneShotAttemptLatch", +] diff --git a/backtrader_runtime/ctp_i3_oneshot_md_readonly.py b/backtrader_runtime/ctp_i3_oneshot_md_readonly.py new file mode 100644 index 00000000..d527b981 --- /dev/null +++ b/backtrader_runtime/ctp_i3_oneshot_md_readonly.py @@ -0,0 +1,775 @@ +"""Unregistered I3 one-shot, market-data-only diagnostic adapter. + +The I3 SDK's ``OneShotMdDiagnosticClient`` sends one login request with ID 0 +and accepts its single subscription only after a successful login callback. +This adapter preserves that callback-driven order and requires a matching +single tick plus a complete native stop receipt. It has no registry entry, +CLI route, Trader client, or execution capability. Callers must perform the +sealed runtime, artifact, and credential gates before injecting the client +and receipt types. +""" + +from __future__ import annotations + +import hashlib +import hmac +import threading +import time +from dataclasses import dataclass, field +from typing import Any + +from .ctp_sdk_market_readonly import ( + _MAX_NATIVE_JOIN_WAIT_SECONDS, + _AccountLease, + _MD_LOGIN_BROKER_ID_SHAPES, + _MD_LOGIN_NATIVE_FIELD_SHAPES, + _MD_LOGIN_TRADING_DAY_SHAPES, + _MD_LOGIN_USER_ID_SHAPES, + _account_lock_key, + _field_text, + _hold_lease_unknown, + _md_client_snapshot, + _read_credential, + _reject, + _timeout, + _valid_tick, + _validated_admission, + CtpMarketCredentialSource, + CtpSdkMarketReadOnlyError, +) + + +_I3_SDK_MODULE = "bt_api_ctp.ctp.client" +_I3_CLIENT_NAME = "OneShotMdDiagnosticClient" +_I3_STOP_RECEIPT_NAME = "CtpNativeStopReceipt" +_LOGIN_DIAGNOSTIC_ACCEPTED = (1, "accepted", "zero", "zero") +_LOGIN_DIAGNOSTIC_TERMINAL = (1, "terminal", "zero", "zero") +_LOGIN_CALLBACK_DISPOSITIONS = frozenset( + { + "none", + "stale_spi", + "generation_mismatch", + "request_id_type_invalid", + "request_id_mismatch", + "nonterminal", + "accepted", + "provider_rejected", + "identity_rejected", + "terminal", + } +) +_LOGIN_REQUEST_ID_RELATIONS = frozenset( + {"not_observed", "invalid", "zero", "lower", "equal", "higher"} +) +_LOGIN_RESPONSE_ERROR_STATUSES = frozenset( + {"not_observed", "missing", "invalid", "zero", "nonzero"} +) +_LOGIN_FAILURE_CATEGORY_BY_TERMINAL_REASON = { + "login_request_id_type_invalid": "request_id_invalid", + "login_request_id_mismatch": "request_id_mismatch", + "login_response_nonterminal": "response_nonterminal", + "provider_login_rejected": "provider_rejected", + "login_response_invalid": "response_invalid", + "broker_id_mismatch": "broker_id_mismatch", + "user_id_mismatch": "user_id_mismatch", + "trading_day_invalid": "trading_day_invalid", +} + + +@dataclass(frozen=True) +class CtpI3OneShotMdObservation: + """A closed, exact-scope observation from one I3 one-shot MD client.""" + + md_front_sha256: str + account_fingerprint_sha256: str = field(repr=False) + instrument_id: str + exchange_id: str + connection_generation: int + login_request_id: int + market_login_ready: bool + subscription_acknowledged: bool + matching_tick_observed: bool + client_stop_returned: bool + native_join_pending: bool + + @property + def probe_session_closed(self) -> bool: + """Whether the SDK returned a complete stop receipt.""" + + return self.client_stop_returned and not self.native_join_pending + + @property + def order_submission_authorized(self) -> bool: + """This observation never authorizes trading.""" + + return False + + @property + def trading_writes(self) -> int: + """The market-data adapter has no trading request path.""" + + return 0 + + @property + def settlement_writes(self) -> int: + """The market-data adapter has no settlement request path.""" + + return 0 + + def as_public_dict(self) -> dict[str, Any]: + """Return a value-free summary without raw account or front values.""" + + return { + "account_scope_bound": bool(self.account_fingerprint_sha256), + "client_stop_returned": self.client_stop_returned, + "connection_generation": self.connection_generation, + "exchange_id": self.exchange_id, + "instrument_id": self.instrument_id, + "login_request_id": self.login_request_id, + "market_login_ready": self.market_login_ready, + "matching_tick_observed": self.matching_tick_observed, + "md_front_sha256": self.md_front_sha256, + "native_join_pending": self.native_join_pending, + "order_submission_authorized": False, + "probe_session_closed": self.probe_session_closed, + "settlement_writes": 0, + "subscription_acknowledged": self.subscription_acknowledged, + "trading_writes": 0, + } + + +def _require_i3_sdk_types(client_type: Any, stop_receipt_type: Any) -> None: + """Check class surface labels; composition must inject installed SDK classes.""" + + if ( + type(client_type) is not type + or client_type.__name__ != _I3_CLIENT_NAME + or client_type.__module__ != _I3_SDK_MODULE + or type(stop_receipt_type) is not type + or stop_receipt_type.__name__ != _I3_STOP_RECEIPT_NAME + or stop_receipt_type.__module__ != _I3_SDK_MODULE + ): + _reject("market_client_type_required") + + +def _login_diagnostic(client: Any) -> tuple[Any, ...] | None: + """Copy the SDK's fixed login callback classification, without payloads.""" + + try: + diagnostic = client.login_callback_diagnostic + count = diagnostic.callback_count + disposition = diagnostic.disposition.value + request_relation = diagnostic.request_id_relation.value + response_status = diagnostic.response_error_status.value + except Exception: + return None + if ( + type(count) is not int + or not 0 <= count <= 1_000_000 + or type(disposition) is not str + or disposition not in _LOGIN_CALLBACK_DISPOSITIONS + or type(request_relation) is not str + or request_relation not in _LOGIN_REQUEST_ID_RELATIONS + or type(response_status) is not str + or response_status not in _LOGIN_RESPONSE_ERROR_STATUSES + ): + return None + return count, disposition, request_relation, response_status + + +def _login_broker_id_shape(client: Any) -> str | None: + """Copy the I5 SDK's closed broker-ID shape enum, when available. + + This is value-only diagnostic evidence. The raw broker ID and every + unknown SDK value are omitted. Earlier SDKs do not expose this field. + """ + + try: + value = client.login_callback_diagnostic.broker_id_shape.value + except Exception: + return None + if type(value) is str and value in _MD_LOGIN_BROKER_ID_SHAPES: + return value + return None + + +def _login_user_id_shape(client: Any) -> str | None: + """Copy the I6 SDK's closed user-ID shape enum, when available.""" + + try: + value = client.login_callback_diagnostic.user_id_shape.value + except Exception: + return None + if type(value) is str and value in _MD_LOGIN_USER_ID_SHAPES: + return value + return None + + +def _login_trading_day_shape(client: Any) -> str | None: + """Copy the I6 SDK's closed trading-day shape enum, when available.""" + + try: + value = client.login_callback_diagnostic.trading_day_shape.value + except Exception: + return None + if type(value) is str and value in _MD_LOGIN_TRADING_DAY_SHAPES: + return value + return None + + +def _login_native_field_shape(client: Any, field_name: str) -> str | None: + """Copy one of the SDK's bounded native login-field shape enums.""" + + if field_name not in {"native_broker_id_shape", "native_user_id_shape"}: + return None + try: + value = getattr(client.login_callback_diagnostic, field_name).value + except Exception: + return None + if type(value) is str and value in _MD_LOGIN_NATIVE_FIELD_SHAPES: + return value + return None + + +def _failure_diagnostics(client: Any) -> dict[str, Any]: + """Copy only bounded callback enums and a fixed login-failure category.""" + + diagnostic = _login_diagnostic(client) + if diagnostic is None: + callback_count = disposition = request_relation = response_status = None + else: + callback_count, disposition, request_relation, response_status = diagnostic + + category = None + try: + terminal_reason = client.diagnostic_terminal_reason + except Exception: + terminal_reason = None + if type(terminal_reason) is str: + category = _LOGIN_FAILURE_CATEGORY_BY_TERMINAL_REASON.get(terminal_reason) + + return { + "login_callback_count": callback_count, + "login_callback_disposition": disposition, + "login_request_id_relation": request_relation, + "login_response_error_status": response_status, + "login_failure_category": category, + "login_broker_id_shape": _login_broker_id_shape(client), + "login_user_id_shape": _login_user_id_shape(client), + "login_trading_day_shape": _login_trading_day_shape(client), + "native_broker_id_shape": _login_native_field_shape( + client, "native_broker_id_shape" + ), + "native_user_id_shape": _login_native_field_shape(client, "native_user_id_shape"), + } + + +def _binding_error( + snapshot: tuple[Any, ...] | None, + *, + front: str, + broker_id: str, + user_id: str, + expected_generation: int | None, + require_ready: bool, + require_terminal: bool = False, +) -> str | None: + """Check stable client identity while allowing the I3 tick terminal state.""" + + if snapshot is None: + return "market_session_state_unavailable" + current_front, current_broker, current_user, generation, connected, logged_in = snapshot + if current_front != front: + return "market_front_binding_mismatch" + if current_broker != broker_id or current_user != user_id: + return "market_client_identity_mismatch" + if generation < 1: + return "market_session_not_ready" + if expected_generation is not None and generation != expected_generation: + return "market_connection_generation_changed" + if require_ready and (connected is not True or logged_in is not True): + return "market_session_not_ready" + if require_terminal and (connected is not False or logged_in is not False): + return "market_session_state_unavailable" + return None + + +def _diagnostic_flags(client: Any) -> tuple[Any, ...] | None: + """Read I3's fixed one-shot terminal and subscription flags.""" + + try: + return ( + client.diagnostic_terminal, + client.diagnostic_terminal_reason, + client.diagnostic_subscription_acknowledged, + client.diagnostic_first_tick_received, + ) + except Exception: + return None + + +def _accepted_login_callback(client: Any, *, terminal: bool = False) -> bool: + diagnostic = _login_diagnostic(client) + expected = _LOGIN_DIAGNOSTIC_TERMINAL if terminal else _LOGIN_DIAGNOSTIC_ACCEPTED + return diagnostic == expected + + +def _validated_i3_stop_receipt( + receipt: Any, + *, + receipt_type: type[Any], + expected_generation: int | None, +) -> tuple[bool, bool | None, bool | None] | None: + """Validate the exact I3 public receipt and its internally coherent fields.""" + + if type(receipt) is not receipt_type or type(expected_generation) is not int: + return None + try: + generation = receipt.connection_generation + join_required = receipt.join_required + join_completed = receipt.join_completed + native_released = receipt.native_released + thread_alive = receipt.thread_alive + timed_out = receipt.timed_out + client_stop_returned = receipt.client_stop_returned + complete = receipt.complete + except Exception: + return None + if ( + type(generation) is not int + or generation < 0 + or generation != expected_generation + or type(join_required) is not bool + or type(join_completed) is not bool + or type(native_released) is not bool + or (thread_alive is not None and type(thread_alive) is not bool) + or type(timed_out) is not bool + or (client_stop_returned is not None and type(client_stop_returned) is not bool) + or type(complete) is not bool + or (join_completed and thread_alive is not False) + ): + return None + derived_complete = ( + native_released + and (not join_required or join_completed) + and thread_alive is False + and not timed_out + and client_stop_returned is True + ) + if complete is not derived_complete: + return None + return complete, thread_alive, client_stop_returned + + +def _close_client( + client: Any, + *, + lease: _AccountLease, + stop_receipt_type: type[Any], +) -> tuple[str, bool | None, bool]: + """Require a coherent stop receipt; retain the account lease if uncertain.""" + + try: + join_thread = getattr(client, "_thread", None) + except Exception: + join_thread = None + before_stop = _md_client_snapshot(client) + expected_generation = None if before_stop is None else before_stop[3] + try: + stop_and_wait = getattr(client, "stop_and_wait") + except Exception: + stop_and_wait = None + if not callable(stop_and_wait): + stop_returned: bool | None = None + try: + stop = getattr(client, "stop", None) + if callable(stop): + stop() + stop_returned = True + except Exception: + stop_returned = False + _hold_lease_unknown(lease, client, join_thread) + return "native_stop_method_unknown", stop_returned, False + + try: + receipt = stop_and_wait(timeout=_MAX_NATIVE_JOIN_WAIT_SECONDS) + except Exception: + _hold_lease_unknown(lease, client, join_thread) + return "stop_failed", None, False + + stop_state = _validated_i3_stop_receipt( + receipt, + receipt_type=stop_receipt_type, + expected_generation=expected_generation, + ) + if stop_state is None: + _hold_lease_unknown(lease, client, join_thread) + return "native_stop_receipt_unknown", None, False + + # A complete native stop receipt is not sufficient while an SDK SPI + # callback still owns the client's callback context. This exact public + # count is required for acceptance: artifacts that lack it, including the + # earlier I3 wheel, fail closed; I4 exposes the count required here. + try: + active_callbacks = getattr(client, "diagnostic_callbacks_active") + except Exception: + active_callbacks = None + if type(active_callbacks) is not int or active_callbacks < 0 or active_callbacks != 0: + _hold_lease_unknown(lease, client, join_thread) + return "native_stop_receipt_unknown", stop_state[2], False + + complete, receipt_thread_alive, client_stop_returned = stop_state + if complete: + lease.release() + return "stop_returned", True, True + + if receipt_thread_alive is True: + if type(join_thread) is threading.Thread: + try: + thread_alive = join_thread.is_alive() + except Exception: + thread_alive = None + else: + thread_alive = None + if thread_alive is True: + _hold_lease_unknown(lease, client, join_thread) + return "native_join_pending", client_stop_returned, False + if thread_alive is False: + _hold_lease_unknown(lease, client, join_thread) + return "native_stop_receipt_inconsistent", client_stop_returned, False + _hold_lease_unknown(lease, client, join_thread) + return "native_join_state_unknown", client_stop_returned, False + + _hold_lease_unknown(lease, client, join_thread) + return "native_stop_incomplete", client_stop_returned, False + + +def probe_i3_oneshot_md_readonly( + *, + admission: Any, + credential_source: CtpMarketCredentialSource, + client_type: Any, + stop_receipt_type: Any, + timeout_seconds: float = 15.0, +) -> CtpI3OneShotMdObservation: + """Run one login-ID-0, exact-subscription, one-tick I3 MD observation. + + The admission must already be the exact sealed read-only route and the + credential source must revalidate its seal for every value read. This + helper preserves the admission's front and contract strings verbatim. It + calls ``subscribe`` only after the accepted login callback and never + creates a Trader or execution client. The injected types are intended for + the reviewed I3 composition and offline fakes; this module is not + registered or exposed through the default CLI. + """ + + admitted = _validated_admission(admission) + _require_i3_sdk_types(client_type, stop_receipt_type) + timeout = _timeout(timeout_seconds) + front = admitted.md_front + instrument = admitted.instrument_id + exchange = admitted.exchange_id + deadline = time.monotonic() + timeout + lease = _AccountLease(_account_lock_key(admitted.account_fingerprint_sha256)) + lease.acquire() + + client = None + client_stop_returned: bool | None = None + close_state = "not_started" + close_complete = False + primary_error: str | None = None + connection_generation: int | None = None + failure_diagnostics: dict[str, Any] = { + "login_callback_count": None, + "login_callback_disposition": None, + "login_request_id_relation": None, + "login_response_error_status": None, + "login_failure_category": None, + "login_broker_id_shape": None, + "login_user_id_shape": None, + "login_trading_day_shape": None, + } + condition = threading.Condition() + state: dict[str, Any] = { + "acknowledged": False, + "closed": False, + "error": None, + "login": False, + "login_callback_count": 0, + "login_generation": None, + "subscription_submitted": False, + "tick": False, + } + + try: + broker_id = _read_credential(credential_source, "broker_id") + user_id = _read_credential(credential_source, "user_id") + password = _read_credential(credential_source, "password") + account_digest = hashlib.sha256( + "{0}:{1}".format(broker_id, user_id).encode("utf-8") + ).hexdigest() + if not hmac.compare_digest(account_digest, admitted.account_fingerprint_sha256): + _reject("credential_account_mismatch") + if time.monotonic() >= deadline: + _reject("probe_deadline_expired") + + client = client_type(front, broker_id, user_id, password) + if type(client) is not client_type: + _reject("market_client_type_required") + initial_snapshot = _md_client_snapshot(client) + if initial_snapshot is None: + _reject("market_client_state_unavailable") + if initial_snapshot[0] != front: + _reject("market_front_binding_mismatch") + if initial_snapshot[1] != broker_id or initial_snapshot[2] != user_id: + _reject("market_client_identity_mismatch") + initial_generation = initial_snapshot[3] + + def record_error(reason: str) -> None: + with condition: + if not state["closed"] and state["error"] is None: + state["error"] = reason + condition.notify_all() + + def check_binding( + *, expected_generation: int | None, require_ready: bool, require_terminal: bool = False + ) -> tuple[Any, ...] | None: + snapshot = _md_client_snapshot(client) + reason = _binding_error( + snapshot, + front=front, + broker_id=broker_id, + user_id=user_id, + expected_generation=expected_generation, + require_ready=require_ready, + require_terminal=require_terminal, + ) + if reason is not None: + record_error(reason) + return None + return snapshot + + def on_login(login_field: Any) -> None: + with condition: + state["login_callback_count"] += 1 + callback_count = state["login_callback_count"] + if callback_count != 1 or not _accepted_login_callback(client): + record_error("market_login_identity_mismatch") + return + try: + login_broker = getattr(login_field, "BrokerID") + login_user = getattr(login_field, "UserID") + except Exception: + record_error("market_login_identity_unavailable") + return + if ( + type(login_broker) is not str + or type(login_user) is not str + or login_broker != broker_id + or login_user != user_id + ): + record_error("market_login_identity_mismatch") + return + snapshot = check_binding( + expected_generation=None, + require_ready=True, + ) + if snapshot is None or snapshot[3] <= initial_generation: + record_error("market_session_not_ready") + return + with condition: + if not state["closed"] and state["error"] is None: + state["login"] = True + state["login_generation"] = snapshot[3] + condition.notify_all() + + def on_error(_response: Any) -> None: + with condition: + reason = ( + "market_login_identity_mismatch" + if not state["login"] + else "market_subscription_rejected" + if not state["acknowledged"] + else "market_probe_failed" + ) + record_error(reason) + + def on_disconnect(_reason: Any) -> None: + record_error("market_front_disconnected") + + def on_subscribe(specific_instrument: Any, response: Any) -> None: + with condition: + login_generation = state["login_generation"] + was_submitted = state["subscription_submitted"] + already_acknowledged = state["acknowledged"] + if not was_submitted or login_generation is None or already_acknowledged: + record_error("market_subscription_rejected") + return + if _field_text(specific_instrument, "InstrumentID") != instrument: + record_error("market_subscription_rejected") + return + try: + error_id = getattr(response, "ErrorID") + except Exception: + error_id = None + flags = _diagnostic_flags(client) + snapshot = check_binding( + expected_generation=login_generation, + require_ready=True, + ) + if ( + type(error_id) is not int + or error_id != 0 + or flags is None + or flags[2] is not True + or snapshot is None + ): + record_error("market_subscription_rejected") + return + with condition: + if not state["closed"] and state["error"] is None: + state["acknowledged"] = True + condition.notify_all() + + def on_tick(tick: Any) -> None: + with condition: + login_generation = state["login_generation"] + acknowledged = state["acknowledged"] + already_observed = state["tick"] + if not acknowledged or login_generation is None or already_observed: + record_error("market_probe_failed") + return + snapshot = check_binding( + expected_generation=login_generation, + require_ready=False, + require_terminal=True, + ) + flags = _diagnostic_flags(client) + if ( + snapshot is None + or flags is None + or flags[0] is not True + or flags[1] != "diagnostic_complete" + or flags[2] is not True + or flags[3] is not True + or not _accepted_login_callback(client, terminal=True) + or not _valid_tick(tick, instrument, exchange) + ): + record_error("market_probe_failed") + return + with condition: + if not state["closed"] and state["error"] is None: + state["tick"] = True + condition.notify_all() + + client.on_login = on_login + client.on_error = on_error + client.on_disconnect = on_disconnect + client.on_subscribe = on_subscribe + client.on_tick = on_tick + + client.start(block=False) + if time.monotonic() >= deadline: + _reject("probe_deadline_expired") + with condition: + while not state["login"]: + if state["error"] is not None: + _reject(state["error"]) + remaining = deadline - time.monotonic() + if remaining <= 0: + _reject("market_login_timeout") + condition.wait(remaining) + + login_generation = state["login_generation"] + if ( + type(login_generation) is not int + or not _accepted_login_callback(client) + or state["login_callback_count"] != 1 + ): + _reject("market_login_identity_mismatch") + snapshot = _md_client_snapshot(client) + binding_reason = _binding_error( + snapshot, + front=front, + broker_id=broker_id, + user_id=user_id, + expected_generation=login_generation, + require_ready=True, + ) + if binding_reason is not None: + _reject(binding_reason) + + with condition: + if state["error"] is not None: + _reject(state["error"]) + state["subscription_submitted"] = True + request_result = client.subscribe([instrument]) + if type(request_result) is not int or request_result != 0: + _reject("market_subscription_rejected") + + with condition: + while not (state["acknowledged"] and state["tick"]): + if state["error"] is not None: + _reject(state["error"]) + remaining = deadline - time.monotonic() + if remaining <= 0: + _reject( + "subscription_ack_timeout" + if not state["acknowledged"] + else "matching_tick_not_observed" + ) + condition.wait(remaining) + if state["error"] is not None: + _reject(state["error"]) + state["closed"] = True + connection_generation = login_generation + except CtpSdkMarketReadOnlyError as error: + primary_error = error.reason + except Exception: + # Native SDK exceptions can contain provider values; expose no text. + primary_error = "market_probe_failed" + finally: + if client is None: + lease.release() + else: + with condition: + state["closed"] = True + close_state, client_stop_returned, close_complete = _close_client( + client, + lease=lease, + stop_receipt_type=stop_receipt_type, + ) + failure_diagnostics = _failure_diagnostics(client) + + if not close_complete: + _reject( + "market_client_stop_failed", + close_state=close_state, + primary_reason=primary_error, + client_stop_returned=client_stop_returned, + **failure_diagnostics, + ) + if primary_error is not None: + _reject( + primary_error, + close_state=close_state, + client_stop_returned=client_stop_returned, + **failure_diagnostics, + ) + assert connection_generation is not None + return CtpI3OneShotMdObservation( + md_front_sha256=hashlib.sha256(front.encode("utf-8")).hexdigest(), + account_fingerprint_sha256=admitted.account_fingerprint_sha256, + instrument_id=instrument, + exchange_id=exchange, + connection_generation=connection_generation, + login_request_id=0, + market_login_ready=True, + subscription_acknowledged=True, + matching_tick_observed=True, + client_stop_returned=client_stop_returned, + native_join_pending=False, + ) + + +__all__ = ["CtpI3OneShotMdObservation", "probe_i3_oneshot_md_readonly"] diff --git a/backtrader_runtime/ctp_i8_oneshot_md_diagnostic.py b/backtrader_runtime/ctp_i8_oneshot_md_diagnostic.py new file mode 100644 index 00000000..91a845b6 --- /dev/null +++ b/backtrader_runtime/ctp_i8_oneshot_md_diagnostic.py @@ -0,0 +1,1275 @@ +"""Unregistered I8 identity-unverified, one-shot MD diagnostic candidate. + +The retained I8 wheel and isolated interpreter have code-owned pins and a +verified installed RECORD. Independent reproducibility review remains pending. +The public module entry is a supervised, one-shot launcher. Its permanent +owner-only latch blocks every retry, including after a clean exit. Its +diagnostic receipt is not preflight acceptance, and this module is not +registered or exposed by the CLI. +""" + +from __future__ import annotations + +import hashlib +import importlib +import json +import logging +import os +import stat +import sys +import ctypes +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Callable, Mapping, Optional, Sequence + +from .capability_imports import trusted_installed_capability_import_context +from .credential_resolver import ( + CredentialResolutionError, + require_resolved_runtime_credentials_seal, + resolve_runtime_credentials, +) +from .ctp_artifact_provenance import CtpArtifactProvenanceError +from .ctp_i8_oneshot_md_readonly import ( + CtpI8OneShotMdDiagnosticEvidence, + CtpI8OneShotMdObservation, + probe_i8_oneshot_md_readonly, +) +from .ctp_readonly_job_supervisor import ( + FailClosedLatch, + FixedChildCommand, + ProcessEvidence, + SupervisedResult, + ValueFreeReceiptSchema, + parse_single_json_receipt, + run_readonly_child, +) +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from .inventory import ( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + iteration41_runtime_registry, +) +from .registry import require_effective_runtime_config_seal, validate_runtime_config + + +_STATUS_VALUES = ("diagnostic_complete", "incomplete", "rejected") +_REASON_VALUES = ( + "arguments_not_allowed", + "configuration_rejected", + "credential_rejected", + "front_selection_rejected", + "identity_unverified_tick_observed", + "i8_adapter_unavailable", + "market_observation_incomplete", + "market_probe_rejected", + "native_join_pending", + "native_shutdown_uncertain", + "runtime_policy_rejected", + "sdk_artifact_rejected", + "sdk_artifact_unavailable", + "supervisor_context_required", +) +_STAGE_VALUES = ( + "arguments", + "configuration", + "credentials", + "front_selection", + "market_data", + "sdk_artifact", +) +_EVIDENCE_LEVEL_VALUES = ("unavailable", "partial", "complete") +_CLOSE_STATE_VALUES = ( + "not_started", + "native_stop_method_unknown", + "stop_failed", + "native_stop_receipt_unknown", + "native_stop_receipt_inconsistent", + "native_join_state_unknown", + "native_join_pending", + "native_stop_incomplete", + "stop_returned", + "unavailable", +) +_PRIMARY_ERROR_VALUES = ( + "admission_invalid", + "credential_account_mismatch", + "probe_deadline_expired", + "market_client_type_required", + "market_client_state_unavailable", + "market_front_binding_mismatch", + "market_client_identity_mismatch", + "market_login_identity_mismatch", + "market_subscription_rejected", + "market_front_disconnected", + "market_probe_failed", + "market_login_timeout", + "subscription_ack_timeout", + "matching_tick_not_observed", + "market_observation_incomplete", + "unavailable", +) +_LOGIN_DISPOSITION_VALUES = ( + "none", + "stale_spi", + "generation_mismatch", + "request_id_type_invalid", + "request_id_mismatch", + "nonterminal", + "accepted", + "identity_unverified", + "provider_rejected", + "identity_rejected", + "terminal", + "unavailable", +) +_LOGIN_REQUEST_RELATION_VALUES = ( + "not_observed", + "invalid", + "zero", + "lower", + "equal", + "higher", + "unavailable", +) +_LOGIN_RESPONSE_ERROR_VALUES = ( + "not_observed", + "missing", + "invalid", + "zero", + "nonzero", + "unavailable", +) +_LOGIN_ID_SHAPE_VALUES = ( + "not_observed", + "unreadable", + "empty", + "ascii_mismatch", + "nonascii_or_replacement", + "whitespace_or_control", + "exact_match", + "unavailable", +) +_LOGIN_TRADING_DAY_SHAPE_VALUES = ( + "not_observed", + "unreadable", + "empty", + "invalid_format", + "invalid_calendar", + "valid", + "unavailable", +) +_NATIVE_FIELD_SHAPE_VALUES = ( + "not_observed", + "unreadable", + "empty", + "nonempty_terminated", + "unterminated", + "unavailable", +) +_LOGIN_CALLBACK_COUNT_VALUES = ("zero", "one", "multiple", "unavailable") +_I8_EVIDENCE_BOOL_FIELDS = ( + "client_stop_returned", + "identity_unverified", + "matching_tick_observed", + "market_login_ready", + "native_join_pending", + "native_shutdown_uncertain", + "probe_session_closed", + "same_trading_day_observed", + "subscription_acknowledged", +) +_I8_EVIDENCE_ENUM_FIELDS = { + "close_state": _CLOSE_STATE_VALUES, + "evidence_level": _EVIDENCE_LEVEL_VALUES, + "login_broker_id_shape": _LOGIN_ID_SHAPE_VALUES, + "login_callback_count": _LOGIN_CALLBACK_COUNT_VALUES, + "login_callback_disposition": _LOGIN_DISPOSITION_VALUES, + "login_request_id_relation": _LOGIN_REQUEST_RELATION_VALUES, + "login_response_error_status": _LOGIN_RESPONSE_ERROR_VALUES, + "login_trading_day_shape": _LOGIN_TRADING_DAY_SHAPE_VALUES, + "login_user_id_shape": _LOGIN_ID_SHAPE_VALUES, + "native_broker_id_shape": _NATIVE_FIELD_SHAPE_VALUES, + "native_user_id_shape": _NATIVE_FIELD_SHAPE_VALUES, +} +_I8_COMPLETE_EVIDENCE_VALUES = { + "close_state": "stop_returned", + "evidence_level": "complete", + "identity_unverified": True, + "login_broker_id_shape": "empty", + "login_callback_count": "one", + "login_callback_disposition": "identity_unverified", + "login_request_id_relation": "zero", + "login_response_error_status": "zero", + "login_trading_day_shape": "valid", + "login_user_id_shape": "empty", + "matching_tick_observed": True, + "market_login_ready": False, + "native_broker_id_shape": "empty", + "native_join_pending": False, + "native_shutdown_uncertain": False, + "native_user_id_shape": "empty", + "primary_error": None, + "probe_session_closed": True, + "same_trading_day_observed": True, + "subscription_acknowledged": True, + "client_stop_returned": True, +} + +I8_CHILD_RECEIPT_SCHEMA = ValueFreeReceiptSchema( + enum_fields={ + "close_state": _CLOSE_STATE_VALUES, + "evidence_level": _EVIDENCE_LEVEL_VALUES, + "login_broker_id_shape": _LOGIN_ID_SHAPE_VALUES, + "login_callback_count": _LOGIN_CALLBACK_COUNT_VALUES, + "login_callback_disposition": _LOGIN_DISPOSITION_VALUES, + "login_request_id_relation": _LOGIN_REQUEST_RELATION_VALUES, + "login_response_error_status": _LOGIN_RESPONSE_ERROR_VALUES, + "login_trading_day_shape": _LOGIN_TRADING_DAY_SHAPE_VALUES, + "login_user_id_shape": _LOGIN_ID_SHAPE_VALUES, + "native_broker_id_shape": _NATIVE_FIELD_SHAPE_VALUES, + "native_user_id_shape": _NATIVE_FIELD_SHAPE_VALUES, + "reason": _REASON_VALUES, + "stage": _STAGE_VALUES, + "status": _STATUS_VALUES, + }, + bool_fields=("order_submission_authorized",), + nullable_enum_fields={"primary_error": _PRIMARY_ERROR_VALUES}, + nullable_bool_fields=_I8_EVIDENCE_BOOL_FIELDS, +) + +# Reviewed no-system-site-packages I8 installation. This path is code-owned; +# neither config, CLI arguments, nor environment may replace it. +_I8_RUNTIME_ROOT = Path(r"D:\temp\i8-readonly-installed-wheel-venv-a7d9b04") +_I8_INTERPRETER = _I8_RUNTIME_ROOT / "Scripts" / "python.exe" +_I8_BASE_PYTHON_ROOT = Path(r"C:\anaconda3") +_I8_LATCH_PATH = ( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR / "state" / "i8-readonly-md-supervisor-no-retry.latch" +) +_I8_LATCH_CONTENT = b"i8-readonly-md-attempted-v1\n" +_I8_JOB_HANDLE_ENV = "bt_i8_parent_job_handle" +_I8_RETAINED_JOB_CONTROL: Optional[object] = None +_I8_CHILD_ENTRY_SOURCE = ( + "from backtrader_runtime.ctp_i8_oneshot_md_diagnostic import " + "_run_child_entry; raise SystemExit(_run_child_entry())" +) + + +class _PersistentI8NoRetryLatch: + """Owner-only permanent reservation; this diagnostic cannot be rerun.""" + + _latch_content = _I8_LATCH_CONTENT + + def __init__(self, path: Path = _I8_LATCH_PATH) -> None: + self._path = Path(path) + self._attempt_reserved = False + + def _verify_ancestor_chain(self) -> None: + def _scan() -> None: + current = Path(self._path.anchor) + for component in self._path.parts[1:-1]: + current = current / component + result = os.lstat(current) + if ( + stat.S_ISLNK(result.st_mode) + or bool(getattr(result, "st_file_attributes", 0) & 0x400) + or not stat.S_ISDIR(result.st_mode) + ): + raise OSError("latch_ancestor_invalid") + + if self._path == _I8_LATCH_PATH: + registry = iteration41_runtime_registry() + registration = registry.require_runtime_dir(ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR) + if registration.runtime_id != ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID: + raise OSError("latch_runtime_registration_invalid") + with registry.verified_runtime_directory(registration): + _scan() + return + _scan() + + @contextmanager + def _verified_directory(self): + directory = self._path.parent + self._verify_ancestor_chain() + before = os.lstat(directory) + if ( + stat.S_ISLNK(before.st_mode) + or bool(getattr(before, "st_file_attributes", 0) & 0x400) + or not stat.S_ISDIR(before.st_mode) + ): + raise OSError("latch_directory_invalid") + descriptor: Optional[int] = None + if os.name == "nt": + from .credential_resolver import ( + _open_windows_metadata_handle, + _windows_acl_for_handle, + _windows_os_handle, + ) + + descriptor = _open_windows_metadata_handle(directory, is_directory=True) + opened = os.fstat(descriptor) + _windows_acl_for_handle(_windows_os_handle(descriptor)) + elif os.name == "posix": + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(directory, flags) + opened = os.fstat(descriptor) + if ( + opened.st_uid != os.geteuid() + or stat.S_IMODE(opened.st_mode) & ~0o700 + or stat.S_IMODE(opened.st_mode) & 0o700 != 0o700 + ): + os.close(descriptor) + raise OSError("latch_directory_not_private") + else: + raise OSError("latch_platform_unsupported") + if (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino): + os.close(descriptor) + raise OSError("latch_directory_identity_changed") + try: + after = os.lstat(directory) + if ( + stat.S_ISLNK(after.st_mode) + or bool(getattr(after, "st_file_attributes", 0) & 0x400) + or (after.st_dev, after.st_ino) != (opened.st_dev, opened.st_ino) + ): + raise OSError("latch_directory_identity_changed") + yield descriptor if os.name == "posix" else None + self._verify_ancestor_chain() + after = os.lstat(directory) + final_opened = os.fstat(descriptor) + if ( + stat.S_ISLNK(after.st_mode) + or bool(getattr(after, "st_file_attributes", 0) & 0x400) + or (after.st_dev, after.st_ino) != (opened.st_dev, opened.st_ino) + or (final_opened.st_dev, final_opened.st_ino) != (opened.st_dev, opened.st_ino) + ): + raise OSError("latch_directory_identity_changed") + finally: + os.close(descriptor) + + def _read_file(self, directory_fd: Optional[int]) -> Optional[bool]: + try: + before = ( + os.stat(self._path.name, dir_fd=directory_fd, follow_symlinks=False) + if directory_fd is not None + else os.lstat(self._path) + ) + except FileNotFoundError: + return False + if ( + stat.S_ISLNK(before.st_mode) + or bool(getattr(before, "st_file_attributes", 0) & 0x400) + or not stat.S_ISREG(before.st_mode) + or before.st_nlink != 1 + ): + raise OSError("latch_file_invalid") + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) + if directory_fd is not None: + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(self._path.name, flags, dir_fd=directory_fd) + else: + descriptor = os.open(self._path, flags) + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or opened.st_nlink != 1 + or (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino) + ): + raise OSError("latch_file_identity_changed") + if os.name == "nt": + from .credential_resolver import _windows_acl_for_handle, _windows_os_handle + + _windows_acl_for_handle(_windows_os_handle(descriptor)) + elif opened.st_uid != os.geteuid() or stat.S_IMODE(opened.st_mode) & ~0o600: + raise OSError("latch_file_not_private") + content = bytearray() + while len(content) <= len(self._latch_content): + chunk = os.read(descriptor, len(self._latch_content) + 1 - len(content)) + if not chunk: + break + content.extend(chunk) + final = ( + os.stat(self._path.name, dir_fd=directory_fd, follow_symlinks=False) + if directory_fd is not None + else os.lstat(self._path) + ) + final_opened = os.fstat(descriptor) + if (final.st_dev, final.st_ino) != (opened.st_dev, opened.st_ino) or ( + final_opened.st_dev, + final_opened.st_ino, + ) != (opened.st_dev, opened.st_ino): + raise OSError("latch_file_identity_changed") + if bytes(content) != self._latch_content: + raise OSError("latch_file_poisoned") + return True + finally: + os.close(descriptor) + + def _read_marker(self) -> Optional[bool]: + with self._verified_directory() as directory_fd: + return self._read_file(directory_fd) + + def _write_marker(self) -> bool: + with self._verified_directory() as directory_fd: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) + try: + descriptor = ( + os.open(self._path.name, flags, 0o600, dir_fd=directory_fd) + if directory_fd is not None + else os.open(self._path, flags, 0o600) + ) + except FileExistsError: + return False + try: + if os.name == "nt": + self._set_windows_file_acl() + else: + os.fchmod(descriptor, 0o600) + written = os.write(descriptor, self._latch_content) + if written != len(self._latch_content): + raise OSError("latch_write_incomplete") + os.fsync(descriptor) + finally: + os.close(descriptor) + return self._read_marker() is True + + def _set_windows_file_acl(self) -> None: + import msvcrt + from ctypes import wintypes + + from .ctp_private_config_setup import _set_windows_owner_acl + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + create_file.restype = wintypes.HANDLE + handle = create_file( + str(self._path), + 0x00020000 | 0x00040000 | 0x80000000 | 0x40000000, + 0x00000001 | 0x00000002, + None, + 3, + 0x00200000, + None, + ) + invalid_handle = ctypes.c_void_p(-1).value + if handle == invalid_handle or handle == -1: + raise OSError(ctypes.get_last_error(), "latch_acl_handle_unavailable") + descriptor = msvcrt.open_osfhandle(int(handle), os.O_RDWR | os.O_BINARY) + try: + _set_windows_owner_acl(descriptor, directory=False) + finally: + os.close(descriptor) + + def begin_attempt(self) -> bool: + """Atomically commit a permanent no-retry marker before child creation.""" + + if self._attempt_reserved: + return False + if self._read_marker() is True: + return False + if not self._write_marker(): + return False + self._attempt_reserved = True + return True + + def is_tripped(self) -> bool: + present = self._read_marker() + return False if self._attempt_reserved and present is True else present is True + + def trip(self, _reason: str) -> bool: + """The durable reservation itself is the no-retry trip state.""" + + return self._read_marker() is True or self._write_marker() + + +def _emit_i8( + *, + status: str, + reason: str, + stage: str, + evidence_level: str = "unavailable", + close_state: str = "unavailable", + primary_error: Optional[str] = "unavailable", + login_callback_count: str = "unavailable", + login_callback_disposition: str = "unavailable", + login_request_id_relation: str = "unavailable", + login_response_error_status: str = "unavailable", + login_broker_id_shape: str = "unavailable", + login_user_id_shape: str = "unavailable", + login_trading_day_shape: str = "unavailable", + native_broker_id_shape: str = "unavailable", + native_user_id_shape: str = "unavailable", + identity_unverified: Optional[bool] = None, + market_login_ready: Optional[bool] = None, + subscription_acknowledged: Optional[bool] = None, + matching_tick_observed: Optional[bool] = None, + same_trading_day_observed: Optional[bool] = None, + client_stop_returned: Optional[bool] = None, + native_join_pending: Optional[bool] = None, + native_shutdown_uncertain: Optional[bool] = None, + probe_session_closed: Optional[bool] = None, +) -> None: + """Write only the child's strict value-free supervisor receipt.""" + + payload = { + "client_stop_returned": _nullable_bool(client_stop_returned), + "close_state": close_state if close_state in _CLOSE_STATE_VALUES else "unavailable", + "evidence_level": ( + evidence_level if evidence_level in _EVIDENCE_LEVEL_VALUES else "unavailable" + ), + "identity_unverified": _nullable_bool(identity_unverified), + "login_broker_id_shape": ( + login_broker_id_shape + if login_broker_id_shape in _LOGIN_ID_SHAPE_VALUES + else "unavailable" + ), + "login_callback_count": ( + login_callback_count + if login_callback_count in _LOGIN_CALLBACK_COUNT_VALUES + else "unavailable" + ), + "login_callback_disposition": ( + login_callback_disposition + if login_callback_disposition in _LOGIN_DISPOSITION_VALUES + else "unavailable" + ), + "login_request_id_relation": ( + login_request_id_relation + if login_request_id_relation in _LOGIN_REQUEST_RELATION_VALUES + else "unavailable" + ), + "login_response_error_status": ( + login_response_error_status + if login_response_error_status in _LOGIN_RESPONSE_ERROR_VALUES + else "unavailable" + ), + "login_trading_day_shape": ( + login_trading_day_shape + if login_trading_day_shape in _LOGIN_TRADING_DAY_SHAPE_VALUES + else "unavailable" + ), + "login_user_id_shape": ( + login_user_id_shape if login_user_id_shape in _LOGIN_ID_SHAPE_VALUES else "unavailable" + ), + "matching_tick_observed": _nullable_bool(matching_tick_observed), + "market_login_ready": _nullable_bool(market_login_ready), + "native_broker_id_shape": ( + native_broker_id_shape + if native_broker_id_shape in _NATIVE_FIELD_SHAPE_VALUES + else "unavailable" + ), + "native_join_pending": _nullable_bool(native_join_pending), + "native_shutdown_uncertain": _nullable_bool(native_shutdown_uncertain), + "native_user_id_shape": ( + native_user_id_shape + if native_user_id_shape in _NATIVE_FIELD_SHAPE_VALUES + else "unavailable" + ), + "order_submission_authorized": False, + "primary_error": ( + primary_error + if primary_error is None or primary_error in _PRIMARY_ERROR_VALUES + else "unavailable" + ), + "probe_session_closed": _nullable_bool(probe_session_closed), + "reason": reason if reason in _REASON_VALUES else "runtime_policy_rejected", + "same_trading_day_observed": _nullable_bool(same_trading_day_observed), + "stage": stage if stage in _STAGE_VALUES else "configuration", + "status": status if status in _STATUS_VALUES else "rejected", + "subscription_acknowledged": _nullable_bool(subscription_acknowledged), + } + sys.stdout.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def _nullable_bool(value: object) -> Optional[bool]: + return value if type(value) is bool else None + + +def _i8_evidence_receipt_fields(value: object) -> dict[str, object]: + """Project only fields from the exact frozen adapter evidence type.""" + + unavailable: dict[str, object] = dict.fromkeys(_I8_EVIDENCE_ENUM_FIELDS, "unavailable") + unavailable["primary_error"] = "unavailable" + unavailable.update(dict.fromkeys(_I8_EVIDENCE_BOOL_FIELDS)) + unavailable.update( + market_login_ready=None, + native_shutdown_uncertain=None, + probe_session_closed=None, + ) + if type(value) is not CtpI8OneShotMdDiagnosticEvidence: + return unavailable + + projected = dict(unavailable) + for name, allowed in _I8_EVIDENCE_ENUM_FIELDS.items(): + try: + item = getattr(value, name) + except Exception: + continue + if type(item) is str and item in allowed: + projected[name] = item + try: + primary_error = value.primary_error + except Exception: + primary_error = "unavailable" + if primary_error is None or ( + type(primary_error) is str and primary_error in _PRIMARY_ERROR_VALUES + ): + projected["primary_error"] = primary_error + for name in _I8_EVIDENCE_BOOL_FIELDS: + try: + item = getattr(value, name) + except Exception: + continue + if item is None or type(item) is bool: + projected[name] = item + + close_state = projected["close_state"] + evidence_level = projected["evidence_level"] + if evidence_level == "complete": + projected.update( + market_login_ready=False, + native_shutdown_uncertain=False, + probe_session_closed=True, + ) + elif close_state == "stop_returned": + projected.update(native_shutdown_uncertain=False, probe_session_closed=True) + elif close_state not in {"unavailable", "not_started"}: + projected.update(native_shutdown_uncertain=True, probe_session_closed=False) + return projected + + +def parse_i8_child_receipt(raw: bytes) -> Optional[Mapping[str, object]]: + """Parse one duplicate-free I8 receipt against its exact enum schema.""" + + return parse_single_json_receipt(raw, I8_CHILD_RECEIPT_SCHEMA) + + +def _candidate_front_pair(front_pair: object) -> tuple[str, str]: + try: + td_front = front_pair.get("td_front") + md_front = front_pair.get("md_front") + except Exception: + td_front = md_front = None + if type(td_front) is not str or type(md_front) is not str: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the sealed CTP front pair is invalid", + field_path="ctp.front_pairs", + reason="ctp_simnow_preflight_front_selection_required", + ) + return td_front, md_front + + +def _selected_front_index(selection: object, front_pairs: tuple[Any, ...]) -> int: + try: + index = selection.config_index + pair = selection.pair + selected_pair = (pair.td_front, pair.md_front) + except Exception: + index = None + selected_pair = None + if type(index) is not int or not 0 <= index < len(front_pairs): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the selected CTP front pair index is invalid", + field_path="ctp.front_pairs", + reason="ctp_simnow_preflight_front_selection_required", + ) + if selected_pair != _candidate_front_pair(front_pairs[index]): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the selected CTP front pair does not match sealed config", + field_path="ctp.front_pairs", + reason="ctp_simnow_preflight_front_selection_mismatch", + ) + return index + + +def _validate_bound_scope( + private: object, admission: object, index: int, front_pairs: tuple[Any, ...] +) -> None: + try: + expected = ( + private.instrument_id, + private.exchange_id, + private.hedge_flag, + ) + admitted = ( + admission.instrument_id, + admission.exchange_id, + admission.hedge_flag, + ) + fronts = (admission.td_front, admission.md_front) + except Exception: + expected = admitted = fronts = None + if ( + expected is None + or admitted != expected + or fronts != _candidate_front_pair(front_pairs[index]) + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the admitted CTP scope does not match the sealed configuration", + field_path="ctp.instrument_id", + reason="ctp_simnow_preflight_front_selection_mismatch", + ) + + +def _validate_observation(observation: object, admission: object) -> bool: + return ( + type(observation) is CtpI8OneShotMdObservation + and _i8_evidence_receipt_fields(getattr(observation, "diagnostic_evidence", None)) + == _I8_COMPLETE_EVIDENCE_VALUES + and type(getattr(admission, "md_front", None)) is str + and type(getattr(admission, "instrument_id", None)) is str + and type(getattr(admission, "exchange_id", None)) is str + and type(getattr(admission, "account_fingerprint_sha256", None)) is str + and observation.md_front_sha256 + == hashlib.sha256(admission.md_front.encode("utf-8")).hexdigest() + and observation.account_fingerprint_sha256 == admission.account_fingerprint_sha256 + and observation.instrument_id == admission.instrument_id + and observation.exchange_id == admission.exchange_id + and observation.identity_unverified is True + and observation.market_login_ready is False + and observation.subscription_acknowledged is True + and observation.matching_tick_observed is True + and observation.same_trading_day_observed is True + and observation.client_stop_returned is True + and observation.native_join_pending is False + and observation.probe_session_closed is True + and observation.order_submission_authorized is False + and type(observation.trading_writes) is int + and observation.trading_writes == 0 + and type(observation.settlement_writes) is int + and observation.settlement_writes == 0 + ) + + +def _reason_for_exception(error: BaseException, stage: str) -> str: + if isinstance(error, CtpArtifactProvenanceError): + return ( + "sdk_artifact_unavailable" + if error.reason == "artifact_pin_unavailable" + else "sdk_artifact_rejected" + ) + if isinstance(error, CredentialResolutionError): + return "credential_rejected" + if isinstance(error, RuntimeConfigError): + return { + "configuration": "configuration_rejected", + "front_selection": "front_selection_rejected", + }.get(stage, "runtime_policy_rejected") + return "market_probe_rejected" if stage == "market_data" else "runtime_policy_rejected" + + +def _fixed_i8_child_command() -> Optional[FixedChildCommand]: + """Build the only approved child command, failing closed if its venv moved.""" + + if os.name != "nt": + return None + try: + interpreter_info = os.lstat(_I8_INTERPRETER) + venv_info = os.lstat(_I8_RUNTIME_ROOT / "pyvenv.cfg") + if ( + not Path(_I8_INTERPRETER).is_file() + or getattr(interpreter_info, "st_file_attributes", 0) & 0x400 + or getattr(venv_info, "st_file_attributes", 0) & 0x400 + ): + return None + venv_config = (_I8_RUNTIME_ROOT / "pyvenv.cfg").read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + + normalized = {line.strip().casefold() for line in venv_config.splitlines()} + if ( + "include-system-site-packages = false" not in normalized + or "version = 3.11.5" not in normalized + or "home = c:\\anaconda3" not in normalized + ): + return None + + system_root = os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") + if type(system_root) is not str or not Path(system_root).is_absolute(): + return None + temp_dir = os.environ.get("TEMP") or os.environ.get("TMP") + if type(temp_dir) is not str or not Path(temp_dir).is_absolute(): + return None + + env = { + "SYSTEMROOT": system_root, + "WINDIR": system_root, + "PATH": ";".join( + ( + str(_I8_INTERPRETER.parent), + str(_I8_BASE_PYTHON_ROOT), + str(Path(system_root) / "System32"), + ) + ), + "TEMP": temp_dir, + "TMP": temp_dir, + "PYTHONNOUSERSITE": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUNBUFFERED": "1", + "PYTHONUTF8": "1", + } + try: + return FixedChildCommand( + ( + str(_I8_INTERPRETER), + "-c", + _I8_CHILD_ENTRY_SOURCE, + ), + Path(__file__).resolve().parents[1], + env, + job_handle_env_name=_I8_JOB_HANDLE_ENV, + ) + except (OSError, TypeError, ValueError): + return None + + +def _run_diagnostic_impl(argv: Optional[Sequence[str]] = None) -> int: + """Private fixed-scope child body; the public function rejects direct use.""" + + if tuple(sys.argv[1:] if argv is None else argv): + _emit_i8(status="rejected", reason="arguments_not_allowed", stage="arguments") + return 2 + logging.disable(logging.CRITICAL) + stage = "configuration" + try: + registry = iteration41_runtime_registry() + effective = validate_runtime_config( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + registry, + ) + require_effective_runtime_config_seal(effective, registry) + + from .ctp_simnow_operator import ( + CtpSimNowConfigReadOnlyBinding, + _select_configured_front_pair, + ) + + registration = registry.require_runtime_dir(effective.config.strategy_dir) + if ( + registration is not effective.registration + or registration.runtime_id != ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the configured runtime registration changed", + field_path="runtime.preset", + reason="runtime_registration_mismatch", + ) + binding = registry.require_ctp_simnow_readonly_binding(registration.runtime_id) + if type(binding) is not CtpSimNowConfigReadOnlyBinding: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime does not have the config-driven CTP binding", + field_path="runtime.preset", + reason="ctp_simnow_preflight_front_policy_required", + ) + private, _, unvalidated_pairs = binding._sealed_private_config(effective, registry) + if type(unvalidated_pairs) is not tuple or not 1 <= len(unvalidated_pairs) <= 8: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the sealed CTP front pair set is invalid", + field_path="ctp.front_pairs", + reason="ctp_simnow_preflight_front_selection_required", + ) + + stage = "sdk_artifact" + with trusted_installed_capability_import_context(("bt_api_base", "bt_api_ctp")): + from .ctp_artifact_provenance import ( + verify_ctp_i8_oneshot_md_diagnostic_artifact_provenance_for_fronts, + ) + + candidate_td, candidate_md = _candidate_front_pair(unvalidated_pairs[0]) + # No TCP selection, credentials, or SDK import precedes this gate. + verify_ctp_i8_oneshot_md_diagnostic_artifact_provenance_for_fronts( + td_front=candidate_td, + md_front=candidate_md, + ) + + stage = "front_selection" + selection = _select_configured_front_pair(unvalidated_pairs) + selected_index = _selected_front_index(selection, unvalidated_pairs) + admission, scope = binding._route( + effective, + registry, + selected_front_pair=selection.pair, + ) + _validate_bound_scope(private, admission, selected_index, unvalidated_pairs) + verify_ctp_i8_oneshot_md_diagnostic_artifact_provenance_for_fronts( + td_front=admission.td_front, + md_front=admission.md_front, + ) + + stage = "credentials" + credentials = resolve_runtime_credentials(effective, registry, scope) + require_resolved_runtime_credentials_seal(credentials, effective, registry, scope) + from .ctp_simnow_readonly_runtime import _SealedCtpCredentialSource + + credential_source = _SealedCtpCredentialSource( + credentials, + effective, + registry, + scope, + ) + + stage = "market_data" + with trusted_installed_capability_import_context(("bt_api_base", "bt_api_ctp")): + sdk_module = importlib.import_module("bt_api_ctp.ctp.client") + client_type = getattr(sdk_module, "OneShotMdDiagnosticClient") + stop_receipt_type = getattr(sdk_module, "CtpNativeStopReceipt") + observation = probe_i8_oneshot_md_readonly( + admission=admission, + credential_source=credential_source, + client_type=client_type, + stop_receipt_type=stop_receipt_type, + expected_diagnostic_instrument=admission.instrument_id, + timeout_seconds=15.0, + ) + + evidence_fields = _i8_evidence_receipt_fields( + getattr(observation, "diagnostic_evidence", None) + ) + if not _validate_observation(observation, admission): + join_pending = evidence_fields["native_join_pending"] is True + _emit_i8( + status="incomplete" if join_pending else "rejected", + reason="native_join_pending" if join_pending else "market_observation_incomplete", + stage="market_data", + **evidence_fields, + ) + return 3 if join_pending else 2 + + _emit_i8( + status="diagnostic_complete", + reason="identity_unverified_tick_observed", + stage="market_data", + **evidence_fields, + ) + return 0 + except Exception as error: + evidence_fields = _i8_evidence_receipt_fields( + getattr(error, "i8_diagnostic_evidence", None) + ) + close_state = evidence_fields["close_state"] + join_pending = close_state == "native_join_pending" + uncertain_close = evidence_fields["native_shutdown_uncertain"] is True or ( + close_state in _CLOSE_STATE_VALUES + and close_state not in {"unavailable", "not_started", "stop_returned"} + ) + _emit_i8( + status="incomplete" if uncertain_close else "rejected", + reason=( + "native_join_pending" + if join_pending + else "native_shutdown_uncertain" + if uncertain_close + else _reason_for_exception(error, stage) + ), + stage=stage, + **evidence_fields, + ) + return 3 if uncertain_close else 2 + + +def run_diagnostic(argv: Optional[Sequence[str]] = None) -> int: + """Reject direct calls; provider work is available only through the Job child.""" + + arguments = tuple(sys.argv[1:] if argv is None else argv) + _emit_i8( + status="rejected", + reason="arguments_not_allowed" if arguments else "supervisor_context_required", + stage="arguments", + ) + return 2 + + +def _supervise_i8_child( + fail_closed_latch: Optional[FailClosedLatch], + *, + runner: Callable[..., SupervisedResult] = run_readonly_child, +) -> SupervisedResult: + """Run the fixed I8 child under the strict Job supervisor. + + The caller supplies only the durable no-retry latch. The command, venv, + repository root, and environment are code-owned. The latch must persist + uncertainty across process restarts. + """ + + global _I8_RETAINED_JOB_CONTROL + command = _fixed_i8_child_command() + begin_attempt = getattr(fail_closed_latch, "begin_attempt", None) + if command is None or not callable(begin_attempt): + return SupervisedResult( + "supervisor_error", + "i8_runtime_or_latch_unavailable", + None, + ProcessEvidence(False, False, False, None, None, False, None, None, "unavailable"), + ) + try: + if begin_attempt() is not True: + return SupervisedResult( + "latched", + "prior_attempt_or_poisoned_latch", + None, + ProcessEvidence(False, False, False, None, None, False, None, None, "latched"), + ) + result = runner( + command, + parse_i8_child_receipt, + receipt_schema=I8_CHILD_RECEIPT_SCHEMA, + fail_closed_latch=fail_closed_latch, + deadline_seconds=45.0, + max_stdout_bytes=16 * 1024, + termination_grace_seconds=5.0, + ) + except Exception: + return SupervisedResult( + "supervisor_error", + "i8_supervisor_failed", + None, + ProcessEvidence(False, False, False, None, None, False, None, None, "unavailable"), + ) + if type(result) is not SupervisedResult: + return SupervisedResult( + "supervisor_error", + "i8_supervisor_result_invalid", + None, + ProcessEvidence(False, False, False, None, None, False, None, None, "unavailable"), + ) + if result.retained_control is not None: + _I8_RETAINED_JOB_CONTROL = result.retained_control + if _parent_confirms_i8_diagnostic(result): + return SupervisedResult( + "diagnostic_complete", + "identity_unverified_tick_observed", + result.sdk_receipt, + result.process_evidence, + result.retained_control, + ) + exact_receipt = _exact_i8_child_receipt(result.sdk_receipt) + if exact_receipt is not None: + if exact_receipt["status"] == "rejected": + status, reason = "rejected", "child_diagnostic_rejected" + elif exact_receipt["status"] == "diagnostic_complete": + status, reason = "incomplete", "parent_completion_evidence_missing" + else: + status, reason = "incomplete", "child_diagnostic_incomplete" + return SupervisedResult( + status, + reason, + exact_receipt, + result.process_evidence, + result.retained_control, + ) + if result.status == "diagnostic_complete": + return SupervisedResult( + "incomplete", + "parent_completion_evidence_missing", + result.sdk_receipt, + result.process_evidence, + result.retained_control, + ) + return result + + +def supervise_i8_child() -> SupervisedResult: + """Launch only the code-owned one-shot child with the durable latch.""" + + return _supervise_i8_child(_PersistentI8NoRetryLatch()) + + +def _parent_confirms_i8_diagnostic(result: object) -> bool: + """Accept only the exact successful child receipt plus parent OS evidence.""" + + if type(result) is not SupervisedResult: + return False + receipt = result.sdk_receipt + evidence = result.process_evidence + exact_receipt = _exact_i8_child_receipt(receipt) + if exact_receipt is None: + return False + expected_fields = ( + set(I8_CHILD_RECEIPT_SCHEMA.enum_fields) + | set(I8_CHILD_RECEIPT_SCHEMA.bool_fields) + | set(I8_CHILD_RECEIPT_SCHEMA.nullable_enum_fields) + | set(I8_CHILD_RECEIPT_SCHEMA.nullable_bool_fields) + ) + required_true = { + "client_stop_returned", + "identity_unverified", + "matching_tick_observed", + "probe_session_closed", + "same_trading_day_observed", + "subscription_acknowledged", + } + if ( + set(exact_receipt) != expected_fields + or any( + exact_receipt.get(name) != value for name, value in _I8_COMPLETE_EVIDENCE_VALUES.items() + ) + or exact_receipt.get("status") != "diagnostic_complete" + or exact_receipt.get("reason") != "identity_unverified_tick_observed" + or exact_receipt.get("stage") != "market_data" + or any(exact_receipt.get(name) is not True for name in required_true) + or any( + exact_receipt.get(name) is not False + for name in ( + "market_login_ready", + "native_join_pending", + "native_shutdown_uncertain", + "order_submission_authorized", + ) + ) + or type(evidence) is not ProcessEvidence + ): + return False + return ( + result.status == "child_exited" + and result.reason == "child_process_exited" + and type(evidence.process_created) is bool + and evidence.process_created is True + and type(evidence.job_assignment_observed) is bool + and evidence.job_assignment_observed is True + and type(evidence.process_resumed) is bool + and evidence.process_resumed is True + and type(evidence.process_exit_observed) is bool + and evidence.process_exit_observed is True + and type(evidence.process_exit_code) is int + and evidence.process_exit_code == 0 + and type(evidence.job_termination_requested) is bool + and evidence.job_termination_requested is False + and evidence.job_termination_call_succeeded is None + and type(evidence.job_empty_observed) is bool + and evidence.job_empty_observed is True + and evidence.containment == "verified" + and result.retained_control is None + ) + + +def _exact_i8_child_receipt(value: object) -> Optional[dict[str, object]]: + """Revalidate a parent-held receipt against the exact child schema.""" + + if type(value) is not dict: + return None + try: + encoded = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + except (TypeError, ValueError, UnicodeError): + return None + parsed = parse_i8_child_receipt(encoded) + if type(parsed) is not dict or parsed != value: + return None + return parsed + + +def _is_current_process_in_job() -> bool: + """Require membership in the exact inherited supervisor Job Object.""" + + if os.name != "nt": + return False + raw_handle = os.environ.pop(_I8_JOB_HANDLE_ENV, None) + if ( + type(raw_handle) is not str + or not raw_handle.isdecimal() + or len(raw_handle) > 20 + or int(raw_handle) <= 0 + ): + return False + job_handle = ctypes.c_void_p(int(raw_handle)) + try: + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetCurrentProcess.argtypes = () + kernel32.GetCurrentProcess.restype = ctypes.c_void_p + is_process_in_job = kernel32.IsProcessInJob + is_process_in_job.argtypes = ( + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_int), + ) + is_process_in_job.restype = ctypes.c_int + in_job = ctypes.c_int() + result = ( + bool(is_process_in_job(kernel32.GetCurrentProcess(), job_handle, ctypes.byref(in_job))) + and in_job.value != 0 + ) + return result + except Exception: + return False + finally: + try: + kernel32.CloseHandle.argtypes = (ctypes.c_void_p,) + kernel32.CloseHandle.restype = ctypes.c_int + kernel32.CloseHandle(job_handle) + except Exception: + pass + + +def _run_child_entry() -> int: + """Private child entry; direct module execution goes through ``main``.""" + + if not _is_current_process_in_job(): + _emit_i8( + status="rejected", + reason="supervisor_context_required", + stage="arguments", + ) + return 2 + return _run_diagnostic_impl(()) + + +def _result_to_parent_payload(result: SupervisedResult) -> dict[str, object]: + evidence = result.process_evidence + return { + "process_evidence": { + "containment": evidence.containment, + "job_assignment_observed": evidence.job_assignment_observed, + "job_empty_observed": evidence.job_empty_observed, + "job_termination_call_succeeded": evidence.job_termination_call_succeeded, + "job_termination_requested": evidence.job_termination_requested, + "process_created": evidence.process_created, + "process_exit_code": evidence.process_exit_code, + "process_exit_observed": evidence.process_exit_observed, + "process_resumed": evidence.process_resumed, + }, + "reason": result.reason, + "sdk_receipt": result.sdk_receipt, + "status": result.status, + } + + +def main() -> int: + """Public module entry: reserve once, supervise, and report both evidence channels.""" + + if tuple(sys.argv[1:]): + _emit_i8(status="rejected", reason="arguments_not_allowed", stage="arguments") + return 2 + result = supervise_i8_child() + sys.stdout.write(json.dumps(_result_to_parent_payload(result), sort_keys=True) + "\n") + sys.stdout.flush() + if result.status == "diagnostic_complete": + return 0 + return 3 if result.status in {"pending_native_join", "timed_out"} else 2 + + +if __name__ == "__main__": # pragma: no cover - one-shot child only + raise SystemExit(main()) + + +__all__ = [ + "CtpI8OneShotMdObservation", + "I8_CHILD_RECEIPT_SCHEMA", + "main", + "parse_i8_child_receipt", +] diff --git a/backtrader_runtime/ctp_i8_oneshot_md_readonly.py b/backtrader_runtime/ctp_i8_oneshot_md_readonly.py new file mode 100644 index 00000000..3b057fb7 --- /dev/null +++ b/backtrader_runtime/ctp_i8_oneshot_md_readonly.py @@ -0,0 +1,744 @@ +"""Offline-adaptable I8 MD-only identity-unverified one-shot protocol. + +This adapter accepts only the I8 SDK client's fixed login-ID-zero callback, +submits the single sealed-config instrument after the explicit +``on_identity_unverified`` callback, and requires its matching final +subscription acknowledgement, same-trading-day first tick, and complete +native stop receipt. It creates no Trader or execution client. +""" + +from __future__ import annotations + +import hashlib +import hmac +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from .ctp_i3_oneshot_md_readonly import ( + _close_client, + _require_i3_sdk_types, +) +from .ctp_sdk_market_readonly import ( + _AccountLease, + _account_lock_key, + _field_text, + _md_client_snapshot, + _read_credential, + _reject, + _timeout, + _valid_tick, + _validated_admission, + CtpMarketCredentialSource, + CtpSdkMarketReadOnlyError, +) + + +_I8_CALLBACK_DISPOSITION = "identity_unverified" +_I8_CALLBACK_NATIVE_SHAPE = "empty" +_I8_CLOSE_STATES = frozenset( + { + "not_started", + "native_stop_method_unknown", + "stop_failed", + "native_stop_receipt_unknown", + "native_stop_receipt_inconsistent", + "native_join_state_unknown", + "native_join_pending", + "native_stop_incomplete", + "stop_returned", + } +) +_I8_PRIMARY_ERRORS = frozenset( + { + "admission_invalid", + "credential_account_mismatch", + "probe_deadline_expired", + "market_client_type_required", + "market_client_state_unavailable", + "market_front_binding_mismatch", + "market_client_identity_mismatch", + "market_login_identity_mismatch", + "market_subscription_rejected", + "market_front_disconnected", + "market_probe_failed", + "market_login_timeout", + "subscription_ack_timeout", + "matching_tick_not_observed", + "market_observation_incomplete", + } +) +_I8_ERROR_REASONS = _I8_PRIMARY_ERRORS | {"market_client_stop_failed"} +_I8_LOGIN_DISPOSITIONS = frozenset( + { + "none", + "stale_spi", + "generation_mismatch", + "request_id_type_invalid", + "request_id_mismatch", + "nonterminal", + "accepted", + "identity_unverified", + "provider_rejected", + "identity_rejected", + "terminal", + } +) +_I8_REQUEST_RELATIONS = frozenset({"not_observed", "invalid", "zero", "lower", "equal", "higher"}) +_I8_RESPONSE_STATUSES = frozenset({"not_observed", "missing", "invalid", "zero", "nonzero"}) +_I8_LOGIN_ID_SHAPES = frozenset( + { + "not_observed", + "unreadable", + "empty", + "ascii_mismatch", + "nonascii_or_replacement", + "whitespace_or_control", + "exact_match", + } +) +_I8_TRADING_DAY_SHAPES = frozenset( + {"not_observed", "unreadable", "empty", "invalid_format", "invalid_calendar", "valid"} +) +_I8_NATIVE_SHAPES = frozenset( + {"not_observed", "unreadable", "empty", "nonempty_terminated", "unterminated"} +) + + +@dataclass(frozen=True) +class CtpI8OneShotMdDiagnosticEvidence: + """Value-free, bounded evidence retained when an I8 probe raises. + + Boolean ``True`` values report only positively captured progress. ``None`` + means the event was unavailable or not safely established; partial + evidence never treats a missing observation as proof of absence. + """ + + evidence_level: str + close_state: str + primary_error: str | None + login_callback_count: str + login_callback_disposition: str + login_request_id_relation: str + login_response_error_status: str + login_broker_id_shape: str + login_user_id_shape: str + login_trading_day_shape: str + native_broker_id_shape: str + native_user_id_shape: str + identity_unverified: bool | None + subscription_acknowledged: bool | None + matching_tick_observed: bool | None + same_trading_day_observed: bool | None + client_stop_returned: bool | None + native_join_pending: bool | None + + +@dataclass(frozen=True) +class CtpI8OneShotMdObservation: + """Exact, value-free observation from one I8 MD client.""" + + md_front_sha256: str + account_fingerprint_sha256: str = field(repr=False) + instrument_id: str + exchange_id: str + connection_generation: int + identity_unverified: bool + market_login_ready: bool + subscription_acknowledged: bool + matching_tick_observed: bool + same_trading_day_observed: bool + client_stop_returned: bool + native_join_pending: bool + diagnostic_evidence: CtpI8OneShotMdDiagnosticEvidence | None = None + + @property + def probe_session_closed(self) -> bool: + return self.client_stop_returned and not self.native_join_pending + + @property + def order_submission_authorized(self) -> bool: + return False + + @property + def trading_writes(self) -> int: + return 0 + + @property + def settlement_writes(self) -> int: + return 0 + + +def _i8_login_diagnostic(client: Any) -> tuple[int, str, str, str, str, str, str] | None: + """Read the exact I8 bounded login identity shape without copying IDs.""" + + try: + diagnostic = client.login_callback_diagnostic + count = diagnostic.callback_count + disposition = diagnostic.disposition.value + request_relation = diagnostic.request_id_relation.value + response_status = diagnostic.response_error_status.value + broker_shape = diagnostic.native_broker_id_shape.value + user_shape = diagnostic.native_user_id_shape.value + trading_day_shape = diagnostic.trading_day_shape.value + except Exception: + return None + values = ( + disposition, + request_relation, + response_status, + broker_shape, + user_shape, + trading_day_shape, + ) + if type(count) is not int or count != 1 or any(type(value) is not str for value in values): + return None + return (count, *values) + + +def _i8_enum_value(diagnostic: Any, field_name: str, allowed: frozenset[str]) -> str: + try: + value = getattr(getattr(diagnostic, field_name), "value") + except Exception: + return "unavailable" + if type(value) is str and value in allowed: + return value + return "unavailable" + + +def _i8_callback_evidence(client: Any) -> tuple[str, ...]: + """Copy only bounded SDK enum/count classifications; never retain payloads.""" + + try: + diagnostic = client.login_callback_diagnostic + except Exception: + diagnostic = None + count_value = None + if diagnostic is not None: + try: + candidate = diagnostic.callback_count + except Exception: + candidate = None + if type(candidate) is int and 0 <= candidate <= 1_000_000: + count_value = "zero" if candidate == 0 else "one" if candidate == 1 else "multiple" + if diagnostic is None: + return ( + "unavailable", + "unavailable", + "unavailable", + "unavailable", + "unavailable", + "unavailable", + "unavailable", + "unavailable", + "unavailable", + ) + return ( + count_value or "unavailable", + _i8_enum_value(diagnostic, "disposition", _I8_LOGIN_DISPOSITIONS), + _i8_enum_value(diagnostic, "request_id_relation", _I8_REQUEST_RELATIONS), + _i8_enum_value(diagnostic, "response_error_status", _I8_RESPONSE_STATUSES), + _i8_enum_value(diagnostic, "broker_id_shape", _I8_LOGIN_ID_SHAPES), + _i8_enum_value(diagnostic, "user_id_shape", _I8_LOGIN_ID_SHAPES), + _i8_enum_value(diagnostic, "trading_day_shape", _I8_TRADING_DAY_SHAPES), + _i8_enum_value(diagnostic, "native_broker_id_shape", _I8_NATIVE_SHAPES), + _i8_enum_value(diagnostic, "native_user_id_shape", _I8_NATIVE_SHAPES), + ) + + +def _i8_progress_evidence(condition: threading.Condition, state: dict[str, Any]) -> dict[str, bool]: + """Snapshot only adapter-confirmed positive progress, never inferred negatives.""" + + with condition: + return { + "identity_unverified": state.get("identity") is True, + "subscription_acknowledged": state.get("acknowledged") is True, + "matching_tick_observed": state.get("tick") is True, + "same_trading_day_observed": state.get("same_trading_day") is True, + } + + +def _i8_make_evidence( + *, + evidence_level: str, + close_state: Any, + primary_error: Any, + callback: tuple[str, ...], + progress: dict[str, bool], + client_stop_returned: Any, +) -> CtpI8OneShotMdDiagnosticEvidence: + safe_close_state = ( + close_state + if type(close_state) is str and close_state in _I8_CLOSE_STATES + else "unavailable" + ) + safe_primary_error = ( + None + if primary_error is None + else primary_error + if type(primary_error) is str and primary_error in _I8_PRIMARY_ERRORS + else "unavailable" + ) + safe_stop_returned = client_stop_returned if type(client_stop_returned) is bool else None + if safe_close_state == "native_join_pending": + join_pending: bool | None = True + elif safe_close_state == "stop_returned": + join_pending = False + else: + join_pending = None + safe_progress = { + name: True if progress.get(name) is True else None + for name in ( + "identity_unverified", + "subscription_acknowledged", + "matching_tick_observed", + "same_trading_day_observed", + ) + } + safe_callback = callback if len(callback) == 9 else ("unavailable",) * 9 + return CtpI8OneShotMdDiagnosticEvidence( + evidence_level=evidence_level, + close_state=safe_close_state, + primary_error=safe_primary_error, + login_callback_count=safe_callback[0], + login_callback_disposition=safe_callback[1], + login_request_id_relation=safe_callback[2], + login_response_error_status=safe_callback[3], + login_broker_id_shape=safe_callback[4], + login_user_id_shape=safe_callback[5], + login_trading_day_shape=safe_callback[6], + native_broker_id_shape=safe_callback[7], + native_user_id_shape=safe_callback[8], + identity_unverified=safe_progress["identity_unverified"], + subscription_acknowledged=safe_progress["subscription_acknowledged"], + matching_tick_observed=safe_progress["matching_tick_observed"], + same_trading_day_observed=safe_progress["same_trading_day_observed"], + client_stop_returned=safe_stop_returned, + native_join_pending=join_pending, + ) + + +def _safe_i8_primary_reason(value: Any) -> str | None: + if value is None: + return None + if type(value) is str and value in _I8_PRIMARY_ERRORS: + return value + return "unavailable" + + +def _reject_with_i8_evidence( + reason: str, + *, + evidence: CtpI8OneShotMdDiagnosticEvidence, + close_state: str, + primary_reason: str | None, + client_stop_returned: bool | None, +) -> None: + safe_reason = ( + reason if type(reason) is str and reason in _I8_ERROR_REASONS else "market_probe_failed" + ) + safe_stop_returned = client_stop_returned if type(client_stop_returned) is bool else None + try: + _reject( + safe_reason, + close_state=evidence.close_state, + primary_reason=_safe_i8_primary_reason(primary_reason), + client_stop_returned=safe_stop_returned, + ) + except CtpSdkMarketReadOnlyError as error: + error.i8_diagnostic_evidence = evidence + raise + + +def _identity_unverified_day(client: Any) -> str | None: + try: + state_lock = client._state_lock + with state_lock: + trading_day = client._diagnostic_identity_unverified_trading_day + except Exception: + return None + if type(trading_day) is not str or len(trading_day) != 8 or not trading_day.isascii(): + return None + try: + datetime.strptime(trading_day, "%Y%m%d") + except ValueError: + return None + return trading_day + + +def _valid_identity_unverified_callback( + client: Any, + *, + front: str, + broker_id: str, + user_id: str, + initial_generation: int, +) -> tuple[int, str] | None: + snapshot = _md_client_snapshot(client) + diagnostic = _i8_login_diagnostic(client) + day = _identity_unverified_day(client) + if ( + snapshot is None + or diagnostic is None + or day is None + or snapshot[0] != front + or snapshot[1] != broker_id + or snapshot[2] != user_id + or snapshot[3] <= initial_generation + or snapshot[4] is not True + or snapshot[5] is not False + or client.diagnostic_identity_unverified is not True + or client.active_md_identity is not None + or client.is_ready is not False + or diagnostic[1] != _I8_CALLBACK_DISPOSITION + or diagnostic[2] != "zero" + or diagnostic[3] != "zero" + or diagnostic[4] != _I8_CALLBACK_NATIVE_SHAPE + or diagnostic[5] != _I8_CALLBACK_NATIVE_SHAPE + or diagnostic[6] != "valid" + ): + return None + return snapshot[3], day + + +def probe_i8_oneshot_md_readonly( + *, + admission: Any, + credential_source: CtpMarketCredentialSource, + client_type: Any, + stop_receipt_type: Any, + expected_diagnostic_instrument: str, + timeout_seconds: float = 15.0, +) -> CtpI8OneShotMdObservation: + """Run one strict identity-unverified MD observation on injected SDK types.""" + + admitted = _validated_admission(admission) + _require_i3_sdk_types(client_type, stop_receipt_type) + timeout = _timeout(timeout_seconds) + if ( + type(expected_diagnostic_instrument) is not str + or expected_diagnostic_instrument != admitted.instrument_id + ): + _reject("admission_invalid") + + front = admitted.md_front + instrument = admitted.instrument_id + exchange = admitted.exchange_id + deadline = time.monotonic() + timeout + lease = _AccountLease(_account_lock_key(admitted.account_fingerprint_sha256)) + lease.acquire() + + client = None + close_state = "not_started" + client_stop_returned: bool | None = None + close_complete = False + callback_evidence = ("unavailable",) * 9 + progress_evidence = { + "identity_unverified": False, + "subscription_acknowledged": False, + "matching_tick_observed": False, + "same_trading_day_observed": False, + } + evidence_level = "unavailable" + primary_error: str | None = None + connection_generation: int | None = None + same_trading_day_observed = False + condition = threading.Condition() + state: dict[str, Any] = { + "acknowledged": False, + "closed": False, + "error": None, + "identity": False, + "login_callback_count": 0, + "login_generation": None, + "subscription_submitted": False, + "tick": False, + "trading_day": None, + } + + try: + broker_id = _read_credential(credential_source, "broker_id") + user_id = _read_credential(credential_source, "user_id") + password = _read_credential(credential_source, "password") + account_digest = hashlib.sha256( + "{0}:{1}".format(broker_id, user_id).encode("utf-8") + ).hexdigest() + if not hmac.compare_digest(account_digest, admitted.account_fingerprint_sha256): + _reject("credential_account_mismatch") + if time.monotonic() >= deadline: + _reject("probe_deadline_expired") + + client = client_type( + front, + broker_id, + user_id, + password, + expected_diagnostic_instrument=instrument, + ) + if type(client) is not client_type: + _reject("market_client_type_required") + initial_snapshot = _md_client_snapshot(client) + if initial_snapshot is None: + _reject("market_client_state_unavailable") + if initial_snapshot[0] != front: + _reject("market_front_binding_mismatch") + if initial_snapshot[1] != broker_id or initial_snapshot[2] != user_id: + _reject("market_client_identity_mismatch") + initial_generation = initial_snapshot[3] + + def record_error(reason: str) -> None: + with condition: + if not state["closed"] and state["error"] is None: + state["error"] = reason + condition.notify_all() + + def on_identity_unverified() -> None: + with condition: + state["login_callback_count"] += 1 + callback_count = state["login_callback_count"] + identity_state = _valid_identity_unverified_callback( + client, + front=front, + broker_id=broker_id, + user_id=user_id, + initial_generation=initial_generation, + ) + if callback_count != 1 or identity_state is None: + record_error("market_login_identity_mismatch") + return + generation, trading_day = identity_state + with condition: + if state["closed"] or state["error"] is not None: + return + state["identity"] = True + state["login_generation"] = generation + state["trading_day"] = trading_day + state["subscription_submitted"] = True + condition.notify_all() + result = client.subscribe(instrument) + if type(result) is not int or result != 0: + record_error("market_subscription_rejected") + + def on_login(_login_field: Any) -> None: + # I8 must use the explicit unverified callback and stay logged out. + record_error("market_login_identity_mismatch") + + def on_error(_response: Any) -> None: + with condition: + reason = ( + "market_login_identity_mismatch" + if not state["identity"] + else "market_subscription_rejected" + if not state["acknowledged"] + else "market_probe_failed" + ) + record_error(reason) + + def on_disconnect(_reason: Any) -> None: + record_error("market_front_disconnected") + + def on_subscribe(specific_instrument: Any, response: Any) -> None: + with condition: + generation = state["login_generation"] + was_submitted = state["subscription_submitted"] + already_acknowledged = state["acknowledged"] + snapshot = _md_client_snapshot(client) + try: + error_id = response.ErrorID + except Exception: + error_id = None + if ( + not was_submitted + or generation is None + or already_acknowledged + or _field_text(specific_instrument, "InstrumentID") != instrument + or type(error_id) is not int + or error_id != 0 + or snapshot is None + or snapshot[3] != generation + or snapshot[0] != front + or snapshot[4] is not True + or snapshot[5] is not False + or client.diagnostic_subscription_acknowledged is not True + ): + record_error("market_subscription_rejected") + return + with condition: + if not state["closed"] and state["error"] is None: + state["acknowledged"] = True + condition.notify_all() + + def on_tick(tick: Any) -> None: + with condition: + generation = state["login_generation"] + expected_day = state["trading_day"] + acknowledged = state["acknowledged"] + already_observed = state["tick"] + snapshot = _md_client_snapshot(client) + tick_day = _field_text(tick, "TradingDay") + flags_match = False + try: + with client._state_lock: + flags_match = ( + client.diagnostic_terminal is True + and client.diagnostic_terminal_reason == "diagnostic_complete" + and client.diagnostic_subscription_acknowledged is True + and client.diagnostic_first_tick_received is True + and client._diagnostic_identity_unverified is True + and client._diagnostic_identity_unverified_active is False + and client._loggedin is False + and client._active_md_identity is None + ) + except Exception: + flags_match = False + if ( + not acknowledged + or generation is None + or already_observed + or snapshot is None + or snapshot[0] != front + or snapshot[1] != broker_id + or snapshot[2] != user_id + or snapshot[3] != generation + or snapshot[4] is not False + or snapshot[5] is not False + or expected_day is None + or tick_day != expected_day + or not flags_match + or not _valid_tick(tick, instrument, exchange) + ): + record_error("market_probe_failed") + return + with condition: + if not state["closed"] and state["error"] is None: + state["tick"] = True + state["same_trading_day"] = True + condition.notify_all() + + client.on_identity_unverified = on_identity_unverified + client.on_login = on_login + client.on_error = on_error + client.on_disconnect = on_disconnect + client.on_subscribe = on_subscribe + client.on_tick = on_tick + client.start(block=False) + + with condition: + while not (state["identity"] and state["acknowledged"] and state["tick"]): + if state["error"] is not None: + _reject(state["error"]) + remaining = deadline - time.monotonic() + if remaining <= 0: + if not state["identity"]: + _reject("market_login_timeout") + _reject( + "subscription_ack_timeout" + if not state["acknowledged"] + else "matching_tick_not_observed" + ) + condition.wait(remaining) + if state["error"] is not None: + _reject(state["error"]) + state["closed"] = True + connection_generation = state["login_generation"] + same_trading_day_observed = state.get("same_trading_day") is True + except CtpSdkMarketReadOnlyError as error: + primary_error = error.reason + except Exception: + # SDK exceptions can include raw provider values; preserve no text. + primary_error = "market_probe_failed" + finally: + if client is None: + lease.release() + else: + with condition: + state["closed"] = True + callback_evidence = _i8_callback_evidence(client) + progress_evidence = _i8_progress_evidence(condition, state) + evidence_level = "partial" + close_state, client_stop_returned, close_complete = _close_client( + client, + lease=lease, + stop_receipt_type=stop_receipt_type, + ) + + if not close_complete: + evidence = _i8_make_evidence( + evidence_level=evidence_level, + close_state=close_state, + primary_error=primary_error, + callback=callback_evidence, + progress=progress_evidence, + client_stop_returned=client_stop_returned, + ) + _reject_with_i8_evidence( + "market_client_stop_failed", + evidence=evidence, + close_state=close_state, + primary_reason=primary_error, + client_stop_returned=client_stop_returned, + ) + if primary_error is not None: + evidence = _i8_make_evidence( + evidence_level=evidence_level, + close_state=close_state, + primary_error=primary_error, + callback=callback_evidence, + progress=progress_evidence, + client_stop_returned=client_stop_returned, + ) + _reject_with_i8_evidence( + primary_error, + evidence=evidence, + close_state=close_state, + primary_reason=primary_error, + client_stop_returned=client_stop_returned, + ) + if type(connection_generation) is not int or not same_trading_day_observed: + evidence = _i8_make_evidence( + evidence_level=evidence_level, + close_state=close_state, + primary_error=None, + callback=callback_evidence, + progress=progress_evidence, + client_stop_returned=client_stop_returned, + ) + _reject_with_i8_evidence( + "market_observation_incomplete", + evidence=evidence, + close_state=close_state, + primary_reason=None, + client_stop_returned=client_stop_returned, + ) + evidence = _i8_make_evidence( + evidence_level="complete", + close_state=close_state, + primary_error=None, + callback=callback_evidence, + progress=progress_evidence, + client_stop_returned=client_stop_returned, + ) + return CtpI8OneShotMdObservation( + md_front_sha256=hashlib.sha256(front.encode("utf-8")).hexdigest(), + account_fingerprint_sha256=admitted.account_fingerprint_sha256, + instrument_id=instrument, + exchange_id=exchange, + connection_generation=connection_generation, + identity_unverified=True, + market_login_ready=False, + subscription_acknowledged=True, + matching_tick_observed=True, + same_trading_day_observed=True, + client_stop_returned=client_stop_returned, + native_join_pending=False, + diagnostic_evidence=evidence, + ) + + +__all__ = [ + "CtpI8OneShotMdDiagnosticEvidence", + "CtpI8OneShotMdObservation", + "probe_i8_oneshot_md_readonly", +] diff --git a/backtrader_runtime/ctp_native_shutdown.py b/backtrader_runtime/ctp_native_shutdown.py new file mode 100644 index 00000000..5121b9d1 --- /dev/null +++ b/backtrader_runtime/ctp_native_shutdown.py @@ -0,0 +1,111 @@ +"""Strict SDK shutdown receipt checks for native CTP clients. + +The managed SimNow path may release its account lease only after the SDK's +exact public stop receipt proves native Join/Release cleanup completed. +The SDK's ``stop_and_wait`` first calls synchronous ``stop()`` and only then +applies its timeout to native Join observation. This helper does not bound the +total wall-clock duration of that call; a hard deadline requires a supervising +process that can terminate a stuck caller. +""" + +from __future__ import annotations + +from typing import Any + + +_STOP_WAIT_TIMEOUT_SECONDS = 2.0 + + +def _native_stop_receipt_type() -> type | None: + """Return only the public receipt class from the expected SDK module.""" + + try: + from bt_api_ctp.ctp.client import CtpNativeStopReceipt + except Exception: + return None + if ( + type(CtpNativeStopReceipt) is not type + or CtpNativeStopReceipt.__module__ != "bt_api_ctp.ctp.client" + or CtpNativeStopReceipt.__name__ != "CtpNativeStopReceipt" + ): + return None + return CtpNativeStopReceipt + + +def stop_ctp_native_client(client: Any) -> bool: + """Attempt one SDK stop and accept only a coherent SDK receipt. + + This intentionally has no ``stop()`` fallback. A legacy method or a + duck-typed receipt may perform cleanup, but neither can prove it completed + and therefore neither can authorize releasing the account lease. + + The 2-second timeout passed to ``stop_and_wait`` bounds only its native + Join wait. The SDK calls ``stop()`` synchronously before that wait, so this + function itself has no hard wall-clock bound. + """ + + try: + expected_type = _native_stop_receipt_type() + except BaseException: + expected_type = None + + try: + expected_generation = getattr(client, "connection_generation") + except BaseException: + expected_generation = None + try: + stop_and_wait = getattr(client, "stop_and_wait") + except BaseException: + return False + if not callable(stop_and_wait): + return False + + try: + receipt = stop_and_wait(timeout=_STOP_WAIT_TIMEOUT_SECONDS) + except BaseException: + return False + + if expected_type is None or type(receipt) is not expected_type: + return False + try: + generation = receipt.connection_generation + join_required = receipt.join_required + join_completed = receipt.join_completed + native_released = receipt.native_released + thread_alive = receipt.thread_alive + timed_out = receipt.timed_out + complete = receipt.complete + except BaseException: + return False + + if ( + type(expected_generation) is not int + or expected_generation < 0 + or type(generation) is not int + or generation != expected_generation + or type(join_required) is not bool + or type(join_completed) is not bool + or type(native_released) is not bool + or type(thread_alive) is not bool + or type(timed_out) is not bool + or type(complete) is not bool + ): + return False + + derived_complete = ( + native_released + and (not join_required or join_completed) + and thread_alive is False + and not timed_out + ) + return bool( + complete is True + and derived_complete + and native_released is True + and (join_required is False or join_completed is True) + and thread_alive is False + and timed_out is False + ) + + +__all__ = ["stop_ctp_native_client"] diff --git a/backtrader_runtime/ctp_preflight.py b/backtrader_runtime/ctp_preflight.py new file mode 100644 index 00000000..90a56891 --- /dev/null +++ b/backtrader_runtime/ctp_preflight.py @@ -0,0 +1,953 @@ +"""Pure, injected CTP SimNow read-only session-preflight contract. + +This module is intentionally a narrow composition boundary. It does not +import a CTP SDK, resolve a secret, choose a network endpoint, or provide a +default session factory. A caller must inject a read-only session factory +after obtaining both :class:`ProviderSessionPreflightBinding` and a verified +test-execution profile observation through the sealed runtime path. + +The result is evidence that one injected session returned a stable, complete +read-only query summary. It is not an execution permit, an order route, or a +claim that SimNow has been connected successfully in a real environment. +""" + +from __future__ import annotations + +import datetime as _datetime +import hashlib +import hmac +import json +import math +import re +import time +from dataclasses import dataclass, field +from typing import Any, Optional, Protocol, Sequence, Tuple + +from .provider_deployment import ( + ProviderDeploymentReceiptValidation, + ProviderDeploymentReceiptVerifier, +) +from .provider_preflight import ( + ProviderSessionPreflightBinding, + ProviderSessionPreflightRegistration, + validate_provider_session_preflight_binding, +) +from .registry import EffectiveRuntimeConfig, RuntimeRegistry +from .test_execution_profile import ( + TEST_EXECUTION_PROFILE_PRECHECKED, + TestExecutionPreflightContext, + TestExecutionProfileObservation, + TestExecutionProfileVerifier, + validate_test_execution_profile, +) + + +CTP_PROVIDER = "ctp" +CTP_SIMNOW_ENVIRONMENT_PATTERN = r"^simnow(?:_set[1-9][0-9]*)?$" +REQUIRED_CTP_READ_ONLY_QUERIES = ( + "account", + "positions", + "orders", + "trades", + "instruments", + "margin_rates", + "commission_rates", +) +_REQUIRED_RATE_EXCHANGE_SCOPES = ("commission_rates", "margin_rates") +_RATE_EXCHANGE_SCOPE_VALUES = frozenset(("exact", "unverified")) + +_CTP_SIMNOW_ENVIRONMENT_RE = re.compile(CTP_SIMNOW_ENVIRONMENT_PATTERN) +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +class CtpReadOnlyPreflightError(ValueError): + """A redacted, fail-closed CTP read-only preflight rejection.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise CtpReadOnlyPreflightError(reason, message) + + +def _sha256(value: Any, field_name: str) -> str: + if type(value) is not str or not _SHA256_RE.fullmatch(value): + _reject("invalid_digest", "invalid {0}".format(field_name)) + return value + + +def _simnow_environment(value: Any, field_name: str) -> str: + if ( + type(value) is not str + or value != value.strip() + or not _CTP_SIMNOW_ENVIRONMENT_RE.fullmatch(value) + ): + _reject("environment_not_simnow", "invalid SimNow {0}".format(field_name)) + return value + + +def _trading_day(value: Any) -> str: + if type(value) is not str or not re.fullmatch(r"[0-9]{8}", value): + _reject("invalid_trading_day", "invalid CTP trading day") + try: + _datetime.datetime.strptime(value, "%Y%m%d") + except ValueError: + _reject("invalid_trading_day", "invalid CTP trading day") + return value + + +def _connection_generation(value: Any) -> int: + if type(value) is not int or value <= 0 or value > (2**63 - 1): + _reject("invalid_connection_generation", "invalid CTP connection generation") + return value + + +def _timestamp(value: Any, field_name: str) -> float: + if type(value) not in (int, float): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + try: + normalized = float(value) + except (OverflowError, TypeError, ValueError): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + if not math.isfinite(normalized): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + return 0.0 if normalized == 0.0 else normalized + + +@dataclass(frozen=True) +class CtpReadOnlySessionIdentity: + """The non-secret identity facts returned by one CTP session.""" + + provider: str + environment: str + account_fingerprint_sha256: str = field(repr=False) + trading_day: str + connection_generation: int + + def __post_init__(self) -> None: + if self.provider != CTP_PROVIDER: + _reject("provider_not_ctp", "CTP read-only preflight requires provider ctp") + object.__setattr__( + self, "environment", _simnow_environment(self.environment, "environment") + ) + object.__setattr__( + self, + "account_fingerprint_sha256", + _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256"), + ) + object.__setattr__(self, "trading_day", _trading_day(self.trading_day)) + object.__setattr__( + self, "connection_generation", _connection_generation(self.connection_generation) + ) + + def as_public_dict(self) -> dict[str, Any]: + """Return session facts without a reversible low-entropy account hash.""" + + return { + "account_scope": "redacted", + "connection_generation": self.connection_generation, + "environment": self.environment, + "provider": self.provider, + "trading_day": self.trading_day, + } + + +def _normalise_identity(value: Any, field_name: str) -> CtpReadOnlySessionIdentity: + if type(value) is not CtpReadOnlySessionIdentity: + _reject("invalid_session_identity", "invalid {0}".format(field_name)) + return CtpReadOnlySessionIdentity( + provider=value.provider, + environment=value.environment, + account_fingerprint_sha256=value.account_fingerprint_sha256, + trading_day=value.trading_day, + connection_generation=value.connection_generation, + ) + + +def _normalise_query_digests(value: Any) -> Tuple[Tuple[str, str], ...]: + if type(value) not in (tuple, list): + _reject("invalid_query_snapshot", "query snapshot must contain query digest pairs") + + pairs = tuple(value) + names = [] + normalized = [] + for pair in pairs: + if type(pair) not in (tuple, list) or len(pair) != 2: + _reject("invalid_query_snapshot", "invalid CTP query digest pair") + name, digest = pair + if type(name) is not str or name not in REQUIRED_CTP_READ_ONLY_QUERIES: + _reject("unexpected_query", "CTP query snapshot contains an unsupported query") + names.append(name) + normalized.append((name, _sha256(digest, "query_digest"))) + + if len(set(names)) != len(names): + _reject("duplicate_query", "CTP query snapshot contains a duplicate query") + missing = tuple(name for name in REQUIRED_CTP_READ_ONLY_QUERIES if name not in names) + if missing: + _reject("missing_required_query", "CTP query snapshot is incomplete") + if len(names) != len(REQUIRED_CTP_READ_ONLY_QUERIES): + _reject("invalid_query_snapshot", "CTP query snapshot has an invalid query count") + return tuple(sorted(normalized)) + + +def _normalise_rate_exchange_scopes(value: Any) -> Tuple[Tuple[str, str], ...]: + if type(value) not in (tuple, list): + _reject("invalid_rate_exchange_scope", "invalid CTP rate exchange scope summary") + normalized = [] + for pair in value: + if type(pair) not in (tuple, list) or len(pair) != 2: + _reject("invalid_rate_exchange_scope", "invalid CTP rate exchange scope pair") + query_name, scope = pair + if ( + type(query_name) is not str + or query_name not in _REQUIRED_RATE_EXCHANGE_SCOPES + or type(scope) is not str + or scope not in _RATE_EXCHANGE_SCOPE_VALUES + ): + _reject("invalid_rate_exchange_scope", "invalid CTP rate exchange scope value") + normalized.append((query_name, scope)) + if len(normalized) not in (0, len(_REQUIRED_RATE_EXCHANGE_SCOPES)): + _reject("missing_rate_exchange_scope", "CTP rate exchange scope summary is incomplete") + names = tuple(name for name, _scope in normalized) + if len(set(names)) != len(names): + _reject("duplicate_rate_exchange_scope", "CTP rate exchange scope summary has duplicates") + if normalized and set(names) != set(_REQUIRED_RATE_EXCHANGE_SCOPES): + _reject("missing_rate_exchange_scope", "CTP rate exchange scope summary is incomplete") + return tuple(sorted(normalized)) + + +def _canonical_snapshot_payload( + identity: CtpReadOnlySessionIdentity, + query_digests: Tuple[Tuple[str, str], ...], + native_certificate_sha256: Optional[str] = None, + rate_exchange_scopes: Tuple[Tuple[str, str], ...] = (), +) -> bytes: + payload = { + "identity": identity.as_public_dict(), + "query_digests": [[name, digest] for name, digest in query_digests], + } + if native_certificate_sha256 is not None: + payload["native_certificate_sha256"] = native_certificate_sha256 + if rate_exchange_scopes: + payload["rate_exchange_scopes"] = [list(pair) for pair in rate_exchange_scopes] + return json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +@dataclass(frozen=True) +class CtpReadOnlyQuerySnapshot: + """Digest-only complete summary of the required read-only CTP queries.""" + + identity: CtpReadOnlySessionIdentity + query_digests: Tuple[Tuple[str, str], ...] + snapshot_sha256: str + native_certificate_sha256: Optional[str] = None + rate_exchange_scopes: Tuple[Tuple[str, str], ...] = () + + def __post_init__(self) -> None: + identity = _normalise_identity(self.identity, "query snapshot identity") + query_digests = _normalise_query_digests(self.query_digests) + snapshot_sha256 = _sha256(self.snapshot_sha256, "snapshot_sha256") + native_certificate_sha256 = ( + None + if self.native_certificate_sha256 is None + else _sha256(self.native_certificate_sha256, "native_certificate_sha256") + ) + rate_exchange_scopes = _normalise_rate_exchange_scopes(self.rate_exchange_scopes) + if native_certificate_sha256 is None and rate_exchange_scopes: + _reject( + "rate_exchange_scope_requires_certificate", + "CTP rate exchange scopes require native certificate provenance", + ) + if native_certificate_sha256 is not None and not rate_exchange_scopes: + _reject( + "missing_rate_exchange_scope", + "native CTP certificate is missing rate exchange scope evidence", + ) + expected_digest = hashlib.sha256( + _canonical_snapshot_payload( + identity, query_digests, native_certificate_sha256, rate_exchange_scopes + ) + ).hexdigest() + if not hmac.compare_digest(snapshot_sha256, expected_digest): + _reject( + "snapshot_digest_mismatch", "CTP query snapshot digest does not match its summary" + ) + object.__setattr__(self, "identity", identity) + object.__setattr__(self, "query_digests", query_digests) + object.__setattr__(self, "snapshot_sha256", snapshot_sha256) + object.__setattr__(self, "native_certificate_sha256", native_certificate_sha256) + object.__setattr__(self, "rate_exchange_scopes", rate_exchange_scopes) + + @classmethod + def from_query_digests( + cls, + identity: CtpReadOnlySessionIdentity, + query_digests: Sequence[Tuple[str, str]], + *, + native_certificate_sha256: Optional[str] = None, + rate_exchange_scopes: Sequence[Tuple[str, str]] = (), + ) -> "CtpReadOnlyQuerySnapshot": + """Build a canonical snapshot after validating complete query coverage.""" + + normalized_identity = _normalise_identity(identity, "query snapshot identity") + normalized_digests = _normalise_query_digests(query_digests) + normalized_certificate_sha256 = ( + None + if native_certificate_sha256 is None + else _sha256(native_certificate_sha256, "native_certificate_sha256") + ) + normalized_rate_exchange_scopes = _normalise_rate_exchange_scopes(rate_exchange_scopes) + if normalized_certificate_sha256 is None and normalized_rate_exchange_scopes: + _reject( + "rate_exchange_scope_requires_certificate", + "CTP rate exchange scopes require native certificate provenance", + ) + if normalized_certificate_sha256 is not None and not normalized_rate_exchange_scopes: + _reject( + "missing_rate_exchange_scope", + "native CTP certificate is missing rate exchange scope evidence", + ) + digest = hashlib.sha256( + _canonical_snapshot_payload( + normalized_identity, + normalized_digests, + normalized_certificate_sha256, + normalized_rate_exchange_scopes, + ) + ).hexdigest() + return cls( + identity=normalized_identity, + query_digests=normalized_digests, + snapshot_sha256=digest, + native_certificate_sha256=normalized_certificate_sha256, + rate_exchange_scopes=normalized_rate_exchange_scopes, + ) + + def as_public_dict(self) -> dict[str, Any]: + """Return digest-only evidence; query payloads are deliberately absent.""" + + result = { + "identity": self.identity.as_public_dict(), + "query_digests": self.query_digests, + "snapshot_sha256": self.snapshot_sha256, + } + if self.native_certificate_sha256 is not None: + result["native_certificate_sha256"] = self.native_certificate_sha256 + if self.rate_exchange_scopes: + result["rate_exchange_scopes"] = self.rate_exchange_scopes + return result + + +def _normalise_snapshot(value: Any) -> CtpReadOnlyQuerySnapshot: + if type(value) is not CtpReadOnlyQuerySnapshot: + _reject("invalid_query_snapshot", "invalid CTP query snapshot") + return CtpReadOnlyQuerySnapshot( + identity=value.identity, + query_digests=value.query_digests, + snapshot_sha256=value.snapshot_sha256, + native_certificate_sha256=value.native_certificate_sha256, + rate_exchange_scopes=value.rate_exchange_scopes, + ) + + +@dataclass(frozen=True) +class CtpReadOnlySessionRequest: + """The minimal, non-secret request handed to an injected session factory.""" + + provider: str + environment: str + account_fingerprint_sha256: str = field(repr=False) + valid_until: float + + def __post_init__(self) -> None: + if self.provider != CTP_PROVIDER: + _reject("provider_not_ctp", "CTP read-only preflight requires provider ctp") + object.__setattr__( + self, "environment", _simnow_environment(self.environment, "environment") + ) + object.__setattr__( + self, + "account_fingerprint_sha256", + _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256"), + ) + object.__setattr__(self, "valid_until", _timestamp(self.valid_until, "valid_until")) + + +class CtpReadOnlySession(Protocol): + """The only session operations this contract can invoke. + + Implementations may use a provider-specific client internally, but this + boundary has no method for execution, cancellation, settlement, or arming. + """ + + def read_identity(self) -> CtpReadOnlySessionIdentity: + """Return the current, non-secret CTP session identity.""" + + def read_query_snapshot(self) -> CtpReadOnlyQuerySnapshot: + """Return one complete digest-only summary of required read-only queries.""" + + def close_read_only(self) -> None: + """Close the read-only session without performing a provider write.""" + + +class CtpReadOnlySessionFactory(Protocol): + """Injected factory; this module intentionally supplies no SDK-backed default.""" + + def open_read_only(self, request: CtpReadOnlySessionRequest) -> CtpReadOnlySession: + """Create one read-only session for an already validated request.""" + + +@dataclass(frozen=True) +class CtpReadOnlyPreflightResult: + """Stable read-only evidence with explicit non-execution authority fields.""" + + receipt_id: str + test_profile_id: str + test_profile_sha256: str + test_profile_valid_until: float + provider: str + environment: str + account_fingerprint_sha256: str = field(repr=False) + trading_day: str + connection_generation: int + query_snapshot: CtpReadOnlyQuerySnapshot + provider_preflight_started: bool = True + session_connected: bool = True + execution_authorized: bool = False + external_writes_authorized: bool = False + order_submission_authorized: bool = False + cancellation_authorized: bool = False + settlement_authorized: bool = False + arming_authorized: bool = False + + def __post_init__(self) -> None: + if type(self.receipt_id) is not str or not self.receipt_id: + _reject("invalid_receipt", "invalid preflight receipt identity") + if type(self.test_profile_id) is not str or not self.test_profile_id: + _reject("invalid_test_profile", "invalid test execution profile identity") + object.__setattr__( + self, + "test_profile_sha256", + _sha256(self.test_profile_sha256, "test_profile_sha256"), + ) + object.__setattr__( + self, + "test_profile_valid_until", + _timestamp(self.test_profile_valid_until, "test_profile_valid_until"), + ) + if self.provider != CTP_PROVIDER: + _reject("provider_not_ctp", "CTP read-only preflight requires provider ctp") + environment = _simnow_environment(self.environment, "environment") + account_fingerprint = _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256") + trading_day = _trading_day(self.trading_day) + generation = _connection_generation(self.connection_generation) + snapshot = _normalise_snapshot(self.query_snapshot) + expected_identity = CtpReadOnlySessionIdentity( + provider=self.provider, + environment=environment, + account_fingerprint_sha256=account_fingerprint, + trading_day=trading_day, + connection_generation=generation, + ) + if snapshot.identity != expected_identity: + _reject( + "snapshot_identity_mismatch", "CTP query snapshot identity does not match session" + ) + if ( + self.provider_preflight_started is not True + or self.session_connected is not True + or self.execution_authorized is not False + or self.external_writes_authorized is not False + or self.order_submission_authorized is not False + or self.cancellation_authorized is not False + or self.settlement_authorized is not False + or self.arming_authorized is not False + ): + raise ValueError("CTP read-only preflight cannot grant execution or write authority") + object.__setattr__(self, "environment", environment) + object.__setattr__(self, "account_fingerprint_sha256", account_fingerprint) + object.__setattr__(self, "trading_day", trading_day) + object.__setattr__(self, "connection_generation", generation) + object.__setattr__(self, "query_snapshot", snapshot) + + def __bool__(self) -> bool: + raise TypeError( + "CtpReadOnlyPreflightResult is read-only evidence, not an execution authorization; " + "do not use it as a boolean" + ) + + def as_public_dict(self) -> dict[str, Any]: + """Return digest-only evidence with explicit false execution/write fields.""" + + return { + "account_scope": "redacted", + "arming_authorized": self.arming_authorized, + "cancellation_authorized": self.cancellation_authorized, + "connection_generation": self.connection_generation, + "environment": self.environment, + "execution_authorized": self.execution_authorized, + "external_writes_authorized": self.external_writes_authorized, + "order_submission_authorized": self.order_submission_authorized, + "provider": self.provider, + "provider_preflight_started": self.provider_preflight_started, + "query_snapshot": self.query_snapshot.as_public_dict(), + "receipt_id": self.receipt_id, + "session_connected": self.session_connected, + "settlement_authorized": self.settlement_authorized, + "test_profile_id": self.test_profile_id, + "test_profile_sha256": self.test_profile_sha256, + "test_profile_valid_until": self.test_profile_valid_until, + "trading_day": self.trading_day, + } + + +def _binding_request(binding: Any) -> CtpReadOnlySessionRequest: + """Validate a potentially tampered frozen binding before constructing a session.""" + + if type(binding) is not ProviderSessionPreflightBinding: + _reject("binding_required", "a ProviderSessionPreflightBinding is required") + validation = binding.receipt_validation + if type(validation) is not ProviderDeploymentReceiptValidation: + _reject("binding_invalid", "provider preflight binding receipt validation is invalid") + if ( + binding.preflight_binding_valid is not True + or binding.provider_preflight_started is not False + or binding.secrets_resolved is not False + or binding.session_connected is not False + or binding.execution_authorized is not False + or binding.external_writes_authorized is not False + or validation.receipt_binding_valid is not True + or validation.deployment_authorized is not False + or validation.execution_authorized is not False + or validation.secrets_resolved is not False + or validation.provider_preflight_started is not False + ): + _reject("binding_invalid", "provider preflight binding cannot grant session authority") + if ( + binding.mode != "simulation" + or binding.preset != "sandbox" + or binding.account_access != "sandbox_direct_provider" + ): + _reject( + "binding_not_simnow_sandbox", "binding is not the reviewed CTP SimNow sandbox route" + ) + if validation.provider != CTP_PROVIDER: + _reject("provider_not_ctp", "provider receipt does not describe CTP") + + valid_until = _timestamp(validation.valid_until, "binding valid_until") + checked_at = _timestamp(time.time(), "now") + if valid_until <= checked_at: + _reject("binding_expired", "provider preflight binding has expired") + return CtpReadOnlySessionRequest( + provider=validation.provider, + environment=validation.environment, + account_fingerprint_sha256=validation.account_fingerprint_sha256, + valid_until=valid_until, + ) + + +def _matching_identity( + expected: CtpReadOnlySessionRequest, identity: CtpReadOnlySessionIdentity +) -> bool: + return ( + identity.provider == expected.provider + and identity.environment == expected.environment + and hmac.compare_digest( + identity.account_fingerprint_sha256, expected.account_fingerprint_sha256 + ) + ) + + +def _normalise_test_profile_context( + value: Any, + binding: ProviderSessionPreflightBinding, + registration: ProviderSessionPreflightRegistration, +) -> TestExecutionPreflightContext: + """Bind a zero-write profile context to the sealed deployment facts. + + The test-profile verifier validates the raw profile against this context. + Reconstructing the frozen context also detects unsafe ``object.__setattr__`` + mutation before any factory receives a request. + """ + + if type(value) is not TestExecutionPreflightContext: + _reject("test_profile_context_required", "a test execution profile context is required") + try: + context = TestExecutionPreflightContext( + provider=value.provider, + environment=value.environment, + account_fingerprint_sha256=value.account_fingerprint_sha256, + approval_receipt_digest=value.approval_receipt_digest, + effective_config_digest=value.effective_config_digest, + artifact_sha256=value.artifact_sha256, + capability_receipt_digest=value.capability_receipt_digest, + instrument=value.instrument, + quantity=value.quantity, + requested_external_writes=value.requested_external_writes, + cleanup_ready=value.cleanup_ready, + reconciliation_ready=value.reconciliation_ready, + ) + except Exception: + _reject("test_profile_context_invalid", "test execution profile context is invalid") + + deployment = registration.deployment + validation = binding.receipt_validation + if context.provider != CTP_PROVIDER: + _reject( + "test_profile_provider_mismatch", "test execution profile context provider is not CTP" + ) + if context.environment != "simnow": + _reject( + "test_profile_environment_mismatch", + "test execution profile context is not in the SimNow environment class", + ) + if context.requested_external_writes != 0: + _reject( + "test_profile_writes_not_zero", + "CTP read-only preflight requires zero requested external writes", + ) + + expected_digests = ( + ( + context.approval_receipt_digest, + deployment.approval_receipt_digest, + validation.approval_receipt_digest, + ), + ( + context.account_fingerprint_sha256, + deployment.account_fingerprint_sha256, + validation.account_fingerprint_sha256, + ), + ( + context.effective_config_digest, + deployment.effective_config_digest, + validation.effective_config_digest, + ), + (context.artifact_sha256, deployment.artifact_sha256, validation.artifact_sha256), + ( + context.capability_receipt_digest, + deployment.capability_receipt_digest, + validation.capability_receipt_digest, + ), + ) + if any( + not hmac.compare_digest(actual, deployment_value) + or not hmac.compare_digest(actual, receipt_value) + for actual, deployment_value, receipt_value in expected_digests + ): + _reject( + "test_profile_context_binding_mismatch", + "test execution profile context does not match sealed provider binding", + ) + return context + + +def _normalise_test_profile_observation( + value: Any, + request: CtpReadOnlySessionRequest, + expected_approval_receipt_digest: str, +) -> TestExecutionProfileObservation: + """Require an independently verified, zero-write SimNow profile observation. + + ``TestExecutionProfileObservation`` deliberately redacts the account + fingerprint, so the exact fingerprint continues to be bound by the + deployment receipt/session identity. The profile supplies a separate, + verified sandbox and zero-write constraint; its canonical environment is + ``simnow`` while the deployment route may name a reviewed SimNow front set + such as ``simnow_set2``. + """ + + if type(value) is not TestExecutionProfileObservation: + _reject( + "test_profile_required", + "a verified TestExecutionProfileObservation is required", + ) + try: + observation = TestExecutionProfileObservation( + profile_id=value.profile_id, + profile_sha256=value.profile_sha256, + provider=value.provider, + environment=value.environment, + approval_receipt_digest=value.approval_receipt_digest, + instrument=value.instrument, + quantity=value.quantity, + requested_external_writes=value.requested_external_writes, + checked_at=value.checked_at, + valid_until=value.valid_until, + status=value.status, + profile_binding_valid=value.profile_binding_valid, + profile_verifier_accepted=value.profile_verifier_accepted, + preflight_authorized=value.preflight_authorized, + execution_authorized=value.execution_authorized, + provider_preflight_started=value.provider_preflight_started, + provider_connected=value.provider_connected, + external_writes_started=value.external_writes_started, + ) + except Exception: + _reject("test_profile_invalid", "test execution profile observation is invalid") + + if ( + observation.status != TEST_EXECUTION_PROFILE_PRECHECKED + or observation.profile_binding_valid is not True + or observation.profile_verifier_accepted is not True + or observation.preflight_authorized is not False + or observation.execution_authorized is not False + or observation.provider_preflight_started is not False + or observation.provider_connected is not False + or observation.external_writes_started is not False + ): + _reject("test_profile_invalid", "test execution profile cannot grant provider authority") + if observation.provider != request.provider: + _reject( + "test_profile_provider_mismatch", "test execution profile provider does not match CTP" + ) + if not hmac.compare_digest( + observation.approval_receipt_digest, expected_approval_receipt_digest + ): + _reject( + "test_profile_approval_mismatch", + "test execution profile approval does not match provider receipt", + ) + if observation.environment != "simnow": + _reject( + "test_profile_environment_mismatch", + "test execution profile environment is not the SimNow class", + ) + if observation.requested_external_writes != 0: + _reject( + "test_profile_writes_not_zero", + "CTP read-only preflight requires a zero-write test execution profile", + ) + checked_at = _timestamp(time.time(), "now") + if observation.valid_until <= checked_at: + _reject("test_profile_expired", "test execution profile observation has expired") + return observation + + +def _monotonic_session_deadline(valid_until: float) -> float: + """Pin the verified wall-clock remainder to a monotonic clock as well.""" + + remaining = valid_until - _timestamp(time.time(), "now") + if remaining <= 0: + _reject("session_deadline_expired", "CTP read-only preflight deadline has expired") + return _timestamp(time.monotonic(), "monotonic_now") + remaining + + +def _require_session_deadline(valid_until: float, monotonic_deadline: float) -> None: + """Recheck both clocks after every blocking session step.""" + + if ( + _timestamp(time.time(), "now") >= valid_until + or _timestamp(time.monotonic(), "monotonic_now") >= monotonic_deadline + ): + _reject("session_deadline_expired", "CTP read-only preflight deadline has expired") + + +def _run_ctp_simnow_readonly_preflight_from_verified( + binding: ProviderSessionPreflightBinding, + test_profile: TestExecutionProfileObservation, + session_factory: Optional[CtpReadOnlySessionFactory] = None, +) -> CtpReadOnlyPreflightResult: + """Run the session half after public inputs have been independently validated. + + Both non-authoritative offline observations are completely checked before + the factory is touched. The factory, session identity and query summary + are each checked again at this boundary. Any malformed, duplicate, + incomplete, changing, or exceptional observation rejects the entire + preflight and never produces an authority result. + """ + + request = _binding_request(binding) + profile_observation = _normalise_test_profile_observation( + test_profile, request, binding.receipt_validation.approval_receipt_digest + ) + request = CtpReadOnlySessionRequest( + provider=request.provider, + environment=request.environment, + account_fingerprint_sha256=request.account_fingerprint_sha256, + valid_until=min(request.valid_until, profile_observation.valid_until), + ) + monotonic_deadline = _monotonic_session_deadline(request.valid_until) + _require_session_deadline(request.valid_until, monotonic_deadline) + if session_factory is None: + _reject( + "session_factory_required", + "CTP read-only preflight requires an injected session factory", + ) + try: + open_read_only = getattr(session_factory, "open_read_only", None) + except Exception: + _reject("invalid_session_factory", "invalid CTP read-only session factory") + if not callable(open_read_only): + _reject("invalid_session_factory", "invalid CTP read-only session factory") + + try: + session = open_read_only(request) + except CtpReadOnlyPreflightError: + raise + except Exception: + _reject("session_factory_failed", "unable to open CTP read-only session") + if session is None: + _reject("invalid_session", "CTP read-only session factory returned no session") + + result: Optional[CtpReadOnlyPreflightResult] = None + error: Optional[CtpReadOnlyPreflightError] = None + close_read_only = None + try: + try: + # Capture the close operation first. A broken read method attribute + # may raise before any query starts, but a captured close still runs. + close_read_only = getattr(session, "close_read_only", None) + if not callable(close_read_only): + _reject("invalid_session", "CTP read-only session has no close operation") + _require_session_deadline(request.valid_until, monotonic_deadline) + read_identity = getattr(session, "read_identity", None) + read_snapshot = getattr(session, "read_query_snapshot", None) + if not callable(read_identity) or not callable(read_snapshot): + _reject("invalid_session", "CTP read-only session has an invalid protocol") + + first_identity = _normalise_identity(read_identity(), "session identity") + _require_session_deadline(request.valid_until, monotonic_deadline) + if not _matching_identity(request, first_identity): + _reject( + "session_identity_mismatch", + "CTP session identity does not match receipt binding", + ) + + snapshot = _normalise_snapshot(read_snapshot()) + _require_session_deadline(request.valid_until, monotonic_deadline) + if snapshot.identity != first_identity: + _reject( + "snapshot_identity_mismatch", + "CTP query snapshot identity does not match session", + ) + + final_identity = _normalise_identity(read_identity(), "final session identity") + _require_session_deadline(request.valid_until, monotonic_deadline) + if final_identity != first_identity: + _reject("session_identity_changed", "CTP session identity changed during preflight") + + result = CtpReadOnlyPreflightResult( + receipt_id=validation_receipt_id(binding), + test_profile_id=profile_observation.profile_id, + test_profile_sha256=profile_observation.profile_sha256, + test_profile_valid_until=profile_observation.valid_until, + provider=first_identity.provider, + environment=first_identity.environment, + account_fingerprint_sha256=first_identity.account_fingerprint_sha256, + trading_day=first_identity.trading_day, + connection_generation=first_identity.connection_generation, + query_snapshot=snapshot, + ) + except CtpReadOnlyPreflightError as caught: + error = caught + except Exception: + error = CtpReadOnlyPreflightError( + "read_only_session_failed", "CTP read-only session preflight failed" + ) + finally: + if callable(close_read_only): + try: + close_read_only() + except Exception: + if error is None: + error = CtpReadOnlyPreflightError( + "session_close_failed", "unable to close CTP read-only session" + ) + if error is None: + try: + _require_session_deadline(request.valid_until, monotonic_deadline) + except CtpReadOnlyPreflightError as caught: + error = caught + if error is not None: + raise error + if result is None: + _reject("read_only_session_failed", "CTP read-only session preflight failed") + return result + + +def run_ctp_simnow_readonly_preflight( + *, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + provider_registration: ProviderSessionPreflightRegistration, + provider_receipt: Any, + provider_receipt_verifier: Optional[ProviderDeploymentReceiptVerifier] = None, + test_profile: Any, + test_profile_context: TestExecutionPreflightContext, + test_profile_verifier: Optional[TestExecutionProfileVerifier] = None, + session_factory: Optional[CtpReadOnlySessionFactory] = None, +) -> CtpReadOnlyPreflightResult: + """Validate sealed inputs, then run one injected CTP SimNow read-only session. + + This is the only public route. It accepts raw receipt/profile wires and + their offline verifiers, derives both non-authoritative observations at + this boundary, and performs every seal/profile check before constructing a + session. Passing a hand-built observation is deliberately not supported. + """ + + try: + binding = validate_provider_session_preflight_binding( + effective, + registry, + provider_registration, + provider_receipt, + verifier=provider_receipt_verifier, + ) + except Exception: + _reject( + "provider_binding_validation_failed", + "sealed provider receipt binding validation failed", + ) + context = _normalise_test_profile_context( + test_profile_context, + binding, + provider_registration, + ) + try: + profile_observation = validate_test_execution_profile( + test_profile, + context=context, + verifier=test_profile_verifier, + ) + except Exception: + _reject( + "test_profile_validation_failed", + "sealed test execution profile validation failed", + ) + return _run_ctp_simnow_readonly_preflight_from_verified( + binding, + profile_observation, + session_factory=session_factory, + ) + + +def validation_receipt_id(binding: ProviderSessionPreflightBinding) -> str: + """Read the already validated receipt identifier without using binding truthiness.""" + + receipt_id = binding.receipt_validation.receipt_id + if type(receipt_id) is not str or not receipt_id: + _reject("invalid_receipt", "invalid preflight receipt identity") + return receipt_id + + +__all__ = [ + "CTP_PROVIDER", + "CTP_SIMNOW_ENVIRONMENT_PATTERN", + "REQUIRED_CTP_READ_ONLY_QUERIES", + "CtpReadOnlyPreflightError", + "CtpReadOnlyPreflightResult", + "CtpReadOnlyQuerySnapshot", + "CtpReadOnlySession", + "CtpReadOnlySessionFactory", + "CtpReadOnlySessionIdentity", + "CtpReadOnlySessionRequest", + "run_ctp_simnow_readonly_preflight", +] diff --git a/backtrader_runtime/ctp_private_config_setup.py b/backtrader_runtime/ctp_private_config_setup.py new file mode 100644 index 00000000..6844961f --- /dev/null +++ b/backtrader_runtime/ctp_private_config_setup.py @@ -0,0 +1,1751 @@ +"""Prepare the reserved local CTP SimNow config from an explicit private source. + +This helper has no provider imports or network path. It copies only a fixed +set of CTP connection fields into the Iteration 41 schema; mode, preset, +strategy identity, and destination are code-owned. A prepared config grants +no route or trading authority. +""" + +from __future__ import annotations + +import json +import errno +import os +import re +import stat +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Sequence, Tuple, Union + +from .config import _load_strict_yaml, _parse_verified_runtime_config_text +from .errors import CONFIG_EXISTS, CONFIG_SCHEMA_UNSUPPORTED, RuntimeConfigError +from .inventory import ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_007_CTP_PRIVATE_STRATEGY_ID, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_013_3_STRATEGY_ID, +) + + +CTP_SIMNOW_PRIVATE_RUNTIME_DIR = ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR +CTP_SIMNOW_PRIVATE_STRATEGY_ID = ITERATION41_013_3_STRATEGY_ID +_CTP_PRIVATE_CONFIG_TARGETS = MappingProxyType( + { + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID: ( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_013_3_STRATEGY_ID, + ), + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID: ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_007_CTP_PRIVATE_STRATEGY_ID, + ), + } +) +MAX_SOURCE_BYTES = 64 * 1024 +MAX_SOURCE_VALUE_BYTES = 2048 + +_FIELDS = ( + "md_front", + "td_front", + "instrument_id", + "exchange_id", + "hedge_flag", + "broker_id", + "user_id", + "password", + "app_id", + "auth_code", +) +_NON_FRONT_FIELDS = tuple(name for name in _FIELDS if name not in ("md_front", "td_front")) +_FRONT_PAIR_FIELDS = frozenset(("md_front", "td_front")) +_MAX_FRONT_PAIRS = 8 +_ENV_SET1_FRONT_KEY_RE = re.compile(r"^CTP_SET1_(MD|TD)_FRONT_(\d+)$") +_ENV_SET2_FRONT_KEYS = { + "CTP_SET2_MD_FRONT": "md_front", + "CTP_SET2_TD_FRONT": "td_front", +} +_ENV_KEYS = { + "CTP_MD_FRONT": "md_front", + "SIMNOW_MD_FRONT": "md_front", + "simnow_md_front": "md_front", + "CTP_TD_FRONT": "td_front", + "SIMNOW_TD_FRONT": "td_front", + "simnow_td_front": "td_front", + "CTP_INSTRUMENT": "instrument_id", + "CTP_INSTRUMENT_ID": "instrument_id", + "SIMNOW_INSTRUMENT_ID": "instrument_id", + "CTP_EXCHANGE": "exchange_id", + "CTP_EXCHANGE_ID": "exchange_id", + "SIMNOW_EXCHANGE_ID": "exchange_id", + "CTP_HEDGE_FLAG": "hedge_flag", + "SIMNOW_HEDGE_FLAG": "hedge_flag", + "CTP_BROKER_ID": "broker_id", + "SIMNOW_BROKER_ID": "broker_id", + "CTP_USER_ID": "user_id", + "CTP_INVESTOR_ID": "user_id", + "SIMNOW_USER_ID": "user_id", + "simnow_user_id": "user_id", + "CTP_PASSWORD": "password", + "SIMNOW_PASSWORD": "password", + "simnow_password": "password", + "CTP_APP_ID": "app_id", + "SIMNOW_APP_ID": "app_id", + "CTP_AUTH_CODE": "auth_code", + "SIMNOW_AUTH_CODE": "auth_code", +} +_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_PLACEHOLDER_RE = re.compile(r"^(?:<.*>|\$\{.*\}|(?:your|replace|change|enter)[-_ ].*)$", re.I) +_PLACEHOLDER_WORDS = frozenset(("todo", "changeme", "change-me", "replace-me", "your-password")) + + +@dataclass(frozen=True) +class PreparedCtpSimNowConfig: + """A redacted summary of a successfully created private config.""" + + path: Path + config_digest: str + + def as_public_dict(self) -> Dict[str, Any]: + return { + "status": "prepared_offline", + "config_path": str(self.path), + "config_digest": self.config_digest, + "mode": "simulation", + "preset": "sandbox", + "runtime_registered": True, + "provider_io": False, + "external_writes": False, + "execution_authorized": False, + "admission": "not_granted", + } + + +@dataclass(frozen=True) +class _CreatedPrivateFile: + """Identity and optional handle retained for cleanup after creation.""" + + identity: os.stat_result + retained_fd: Optional[int] + + +def _error(reason: str, message: str, field_path: str = "source") -> RuntimeConfigError: + return RuntimeConfigError( + CONFIG_SCHEMA_UNSUPPORTED, + message, + field_path=field_path, + reason=reason, + ) + + +def _same_identity(left: os.stat_result, right: os.stat_result) -> bool: + return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino) + + +def _unlink_posix_created_file(directory_descriptor: int, identity: os.stat_result) -> None: + """Unlink the target only while its entry still names this call's inode.""" + + try: + entry = os.stat("config.yaml", dir_fd=directory_descriptor, follow_symlinks=False) + except FileNotFoundError: + return + if not stat.S_ISREG(entry.st_mode) or not _same_identity(identity, entry): + raise OSError("config.yaml no longer names the created private file") + os.unlink("config.yaml", dir_fd=directory_descriptor) + + +def _reject_source(reason: str, message: str, field_path: str = "source") -> None: + raise _error(reason, message, field_path) + + +def _is_windows_reparse(result: os.stat_result) -> bool: + attributes = getattr(result, "st_file_attributes", 0) + return bool(attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + +def _windows_directory_identity_chain(runtime_dir: Path): + """Inspect every directory component and reject Windows reparse points.""" + + absolute = Path(os.path.abspath(str(runtime_dir))) + if not absolute.anchor: + _reject_source( + "private_target_path_invalid", + "reserved CTP private runtime path must be absolute", + "config.yaml", + ) + + paths = [Path(absolute.anchor)] + current = paths[0] + for part in absolute.parts: + if part == absolute.anchor: + continue + current = current / part + paths.append(current) + + identities = [] + for path in paths: + try: + entry = os.lstat(str(path)) + except OSError: + _reject_source( + "private_target_directory_unavailable", + "reserved CTP private runtime directory is unavailable", + "config.yaml", + ) + if stat.S_ISLNK(entry.st_mode) or _is_windows_reparse(entry): + _reject_source( + "private_target_directory_invalid", + "reserved CTP private runtime path cannot contain reparse points", + "config.yaml", + ) + if not stat.S_ISDIR(entry.st_mode): + _reject_source( + "private_target_directory_invalid", + "reserved CTP private runtime path must contain only directories", + "config.yaml", + ) + identities.append((path, entry)) + return tuple(identities) + + +def _same_directory_identity_chain(left, right) -> bool: + return len(left) == len(right) and all( + os.path.normcase(str(left_path)) == os.path.normcase(str(right_path)) + and _same_identity(left_stat, right_stat) + for (left_path, left_stat), (right_path, right_stat) in zip(left, right) + ) + + +def _open_posix_directory_chain(runtime_dir: Path): + """Open each POSIX path component relative to its verified parent.""" + + required_flags = ("O_DIRECTORY", "O_NOFOLLOW") + if ( + not runtime_dir.is_absolute() + or any(not hasattr(os, name) for name in required_flags) + or os.open not in getattr(os, "supports_dir_fd", set()) + ): + _reject_source( + "private_target_security_unavailable", + "this POSIX platform cannot safely open every private target path component", + "config.yaml", + ) + + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + descriptors = [] + identities = [] + try: + descriptor = os.open("/", flags) + descriptors.append(descriptor) + current = Path("/") + root_stat = os.fstat(descriptor) + if not stat.S_ISDIR(root_stat.st_mode): + _reject_source( + "private_target_directory_invalid", + "reserved CTP private runtime path must contain only directories", + "config.yaml", + ) + identities.append((current, root_stat)) + + for component in runtime_dir.parts: + if component == "/": + continue + descriptor = os.open(component, flags, dir_fd=descriptors[-1]) + descriptors.append(descriptor) + current = current / component + component_stat = os.fstat(descriptor) + if not stat.S_ISDIR(component_stat.st_mode): + _reject_source( + "private_target_directory_invalid", + "reserved CTP private runtime path must contain only directories", + "config.yaml", + ) + identities.append((current, component_stat)) + + final_descriptor = descriptors[-1] + for descriptor in reversed(descriptors[:-1]): + os.close(descriptor) + descriptors.clear() + return tuple(identities), final_descriptor + except RuntimeConfigError: + for descriptor in descriptors: + try: + os.close(descriptor) + except OSError: + pass + raise + except OSError as error: + for descriptor in descriptors: + try: + os.close(descriptor) + except OSError: + pass + if error.errno in (errno.ELOOP, errno.ENOTDIR): + _reject_source( + "private_target_directory_invalid", + "reserved CTP private runtime path cannot contain symbolic links", + "config.yaml", + ) + _reject_source( + "private_target_directory_unavailable", + "reserved CTP private runtime directory cannot be opened safely", + "config.yaml", + ) + except BaseException: + for descriptor in descriptors: + try: + os.close(descriptor) + except OSError: + pass + raise + + +def _source_security(descriptor: int, file_stat: os.stat_result) -> None: + if file_stat.st_nlink != 1: + _reject_source( + "private_source_hardlink_not_allowed", "source must have one filesystem link" + ) + if os.name == "posix": + if not hasattr(os, "geteuid"): + _reject_source( + "private_source_security_unavailable", "source ownership cannot be verified" + ) + uid = os.geteuid() + mode = stat.S_IMODE(file_stat.st_mode) + if file_stat.st_uid != uid or mode & ~0o600 or mode & stat.S_IRUSR == 0: + _reject_source( + "private_source_permissions_unsafe", + "source must be owned by the current user and readable only by its owner", + ) + return + if os.name == "nt": + try: + from .credential_resolver import _windows_acl_for_handle, _windows_os_handle + + _windows_acl_for_handle(_windows_os_handle(descriptor)) + except Exception: + _reject_source( + "private_source_acl_unsafe", + "Windows could not verify an owner-only source ACL", + ) + return + _reject_source("private_source_security_unavailable", "source security cannot be verified here") + + +@contextmanager +def _open_explicit_private_source(source: Path) -> Iterator[Tuple[int, os.stat_result]]: + """Open one absolute source only after validating its identity and ACL.""" + + if not source.is_absolute(): + _reject_source( + "source_path_must_be_absolute", + "name the local source with an absolute path; no working-directory search is used", + ) + try: + before = os.lstat(str(source)) + except OSError: + _reject_source("source_unavailable", "the explicitly named source file is unavailable") + if stat.S_ISLNK(before.st_mode) or _is_windows_reparse(before): + _reject_source( + "source_link_not_allowed", "source must not be a symbolic link or reparse point" + ) + if not stat.S_ISREG(before.st_mode): + _reject_source("source_not_regular_file", "source must be a regular local file") + if before.st_size > MAX_SOURCE_BYTES: + _reject_source("source_too_large", "source exceeds the supported local file size") + + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(str(source), flags) + except OSError: + _reject_source("source_unavailable", "the explicitly named source file cannot be opened") + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or not _same_identity(before, opened) + or opened.st_size > MAX_SOURCE_BYTES + or _is_windows_reparse(opened) + ): + _reject_source("source_identity_changed", "source identity changed while it was opened") + _source_security(descriptor, opened) + yield descriptor, opened + finally: + os.close(descriptor) + + +def _read_opened_private_source( + source: Path, + descriptor: int, + opened: os.stat_result, +) -> str: + """Read an already ACL-checked descriptor and recheck its file identity.""" + + with os.fdopen(os.dup(descriptor), "rb") as handle: + payload = handle.read(MAX_SOURCE_BYTES + 1) + after = os.fstat(descriptor) + try: + path_after = os.lstat(str(source)) + except OSError: + _reject_source("source_identity_changed", "source identity changed while it was read") + if ( + len(payload) > MAX_SOURCE_BYTES + or len(payload) != opened.st_size + or after.st_size != opened.st_size + or getattr(after, "st_mtime_ns", None) != getattr(opened, "st_mtime_ns", None) + or not _same_identity(opened, after) + or not _same_identity(opened, path_after) + ): + _reject_source("source_identity_changed", "source identity changed while it was read") + try: + return payload.decode("utf-8-sig") + except UnicodeDecodeError: + _reject_source("source_encoding_invalid", "source must be valid UTF-8 text") + raise AssertionError("unreachable") + + +def _read_explicit_private_source(source: Path) -> str: + with _open_explicit_private_source(source) as (descriptor, opened): + return _read_opened_private_source(source, descriptor, opened) + + +def _unquote_env_value(raw: str, line_number: int) -> str: + value = raw.strip() + if not value: + return "" + if value[0] in "'\"": + quote = value[0] + if len(value) < 2 or value[-1] != quote: + _reject_source( + "source_syntax_invalid", + "source contains an unterminated quoted value at line {0}".format(line_number), + ) + value = value[1:-1] + elif value[-1] in "'\"": + _reject_source( + "source_syntax_invalid", + "source contains an unmatched quote at line {0}".format(line_number), + ) + try: + size = len(value.encode("utf-8")) + except UnicodeEncodeError: + _reject_source("source_value_invalid", "source contains a value that is not valid UTF-8") + if size > MAX_SOURCE_VALUE_BYTES or "\x00" in value: + _reject_source("source_value_invalid", "source contains an unsupported field value") + return value + + +def _parse_env_source(text: str) -> Dict[str, Any]: + values: Dict[str, Any] = {} + grouped_fronts: Dict[Tuple[str, int, str], str] = {} + for line_number, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if "=" not in line: + _reject_source( + "source_syntax_invalid", + "source line {0} must use KEY=VALUE syntax".format(line_number), + ) + name, raw_value = line.split("=", 1) + key = name.strip() + if not _ENV_KEY_RE.fullmatch(key): + _reject_source( + "source_syntax_invalid", + "source contains an invalid key name at line {0}".format(line_number), + ) + set1_match = _ENV_SET1_FRONT_KEY_RE.fullmatch(key) + if set1_match: + endpoint = set1_match.group(1).lower() + "_front" + try: + index = int(set1_match.group(2)) + except ValueError: + _reject_source( + "source_front_pair_invalid", + "source has an invalid CTP_SET1 front pair index", + "ctp_simnow.front_pairs", + ) + if index < 1: + _reject_source( + "source_front_pair_invalid", + "CTP_SET1 front pair indexes must start at 1", + "ctp_simnow.front_pairs", + ) + grouped_key = ("set1", index, endpoint) + elif key in _ENV_SET2_FRONT_KEYS: + grouped_key = ("set2", 1, _ENV_SET2_FRONT_KEYS[key]) + else: + grouped_key = None + + if grouped_key is not None: + value = _unquote_env_value(raw_value, line_number) + if grouped_key in grouped_fronts and grouped_fronts[grouped_key] != value: + _reject_source( + "source_duplicate_field", + "source has conflicting values for ctp_simnow.front_pairs", + "ctp_simnow.front_pairs", + ) + grouped_fronts[grouped_key] = value + continue + + if key.startswith( + ( + "CTP_SET1_MD_FRONT", + "CTP_SET1_TD_FRONT", + "CTP_SET2_MD_FRONT", + "CTP_SET2_TD_FRONT", + ) + ): + _reject_source( + "source_front_pair_invalid", + "source has an unsupported CTP_SET front key", + "ctp_simnow.front_pairs", + ) + + target = _ENV_KEYS.get(key) + if target is None: + # Legacy mode/profile and approval variables are deliberately not + # consulted. In particular they can never select a SimNow set. + continue + value = _unquote_env_value(raw_value, line_number) + if target in values and values[target] != value: + _reject_source( + "source_duplicate_field", + "source has conflicting values for aliases mapped to ctp_simnow.{0}".format(target), + "ctp_simnow.{0}".format(target), + ) + values[target] = value + + has_current_front = bool(_FRONT_PAIR_FIELDS.intersection(values)) + has_grouped_front = bool(grouped_fronts) + if has_grouped_front and has_current_front and not _FRONT_PAIR_FIELDS.issubset(values): + _reject_source( + "source_front_pair_incomplete", + "source must provide both CTP_MD_FRONT and CTP_TD_FRONT", + "ctp_simnow.front_pairs", + ) + + if has_grouped_front: + grouped_pairs = [] + ordered_fronts = sorted( + grouped_fronts, + key=lambda item: (0 if item[0] == "set1" else 1, item[1], item[2]), + ) + seen_slots = set() + ordered_slots = [] + for group, index, _endpoint in ordered_fronts: + slot = (group, index) + if slot not in seen_slots: + seen_slots.add(slot) + ordered_slots.append(slot) + for group, index in ordered_slots: + md_key = (group, index, "md_front") + td_key = (group, index, "td_front") + if md_key not in grouped_fronts or td_key not in grouped_fronts: + _reject_source( + "source_front_pair_incomplete", + "source must provide both MD and TD fronts for every explicit CTP_SET pair", + "ctp_simnow.front_pairs", + ) + grouped_pairs.append( + {"md_front": grouped_fronts[md_key], "td_front": grouped_fronts[td_key]} + ) + + candidates = [] + if has_current_front: + candidates.append( + {"md_front": values.pop("md_front"), "td_front": values.pop("td_front")} + ) + candidates.extend(grouped_pairs) + unique_pairs = [] + seen_pairs = set() + for pair in candidates: + identity = (pair["md_front"], pair["td_front"]) + if identity not in seen_pairs: + seen_pairs.add(identity) + unique_pairs.append(pair) + if len(unique_pairs) > _MAX_FRONT_PAIRS: + _reject_source( + "source_front_pair_invalid", + "source must contain no more than {0} unique CTP front pairs".format( + _MAX_FRONT_PAIRS + ), + "ctp_simnow.front_pairs", + ) + values["front_pairs"] = tuple(unique_pairs) + return values + + +def _parse_yaml_source(text: str) -> Dict[str, Any]: + try: + document = _load_strict_yaml(text) + except RuntimeConfigError: + _reject_source("source_yaml_invalid", "source YAML is invalid or contains duplicate keys") + if type(document) is not dict: + _reject_source( + "source_yaml_schema_invalid", + "source YAML must contain one private CTP mapping with the required CTP fields", + ) + has_canonical = "ctp" in document + has_legacy = "ctp_simnow" in document + if has_canonical and has_legacy: + _reject_source( + "source_yaml_schema_invalid", + "source YAML must contain only one private CTP mapping", + "ctp", + ) + block_name = "ctp" if has_canonical else "ctp_simnow" + if type(document.get(block_name)) is not dict: + _reject_source( + "source_yaml_schema_invalid", + "source YAML must contain one private CTP mapping with the required CTP fields", + block_name, + ) + block = document[block_name] + if any(type(name) is not str for name in block): + _reject_source( + "source_yaml_schema_invalid", + "source {0} field names must be strings".format(block_name), + block_name, + ) + allowed_fields = set(_FIELDS) | {"front_pairs"} + unknown = sorted(set(block) - allowed_fields) + if unknown: + _reject_source( + "source_yaml_schema_invalid", + "source YAML has unsupported {0} fields: {1}".format( + block_name, ", ".join(unknown) + ), + block_name, + ) + has_front_pairs = "front_pairs" in block + has_legacy_fronts = bool(_FRONT_PAIR_FIELDS.intersection(block)) + if has_front_pairs and has_legacy_fronts: + _reject_source( + "source_front_form_conflict", + "source YAML must use either front_pairs or the legacy md_front/td_front pair", + "{0}.front_pairs".format(block_name), + ) + + values: Dict[str, Any] = {} + for name, value in block.items(): + if name == "front_pairs": + values[name] = _parse_yaml_front_pairs(value, field_prefix=block_name) + continue + if type(name) is not str or type(value) is not str: + _reject_source( + "source_yaml_schema_invalid", + "source {0} fields must be strings".format(block_name), + block_name, + ) + values[name] = value + return values + + +def _parse_yaml_front_pairs( + value: Any, *, field_prefix: str = "ctp_simnow" +) -> Tuple[Mapping[str, str], ...]: + """Validate an explicit front-pair list without choosing a candidate.""" + + if type(value) is not list or not 1 <= len(value) <= _MAX_FRONT_PAIRS: + _reject_source( + "source_yaml_schema_invalid", + "source YAML {0}.front_pairs must contain between 1 and {1} pairs".format( + field_prefix, _MAX_FRONT_PAIRS + ), + "{0}.front_pairs".format(field_prefix), + ) + pairs = [] + for index, raw_pair in enumerate(value): + field_path = "{0}.front_pairs[{1}]".format(field_prefix, index) + if type(raw_pair) is not dict or any(type(name) is not str for name in raw_pair): + _reject_source( + "source_yaml_schema_invalid", + "source YAML {0}.front_pairs entries must be mappings".format(field_prefix), + field_path, + ) + if set(raw_pair) != _FRONT_PAIR_FIELDS: + _reject_source( + "source_yaml_schema_invalid", + "source YAML {0}.front_pairs entries must contain md_front and td_front only".format( + field_prefix + ), + field_path, + ) + if any(type(raw_pair[name]) is not str for name in _FRONT_PAIR_FIELDS): + _reject_source( + "source_yaml_schema_invalid", + "source YAML {0}.front_pairs endpoints must be strings".format(field_prefix), + field_path, + ) + pairs.append({"md_front": raw_pair["md_front"], "td_front": raw_pair["td_front"]}) + return tuple(pairs) + + +def _parse_collector_yaml_source(text: str) -> Dict[str, str]: + """Read only the explicit CTP connection fields of a collector config. + + The collector's ``env`` label and timeout never choose fronts or mode. + Contract, exchange and HedgeFlag must arrive from the other explicit + source; the collector format does not contain them. + """ + + try: + document = _load_strict_yaml(text) + except RuntimeConfigError: + _reject_source("source_yaml_invalid", "collector YAML is invalid or has duplicate keys") + if type(document) is not dict or type(document.get("ctp")) is not dict: + _reject_source("source_yaml_schema_invalid", "collector YAML must have one ctp mapping") + block = document["ctp"] + fields = frozenset( + ("md_front", "td_front", "broker_id", "user_id", "password", "app_id", "auth_code") + ) + allowed = fields | {"env", "login_timeout_sec"} + if any(type(name) is not str for name in block) or set(block) - allowed: + _reject_source("source_yaml_schema_invalid", "collector ctp mapping has unsupported fields") + values: Dict[str, str] = {} + for name in fields & set(block): + value = block[name] + if type(value) is not str: + _reject_source( + "source_yaml_schema_invalid", + "collector ctp.{0} must be a string".format(name), + "ctp.{0}".format(name), + ) + values[name] = value + return values + + +def _is_placeholder(value: str) -> bool: + return value.casefold() in _PLACEHOLDER_WORDS or bool(_PLACEHOLDER_RE.fullmatch(value)) + + +def _require_complete_values(values: Mapping[str, Any]) -> Dict[str, Any]: + has_front_pairs = "front_pairs" in values + has_legacy_fronts = bool(_FRONT_PAIR_FIELDS.intersection(values)) + if has_front_pairs and has_legacy_fronts: + _reject_source( + "source_front_form_conflict", + "explicit sources must use either front_pairs or the legacy md_front/td_front pair", + "ctp_simnow.front_pairs", + ) + required_fields = _NON_FRONT_FIELDS + (() if has_front_pairs else ("md_front", "td_front")) + missing = [name for name in required_fields if not values.get(name)] + if missing: + keys = ", ".join("ctp_simnow.{0}".format(name) for name in missing) + raise _error( + "ctp_private_source_incomplete", + "no config.yaml was written; source is missing required non-empty fields: {0}".format( + keys + ), + "ctp_simnow", + ) + for name in required_fields: + value = values[name] + if type(value) is not str or value != value.strip() or "\x00" in value: + _reject_source( + "source_value_invalid", + "source contains an unsupported value for ctp_simnow.{0}".format(name), + "ctp_simnow.{0}".format(name), + ) + try: + encoded_size = len(value.encode("utf-8")) + except UnicodeEncodeError: + _reject_source( + "source_value_invalid", + "source contains an unsupported value for ctp_simnow.{0}".format(name), + "ctp_simnow.{0}".format(name), + ) + if encoded_size > MAX_SOURCE_VALUE_BYTES: + _reject_source( + "source_value_invalid", + "source value is too large for ctp_simnow.{0}".format(name), + "ctp_simnow.{0}".format(name), + ) + if _is_placeholder(value): + _reject_source( + "source_placeholder_value", + "placeholder values are not accepted for ctp_simnow.{0}".format(name), + "ctp_simnow.{0}".format(name), + ) + complete: Dict[str, Any] = {name: values[name] for name in _NON_FRONT_FIELDS} + if has_front_pairs: + # The authoritative runtime parser performs canonical TCP endpoint and + # duplicate-pair validation before the target directory is changed. + complete["front_pairs"] = values["front_pairs"] + else: + complete["md_front"] = values["md_front"] + complete["td_front"] = values["td_front"] + return complete + + +def _render_config( + values: Mapping[str, Any], *, runtime_id: str = ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID +) -> str: + try: + strategy_id = _CTP_PRIVATE_CONFIG_TARGETS[runtime_id][1] + except (KeyError, TypeError): + raise _error( + "private_target_runtime_id_invalid", + "private CTP config identity must be a reviewed code-owned runtime ID", + "strategy.id", + ) from None + lines = [ + "config_schema_version: 4", + "strategy:", + " id: " + json.dumps(strategy_id), + "runtime:", + " mode: simulation", + " preset: sandbox", + "parameters: {}", + "secrets_ref: config_yaml", + "ctp:", + ] + if "front_pairs" in values: + lines.extend( + ( + " front_pairs:", + *( + " - md_front: {0}\n td_front: {1}".format( + json.dumps(pair["md_front"], ensure_ascii=True), + json.dumps(pair["td_front"], ensure_ascii=True), + ) + for pair in values["front_pairs"] + ), + ) + ) + scalar_fields = _NON_FRONT_FIELDS + else: + scalar_fields = _FIELDS + lines.extend( + " {0}: {1}".format(name, json.dumps(values[name], ensure_ascii=True)) + for name in scalar_fields + ) + return "\n".join(lines) + "\n" + + +def _same_directory_entry(directory_fd: int, target_dir: Path, name: str = "config.yaml") -> bool: + try: + os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + return True + except FileNotFoundError: + return False + except (NotImplementedError, TypeError): + try: + os.lstat(str(target_dir / name)) + return True + except FileNotFoundError: + return False + + +def _windows_user_sid() -> Tuple[Any, Any, str]: + """Return the current user SID and loaded Win32 libraries for ACL creation.""" + + try: + import ctypes + + advapi32 = ctypes.WinDLL("Advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + from .credential_resolver import _current_windows_user_sid + + sid_text = _current_windows_user_sid(advapi32, kernel32) + return advapi32, kernel32, sid_text + except Exception: + _reject_source( + "private_target_acl_unavailable", "Windows could not establish a private target ACL" + ) + raise AssertionError("unreachable") + + +def _set_windows_owner_acl(descriptor: int, *, directory: bool) -> None: + """Install a protected owner/SYSTEM/Administrators ACL before writing bytes.""" + + try: + import ctypes + from ctypes import wintypes + + from .credential_resolver import _windows_acl_for_handle, _windows_os_handle + + advapi32, kernel32, user_sid = _windows_user_sid() + inheritable = "OICI;FA" if directory else ";FA" + sddl = "O:{0}D:P(A;{1};;;{0})(A;{1};;;SY)(A;{1};;;BA)".format(user_sid, inheritable) + security_descriptor = ctypes.c_void_p() + convert = advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW + convert.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.DWORD), + ) + convert.restype = wintypes.BOOL + if not convert(sddl, 1, ctypes.byref(security_descriptor), None): + raise OSError(ctypes.get_last_error()) + try: + get_dacl = advapi32.GetSecurityDescriptorDacl + get_dacl.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.BOOL), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.BOOL), + ) + get_dacl.restype = wintypes.BOOL + dacl = ctypes.c_void_p() + dacl_present = wintypes.BOOL() + dacl_defaulted = wintypes.BOOL() + if ( + not get_dacl( + security_descriptor, + ctypes.byref(dacl_present), + ctypes.byref(dacl), + ctypes.byref(dacl_defaulted), + ) + or not dacl_present.value + or not dacl + ): + raise OSError("private ACL has no DACL") + set_security = advapi32.SetSecurityInfo + set_security.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ) + set_security.restype = wintypes.DWORD + result = set_security( + wintypes.HANDLE(_windows_os_handle(descriptor)), + 1, + 0x00000004 | 0x80000000, + None, + None, + dacl, + None, + ) + if result != 0: + raise OSError(result, "SetSecurityInfo failed") + _windows_acl_for_handle(_windows_os_handle(descriptor)) + finally: + kernel32.LocalFree.argtypes = (ctypes.c_void_p,) + kernel32.LocalFree.restype = ctypes.c_void_p + kernel32.LocalFree(security_descriptor) + except RuntimeConfigError: + raise + except Exception: + _reject_source( + "private_target_acl_unavailable", + "Windows could not install and verify the private owner-only ACL", + "config.yaml", + ) + + +@contextmanager +def _verified_target_directory( + runtime_dir: Path, on_identity_change: Optional[Callable[[], None]] = None +) -> Iterator[int]: + before_chain = None + if os.name == "posix": + before_chain, descriptor = _open_posix_directory_chain(runtime_dir) + before = before_chain[-1][1] + elif os.name == "nt": + before_chain = _windows_directory_identity_chain(runtime_dir) + before = before_chain[-1][1] + else: + _reject_source( + "private_target_security_unavailable", + "private target security is unsupported on this platform", + "config.yaml", + ) + if ( + stat.S_ISLNK(before.st_mode) + or _is_windows_reparse(before) + or not stat.S_ISDIR(before.st_mode) + ): + _reject_source( + "private_target_directory_invalid", + "reserved CTP private runtime path must be a real directory", + "config.yaml", + ) + if os.name == "nt": + try: + import ctypes + import msvcrt + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + create_file.restype = wintypes.HANDLE + handle = create_file( + str(runtime_dir), + 0x80000000 | 0x00040000 | 0x00000002, + 0x00000001 | 0x00000002, + None, + 3, + 0x02000000 | 0x00200000, + None, + ) + invalid_handle = ctypes.c_void_p(-1).value + if handle == invalid_handle or handle == -1: + raise OSError(ctypes.get_last_error()) + descriptor = msvcrt.open_osfhandle(handle, os.O_RDONLY | getattr(os, "O_NOINHERIT", 0)) + except Exception: + _reject_source( + "private_target_directory_unavailable", + "Windows could not lock the reserved CTP private runtime directory", + "config.yaml", + ) + + def cleanup_created_file() -> None: + if on_identity_change is not None: + on_identity_change() + + try: + opened = os.fstat(descriptor) + if not stat.S_ISDIR(opened.st_mode) or not _same_identity(before, opened): + _reject_source( + "private_target_identity_changed", + "reserved CTP private runtime directory identity changed", + "config.yaml", + ) + if os.name == "nt" and not _same_directory_identity_chain( + before_chain, _windows_directory_identity_chain(runtime_dir) + ): + _reject_source( + "private_target_identity_changed", + "reserved CTP private runtime directory identity changed", + "config.yaml", + ) + try: + yield descriptor + except BaseException: + cleanup_created_file() + raise + try: + after = os.fstat(descriptor) + if os.name == "nt": + current_chain = _windows_directory_identity_chain(runtime_dir) + current = current_chain[-1][1] + elif os.name == "posix": + current_chain, current_descriptor = _open_posix_directory_chain(runtime_dir) + try: + current = os.fstat(current_descriptor) + finally: + os.close(current_descriptor) + else: + current = os.lstat(str(runtime_dir)) + except RuntimeConfigError: + cleanup_created_file() + _reject_source( + "private_target_identity_changed", + "reserved CTP private runtime directory identity changed", + "config.yaml", + ) + except OSError: + cleanup_created_file() + _reject_source( + "private_target_identity_changed", + "reserved CTP private runtime directory identity changed", + "config.yaml", + ) + identity_changed = not _same_identity(opened, after) or not _same_identity(opened, current) + if os.name in ("nt", "posix"): + identity_changed = identity_changed or not _same_directory_identity_chain( + before_chain, current_chain + ) + if identity_changed: + cleanup_created_file() + _reject_source( + "private_target_identity_changed", + "reserved CTP private runtime directory identity changed", + "config.yaml", + ) + finally: + os.close(descriptor) + + +def _protect_target_directory(descriptor: int) -> None: + if os.name == "posix": + opened = os.fstat(descriptor) + if opened.st_uid != os.geteuid(): + _reject_source( + "private_target_owner_mismatch", + "reserved CTP private runtime directory must be owned by the current user", + "config.yaml", + ) + try: + os.fchmod(descriptor, 0o700) + except OSError: + _reject_source( + "private_target_protection_failed", + "could not restrict the reserved CTP private runtime directory permissions", + "config.yaml", + ) + mode = stat.S_IMODE(os.fstat(descriptor).st_mode) + if mode & ~0o700 or mode & 0o500 != 0o500: + _reject_source( + "private_target_protection_failed", + "reserved CTP private runtime directory is not owner-only", + "config.yaml", + ) + elif os.name == "nt": + _set_windows_owner_acl(descriptor, directory=True) + + +def _mark_windows_handle_for_deletion(handle: int) -> None: + """Mark an opened Windows file for deletion without resolving its path again.""" + + import ctypes + from ctypes import wintypes + + class _FileDispositionInfo(ctypes.Structure): + _fields_ = (("delete_file", wintypes.BOOLEAN),) + + disposition = _FileDispositionInfo(True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + set_info = kernel32.SetFileInformationByHandle + set_info.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD) + set_info.restype = wintypes.BOOL + if not set_info( + wintypes.HANDLE(handle), + 4, # FileDispositionInfo + ctypes.byref(disposition), + ctypes.sizeof(disposition), + ): + raise OSError(ctypes.get_last_error(), "could not remove partial private config") + + +def _mark_windows_file_for_deletion(descriptor: int) -> None: + """Mark a CRT-opened Windows file for deletion by its handle.""" + + from .credential_resolver import _windows_os_handle + + _mark_windows_handle_for_deletion(_windows_os_handle(descriptor)) + + +def _cleanup_windows_handle_conversion(output_handle, output_fd, kernel32) -> bool: + """Delete and close whichever object owns a newly created file handle.""" + + cleanup_failed = False + if output_fd is not None: + # Once open_osfhandle returns, the CRT descriptor owns the native + # handle even if interruption lands before output_handle is cleared. + try: + _mark_windows_file_for_deletion(output_fd) + except BaseException: + cleanup_failed = True + try: + os.close(output_fd) + except BaseException: + cleanup_failed = True + elif output_handle is not None and output_handle.value: + from ctypes import wintypes + + try: + _mark_windows_handle_for_deletion(output_handle.value) + except BaseException: + cleanup_failed = True + kernel32.CloseHandle.argtypes = (wintypes.HANDLE,) + kernel32.CloseHandle.restype = wintypes.BOOL + try: + close_succeeded = kernel32.CloseHandle(output_handle) + except BaseException: + close_succeeded = False + if not close_succeeded: + cleanup_failed = True + return cleanup_failed + + +def _create_windows_private_file_descriptor(directory_descriptor: int) -> int: + """Create an empty protected file relative to the verified directory handle.""" + + import ctypes + import msvcrt + from ctypes import wintypes + + from .credential_resolver import _windows_acl_for_handle, _windows_os_handle + + advapi32, kernel32, user_sid = _windows_user_sid() + sddl = "O:{0}D:P(A;;FA;;;{0})(A;;FA;;;SY)(A;;FA;;;BA)".format(user_sid) + security_descriptor = ctypes.c_void_p() + convert = advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW + convert.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.DWORD), + ) + convert.restype = wintypes.BOOL + if not convert(sddl, 1, ctypes.byref(security_descriptor), None): + raise OSError(ctypes.get_last_error()) + + class _UnicodeString(ctypes.Structure): + _fields_ = ( + ("length", wintypes.USHORT), + ("maximum_length", wintypes.USHORT), + ("buffer", wintypes.LPWSTR), + ) + + class _ObjectAttributes(ctypes.Structure): + _fields_ = ( + ("length", wintypes.ULONG), + ("root_directory", wintypes.HANDLE), + ("object_name", ctypes.POINTER(_UnicodeString)), + ("attributes", wintypes.ULONG), + ("security_descriptor", ctypes.c_void_p), + ("security_quality_of_service", ctypes.c_void_p), + ) + + class _IoStatusBlock(ctypes.Structure): + _fields_ = (("status", ctypes.c_void_p), ("information", ctypes.c_size_t)) + + output_fd = None + output_handle = None + ntdll = ctypes.WinDLL("ntdll") + cleanup_failed = False + try: + filename_buffer = ctypes.create_unicode_buffer("config.yaml") + filename = _UnicodeString( + len("config.yaml") * ctypes.sizeof(wintypes.WCHAR), + (len("config.yaml") + 1) * ctypes.sizeof(wintypes.WCHAR), + ctypes.cast(filename_buffer, wintypes.LPWSTR), + ) + object_attributes = _ObjectAttributes( + ctypes.sizeof(_ObjectAttributes), + wintypes.HANDLE(_windows_os_handle(directory_descriptor)), + ctypes.pointer(filename), + 0x40, # OBJ_CASE_INSENSITIVE + security_descriptor, + None, + ) + io_status = _IoStatusBlock() + create_file = ntdll.NtCreateFile + create_file.argtypes = ( + ctypes.POINTER(wintypes.HANDLE), + wintypes.DWORD, + ctypes.POINTER(_ObjectAttributes), + ctypes.POINTER(_IoStatusBlock), + ctypes.c_void_p, + wintypes.ULONG, + wintypes.ULONG, + wintypes.ULONG, + wintypes.ULONG, + ctypes.c_void_p, + wintypes.ULONG, + ) + create_file.restype = wintypes.LONG + output_handle = wintypes.HANDLE() + status = create_file( + ctypes.byref(output_handle), + 0x40000000 | 0x00020000 | 0x00010000 | 0x00100000 | 0x00000080, + ctypes.byref(object_attributes), + ctypes.byref(io_status), + None, + 0x00000080, # FILE_ATTRIBUTE_NORMAL + 0, + 2, # FILE_CREATE (fail if the name already exists) + 0x00000040 | 0x00000020, # FILE_NON_DIRECTORY_FILE | synchronous I/O + None, + 0, + ) + if status < 0: + if ctypes.c_uint32(status).value == 0xC0000035: # STATUS_OBJECT_NAME_COLLISION + raise FileExistsError(183, "config.yaml already exists") + to_dos_error = ntdll.RtlNtStatusToDosError + to_dos_error.argtypes = (wintypes.LONG,) + to_dos_error.restype = wintypes.ULONG + error_code = int(to_dos_error(status)) + raise OSError(error_code, "config.yaml could not be created") + output_fd = msvcrt.open_osfhandle( + output_handle.value, + os.O_WRONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0), + ) + output_handle = None # the CRT descriptor now owns the Win32 handle + finally: + if output_handle is not None: + cleanup_failed = ( + _cleanup_windows_handle_conversion(output_handle, output_fd, kernel32) + or cleanup_failed + ) + kernel32.LocalFree.argtypes = (ctypes.c_void_p,) + kernel32.LocalFree.restype = ctypes.c_void_p + kernel32.LocalFree(security_descriptor) + if cleanup_failed: + _reject_source( + "private_config_cleanup_failed", + "config.yaml could not be safely removed after handle conversion failed", + "config.yaml", + ) + + try: + _windows_acl_for_handle(_windows_os_handle(output_fd)) + except BaseException: + cleanup_failed = False + try: + _mark_windows_file_for_deletion(output_fd) + except BaseException: + cleanup_failed = True + try: + os.close(output_fd) + except OSError: + cleanup_failed = True + if cleanup_failed: + _reject_source( + "private_config_cleanup_failed", + "config.yaml could not be safely removed after ACL verification failed", + "config.yaml", + ) + raise + return output_fd + + +def _create_private_file(descriptor: int, target: Path, content: bytes) -> _CreatedPrivateFile: + if os.name == "posix": + try: + output_fd = os.open( + "config.yaml", + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=descriptor, + ) + except FileExistsError: + raise RuntimeConfigError( + CONFIG_EXISTS, + "config.yaml already exists and will not be overwritten", + field_path="config.yaml", + reason="config_exists", + ) from None + except OSError: + _reject_source( + "private_config_create_failed", + "config.yaml could not be created safely", + "config.yaml", + ) + else: + try: + output_fd = _create_windows_private_file_descriptor(descriptor) + except FileExistsError: + raise RuntimeConfigError( + CONFIG_EXISTS, + "config.yaml already exists and will not be overwritten", + field_path="config.yaml", + reason="config_exists", + ) from None + except OSError: + _reject_source( + "private_config_create_failed", + "config.yaml could not be created safely", + "config.yaml", + ) + except RuntimeConfigError: + raise + + retained_fd = None + output = None + descriptor_owned = True + cleanup_failed = False + delete_requested = False + created_stat = None + + def request_delete(file_descriptor: int) -> None: + nonlocal cleanup_failed, delete_requested + if os.name != "nt" or delete_requested: + return + try: + _mark_windows_file_for_deletion(file_descriptor) + except BaseException: + cleanup_failed = True + else: + delete_requested = True + + try: + created_stat = os.fstat(output_fd) + output = os.fdopen(output_fd, "wb") + descriptor_owned = False + with output: + try: + output.write(content) + output.flush() + os.fsync(output.fileno()) + written_stat = os.fstat(output.fileno()) + if os.name == "posix": + entry_stat = os.stat("config.yaml", dir_fd=descriptor, follow_symlinks=False) + else: + entry_stat = os.lstat(str(target)) + from .credential_resolver import _windows_acl_for_handle, _windows_os_handle + + _windows_acl_for_handle(_windows_os_handle(output.fileno())) + if ( + not _same_identity(created_stat, written_stat) + or not _same_identity(created_stat, entry_stat) + or written_stat.st_nlink != 1 + or written_stat.st_size != len(content) + ): + raise OSError("created private config identity changed") + if os.name == "posix" and ( + written_stat.st_uid != os.geteuid() + or stat.S_IMODE(written_stat.st_mode) & ~0o600 + or stat.S_IMODE(written_stat.st_mode) & stat.S_IRUSR == 0 + ): + raise OSError("created private config permissions are unsafe") + if os.name == "nt": + retained_fd = os.dup(output.fileno()) + except BaseException: + request_delete(output.fileno()) + raise + except BaseException as failure: + if descriptor_owned: + request_delete(output_fd) + try: + os.close(output_fd) + except OSError: + cleanup_failed = True + elif output is not None and not output.closed: + request_delete(output.fileno()) + try: + output.close() + except OSError: + cleanup_failed = True + if retained_fd is not None: + request_delete(retained_fd) + try: + os.close(retained_fd) + except BaseException: + cleanup_failed = True + retained_fd = None + if os.name == "posix": + try: + if created_stat is None: + raise OSError("created file identity could not be verified") + _unlink_posix_created_file(descriptor, created_stat) + except BaseException: + cleanup_failed = True + if cleanup_failed: + _reject_source( + "private_config_cleanup_failed", + "config.yaml could not be confirmed removed after a write failure", + "config.yaml", + ) + if not isinstance(failure, Exception): + raise + _reject_source( + "private_config_write_failed", + "config.yaml could not be written completely; partial file cleanup was completed", + "config.yaml", + ) + return _CreatedPrivateFile(created_stat, retained_fd) + + +def _normalize_source_specs( + sources: Sequence[Tuple[Union[os.PathLike, str], str]], +) -> Tuple[Tuple[Path, str], ...]: + if isinstance(sources, (str, bytes)): + _reject_source( + "source_list_invalid", "explicit sources must be supplied as path/format pairs" + ) + try: + items = tuple(sources) + except TypeError: + _reject_source( + "source_list_invalid", "explicit sources must be supplied as path/format pairs" + ) + if not items or len(items) > 2: + _reject_source( + "source_count_invalid", + "provide one explicit source or two different explicit source formats", + ) + + normalized = [] + seen_formats = set() + seen_paths = set() + for item in items: + if not isinstance(item, (tuple, list)) or len(item) != 2: + _reject_source( + "source_list_invalid", + "each explicit source must name a path and its env, yaml or collector_yaml format", + ) + source_path, source_format = item + if source_format not in ("env", "yaml", "collector_yaml"): + _reject_source("source_format_invalid", "source format must be explicitly named") + if source_format in seen_formats: + _reject_source( + "source_format_duplicate", + "provide at most one source of each explicit format", + ) + seen_formats.add(source_format) + try: + source = Path(source_path).expanduser() + except (TypeError, ValueError): + _reject_source("source_path_invalid", "source path must be an explicit local file path") + if not source.is_absolute(): + _reject_source( + "source_path_must_be_absolute", + "name the local source with an absolute path; no working-directory search is used", + ) + identity = os.path.normcase(os.path.normpath(str(source))) + if identity in seen_paths: + _reject_source( + "source_path_duplicate", + "the same local source path cannot be supplied more than once", + ) + seen_paths.add(identity) + normalized.append((source, source_format)) + return tuple(normalized) + + +def _resolve_private_config_target(runtime_id: Optional[str]) -> Tuple[Path, str]: + """Resolve only the exact code-owned private target selected by runtime ID.""" + + if runtime_id is not None: + if type(runtime_id) is not str or runtime_id not in _CTP_PRIVATE_CONFIG_TARGETS: + raise _error( + "private_target_runtime_id_invalid", + "private CTP config target must be a reviewed code-owned runtime ID", + "--runtime-id", + ) + return _CTP_PRIVATE_CONFIG_TARGETS[runtime_id][0], runtime_id + + return ( + _CTP_PRIVATE_CONFIG_TARGETS[ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID][0], + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ) + + +def prepare_ctp_simnow_config_sources( + sources: Sequence[Tuple[Union[os.PathLike, str], str]], + *, + runtime_id: Optional[str] = None, +) -> PreparedCtpSimNowConfig: + """Create one protected config from one or two explicitly named sources. + + Every source must be an absolute owner-only local file. All source file + descriptors and ACLs are validated before any source bytes are read. If + fields appear in both sources, the values must match exactly. One explicit + exception combines a YAML ``front_pairs`` list with the complete legacy + pair in an ``.env`` source only when that exact pair is already a listed + candidate; the complete YAML list is retained without selecting a pair. + The function never reads ``os.environ``, searches for files, derives + endpoints from a profile, overwrites an existing config, or starts a + provider. + """ + + target_dir, target_runtime_id = _resolve_private_config_target(runtime_id) + return _prepare_ctp_simnow_config_sources_at_target( + sources, + target_dir=target_dir, + target_runtime_id=target_runtime_id, + ) + + +def _prepare_ctp_simnow_config_sources_at_target( + sources: Sequence[Tuple[Union[os.PathLike, str], str]], + *, + target_dir: Path, + target_runtime_id: str, +) -> PreparedCtpSimNowConfig: + """Shared writer core. Callers must supply a reviewed or test-only target.""" + + source_specs = _normalize_source_specs(sources) + if not target_dir.is_absolute(): + raise _error( + "private_target_path_invalid", + "private runtime target must be an absolute code-owned path", + "config.yaml", + ) + target_dir = Path(os.path.abspath(str(target_dir))) + + created_file = None + target_descriptor = None + + def cleanup_created_file() -> None: + nonlocal created_file + if created_file is None: + return + cleanup_failed = False + if os.name == "nt": + retained_fd = created_file.retained_fd + if retained_fd is None: + return + try: + _mark_windows_file_for_deletion(retained_fd) + except BaseException: + cleanup_failed = True + finally: + try: + os.close(retained_fd) + except BaseException: + cleanup_failed = True + created_file = None + else: + try: + if target_descriptor is None: + raise OSError("private target directory handle is unavailable") + _unlink_posix_created_file(target_descriptor, created_file.identity) + except BaseException: + cleanup_failed = True + else: + created_file = None + if cleanup_failed: + _reject_source( + "private_config_cleanup_failed", + "config.yaml could not be confirmed removed after a target identity change", + "config.yaml", + ) + + with _verified_target_directory(target_dir, cleanup_created_file) as target_fd: + target_descriptor = target_fd + if _same_directory_entry(target_fd, target_dir): + raise RuntimeConfigError( + CONFIG_EXISTS, + "config.yaml already exists and will not be overwritten", + field_path="config.yaml", + reason="config_exists", + ) + + # Establish every source's type, identity, ownership and access policy + # before reading even the first source's bytes. Keeping the opened + # handles pins the validated identities through the merge. + with ExitStack() as source_stack: + opened_sources = [] + for source, source_format in source_specs: + descriptor, opened = source_stack.enter_context( + _open_explicit_private_source(source) + ) + opened_sources.append((source, source_format, descriptor, opened)) + + values: Dict[str, Any] = {} + source_values_by_format: Dict[str, Mapping[str, Any]] = {} + front_pairs_source_format = None + legacy_front_source_formats: Dict[str, str] = {} + for source, source_format, descriptor, opened in opened_sources: + text = _read_opened_private_source(source, descriptor, opened) + if source_format == "env": + source_values = _parse_env_source(text) + elif source_format == "collector_yaml": + source_values = _parse_collector_yaml_source(text) + else: + source_values = _parse_yaml_source(text) + source_values_by_format[source_format] = source_values + if "front_pairs" in source_values: + front_pairs_source_format = source_format + for name in _FRONT_PAIR_FIELDS.intersection(source_values): + legacy_front_source_formats[name] = source_format + for name, value in source_values.items(): + if name in values and values[name] != value: + _reject_source( + "source_field_conflict", + "explicit sources disagree on ctp_simnow.{0}".format(name), + "ctp_simnow.{0}".format(name), + ) + values[name] = value + + if "front_pairs" in values and legacy_front_source_formats: + env_source_values = source_values_by_format.get("env", {}) + env_has_complete_pair = all( + name in env_source_values for name in ("md_front", "td_front") + ) + allowed_env_pair = ( + front_pairs_source_format == "yaml" + and env_has_complete_pair + and legacy_front_source_formats.get("md_front") == "env" + and legacy_front_source_formats.get("td_front") == "env" + ) + if not allowed_env_pair: + _reject_source( + "source_front_form_conflict", + "front_pairs may be combined with legacy fronts only from an .env pair already listed in YAML", + "ctp_simnow.front_pairs", + ) + env_pair = (env_source_values["md_front"], env_source_values["td_front"]) + yaml_pairs = tuple( + (pair["md_front"], pair["td_front"]) for pair in values["front_pairs"] + ) + if env_pair not in yaml_pairs: + _reject_source( + "source_front_pair_not_in_candidates", + "the explicit .env front pair must exactly match a YAML front_pairs candidate", + "ctp_simnow.front_pairs", + ) + # Both environment endpoints have been explicitly checked against + # the list, so the generated config uses the full YAML candidate + # list and cannot lose or reorder alternatives. + values.pop("md_front") + values.pop("td_front") + + complete = _require_complete_values(values) + content = _render_config(complete, runtime_id=target_runtime_id) + # Run the authoritative schema parser before changing target ACLs or + # creating a file. Errors report field names only, never source values. + parsed = _parse_verified_runtime_config_text( + content, + target_dir, + target_dir / "config.yaml", + ) + _protect_target_directory(target_fd) + created_file = _create_private_file( + target_fd, target_dir / "config.yaml", content.encode("utf-8") + ) + if os.name == "posix": + os.fsync(target_fd) + prepared = PreparedCtpSimNowConfig(target_dir / "config.yaml", parsed.config_digest) + if created_file is not None and created_file.retained_fd is not None: + os.close(created_file.retained_fd) + return prepared + + +def _prepare_ctp_simnow_config_sources_for_test( + sources: Sequence[Tuple[Union[os.PathLike, str], str]], + *, + runtime_dir: Union[os.PathLike, str], +) -> PreparedCtpSimNowConfig: + """TEST ONLY: write through the production gates to an isolated temporary target. + + This private seam exists for unit tests that need disposable output paths. It is not + used by the CLI and must not be called by production integrations. + """ + + return _prepare_ctp_simnow_config_sources_at_target( + sources, + target_dir=Path(runtime_dir), + target_runtime_id=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + ) + + +def _prepare_ctp_simnow_config_for_test( + source_path: Union[os.PathLike, str], + *, + source_format: str, + runtime_dir: Union[os.PathLike, str], +) -> PreparedCtpSimNowConfig: + """TEST ONLY: single-source wrapper for the isolated temporary-target seam.""" + + return _prepare_ctp_simnow_config_sources_for_test( + ((source_path, source_format),), runtime_dir=runtime_dir + ) + + +def prepare_ctp_simnow_config( + source_path: Union[os.PathLike, str], + *, + source_format: str, + runtime_id: Optional[str] = None, +) -> PreparedCtpSimNowConfig: + """Create one protected schema-v4 SimNow config from one explicit source.""" + + return prepare_ctp_simnow_config_sources( + ((source_path, source_format),), + runtime_id=runtime_id, + ) diff --git a/backtrader_runtime/ctp_production_execution_admission.py b/backtrader_runtime/ctp_production_execution_admission.py new file mode 100644 index 00000000..910b2957 --- /dev/null +++ b/backtrader_runtime/ctp_production_execution_admission.py @@ -0,0 +1,787 @@ +"""Non-authorizing scope contract for a future bounded CTP production writer. + +This module binds a code-owned production execution registration to the sealed +production ``config.yaml`` and trusted runtime registry. It does not resolve +credentials, verify installed SDK artifacts or receipts, import an SDK, create a +provider session, or grant any write authority. The current default inventory +does not contain a registration of this type. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import weakref +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, Optional, Tuple + +from .config import ( + CtpPrivateConfig, + CtpProductionPrivateConfig, + RuntimeConfig, + require_loaded_runtime_config_seal, +) +from .ctp_production_readonly_admission import production_account_binding_sha256 +from .errors import RuntimeConfigError +from .policy import MANAGED_WRITE_CAPABILITIES +from .registry import ( + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + require_effective_runtime_config_seal, +) + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_PRODUCTION_ID_RE = re.compile(r"^ctp-production:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") +_HEDGE_FLAGS = frozenset(("1", "2", "3")) +_SIDES = frozenset(("BUY", "SELL")) +_PRODUCTION_ENVIRONMENT = "production" +_SECRETS_REF = "config_yaml" +_EXECUTION_REGISTRATION_SEALS: Dict[int, Tuple[Any, str]] = {} + + +class CtpProductionExecutionAdmissionError(ValueError): + """Redacted fail-closed rejection for a production execution scope.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise CtpProductionExecutionAdmissionError(reason, message) + + +def _require_digest(value: Any, name: str) -> str: + if type(value) is not str or _SHA256_RE.fullmatch(value) is None: + _reject("invalid_registration", "invalid {0}".format(name)) + return value + + +def _require_production_identity(value: Any, name: str) -> str: + if type(value) is not str or _PRODUCTION_ID_RE.fullmatch(value) is None: + _reject( + "invalid_registration", + "{0} must use the CTP production identity namespace".format(name), + ) + return value + + +def _front_pair_digest(md_front: str, td_front: str) -> str: + return hashlib.sha256( + b"backtrader-ctp-production-front-pair-v1\0" + _canonical([md_front, td_front]) + ).hexdigest() + + +def _scope_identity_digest( + *, + config_digest: str, + effective_digest: Optional[str], + registration_digest: str, + account_binding_sha256: str, + instrument_id: str, + exchange_id: str, + hedge_flag: str, + front_pair_set_sha256: str, + front_pair_sha256: str, +) -> str: + """Return non-authorizing identity metadata for one resolved scope.""" + + return hashlib.sha256( + b"backtrader-ctp-production-config-scope-identity-v1\0" + + _canonical( + { + "account_binding_sha256": account_binding_sha256, + "config_digest": config_digest, + "effective_digest": effective_digest, + "exchange_id": exchange_id, + "front_pair_sha256": front_pair_sha256, + "front_pair_set_sha256": front_pair_set_sha256, + "hedge_flag": hedge_flag, + "instrument_id": instrument_id, + "registration_digest": registration_digest, + } + ) + ).hexdigest() + + +def production_front_pair_set_sha256(front_pairs: Any) -> str: + """Digest an ordered, code-approved production front-pair candidate set. + + This helper only binds approval metadata to explicit configured values. It + never probes or selects an endpoint. + """ + + if type(front_pairs) not in (tuple, list) or not 1 <= len(front_pairs) <= 8: + _reject("invalid_registration", "invalid approved production front-pair set") + normalized = [] + for pair in front_pairs: + if type(pair) not in (tuple, list) or len(pair) != 2: + _reject("invalid_registration", "invalid approved production front-pair set") + md_front, td_front = pair + for value in (md_front, td_front): + if ( + type(value) is not str + or not value + or value != value.strip() + or len(value) > 128 + or any(character.isspace() or ord(character) < 0x20 for character in value) + ): + _reject("invalid_registration", "invalid approved production front pair") + normalized.append((md_front, td_front)) + if len(set(normalized)) != len(normalized): + _reject("invalid_registration", "approved production front-pair set has duplicates") + return hashlib.sha256( + b"backtrader-ctp-production-front-pair-set-v1\0" + _canonical(normalized) + ).hexdigest() + + +def _require_decimal(value: Any, name: str, *, positive: bool = True) -> Decimal: + if type(value) not in (str, int, float, Decimal): + _reject("invalid_order_limits", "invalid {0}".format(name)) + try: + result = Decimal(str(value)) + except (InvalidOperation, ValueError): + _reject("invalid_order_limits", "invalid {0}".format(name)) + if not result.is_finite() or (positive and result <= 0): + _reject("invalid_order_limits", "invalid {0}".format(name)) + return result + + +def _canonical(payload: Any) -> bytes: + return json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode( + "ascii" + ) + + +def _execution_registration_payload( + registration: "CtpProductionExecutionRegistration", +) -> dict[str, Any]: + runtime = registration.runtime_registration + return { + "account_binding_sha256": registration.account_binding_sha256, + "allowed_offsets": registration.allowed_offsets, + "allowed_sides": registration.allowed_sides, + "approval_receipt_id": registration.approval_receipt_id, + "approval_receipt_sha256": registration.approval_receipt_sha256, + "artifact_id": registration.artifact_id, + "artifact_sha256": registration.artifact_sha256, + "approved_front_pair_set_sha256": registration.approved_front_pair_set_sha256, + "environment": registration.environment, + "exchange_id": registration.exchange_id, + "hedge_flag": registration.hedge_flag, + "instrument_id": registration.instrument_id, + "max_gross_position": registration.max_gross_position, + "max_order_notional": str(registration.max_order_notional), + "max_order_quantity": registration.max_order_quantity, + "max_price": str(registration.max_price), + "md_front": registration.md_front, + "min_price": str(registration.min_price), + "price_tick": str(registration.price_tick), + "quantity_step": registration.quantity_step, + "runtime_registration_digest": runtime.digest, + "runtime_dir": str(runtime.runtime_dir), + "runtime_id": runtime.runtime_id, + "strategy_id": runtime.strategy_id, + "scope_binding_mode": registration.scope_binding_mode, + "td_front": registration.td_front, + } + + +def _payload_digest(payload: Any) -> str: + return hashlib.sha256(_canonical(payload)).hexdigest() + + +def _seal_registration(registration: "CtpProductionExecutionRegistration") -> None: + identifier = id(registration) + + def discard(reference: Any) -> None: + current = _EXECUTION_REGISTRATION_SEALS.get(identifier) + if current is not None and current[0] is reference: + _EXECUTION_REGISTRATION_SEALS.pop(identifier, None) + + reference = weakref.ref(registration, discard) + _EXECUTION_REGISTRATION_SEALS[identifier] = ( + reference, + _payload_digest(_execution_registration_payload(registration)), + ) + + +def _require_registration_seal(registration: "CtpProductionExecutionRegistration") -> None: + current = _EXECUTION_REGISTRATION_SEALS.get(id(registration)) + if current is None or current[0]() is not registration: + _reject("registration_provenance_invalid", "production registration was not sealed") + try: + digest = _payload_digest(_execution_registration_payload(registration)) + except CtpProductionExecutionAdmissionError: + raise + except Exception: + _reject("registration_provenance_invalid", "production registration is invalid") + if digest != current[1]: + _reject("registration_provenance_invalid", "production registration changed after sealing") + + +@dataclass(frozen=True) +class CtpProductionExecutionRegistration: + """Code-owned bounds and production-only receipt/artifact identities. + + Receipt and artifact identities use the ``ctp-production:`` namespace and + are independent values. They are identity bindings only: this contract + does not verify a receipt signature or prove that an installed artifact + matches the digest. The initial supported order envelope is opening limit + orders with exact cancellation; close offsets are rejected. + """ + + runtime_registration: RegisteredRuntime + environment: str + account_binding_sha256: Optional[str] = field(repr=False) + md_front: Optional[str] = field(repr=False) + td_front: Optional[str] = field(repr=False) + instrument_id: Optional[str] + exchange_id: Optional[str] + hedge_flag: Optional[str] + approval_receipt_id: str + approval_receipt_sha256: str + artifact_id: str + artifact_sha256: str + allowed_sides: Tuple[str, ...] + allowed_offsets: Tuple[str, ...] + quantity_step: int + max_order_quantity: int + max_gross_position: int + min_price: Decimal + max_price: Decimal + price_tick: Decimal + max_order_notional: Decimal + approved_front_pair_set_sha256: Optional[str] = field(default=None, repr=False) + scope_binding_mode: str = "pinned" + + def __post_init__(self) -> None: + if type(self.runtime_registration) is not RegisteredRuntime: + _reject("invalid_registration", "invalid registered runtime binding") + if self.environment != _PRODUCTION_ENVIRONMENT: + _reject("environment_mismatch", "CTP production requires environment=production") + if self.scope_binding_mode not in ("pinned", "sealed_config"): + _reject("invalid_registration", "invalid production scope binding mode") + if self.scope_binding_mode == "sealed_config": + if any( + value is not None + for value in ( + self.account_binding_sha256, + self.md_front, + self.td_front, + self.instrument_id, + self.exchange_id, + self.hedge_flag, + self.approved_front_pair_set_sha256, + ) + ): + _reject( + "invalid_registration", + "sealed-config scope cannot also contain pinned CTP selectors", + ) + else: + _require_digest(self.account_binding_sha256, "account_binding_sha256") + _require_digest(self.approval_receipt_sha256, "approval_receipt_sha256") + _require_digest(self.artifact_sha256, "artifact_sha256") + _require_production_identity(self.approval_receipt_id, "approval_receipt_id") + _require_production_identity(self.artifact_id, "artifact_id") + if self.approval_receipt_id == self.artifact_id: + _reject("invalid_registration", "receipt and artifact identities must be distinct") + if self.approval_receipt_sha256 == self.artifact_sha256: + _reject("invalid_registration", "receipt and artifact digests must be distinct") + + if self.scope_binding_mode == "pinned" and (self.md_front is None) != ( + self.td_front is None + ): + _reject( + "invalid_registration", "production exact fronts must be both set or both absent" + ) + if self.scope_binding_mode == "pinned" and self.md_front is None: + _require_digest(self.approved_front_pair_set_sha256, "approved_front_pair_set_sha256") + elif ( + self.scope_binding_mode == "pinned" and self.approved_front_pair_set_sha256 is not None + ): + _reject("invalid_registration", "production exact fronts cannot also pin a front set") + for name in ("md_front", "td_front"): + value = getattr(self, name) + if value is None: + continue + if ( + type(value) is not str + or not value + or value != value.strip() + or len(value) > 128 + or any(character.isspace() or ord(character) < 0x20 for character in value) + ): + _reject("invalid_registration", "invalid CTP production front pair") + for name, pattern in ( + ("instrument_id", _INSTRUMENT_RE), + ("exchange_id", _EXCHANGE_RE), + ): + value = getattr(self, name) + if self.scope_binding_mode == "sealed_config": + continue + if type(value) is not str or value != value.strip() or pattern.fullmatch(value) is None: + _reject("invalid_registration", "invalid production {0}".format(name)) + if self.scope_binding_mode == "pinned" and ( + type(self.hedge_flag) is not str or self.hedge_flag not in _HEDGE_FLAGS + ): + _reject("invalid_registration", "invalid production hedge_flag") + + if ( + type(self.allowed_sides) is not tuple + or not self.allowed_sides + or any(type(side) is not str or side not in _SIDES for side in self.allowed_sides) + or len(set(self.allowed_sides)) != len(self.allowed_sides) + ): + _reject("invalid_order_limits", "allowed_sides must be a unique BUY/SELL tuple") + if self.allowed_offsets != ("OPEN",): + _reject("unsupported_order_offsets", "the bounded production slice supports OPEN only") + for name in ("quantity_step", "max_order_quantity", "max_gross_position"): + value = getattr(self, name) + if type(value) is not int or value <= 0: + _reject("invalid_order_limits", "invalid {0}".format(name)) + if ( + self.max_order_quantity % self.quantity_step != 0 + or self.max_gross_position % self.quantity_step != 0 + or self.max_gross_position < self.max_order_quantity + ): + _reject("invalid_order_limits", "quantity limits are inconsistent") + + min_price = _require_decimal(self.min_price, "min_price") + max_price = _require_decimal(self.max_price, "max_price") + price_tick = _require_decimal(self.price_tick, "price_tick") + max_notional = _require_decimal(self.max_order_notional, "max_order_notional") + if max_price < min_price: + _reject("invalid_order_limits", "max_price must not be below min_price") + if min_price % price_tick or max_price % price_tick: + _reject("invalid_order_limits", "price bounds must align to price_tick") + if max_notional < min_price * min(self.quantity_step, self.max_order_quantity): + _reject("invalid_order_limits", "max_order_notional is below the minimum order value") + object.__setattr__(self, "min_price", min_price) + object.__setattr__(self, "max_price", max_price) + object.__setattr__(self, "price_tick", price_tick) + object.__setattr__(self, "max_order_notional", max_notional) + _seal_registration(self) + + @property + def digest(self) -> str: + """Return a production-domain-separated digest of reviewed scope data.""" + + return hashlib.sha256( + b"backtrader-ctp-production-execution-registration-v1\0" + + _canonical(_execution_registration_payload(self)) + ).hexdigest() + + +@dataclass(frozen=True) +class CtpProductionExecutionConfigBinding: + """Redacted contract evidence; every provider and execution authority is false.""" + + runtime_id: str + strategy_id: str + config_digest: str + effective_digest: Optional[str] + runtime_registration_digest: str + registration_digest: str + scope_identity_sha256: str + environment: str + md_front: Optional[str] = field(repr=False) + td_front: Optional[str] = field(repr=False) + instrument_id: str + exchange_id: str + hedge_flag: str + account_binding_sha256: str = field(repr=False) + approval_receipt_id: str + approval_receipt_sha256: str + artifact_id: str + artifact_sha256: str + allowed_sides: Tuple[str, ...] + allowed_offsets: Tuple[str, ...] + quantity_step: int + max_order_quantity: int + max_gross_position: int + min_price: Decimal + max_price: Decimal + price_tick: Decimal + max_order_notional: Decimal + front_pair_sha256: str + front_pair_set_sha256: str + front_pairs: Tuple[Tuple[str, str], ...] = field(repr=False) + selected_front_index: Optional[int] + contract_verified: bool = field(default=True, init=False) + provider_access_authorized: bool = field(default=False, init=False) + credential_access_authorized: bool = field(default=False, init=False) + execution_authorized: bool = field(default=False, init=False) + external_writes_authorized: bool = field(default=False, init=False) + order_submission_authorized: bool = field(default=False, init=False) + cancellation_authorized: bool = field(default=False, init=False) + arming_authorized: bool = field(default=False, init=False) + + def __bool__(self) -> bool: + raise TypeError("CtpProductionExecutionConfigBinding is non-authorizing contract evidence") + + def as_public_dict(self) -> dict[str, Any]: + """Return bounded non-secret identity and limits without fronts/account values.""" + + return { + "account_scope": "redacted", + "allowed_offsets": self.allowed_offsets, + "allowed_sides": self.allowed_sides, + "approval_receipt_id": self.approval_receipt_id, + "approval_receipt_sha256": self.approval_receipt_sha256, + "artifact_id": self.artifact_id, + "artifact_sha256": self.artifact_sha256, + "arming_authorized": self.arming_authorized, + "cancellation_authorized": self.cancellation_authorized, + "config_digest": self.config_digest, + "contract_verified": self.contract_verified, + "credential_access_authorized": self.credential_access_authorized, + "environment": self.environment, + "effective_digest": self.effective_digest, + "exchange_id": self.exchange_id, + "execution_authorized": self.execution_authorized, + "external_writes_authorized": self.external_writes_authorized, + "front_pair_sha256": self.front_pair_sha256, + "front_pair_set_sha256": self.front_pair_set_sha256, + "hedge_flag": self.hedge_flag, + "instrument_id": self.instrument_id, + "max_gross_position": self.max_gross_position, + "max_order_notional": str(self.max_order_notional), + "max_order_quantity": self.max_order_quantity, + "max_price": str(self.max_price), + "min_price": str(self.min_price), + "order_submission_authorized": self.order_submission_authorized, + "price_tick": str(self.price_tick), + "provider": "ctp", + "provider_access_authorized": self.provider_access_authorized, + "quantity_step": self.quantity_step, + "registration_digest": self.registration_digest, + "runtime_id": self.runtime_id, + "runtime_registration_digest": self.runtime_registration_digest, + "strategy_id": self.strategy_id, + "selected_front_index": self.selected_front_index, + "scope_identity_sha256": self.scope_identity_sha256, + } + + +def require_ctp_production_execution_config_binding( + config: RuntimeConfig, + registry: RuntimeRegistry, + admission_registration: CtpProductionExecutionRegistration, + *, + selected_front_pair: Optional[Tuple[str, str]] = None, + effective_runtime: Optional[EffectiveRuntimeConfig] = None, +) -> CtpProductionExecutionConfigBinding: + """Bind production config and order limits without granting authority. + + The caller must supply the exact code-owned production registration. The + loaded config must be sealed to ``registry``. ``scope_binding_mode`` may + be ``sealed_config`` for the canonical live ``ctp:`` block; in that mode + account, contract, and ordered front candidates are derived from the + sealed config and the registration contains only code-owned policy and + risk limits. A profile-scoped registration additionally requires the + exact sealed ``live/managed_live_direct`` profile, with its narrow CTP + production policy, selected for this exact config and registry. The + effective runtime may be omitted only for the legacy global registration + path. + + The returned scope identity is metadata for a separate approval verifier. + This function does not verify receipts, keys, signatures, credential ACLs, + or installed artifact provenance and never authorizes writes. A digest + change can support stale-receipt rejection only when that external + verifier actually binds its verified receipt to this identity. A + multi-pair config remains unresolved until ``selected_front_pair`` is + supplied by the config-only TCP selector in the production composition. + """ + + if type(config) is not RuntimeConfig: + _reject("config_required", "a loader-produced production RuntimeConfig is required") + if type(registry) is not RuntimeRegistry or registry.trusted is not True: + _reject("registry_required", "a trusted runtime registry is required") + if type(admission_registration) is not CtpProductionExecutionRegistration: + _reject( + "registration_required", + "a code-owned CTP production execution registration is required", + ) + _require_registration_seal(admission_registration) + + try: + require_loaded_runtime_config_seal(config, registry) + except RuntimeConfigError: + _reject("config_provenance_invalid", "runtime config does not match its registry seal") + except Exception: + _reject("config_provenance_invalid", "runtime config does not match its registry seal") + + try: + registered = registry.require_runtime_dir(config.strategy_dir) + registry.verify_runtime_dir_identity(registered) + except Exception: + _reject("runtime_registration_mismatch", "registered production runtime is unavailable") + if registered is not admission_registration.runtime_registration: + _reject("runtime_registration_mismatch", "production runtime does not match its pin") + # The legacy ctp_production block remains parser-bound to its reserved + # directory. The canonical ctp block can reuse the SimNow runtime + # directory after the operator explicitly changes mode/preset and the + # account/front/contract settings. This pure contract still requires an + # exact injected production registration for that same directory. + if os.path.normcase(str(registered.runtime_dir)) != os.path.normcase( + str(config.strategy_dir.resolve(strict=False)) + ): + _reject( + "runtime_registration_mismatch", "production runtime does not match config directory" + ) + + effective_digest: Optional[str] = None + profile_scoped = admission_registration.scope_binding_mode == "sealed_config" and bool( + registered.profiles + ) + if effective_runtime is not None: + if type(effective_runtime) is not EffectiveRuntimeConfig: + _reject( + "effective_config_required", "a resolver-produced effective runtime is required" + ) + try: + require_effective_runtime_config_seal(effective_runtime, registry) + except Exception: + _reject( + "effective_config_provenance_invalid", "effective runtime does not match its seal" + ) + if ( + effective_runtime.config is not config + or effective_runtime.registration is not registered + ): + _reject( + "effective_config_mismatch", "effective runtime does not match the sealed config" + ) + effective_digest = effective_runtime.effective_digest + elif profile_scoped: + _reject( + "effective_config_required", + "profile-scoped production requires its sealed effective runtime", + ) + + has_canonical_live_ctp = ( + config.ctp is not None and config.ctp_simnow is None and config.ctp_production is None + ) + has_legacy_production_ctp = ( + config.ctp is None and config.ctp_simnow is None and config.ctp_production is not None + ) + if admission_registration.scope_binding_mode == "sealed_config" and not has_canonical_live_ctp: + _reject( + "runtime_contract_mismatch", + "sealed-config production scope requires the canonical live ctp block", + ) + if has_legacy_production_ctp and admission_registration.scope_binding_mode != "pinned": + _reject( + "runtime_contract_mismatch", + "legacy production config requires pinned scope registration", + ) + + profile = None + if profile_scoped: + assert effective_runtime is not None # enforced above + profile = effective_runtime.profile + if ( + type(profile) is not RuntimeProfile + or profile is not registered.profile_for("live", "managed_live_direct") + or effective_runtime.config is not config + or effective_runtime.registration is not registered + or effective_runtime.policy.name != "managed_live_direct" + or effective_runtime.policy.mode != "live" + or effective_runtime.policy.environment != _PRODUCTION_ENVIRONMENT + or effective_runtime.policy.required_capabilities != MANAGED_WRITE_CAPABILITIES + or effective_runtime.order_route != "managed_execution" + or effective_runtime.account_access != "direct_provider" + or effective_runtime.required_capabilities != MANAGED_WRITE_CAPABILITIES + or effective_runtime.allows_network is not True + or effective_runtime.allows_external_writes is not True + or effective_runtime.allows_production_writes is not True + or effective_runtime.allows_hypothetical_fills is not False + or effective_runtime.requires_approval is not True + or effective_runtime.requires_live_confirmation is not True + or config.mode != "live" + or config.preset != "managed_live_direct" + or config.secrets_ref != _SECRETS_REF + or tuple(config.parameters) != () + or profile.allowed_parameter_keys != () + or profile.allowed_secrets_refs != (_SECRETS_REF,) + or profile.available_capabilities != MANAGED_WRITE_CAPABILITIES + or profile.approval_receipt_digest != admission_registration.approval_receipt_sha256 + or profile.offline_managed_execution is not False + or profile.sandbox_write_policy != "deny" + or not has_canonical_live_ctp + ): + _reject( + "runtime_contract_mismatch", + "selected profile is not the bounded CTP production execution contract", + ) + elif ( + registered.strategy_id != config.strategy_id + or registered.allowed_presets != ("managed_live_direct",) + or registered.allowed_parameter_keys != () + or registered.allowed_secrets_refs != (_SECRETS_REF,) + or registered.available_capabilities != MANAGED_WRITE_CAPABILITIES + or registered.offline_managed_execution is not False + or registered.sandbox_write_policy != "deny" + or registered.approval_receipt_digest != admission_registration.approval_receipt_sha256 + or config.mode != "live" + or config.preset != "managed_live_direct" + or config.secrets_ref != _SECRETS_REF + or tuple(config.parameters) != () + or not (has_canonical_live_ctp or has_legacy_production_ctp) + ): + _reject( + "runtime_contract_mismatch", + "sealed runtime is not the bounded CTP production execution contract", + ) + + if registered.strategy_id != config.strategy_id: + _reject( + "runtime_contract_mismatch", + "sealed runtime strategy identity does not match the production config", + ) + + private = config.ctp if has_canonical_live_ctp else config.ctp_production + if type(private) not in (CtpPrivateConfig, CtpProductionPrivateConfig): + _reject("runtime_contract_mismatch", "sealed production CTP config is invalid") + configured_account = production_account_binding_sha256(private.broker_id, private.user_id) + if admission_registration.scope_binding_mode == "pinned": + if ( + configured_account != admission_registration.account_binding_sha256 + or private.instrument_id != admission_registration.instrument_id + or private.exchange_id != admission_registration.exchange_id + or private.hedge_flag != admission_registration.hedge_flag + ): + _reject( + "production_pin_mismatch", + "configured CTP production account/contract scope does not match the pin", + ) + + configured_front_pairs = tuple( + (pair["md_front"], pair["td_front"]) for pair in private.front_pairs + ) + if not configured_front_pairs or len(configured_front_pairs) > 8: + _reject("runtime_contract_mismatch", "sealed production front list is invalid") + if ( + admission_registration.scope_binding_mode == "pinned" + and len(configured_front_pairs) > 1 + and admission_registration.approved_front_pair_set_sha256 is None + ): + _reject( + "production_front_set_approval_required", + "multi-pair production config requires an approved candidate-set digest", + ) + if ( + admission_registration.scope_binding_mode == "pinned" + and admission_registration.approved_front_pair_set_sha256 is not None + ): + configured_set_digest = production_front_pair_set_sha256(configured_front_pairs) + if configured_set_digest != admission_registration.approved_front_pair_set_sha256: + _reject( + "production_front_set_mismatch", + "configured production front set does not match the approved set", + ) + elif ( + admission_registration.scope_binding_mode == "pinned" + and ( + admission_registration.md_front, + admission_registration.td_front, + ) + not in configured_front_pairs + ): + _reject("production_pin_mismatch", "approved production front is absent from config") + + if selected_front_pair is None and len(configured_front_pairs) == 1: + selected_front_pair = configured_front_pairs[0] + selected_front_index = None + if selected_front_pair is not None: + if ( + type(selected_front_pair) is not tuple + or len(selected_front_pair) != 2 + or any(type(value) is not str for value in selected_front_pair) + or selected_front_pair not in configured_front_pairs + ): + _reject( + "production_front_selection_mismatch", + "selected production fronts are not in sealed config", + ) + if admission_registration.scope_binding_mode == "pinned" and ( + admission_registration.approved_front_pair_set_sha256 is None + and selected_front_pair + != (admission_registration.md_front, admission_registration.td_front) + ): + _reject( + "production_pin_mismatch", "selected production fronts do not match exact approval" + ) + selected_front_index = configured_front_pairs.index(selected_front_pair) + md_front, td_front = selected_front_pair + front_pair_sha256 = _front_pair_digest(md_front, td_front) + else: + md_front = None + td_front = None + front_pair_sha256 = "0" * 64 + + configured_set_digest = production_front_pair_set_sha256(configured_front_pairs) + scope_identity_sha256 = _scope_identity_digest( + config_digest=config.config_digest, + effective_digest=effective_digest, + registration_digest=admission_registration.digest, + account_binding_sha256=configured_account, + instrument_id=private.instrument_id, + exchange_id=private.exchange_id, + hedge_flag=private.hedge_flag, + front_pair_set_sha256=configured_set_digest, + front_pair_sha256=front_pair_sha256, + ) + + return CtpProductionExecutionConfigBinding( + runtime_id=registered.runtime_id or "", + strategy_id=config.strategy_id, + config_digest=config.config_digest, + effective_digest=effective_digest, + runtime_registration_digest=registered.digest, + registration_digest=admission_registration.digest, + scope_identity_sha256=scope_identity_sha256, + environment=_PRODUCTION_ENVIRONMENT, + md_front=md_front, + td_front=td_front, + instrument_id=private.instrument_id, + exchange_id=private.exchange_id, + hedge_flag=private.hedge_flag, + account_binding_sha256=configured_account, + approval_receipt_id=admission_registration.approval_receipt_id, + approval_receipt_sha256=admission_registration.approval_receipt_sha256, + artifact_id=admission_registration.artifact_id, + artifact_sha256=admission_registration.artifact_sha256, + allowed_sides=admission_registration.allowed_sides, + allowed_offsets=admission_registration.allowed_offsets, + quantity_step=admission_registration.quantity_step, + max_order_quantity=admission_registration.max_order_quantity, + max_gross_position=admission_registration.max_gross_position, + min_price=admission_registration.min_price, + max_price=admission_registration.max_price, + price_tick=admission_registration.price_tick, + max_order_notional=admission_registration.max_order_notional, + front_pair_sha256=front_pair_sha256, + front_pair_set_sha256=configured_set_digest, + front_pairs=configured_front_pairs, + selected_front_index=selected_front_index, + ) + + +__all__ = [ + "CtpProductionExecutionAdmissionError", + "CtpProductionExecutionConfigBinding", + "CtpProductionExecutionRegistration", + "production_front_pair_set_sha256", + "require_ctp_production_execution_config_binding", +] diff --git a/backtrader_runtime/ctp_production_readonly_admission.py b/backtrader_runtime/ctp_production_readonly_admission.py new file mode 100644 index 00000000..8fbe331e --- /dev/null +++ b/backtrader_runtime/ctp_production_readonly_admission.py @@ -0,0 +1,420 @@ +"""Pure CTP production configuration binding for future read-only work. + +This module checks a sealed ``config.yaml`` against an exact code-owned +production registration. It has no credential resolver, SDK, provider, socket, +or execution dependency. A successful result records the exact configured +front pair and scope but authorizes no provider access of any kind. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass, field +from typing import Any + +from .config import ( + CtpPrivateConfig, + RuntimeConfig, + require_loaded_runtime_config_seal, +) +from .errors import RuntimeConfigError +from .registry import RegisteredRuntime, RuntimeRegistry + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") +_HEDGE_FLAGS = frozenset(("1", "2", "3")) +_SECRETS_REF = "config_yaml" +_PRODUCTION_ENVIRONMENT = "production" + + +class CtpProductionReadOnlyAdmissionError(ValueError): + """Redacted fail-closed rejection for a production config binding.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise CtpProductionReadOnlyAdmissionError(reason, message) + + +def _is_identifier(value: Any, pattern: re.Pattern[str]) -> bool: + return ( + type(value) is str + and value == value.strip() + and pattern.fullmatch(value) is not None + ) + + +def _require_sha256(value: Any, field_name: str) -> str: + if type(value) is not str or _SHA256_RE.fullmatch(value) is None: + _reject("invalid_registration", "invalid {0}".format(field_name)) + return value + + +def production_account_binding_sha256(broker_id: str, user_id: str) -> str: + """Return a production-domain-separated digest for one configured account. + + This helper is for code-reviewed registration data. It accepts only the + non-secret account selectors and deliberately has no SimNow counterpart. + """ + + if not _is_identifier(broker_id, _IDENTIFIER_RE) or not _is_identifier( + user_id, _IDENTIFIER_RE + ): + _reject("invalid_account_binding", "invalid CTP production account selectors") + payload = json.dumps([broker_id, user_id], ensure_ascii=True, separators=(",", ":")) + material = b"backtrader-ctp-production-account-v1\0" + payload.encode("ascii") + return hashlib.sha256(material).hexdigest() + + +def _front_pair_sha256(md_front: str, td_front: str) -> str: + payload = json.dumps([md_front, td_front], ensure_ascii=True, separators=(",", ":")) + return hashlib.sha256( + b"backtrader-ctp-production-front-pair-v1\0" + payload.encode("ascii") + ).hexdigest() + + +def _front_pair_set_sha256(front_pairs: tuple[tuple[str, str], ...]) -> str: + payload = json.dumps(front_pairs, ensure_ascii=True, separators=(",", ":")) + return hashlib.sha256( + b"backtrader-ctp-production-front-pair-set-v1\0" + payload.encode("ascii") + ).hexdigest() + + +def _admission_digest(registration: "CtpProductionReadOnlyRegistration") -> str: + payload = { + "account_binding_sha256": registration.account_binding_sha256, + "environment": registration.environment, + "exchange_id": registration.exchange_id, + "hedge_flag": registration.hedge_flag, + "instrument_id": registration.instrument_id, + "md_front": registration.md_front, + "runtime_registration_digest": registration.runtime_registration.digest, + "td_front": registration.td_front, + } + material = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + return hashlib.sha256( + b"backtrader-ctp-production-readonly-admission-v2\0" + material.encode("ascii") + ).hexdigest() + + +@dataclass(frozen=True) +class CtpProductionReadOnlyRegistration: + """Production account and query scope reviewed in code. + + This type is a data contract only. In particular it contains no password, + AppID, AuthCode, receipt, environment selector, SDK profile, or write + capability. ``md_front`` and ``td_front`` are an optional legacy exact-pair + pin; when both are absent, only the sealed config supplies candidate fronts. + The runtime registration must be the identical object present in the + registry which sealed the loaded config. + """ + + runtime_registration: RegisteredRuntime + environment: str + account_binding_sha256: str = field(repr=False) + # Optional legacy validation pin. This pair never supplies or selects an + # endpoint; the sealed config's explicit front_pairs list remains source. + md_front: str | None = field(repr=False) + td_front: str | None = field(repr=False) + instrument_id: str + exchange_id: str + hedge_flag: str + + def __post_init__(self) -> None: + if type(self.runtime_registration) is not RegisteredRuntime: + _reject("invalid_registration", "invalid registered runtime binding") + if type(self.environment) is not str or self.environment != _PRODUCTION_ENVIRONMENT: + _reject( + "environment_mismatch", + "CTP production binding requires the exact production environment", + ) + object.__setattr__( + self, + "account_binding_sha256", + _require_sha256(self.account_binding_sha256, "account_binding_sha256"), + ) + if (self.md_front is None) != (self.td_front is None): + _reject("invalid_registration", "production legacy fronts must be both set or both absent") + for name in ("md_front", "td_front"): + value = getattr(self, name) + if value is None: + continue + if ( + type(value) is not str + or not value + or value != value.strip() + or len(value) > 128 + or any(character.isspace() or ord(character) < 0x20 for character in value) + ): + _reject("invalid_registration", "invalid CTP production front pair") + if not _is_identifier(self.instrument_id, _INSTRUMENT_RE): + _reject("invalid_registration", "invalid production instrument_id") + if not _is_identifier(self.exchange_id, _EXCHANGE_RE): + _reject("invalid_registration", "invalid production exchange_id") + if type(self.hedge_flag) is not str or self.hedge_flag not in _HEDGE_FLAGS: + _reject("invalid_registration", "invalid production hedge_flag") + + +@dataclass(frozen=True) +class CtpProductionReadOnlyConfigBinding: + """Digest-only evidence that one sealed production config matched its pin. + + Configured candidate pairs and any selected front values are retained for + a future reviewed composition root, but omitted from repr and public + serialization. All access and execution authority remains false. + """ + + runtime_id: str + strategy_id: str + config_digest: str + registration_digest: str + environment: str + md_front: str | None = field(repr=False) + td_front: str | None = field(repr=False) + instrument_id: str + exchange_id: str + hedge_flag: str + account_binding_sha256: str = field(repr=False) + front_pair_sha256: str + front_pair_set_sha256: str + front_pairs: tuple[tuple[str, str], ...] = field(default=(), repr=False) + contract_verified: bool = field(default=True, init=False) + provider_read_authorized: bool = field(default=False, init=False) + sdk_import_authorized: bool = field(default=False, init=False) + network_access_authorized: bool = field(default=False, init=False) + credential_access_authorized: bool = field(default=False, init=False) + execution_authorized: bool = field(default=False, init=False) + external_writes_authorized: bool = field(default=False, init=False) + order_submission_authorized: bool = field(default=False, init=False) + cancellation_authorized: bool = field(default=False, init=False) + arming_authorized: bool = field(default=False, init=False) + + def __bool__(self) -> bool: + raise TypeError( + "CtpProductionReadOnlyConfigBinding is non-authorizing binding evidence; " + "do not use it as provider admission" + ) + + def as_public_dict(self) -> dict[str, Any]: + """Return redacted evidence without account selectors or front addresses.""" + + return { + "account_scope": "redacted", + "arming_authorized": self.arming_authorized, + "cancellation_authorized": self.cancellation_authorized, + "config_digest": self.config_digest, + "contract_verified": self.contract_verified, + "credential_access_authorized": self.credential_access_authorized, + "environment": self.environment, + "exchange_id": self.exchange_id, + "execution_authorized": self.execution_authorized, + "external_writes_authorized": self.external_writes_authorized, + "front_pair_sha256": self.front_pair_sha256, + "front_pair_set_sha256": self.front_pair_set_sha256, + "hedge_flag": self.hedge_flag, + "instrument_id": self.instrument_id, + "network_access_authorized": self.network_access_authorized, + "order_submission_authorized": self.order_submission_authorized, + "provider": "ctp", + "provider_read_authorized": self.provider_read_authorized, + "registration_digest": self.registration_digest, + "runtime_id": self.runtime_id, + "sdk_import_authorized": self.sdk_import_authorized, + "strategy_id": self.strategy_id, + } + + +def _normalise_registration(value: Any) -> CtpProductionReadOnlyRegistration: + if type(value) is not CtpProductionReadOnlyRegistration: + _reject( + "registration_required", + "a code-owned CTP production read-only registration is required", + ) + try: + return CtpProductionReadOnlyRegistration( + runtime_registration=value.runtime_registration, + environment=value.environment, + account_binding_sha256=value.account_binding_sha256, + md_front=value.md_front, + td_front=value.td_front, + instrument_id=value.instrument_id, + exchange_id=value.exchange_id, + hedge_flag=value.hedge_flag, + ) + except CtpProductionReadOnlyAdmissionError: + raise + except Exception: + _reject("invalid_registration", "invalid CTP production registration") + + +def require_ctp_production_readonly_config_binding( + config: RuntimeConfig, + registry: RuntimeRegistry, + admission_registration: CtpProductionReadOnlyRegistration, + *, + selected_front_pair: tuple[str, str] | None = None, +) -> CtpProductionReadOnlyConfigBinding: + """Verify production account/scope and config fronts without I/O. + + ``config`` must be the loader-produced object sealed to ``registry``. + Optional ``selected_front_pair`` must be one of the sealed config's + candidates. Omitting it leaves multi-pair configs unresolved regardless + of any legacy validation pin. This gate accepts no effective config or + session factory, and the returned binding cannot authorize a read, SDK + import, network call, credential resolution, or any execution action. + """ + + if type(config) is not RuntimeConfig: + _reject("config_required", "a sealed production runtime config is required") + if type(registry) is not RuntimeRegistry or registry.trusted is not True: + _reject("registry_required", "a trusted runtime registry is required") + registration = _normalise_registration(admission_registration) + + try: + require_loaded_runtime_config_seal(config, registry) + except RuntimeConfigError: + _reject("config_provenance_invalid", "runtime config does not match its registry seal") + except Exception: + _reject("config_provenance_invalid", "runtime config does not match its registry seal") + + try: + registered = registry.require_runtime_dir(config.strategy_dir) + registry.verify_runtime_dir_identity(registered) + except Exception: + _reject("runtime_registration_mismatch", "registered production runtime is unavailable") + if registered is not registration.runtime_registration: + _reject("runtime_registration_mismatch", "production runtime does not match its pin") + # This candidate consumes only the canonical ``ctp`` block from the + # registered config.yaml. Parser compatibility for the retired + # ``ctp_production`` block does not make that second-file path an admitted + # source. No default live registration is created here. + if os.path.normcase(str(registered.runtime_dir)) != os.path.normcase( + str(config.strategy_dir.resolve(strict=False)) + ): + _reject("runtime_registration_mismatch", "production runtime does not match config directory") + + has_canonical_live_ctp = ( + config.ctp is not None + and config.ctp_simnow is None + and config.ctp_production is None + ) + if not has_canonical_live_ctp: + _reject( + "canonical_ctp_config_required", + "production read-only binding requires the canonical ctp block in the registered config", + ) + if ( + registered.strategy_id != config.strategy_id + or registered.allowed_presets != ("managed_live_direct",) + or registered.allowed_parameter_keys != () + or registered.allowed_secrets_refs != (_SECRETS_REF,) + or registered.available_capabilities != () + or registered.capability_modules != () + or registered.offline_managed_execution is not False + or registered.sandbox_write_policy != "deny" + or registered.approval_receipt_digest is not None + or registered.runner_module is not None + or config.mode != "live" + or config.preset != "managed_live_direct" + or config.secrets_ref != _SECRETS_REF + or tuple(config.parameters) != () + or not has_canonical_live_ctp + ): + _reject( + "runtime_contract_mismatch", + "sealed runtime is not the injected CTP production read-only contract", + ) + + private = config.ctp + if type(private) is not CtpPrivateConfig: + _reject("runtime_contract_mismatch", "sealed production CTP config is invalid") + configured_account = production_account_binding_sha256( + private.broker_id, + private.user_id, + ) + if ( + configured_account != registration.account_binding_sha256 + or private.instrument_id != registration.instrument_id + or private.exchange_id != registration.exchange_id + or private.hedge_flag != registration.hedge_flag + ): + _reject( + "production_pin_mismatch", + "configured CTP production endpoints or account scope do not match the reviewed pin", + ) + + configured_front_pairs = tuple( + (pair["md_front"], pair["td_front"]) for pair in private.front_pairs + ) + if not configured_front_pairs or any( + type(md_front) is not str or type(td_front) is not str + for md_front, td_front in configured_front_pairs + ): + _reject("runtime_contract_mismatch", "sealed production front list is invalid") + if len(configured_front_pairs) > 1 and registration.md_front is not None: + _reject( + "legacy_front_pin_incompatible_with_multi_pair_config", + "multi-pair production config requires an unpinned config-driven registration", + ) + if selected_front_pair is None and len(configured_front_pairs) == 1: + selected_front_pair = configured_front_pairs[0] + if selected_front_pair is not None: + if ( + type(selected_front_pair) is not tuple + or len(selected_front_pair) != 2 + or any(type(value) is not str for value in selected_front_pair) + ): + _reject("production_front_selection_mismatch", "selected fronts are not in sealed config") + if selected_front_pair not in configured_front_pairs: + if registration.md_front is not None and selected_front_pair == ( + registration.md_front, + registration.td_front, + ): + _reject("production_pin_mismatch", "configured CTP production fronts do not match pin") + _reject("production_front_selection_mismatch", "selected fronts are not in sealed config") + if registration.md_front is not None and selected_front_pair != ( + registration.md_front, + registration.td_front, + ): + _reject("production_pin_mismatch", "selected CTP production fronts do not match pin") + md_front, td_front = selected_front_pair + front_pair_sha256 = _front_pair_sha256(md_front, td_front) + else: + md_front = None + td_front = None + front_pair_sha256 = "0" * 64 + + return CtpProductionReadOnlyConfigBinding( + runtime_id=registered.runtime_id or "", + strategy_id=config.strategy_id, + config_digest=config.config_digest, + registration_digest=_admission_digest(registration), + environment=_PRODUCTION_ENVIRONMENT, + md_front=md_front, + td_front=td_front, + instrument_id=private.instrument_id, + exchange_id=private.exchange_id, + hedge_flag=private.hedge_flag, + account_binding_sha256=configured_account, + front_pair_sha256=front_pair_sha256, + front_pair_set_sha256=_front_pair_set_sha256(configured_front_pairs), + front_pairs=configured_front_pairs, + ) + + +__all__ = [ + "CtpProductionReadOnlyAdmissionError", + "CtpProductionReadOnlyConfigBinding", + "CtpProductionReadOnlyRegistration", + "production_account_binding_sha256", + "require_ctp_production_readonly_config_binding", +] diff --git a/backtrader_runtime/ctp_readonly_job_supervisor.py b/backtrader_runtime/ctp_readonly_job_supervisor.py new file mode 100644 index 00000000..4ea10f36 --- /dev/null +++ b/backtrader_runtime/ctp_readonly_job_supervisor.py @@ -0,0 +1,1608 @@ +"""Narrow Windows process-tree supervision for read-only diagnostic children. + +This module is an execution-containment prerequisite only. It does not perform +CTP admission, accept a preflight, load credentials, or grant provider or write +capabilities. Callers must pass a fixed command constructed by their own +code, an explicit environment, and a pure parser that projects stdout into a +value-free receipt. Raw child output is never returned or persisted. +""" + +from __future__ import annotations + +import ctypes +import json +import math +import os +import re +import subprocess +import threading +import time +from ctypes import wintypes +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any, Callable, Mapping, Optional, Protocol, Sequence + + +_CREATE_SUSPENDED = 0x00000004 +_CREATE_UNICODE_ENVIRONMENT = 0x00000400 +_EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +_CREATE_NO_WINDOW = 0x08000000 +_STARTF_USESTDHANDLES = 0x00000100 +_STARTF_USESHOWWINDOW = 0x00000001 +_PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002 +_PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 +_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION = 1 +_WAIT_OBJECT_0 = 0x00000000 +_WAIT_TIMEOUT = 0x00000102 +_INFINITE = 0xFFFFFFFF +_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value +_VALUE_FREE_TOKEN = re.compile(r"\A[a-z][a-z0-9_]{0,63}\Z") +_PROCESS_CONTAINMENT_LATCH: Optional[str] = None + + +class SupervisorError(RuntimeError): + """Base exception for invalid supervisor inputs.""" + + +class _ChildCreationError(OSError): + def __init__( + self, + reason: str, + *, + process_created: bool, + job_assigned: bool, + process_exit_observed: Optional[bool], + process_handle: Optional[int] = None, + thread_handle: Optional[int] = None, + job_termination_requested: bool = False, + job_termination_call_succeeded: Optional[bool] = None, + ) -> None: + super().__init__(reason) + self.reason = reason + self.process_created = process_created + self.job_assigned = job_assigned + self.process_exit_observed = process_exit_observed + self.process_handle = process_handle + self.thread_handle = thread_handle + self.job_termination_requested = job_termination_requested + self.job_termination_call_succeeded = job_termination_call_succeeded + + +class _SupervisorDeadlineExceeded(RuntimeError): + """The optional absolute budget expired before a child was created.""" + + +def _deadline_expired(deadline_monotonic: Optional[float]) -> bool: + return deadline_monotonic is not None and time.monotonic() >= deadline_monotonic + + +def _remaining_seconds(deadline_monotonic: Optional[float]) -> Optional[float]: + if deadline_monotonic is None: + return None + return max(0.0, deadline_monotonic - time.monotonic()) + + +def _bounded_timeout_ms(timeout_ms: int, deadline_monotonic: Optional[float]) -> int: + remaining = _remaining_seconds(deadline_monotonic) + if remaining is None: + return timeout_ms + return min(timeout_ms, max(0, int(remaining * 1000))) + + +def _bounded_timeout_seconds( + timeout_seconds: float, deadline_monotonic: Optional[float] +) -> float: + remaining = _remaining_seconds(deadline_monotonic) + if remaining is None: + return timeout_seconds + return min(timeout_seconds, remaining) + + +def _deadline_reason( + relative_deadline: float, absolute_deadline: Optional[float] +) -> str: + if absolute_deadline is not None and absolute_deadline <= relative_deadline: + return "supervisor_deadline_exceeded" + return "child_deadline_exceeded" + + +def _raise_if_setup_deadline_expired( + deadline_monotonic: Optional[float], + *, + process_created: bool = False, + job_assigned: bool = False, +) -> None: + if _deadline_expired(deadline_monotonic): + raise _ChildCreationError( + "supervisor_deadline_exceeded", + process_created=process_created, + job_assigned=job_assigned, + process_exit_observed=None, + ) + + +class FailClosedLatch(Protocol): + """Caller-owned latch that persists a no-retry state across process restarts. + + ``trip`` must be idempotent, durable, and return true only after the + no-retry state is committed. The supervisor also keeps a process-local + latch, but that alone is not a cross-process recovery contract. + """ + + def is_tripped(self) -> bool: + """Return whether a prior containment uncertainty blocks new launches.""" + + def trip(self, reason: str) -> bool: + """Durably latch ``reason`` and confirm the commit.""" + + +@dataclass(frozen=True) +class ValueFreeReceiptSchema: + """Exact output fields and allowed categorical values for one child receipt.""" + + enum_fields: Mapping[str, Sequence[str]] + bool_fields: Sequence[str] + nullable_enum_fields: Mapping[str, Sequence[str]] + nullable_bool_fields: Sequence[str] = () + + def __post_init__(self) -> None: + enum_fields = _copy_enum_fields(self.enum_fields) + nullable_enum_fields = _copy_enum_fields(self.nullable_enum_fields) + bool_fields = tuple(self.bool_fields) + nullable_bool_fields = tuple(self.nullable_bool_fields) + names = ( + set(enum_fields) + | set(nullable_enum_fields) + | set(bool_fields) + | set(nullable_bool_fields) + ) + if ( + len(names) + != len(enum_fields) + + len(nullable_enum_fields) + + len(bool_fields) + + len(nullable_bool_fields) + or not 1 <= len(names) <= 64 + or "native_join_pending" not in set(bool_fields) | set(nullable_bool_fields) + ): + raise ValueError("receipt_schema_invalid") + if any(type(name) is not str or not _VALUE_FREE_TOKEN.fullmatch(name) for name in names): + raise ValueError("receipt_schema_invalid") + if any( + type(name) is not str or not _VALUE_FREE_TOKEN.fullmatch(name) for name in bool_fields + ): + raise ValueError("receipt_schema_invalid") + if any( + type(name) is not str or not _VALUE_FREE_TOKEN.fullmatch(name) + for name in nullable_bool_fields + ): + raise ValueError("receipt_schema_invalid") + object.__setattr__(self, "enum_fields", MappingProxyType(enum_fields)) + object.__setattr__(self, "nullable_enum_fields", MappingProxyType(nullable_enum_fields)) + object.__setattr__(self, "bool_fields", bool_fields) + object.__setattr__(self, "nullable_bool_fields", nullable_bool_fields) + + +def _copy_enum_fields(fields: Mapping[str, Sequence[str]]) -> dict[str, frozenset[str]]: + if not isinstance(fields, Mapping): + raise ValueError("receipt_schema_invalid") + copied: dict[str, frozenset[str]] = {} + for name, values in fields.items(): + if type(name) is not str or not _VALUE_FREE_TOKEN.fullmatch(name): + raise ValueError("receipt_schema_invalid") + if isinstance(values, (str, bytes)): + raise ValueError("receipt_schema_invalid") + try: + allowed = frozenset(values) + except TypeError as exc: + raise ValueError("receipt_schema_invalid") from exc + if not allowed or any( + type(value) is not str or not _VALUE_FREE_TOKEN.fullmatch(value) for value in allowed + ): + raise ValueError("receipt_schema_invalid") + copied[name] = allowed + return copied + + +@dataclass(frozen=True) +class FixedChildCommand: + """An explicit command snapshot; do not populate this from CLI/user input.""" + + argv: Sequence[str] + cwd: Path | str + env: Mapping[str, str] + job_handle_env_name: Optional[str] = None + + def __post_init__(self) -> None: + argv = tuple(self.argv) + if ( + not argv + or any(type(arg) is not str or not arg or "\0" in arg for arg in argv) + or not Path(argv[0]).is_absolute() + ): + raise ValueError("child_command_invalid") + cwd = Path(self.cwd) + if not cwd.is_absolute(): + raise ValueError("child_cwd_must_be_absolute") + if not isinstance(self.env, Mapping): + raise ValueError("child_environment_invalid") + copied: dict[str, str] = {} + seen: set[str] = set() + for key, value in self.env.items(): + if ( + type(key) is not str + or type(value) is not str + or not key + or "=" in key + or "\0" in key + or "\0" in value + ): + raise ValueError("child_environment_invalid") + folded = key.upper() + if folded in seen: + raise ValueError("child_environment_duplicate_key") + seen.add(folded) + copied[key] = value + handle_env_name = self.job_handle_env_name + if handle_env_name is not None and ( + type(handle_env_name) is not str + or not _VALUE_FREE_TOKEN.fullmatch(handle_env_name) + or handle_env_name.upper() in seen + ): + raise ValueError("child_job_handle_environment_invalid") + object.__setattr__(self, "argv", argv) + object.__setattr__(self, "cwd", cwd) + object.__setattr__(self, "env", MappingProxyType(copied)) + + +@dataclass(frozen=True) +class ProcessEvidence: + """Parent-observed operating-system facts, separate from the SDK receipt.""" + + process_created: bool + job_assignment_observed: bool + process_resumed: bool + process_exit_observed: Optional[bool] + process_exit_code: Optional[int] + job_termination_requested: bool + job_termination_call_succeeded: Optional[bool] + job_empty_observed: Optional[bool] + containment: str + + +@dataclass(frozen=True) +class SupervisedResult: + """Outcome metadata and two deliberately separate evidence channels.""" + + status: str + reason: str + sdk_receipt: Optional[Mapping[str, object]] + process_evidence: ProcessEvidence + retained_control: Optional["RetainedJobControl"] = None + + +@dataclass +class RetainedJobControl: + """Live Windows handles retained after uncertain cleanup for explicit retry.""" + + _kernel32: Any + _job_handle: Optional[int] + _process_handle: Optional[int] + _thread_handle: Optional[int] + _job_assigned: bool + _process_resumed: bool + + def resolve(self, timeout_seconds: float = 5.0) -> ProcessEvidence: + """Retry termination and close handles only after OS exit/Job-empty evidence.""" + + if ( + type(timeout_seconds) not in (int, float) + or not math.isfinite(float(timeout_seconds)) + or not 0.1 <= float(timeout_seconds) <= 30 + ): + raise ValueError("termination_grace_out_of_range") + call_succeeded: Optional[bool] = None + requested = self._job_handle is not None or self._process_handle is not None + if self._job_assigned and self._job_handle is not None: + call_succeeded = bool(self._kernel32.TerminateJobObject(self._job_handle, 0xEE2D)) + elif self._process_handle is not None: + call_succeeded = bool(self._kernel32.TerminateProcess(self._process_handle, 0xEE2D)) + + process_exit: Optional[bool] = None + process_exit_code: Optional[int] = None + if self._process_handle is not None: + process_exit, process_exit_code = _wait_for_process_exit( + self._kernel32, + self._process_handle, + int(float(timeout_seconds) * 1000), + ) + + job_empty: Optional[bool] = None + if self._job_handle is not None: + job_empty = _wait_for_job_empty( + self._kernel32, self._job_handle, float(timeout_seconds) + ) + if job_empty is True and process_exit is not True and self._process_handle is not None: + process_exit, process_exit_code = _wait_for_process_exit( + self._kernel32, + self._process_handle, + int(float(timeout_seconds) * 1000), + ) + safe_to_release = ( + process_exit is True + and (job_empty is True or (not self._job_assigned and job_empty in (None, True))) + and call_succeeded is True + ) + if safe_to_release: + if self._thread_handle is not None: + self._kernel32.CloseHandle(self._thread_handle) + self._thread_handle = None + if self._process_handle is not None: + self._kernel32.CloseHandle(self._process_handle) + self._process_handle = None + if self._job_handle is not None: + self._kernel32.CloseHandle(self._job_handle) + self._job_handle = None + return ProcessEvidence( + process_created=True, + job_assignment_observed=self._job_assigned, + process_resumed=self._process_resumed, + process_exit_observed=process_exit, + process_exit_code=process_exit_code, + job_termination_requested=requested, + job_termination_call_succeeded=call_succeeded, + job_empty_observed=job_empty, + containment="verified" if safe_to_release else "uncertain", + ) + + +class _SECURITY_ATTRIBUTES(ctypes.Structure): + _fields_ = [ + ("nLength", wintypes.DWORD), + ("lpSecurityDescriptor", wintypes.LPVOID), + ("bInheritHandle", wintypes.BOOL), + ] + + +class _STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), + ("dwX", wintypes.DWORD), + ("dwY", wintypes.DWORD), + ("dwXSize", wintypes.DWORD), + ("dwYSize", wintypes.DWORD), + ("dwXCountChars", wintypes.DWORD), + ("dwYCountChars", wintypes.DWORD), + ("dwFillAttribute", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("wShowWindow", wintypes.WORD), + ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.POINTER(ctypes.c_byte)), + ("hStdInput", wintypes.HANDLE), + ("hStdOutput", wintypes.HANDLE), + ("hStdError", wintypes.HANDLE), + ] + + +class _STARTUPINFOEXW(ctypes.Structure): + _fields_ = [("StartupInfo", _STARTUPINFOW), ("lpAttributeList", wintypes.LPVOID)] + + +class _PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [ + ("hProcess", wintypes.HANDLE), + ("hThread", wintypes.HANDLE), + ("dwProcessId", wintypes.DWORD), + ("dwThreadId", wintypes.DWORD), + ] + + +class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + +class _JOBOBJECT_IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + +class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", _JOBOBJECT_IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + +class _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", ctypes.c_longlong), + ("TotalKernelTime", ctypes.c_longlong), + ("ThisPeriodTotalUserTime", ctypes.c_longlong), + ("ThisPeriodTotalKernelTime", ctypes.c_longlong), + ("TotalPageFaultCount", wintypes.DWORD), + ("TotalProcesses", wintypes.DWORD), + ("ActiveProcesses", wintypes.DWORD), + ("TotalTerminatedProcesses", wintypes.DWORD), + ] + + +@dataclass +class _CapturedOutput: + limit: int + data: bytearray + overflow: bool = False + read_error: bool = False + total: int = 0 + lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def drain(self, stream: Any) -> None: + try: + while True: + chunk = stream.read(8192) + if not chunk: + return + with self.lock: + self.total += len(chunk) + remaining = self.limit - len(self.data) + if remaining > 0: + self.data.extend(chunk[:remaining]) + if len(chunk) > remaining: + self.overflow = True + except Exception: + self.read_error = True + finally: + try: + stream.close() + except Exception: + pass + + def snapshot(self) -> tuple[bytes, bool, bool]: + with self.lock: + return bytes(self.data), self.overflow, self.read_error + + +def _winapi() -> Any: + if os.name != "nt": + raise OSError("windows_required") + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.INT, + wintypes.LPVOID, + wintypes.DWORD, + ] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.QueryInformationJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.INT, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.QueryInformationJobObject.restype = wintypes.BOOL + kernel32.CreatePipe.argtypes = [ + ctypes.POINTER(wintypes.HANDLE), + ctypes.POINTER(wintypes.HANDLE), + ctypes.POINTER(_SECURITY_ATTRIBUTES), + wintypes.DWORD, + ] + kernel32.CreatePipe.restype = wintypes.BOOL + kernel32.SetHandleInformation.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] + kernel32.SetHandleInformation.restype = wintypes.BOOL + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(_SECURITY_ATTRIBUTES), + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.CreateProcessW.argtypes = [ + wintypes.LPCWSTR, + wintypes.LPWSTR, + wintypes.LPVOID, + wintypes.LPVOID, + wintypes.BOOL, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.LPCWSTR, + ctypes.POINTER(_STARTUPINFOW), + ctypes.POINTER(_PROCESS_INFORMATION), + ] + kernel32.CreateProcessW.restype = wintypes.BOOL + kernel32.InitializeProcThreadAttributeList.argtypes = [ + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_size_t), + ] + kernel32.InitializeProcThreadAttributeList.restype = wintypes.BOOL + kernel32.UpdateProcThreadAttribute.argtypes = [ + wintypes.LPVOID, + wintypes.DWORD, + ctypes.c_size_t, + wintypes.LPVOID, + ctypes.c_size_t, + wintypes.LPVOID, + ctypes.POINTER(ctypes.c_size_t), + ] + kernel32.UpdateProcThreadAttribute.restype = wintypes.BOOL + kernel32.DeleteProcThreadAttributeList.argtypes = [wintypes.LPVOID] + kernel32.DeleteProcThreadAttributeList.restype = None + kernel32.ResumeThread.argtypes = [wintypes.HANDLE] + kernel32.ResumeThread.restype = wintypes.DWORD + kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateJobObject.restype = wintypes.BOOL + kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateProcess.restype = wintypes.BOOL + kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + return kernel32 + + +def _create_job(kernel32: Any) -> int: + job = kernel32.CreateJobObjectW(None, None) + if not job: + raise OSError("job_create_failed") + info = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not kernel32.SetInformationJobObject( + job, + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ctypes.byref(info), + ctypes.sizeof(info), + ): + kernel32.CloseHandle(job) + raise OSError("job_kill_on_close_setup_failed") + return int(job) + + +def _environment_block(env: Mapping[str, str]) -> Any: + pairs = sorted(env.items(), key=lambda item: item[0].upper()) + block = "\0".join(f"{key}={value}" for key, value in pairs) + "\0\0" + return ctypes.create_unicode_buffer(block) + + +def _new_pipe(kernel32: Any) -> tuple[int, int]: + attributes = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), None, True) + read_handle = wintypes.HANDLE() + write_handle = wintypes.HANDLE() + if not kernel32.CreatePipe( + ctypes.byref(read_handle), ctypes.byref(write_handle), ctypes.byref(attributes), 0 + ): + raise OSError("stdout_pipe_create_failed") + if not kernel32.SetHandleInformation(read_handle, 0x00000001, 0): + kernel32.CloseHandle(read_handle) + kernel32.CloseHandle(write_handle) + raise OSError("stdout_pipe_inherit_setup_failed") + return int(read_handle.value), int(write_handle.value) + + +def _create_suspended_process( + kernel32: Any, + command: FixedChildCommand, + job: int, + *, + deadline_monotonic: Optional[float] = None, +) -> tuple[int, int, Any]: + """Create suspended inside the Job atomically and return only if contained. + + PROC_THREAD_ATTRIBUTE_JOB_LIST is required here. A post-CreateProcess + AssignProcessToJobObject call leaves a failure window in which rollback + itself can fail and strand an uncontained child. Windows 10 and newer + assign the child as part of process creation; unsupported systems reject + before a child is created. + """ + + owned_handles: set[int] = set() + process_info = _PROCESS_INFORMATION() + attribute_list: Any = None + process_created = False + process_assigned = False + process_confirmed_dead = False + try: + _raise_if_setup_deadline_expired(deadline_monotonic) + stdout_read, stdout_write = _new_pipe(kernel32) + owned_handles.update((stdout_read, stdout_write)) + _raise_if_setup_deadline_expired(deadline_monotonic) + attributes = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), None, True) + null_stdin = kernel32.CreateFileW( + "NUL", 0x80000000, 0x00000003, ctypes.byref(attributes), 3, 0x00000080, None + ) + if not null_stdin or null_stdin == _INVALID_HANDLE_VALUE: + raise OSError("stdin_handle_create_failed") + owned_handles.add(int(null_stdin)) + _raise_if_setup_deadline_expired(deadline_monotonic) + + startup_ex = _STARTUPINFOEXW() + startup = startup_ex.StartupInfo + startup.cb = ctypes.sizeof(startup_ex) + startup.dwFlags = _STARTF_USESTDHANDLES | _STARTF_USESHOWWINDOW + startup.wShowWindow = 0 + startup.hStdInput = null_stdin + startup.hStdOutput = stdout_write + startup.hStdError = null_stdin + attribute_count = 2 + if command.job_handle_env_name is not None: + if not kernel32.SetHandleInformation(job, 0x00000001, 0x00000001): + raise OSError("startup_job_handle_inherit_failed") + attribute_count += 1 + required_size = ctypes.c_size_t() + kernel32.InitializeProcThreadAttributeList( + None, attribute_count, 0, ctypes.byref(required_size) + ) + _raise_if_setup_deadline_expired(deadline_monotonic) + if not required_size.value: + raise OSError("startup_attribute_size_unavailable") + attribute_buffer = ctypes.create_string_buffer(required_size.value) + attribute_list = ctypes.cast(attribute_buffer, wintypes.LPVOID) + if not kernel32.InitializeProcThreadAttributeList( + attribute_list, attribute_count, 0, ctypes.byref(required_size) + ): + attribute_list = None + raise OSError("startup_attribute_list_init_failed") + startup_ex.lpAttributeList = attribute_list + _raise_if_setup_deadline_expired(deadline_monotonic) + inherit_handles = ( + (wintypes.HANDLE * 3)(null_stdin, stdout_write, job) + if command.job_handle_env_name is not None + else (wintypes.HANDLE * 2)(null_stdin, stdout_write) + ) + if not kernel32.UpdateProcThreadAttribute( + attribute_list, + 0, + _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + ctypes.cast(inherit_handles, wintypes.LPVOID), + ctypes.sizeof(inherit_handles), + None, + None, + ): + raise OSError("startup_handle_list_failed") + _raise_if_setup_deadline_expired(deadline_monotonic) + job_handles = (wintypes.HANDLE * 1)(job) + if not kernel32.UpdateProcThreadAttribute( + attribute_list, + 0, + _PROC_THREAD_ATTRIBUTE_JOB_LIST, + ctypes.cast(job_handles, wintypes.LPVOID), + ctypes.sizeof(job_handles), + None, + None, + ): + raise OSError("startup_job_list_failed") + _raise_if_setup_deadline_expired(deadline_monotonic) + + command_line_text = subprocess.list2cmdline(list(command.argv)) + if len(command_line_text) >= 32767: + raise OSError("child_command_too_long") + command_line = ctypes.create_unicode_buffer(command_line_text) + child_environment = dict(command.env) + if command.job_handle_env_name is not None: + child_environment[command.job_handle_env_name] = str(job) + env_block = _environment_block(child_environment) + _raise_if_setup_deadline_expired(deadline_monotonic) + flags = ( + _CREATE_SUSPENDED + | _CREATE_UNICODE_ENVIRONMENT + | _EXTENDED_STARTUPINFO_PRESENT + | _CREATE_NO_WINDOW + ) + created = kernel32.CreateProcessW( + command.argv[0], + command_line, + None, + None, + True, + flags, + env_block, + str(command.cwd), + ctypes.cast(ctypes.byref(startup_ex), ctypes.POINTER(_STARTUPINFOW)), + ctypes.byref(process_info), + ) + if attribute_list is not None: + kernel32.DeleteProcThreadAttributeList(attribute_list) + attribute_list = None + if not created: + raise OSError("child_process_create_failed") + process_created = True + _raise_if_setup_deadline_expired( + deadline_monotonic, process_created=True, job_assigned=False + ) + if _job_active_processes(kernel32, job) != 1: + # Keep the process suspended until independent Job accounting + # confirms containment. This is defense in depth around the + # atomic JOB_LIST startup attribute. + kernel32.TerminateProcess(process_info.hProcess, 0xEE10) + process_confirmed_dead = ( + kernel32.WaitForSingleObject( + process_info.hProcess, _bounded_timeout_ms(5000, deadline_monotonic) + ) + == _WAIT_OBJECT_0 + ) + raise _ChildCreationError( + "job_assignment_unobserved", + process_created=True, + job_assigned=False, + process_exit_observed=process_confirmed_dead, + ) + # Successful CreateProcessW with JOB_LIST means the suspended process + # entered this Job atomically. There is deliberately no assignment + # fallback that could create an uncontained process. + process_assigned = True + _raise_if_setup_deadline_expired( + deadline_monotonic, process_created=True, job_assigned=True + ) + + for handle in (stdout_write, int(null_stdin)): + if not kernel32.CloseHandle(handle): + job_terminated = bool(kernel32.TerminateJobObject(job, 0xEE13)) + dead = ( + kernel32.WaitForSingleObject( + process_info.hProcess, _bounded_timeout_ms(5000, deadline_monotonic) + ) + == _WAIT_OBJECT_0 + ) + raise _ChildCreationError( + "child_pipe_handle_close_failed", + process_created=True, + job_assigned=True, + process_exit_observed=dead, + job_termination_requested=True, + job_termination_call_succeeded=job_terminated, + ) + owned_handles.discard(handle) + _raise_if_setup_deadline_expired( + deadline_monotonic, process_created=True, job_assigned=True + ) + owned_handles.discard(stdout_read) + _raise_if_setup_deadline_expired( + deadline_monotonic, process_created=True, job_assigned=True + ) + return int(process_info.hProcess), int(process_info.hThread), stdout_read + except BaseException as error: + if process_info.hProcess and not process_assigned and not process_confirmed_dead: + # Any exception after CreateProcess must leave no runnable uncontained child. + kernel32.TerminateProcess(process_info.hProcess, 0xEE12) + process_confirmed_dead = ( + kernel32.WaitForSingleObject( + process_info.hProcess, _bounded_timeout_ms(5000, deadline_monotonic) + ) + == _WAIT_OBJECT_0 + ) + if process_created and not isinstance(error, _ChildCreationError): + error = _ChildCreationError( + "suspended_child_setup_failed", + process_created=True, + job_assigned=process_assigned, + process_exit_observed=(process_confirmed_dead if not process_assigned else None), + ) + if ( + process_info.hProcess + and not process_confirmed_dead + and isinstance(error, _ChildCreationError) + ): + error.process_handle = int(process_info.hProcess) + error.thread_handle = int(process_info.hThread) if process_info.hThread else None + for handle in tuple(owned_handles): + kernel32.CloseHandle(handle) + if attribute_list is not None: + kernel32.DeleteProcThreadAttributeList(attribute_list) + if process_info.hThread and (not process_info.hProcess or process_confirmed_dead): + kernel32.CloseHandle(process_info.hThread) + if process_info.hProcess and process_confirmed_dead: + kernel32.CloseHandle(process_info.hProcess) + if error is not None: + raise error + raise + + +def _handle_to_stream(handle: int) -> Any: + import msvcrt + + fd = msvcrt.open_osfhandle(handle, os.O_RDONLY | os.O_BINARY) + return os.fdopen(fd, "rb", buffering=0) + + +def _job_active_processes(kernel32: Any, job: int) -> Optional[int]: + information = _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() + if not kernel32.QueryInformationJobObject( + job, + _JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION, + ctypes.byref(information), + ctypes.sizeof(information), + None, + ): + return None + return int(information.ActiveProcesses) + + +def _wait_for_job_empty( + kernel32: Any, + job: int, + timeout_seconds: float, + *, + deadline_monotonic: Optional[float] = None, +) -> Optional[bool]: + local_deadline = time.monotonic() + timeout_seconds + if deadline_monotonic is not None: + local_deadline = min(local_deadline, deadline_monotonic) + while True: + if deadline_monotonic is not None and time.monotonic() >= local_deadline: + return False + active = _job_active_processes(kernel32, job) + if active == 0: + return True + if active is None: + return None + if time.monotonic() >= local_deadline: + return False + time.sleep(min(0.025, max(0.0, local_deadline - time.monotonic()))) + + +def _wait_for_process_exit( + kernel32: Any, + process: int, + timeout_ms: int, + *, + deadline_monotonic: Optional[float] = None, +) -> tuple[Optional[bool], Optional[int]]: + """Observe actual process signaling, optionally retrying after Job-empty.""" + + wait_result = kernel32.WaitForSingleObject( + process, _bounded_timeout_ms(timeout_ms, deadline_monotonic) + ) + if wait_result == _WAIT_OBJECT_0: + exit_code = wintypes.DWORD() + code = ( + int(exit_code.value) + if kernel32.GetExitCodeProcess(process, ctypes.byref(exit_code)) + else None + ) + return True, code + if wait_result == _WAIT_TIMEOUT: + return False, None + return None, None + + +def _wait_for_job_empty_with_budget( + kernel32: Any, + job: int, + timeout_seconds: float, + deadline_monotonic: Optional[float], +) -> Optional[bool]: + if deadline_monotonic is None: + return _wait_for_job_empty(kernel32, job, timeout_seconds) + return _wait_for_job_empty( + kernel32, job, timeout_seconds, deadline_monotonic=deadline_monotonic + ) + + +def _wait_for_process_exit_with_budget( + kernel32: Any, + process: int, + timeout_ms: int, + deadline_monotonic: Optional[float], +) -> tuple[Optional[bool], Optional[int]]: + if deadline_monotonic is None: + return _wait_for_process_exit(kernel32, process, timeout_ms) + return _wait_for_process_exit( + kernel32, process, timeout_ms, deadline_monotonic=deadline_monotonic + ) + + +def _validated_receipt( + value: object, schema: ValueFreeReceiptSchema +) -> Optional[dict[str, object]]: + """Apply the caller-owned exact field and enum allowlist at the boundary.""" + + expected = ( + set(schema.bool_fields) + | set(schema.enum_fields) + | set(schema.nullable_enum_fields) + | set(schema.nullable_bool_fields) + ) + if type(value) is not dict or set(value) != expected: + return None + result: dict[str, object] = {} + for key, item in value.items(): + if type(key) is not str or not _VALUE_FREE_TOKEN.fullmatch(key): + return None + if key in schema.bool_fields: + if type(item) is not bool: + return None + elif key in schema.nullable_bool_fields: + if item is not None and type(item) is not bool: + return None + elif key in schema.enum_fields: + if type(item) is not str or item not in schema.enum_fields[key]: + return None + elif key in schema.nullable_enum_fields: + if item is not None and ( + type(item) is not str or item not in schema.nullable_enum_fields[key] + ): + return None + else: + return None + result[key] = item + return result + + +class _DuplicateJsonKey(ValueError): + pass + + +def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise _DuplicateJsonKey("duplicate_json_key") + result[key] = value + return result + + +def _decode_single_json_object(raw: bytes) -> tuple[str, Optional[dict[str, object]]]: + """Return incomplete/invalid/complete without allowing duplicate JSON keys.""" + + try: + text = raw.decode("utf-8", errors="strict") + if not text.endswith("\n"): + return "incomplete", None + lines = [line for line in text.splitlines() if line.strip()] + if len(lines) != 1: + return "invalid", None + value = json.loads(lines[0], object_pairs_hook=_unique_json_object) + except (UnicodeError, ValueError, TypeError): + return "invalid", None + if type(value) is not dict: + return "invalid", None + return "complete", value + + +def parse_single_json_receipt( + raw: bytes, schema: ValueFreeReceiptSchema +) -> Optional[Mapping[str, object]]: + """Parse one duplicate-free JSON object line and enforce its exact schema.""" + + state, value = _decode_single_json_object(raw) + if state != "complete" or value is None: + return None + return _validated_receipt(value, schema) + + +def _safe_parse( + parser: Callable[[bytes], Optional[Mapping[str, object]]], + raw: bytes, + schema: ValueFreeReceiptSchema, +) -> tuple[Optional[dict[str, object]], bool]: + state, _decoded = _decode_single_json_object(raw) + if state == "incomplete": + return None, False + if state != "complete": + return None, True + try: + return _validated_receipt(parser(raw), schema), False + except Exception: + return None, True + + +def _evidence( + *, + created: bool = False, + assigned: bool = False, + resumed: bool = False, + process_exit_observed: Optional[bool] = None, + process_exit_code: Optional[int] = None, + job_termination_requested: bool = False, + job_termination_call_succeeded: Optional[bool] = None, + job_empty_observed: Optional[bool] = None, + containment: str = "not_started", +) -> ProcessEvidence: + return ProcessEvidence( + process_created=created, + job_assignment_observed=assigned, + process_resumed=resumed, + process_exit_observed=process_exit_observed, + process_exit_code=process_exit_code, + job_termination_requested=job_termination_requested, + job_termination_call_succeeded=job_termination_call_succeeded, + job_empty_observed=job_empty_observed, + containment=containment, + ) + + +def _trip_fail_closed_latch(latch: FailClosedLatch, reason: str) -> bool: + global _PROCESS_CONTAINMENT_LATCH + _PROCESS_CONTAINMENT_LATCH = reason + try: + return latch.trip(reason) is True + except Exception: + return False + + +def run_readonly_child( + command: FixedChildCommand, + result_parser: Callable[[bytes], Optional[Mapping[str, object]]], + *, + receipt_schema: ValueFreeReceiptSchema, + fail_closed_latch: FailClosedLatch, + deadline_seconds: float, + deadline_monotonic: Optional[float] = None, + max_stdout_bytes: int = 64 * 1024, + termination_grace_seconds: float = 5.0, +) -> SupervisedResult: + """Run a fixed read-only child inside a kill-on-close Windows Job Object. + + ``result_parser`` must be pure and safe to call on growing stdout prefixes; + return ``None`` until a complete, value-free receipt is available. The + required exact ``receipt_schema`` validates every accepted field and enum. + ``fail_closed_latch`` must durably block a retry after containment uncertainty. + A receipt with ``native_join_pending=True`` immediately terminates the Job. + The parser's output is shape-checked and retained separately from parent OS + observations. This result is not a preflight or session-acceptance decision. + + ``deadline_monotonic``, when supplied, is an absolute cutoff in the same + clock domain as ``time.monotonic()``. It adds an outer budget that includes + latch checks, setup, child observation, and bounded cleanup waits; the + earlier of that cutoff and ``deadline_seconds`` wins. The supervisor checks + the budget between operations and caps wait/join timeouts by its remaining + time. It cannot interrupt a synchronous Windows API or Python call that is + already running (including process creation, termination, handle cleanup, + latch callbacks, and result parsing), and scheduler delays can extend the + observed return time. This is not a hard whole-call time bound. + """ + + if not isinstance(command, FixedChildCommand): + raise TypeError("fixed_child_command_required") + if not callable(result_parser): + raise TypeError("result_parser_required") + if not isinstance(receipt_schema, ValueFreeReceiptSchema): + raise TypeError("value_free_receipt_schema_required") + if not callable(getattr(fail_closed_latch, "is_tripped", None)) or not callable( + getattr(fail_closed_latch, "trip", None) + ): + raise TypeError("fail_closed_latch_required") + if ( + type(deadline_seconds) not in (int, float) + or not math.isfinite(float(deadline_seconds)) + or not 0 < float(deadline_seconds) <= 3600 + ): + raise ValueError("deadline_out_of_range") + if deadline_monotonic is not None and ( + type(deadline_monotonic) not in (int, float) + or not math.isfinite(float(deadline_monotonic)) + ): + raise ValueError("deadline_monotonic_invalid") + absolute_deadline = ( + None if deadline_monotonic is None else float(deadline_monotonic) + ) + if type(max_stdout_bytes) is not int or not 1 <= max_stdout_bytes <= 1024 * 1024: + raise ValueError("stdout_bound_out_of_range") + if ( + type(termination_grace_seconds) not in (int, float) + or not math.isfinite(float(termination_grace_seconds)) + or not 0.1 <= float(termination_grace_seconds) <= 30 + ): + raise ValueError("termination_grace_out_of_range") + global _PROCESS_CONTAINMENT_LATCH + if _PROCESS_CONTAINMENT_LATCH is not None: + return SupervisedResult( + "latched", + "prior_containment_uncertainty", + None, + _evidence(containment="latched"), + ) + if _deadline_expired(absolute_deadline): + return SupervisedResult( + "timed_out", + "supervisor_deadline_exceeded", + None, + _evidence(containment="not_started"), + ) + try: + external_latch = fail_closed_latch.is_tripped() + if type(external_latch) is not bool: + raise TypeError("containment_latch_state_invalid") + if external_latch: + return SupervisedResult( + "latched", + "prior_containment_uncertainty", + None, + _evidence(containment="latched"), + ) + except Exception: + return SupervisedResult( + "supervisor_error", + "containment_latch_unavailable", + None, + _evidence(containment="unavailable"), + ) + if _deadline_expired(absolute_deadline): + return SupervisedResult( + "timed_out", + "supervisor_deadline_exceeded", + None, + _evidence(containment="not_started"), + ) + if os.name != "nt": + return SupervisedResult( + "supervisor_error", + "windows_required", + None, + _evidence(containment="unavailable"), + ) + + kernel32: Any = None + job: Optional[int] = None + process: Optional[int] = None + thread: Optional[int] = None + stream: Any = None + reader: Optional[threading.Thread] = None + capture = _CapturedOutput(max_stdout_bytes, bytearray()) + receipt: Optional[dict[str, object]] = None + parse_failed = False + status = "supervisor_error" + reason = "supervisor_setup_failed" + created = False + assigned = False + resumed = False + process_exit_observed: Optional[bool] = None + process_exit_code: Optional[int] = None + job_termination_requested = False + job_termination_call_succeeded: Optional[bool] = None + job_empty_observed: Optional[bool] = None + containment = "uncertain" + latch_trip_succeeded: Optional[bool] = None + retained_control: Optional[RetainedJobControl] = None + started = time.monotonic() + relative_deadline = started + float(deadline_seconds) + deadline = ( + relative_deadline + if absolute_deadline is None + else min(relative_deadline, absolute_deadline) + ) + budget_deadline = deadline if absolute_deadline is not None else None + terminate_after_loop = False + terminate_code = 0xEE20 + receipt_length: Optional[int] = None + try: + if _deadline_expired(budget_deadline): + raise _SupervisorDeadlineExceeded("supervisor_deadline_exceeded") + kernel32 = _winapi() + if _deadline_expired(budget_deadline): + raise _SupervisorDeadlineExceeded("supervisor_deadline_exceeded") + job = _create_job(kernel32) + if _deadline_expired(budget_deadline): + raise _SupervisorDeadlineExceeded("supervisor_deadline_exceeded") + if budget_deadline is None: + process, thread, stdout_handle = _create_suspended_process(kernel32, command, job) + else: + process, thread, stdout_handle = _create_suspended_process( + kernel32, + command, + job, + deadline_monotonic=budget_deadline, + ) + created = True + assigned = True + if _deadline_expired(budget_deadline): + raise _ChildCreationError( + "supervisor_deadline_exceeded", + process_created=True, + job_assigned=True, + process_exit_observed=None, + process_handle=process, + thread_handle=thread, + ) + stream = _handle_to_stream(stdout_handle) + if _deadline_expired(budget_deadline): + raise _ChildCreationError( + "supervisor_deadline_exceeded", + process_created=True, + job_assigned=True, + process_exit_observed=None, + process_handle=process, + thread_handle=thread, + ) + reader = threading.Thread(target=capture.drain, args=(stream,), daemon=True) + reader.start() + if _deadline_expired(budget_deadline): + raise _ChildCreationError( + "supervisor_deadline_exceeded", + process_created=True, + job_assigned=True, + process_exit_observed=None, + process_handle=process, + thread_handle=thread, + ) + if _deadline_expired(budget_deadline): + status, reason = "timed_out", _deadline_reason(relative_deadline, absolute_deadline) + terminate_after_loop = True + terminate_code = 0xEE27 + else: + resume_result = kernel32.ResumeThread(thread) + if resume_result == 1: + resumed = True + kernel32.CloseHandle(thread) + thread = None + if _deadline_expired(budget_deadline): + status, reason = "timed_out", _deadline_reason( + relative_deadline, absolute_deadline + ) + terminate_after_loop = True + terminate_code = 0xEE27 + elif resume_result != 1: + status, reason = "containment_error", "child_resume_failed" + terminate_after_loop = True + terminate_code = 0xEE21 + else: + status, reason = "running", "child_running" + parsed_length = -1 + while True: + raw, overflow, read_error = capture.snapshot() + if overflow: + status, reason = "invalid_output", "stdout_limit_exceeded" + terminate_after_loop = True + terminate_code = 0xEE22 + break + if read_error: + status, reason = "supervisor_error", "stdout_read_failed" + terminate_after_loop = True + terminate_code = 0xEE23 + break + if len(raw) != parsed_length: + parsed_length = len(raw) + if receipt is not None: + status, reason = "invalid_output", "stdout_changed_after_receipt" + terminate_after_loop = True + terminate_code = 0xEE2C + break + parsed, parser_failed = _safe_parse(result_parser, raw, receipt_schema) + if parser_failed: + status, reason = "invalid_output", "result_parser_failed" + parse_failed = True + terminate_after_loop = True + terminate_code = 0xEE24 + break + if parsed is not None: + receipt = parsed + receipt_length = len(raw) + if receipt["native_join_pending"] is True: + status, reason = "pending_native_join", "native_join_pending" + terminate_after_loop = True + terminate_code = 0xEE25 + break + wait_result = kernel32.WaitForSingleObject(process, 0) + if wait_result == _WAIT_OBJECT_0: + process_exit_observed = True + exit_code = wintypes.DWORD() + if kernel32.GetExitCodeProcess(process, ctypes.byref(exit_code)): + process_exit_code = int(exit_code.value) + else: + status, reason = "supervisor_error", "process_exit_code_unavailable" + if status == "running": + status, reason = "child_exited", "child_process_exited" + break + if wait_result != _WAIT_TIMEOUT: + status, reason = "supervisor_error", "process_wait_failed" + terminate_after_loop = True + terminate_code = 0xEE26 + break + if time.monotonic() >= deadline: + status, reason = "timed_out", _deadline_reason( + relative_deadline, absolute_deadline + ) + terminate_after_loop = True + terminate_code = 0xEE27 + break + sleep_seconds = 0.02 + if budget_deadline is not None: + sleep_seconds = _bounded_timeout_seconds(sleep_seconds, budget_deadline) + if sleep_seconds > 0: + time.sleep(sleep_seconds) + + # Bound all remaining work, including descendants that retained stdout. + if _deadline_expired(budget_deadline): + status, reason = "timed_out", _deadline_reason(relative_deadline, absolute_deadline) + terminate_after_loop = True + terminate_code = 0xEE27 + if terminate_after_loop: + job_termination_requested = True + job_termination_call_succeeded = bool(kernel32.TerminateJobObject(job, terminate_code)) + else: + # A signaled child can briefly remain counted in Job accounting. + # Give the whole Job the existing bounded grace to become empty + # naturally before treating remaining descendants as an error. + job_empty_observed = _wait_for_job_empty_with_budget( + kernel32, job, float(termination_grace_seconds), budget_deadline + ) + if job_empty_observed is not True: + job_termination_requested = True + job_termination_call_succeeded = bool( + kernel32.TerminateJobObject(job, 0xEE28) + ) + + if job_termination_requested: + if process_exit_observed is not True: + process_exit_observed, process_exit_code = _wait_for_process_exit_with_budget( + kernel32, + process, + int(float(termination_grace_seconds) * 1000), + budget_deadline, + ) + # After an explicit kill request, require a fresh bounded Job + # accounting observation; natural-empty evidence is retained only + # when no termination request was needed. + job_empty_observed = _wait_for_job_empty_with_budget( + kernel32, job, float(termination_grace_seconds), budget_deadline + ) + + if reader is not None: + reader_join_timeout = float(termination_grace_seconds) + if budget_deadline is not None: + reader_join_timeout = _bounded_timeout_seconds( + reader_join_timeout, budget_deadline + ) + reader.join(timeout=reader_join_timeout) + raw, overflow, read_error = capture.snapshot() + if receipt is None and not parse_failed and not overflow and not read_error: + final_receipt, parser_failed = _safe_parse(result_parser, raw, receipt_schema) + if parser_failed: + status, reason = "invalid_output", "result_parser_failed" + elif final_receipt is not None: + receipt = final_receipt + receipt_length = len(raw) + if receipt["native_join_pending"] is True: + status, reason = "pending_native_join", "native_join_pending" + if ( + receipt is not None + and receipt["native_join_pending"] is True + and not job_termination_requested + ): + # The child may exit before the output reader exposes its final + # receipt. Still issue an explicit whole-Job termination request. + job_termination_requested = True + job_termination_call_succeeded = bool(kernel32.TerminateJobObject(job, 0xEE25)) + job_empty_observed = _wait_for_job_empty_with_budget( + kernel32, job, float(termination_grace_seconds), budget_deadline + ) + if job_empty_observed is True and process_exit_observed is not True: + process_exit_observed, process_exit_code = _wait_for_process_exit_with_budget( + kernel32, + process, + int(float(termination_grace_seconds) * 1000), + budget_deadline, + ) + if receipt is not None and receipt_length is not None and len(raw) > receipt_length: + status, reason = "invalid_output", "stdout_changed_after_receipt" + receipt = None + if overflow: + status, reason = "invalid_output", "stdout_limit_exceeded" + receipt = None + elif read_error or (reader is not None and reader.is_alive()): + status, reason = "supervisor_error", "stdout_drain_unconfirmed" + elif receipt is None and status in {"child_exited", "running"}: + status, reason = "invalid_output", "result_receipt_unavailable" + + if ( + process_exit_observed is True + and job_empty_observed is True + and (not job_termination_requested or job_termination_call_succeeded is True) + ): + containment = "verified" + else: + containment = "uncertain" + if status not in {"timed_out", "pending_native_join", "invalid_output"}: + status, reason = "containment_error", "process_tree_containment_unverified" + if created and containment != "verified": + latch_trip_succeeded = _trip_fail_closed_latch(fail_closed_latch, reason) + if not latch_trip_succeeded: + status, reason = "supervisor_error", "containment_latch_trip_failed" + except _SupervisorDeadlineExceeded: + status, reason = "timed_out", "supervisor_deadline_exceeded" + containment = "not_started" + except _ChildCreationError as error: + created = error.process_created + assigned = error.job_assigned + process_exit_observed = error.process_exit_observed + job_termination_requested = error.job_termination_requested + job_termination_call_succeeded = error.job_termination_call_succeeded + deadline_error = error.reason == "supervisor_deadline_exceeded" + status, reason = ( + ("timed_out", error.reason) + if deadline_error + else ("containment_error", error.reason) + ) + if error.process_handle is not None: + process = error.process_handle + thread = error.thread_handle + if assigned: + job_termination_requested = True + try: + job_termination_call_succeeded = bool(kernel32.TerminateJobObject(job, 0xEE2A)) + except Exception: + job_termination_call_succeeded = False + else: + try: + kernel32.TerminateProcess(process, 0xEE2B) + except Exception: + pass + try: + process_exit_observed, process_exit_code = _wait_for_process_exit_with_budget( + kernel32, + process, + int(float(termination_grace_seconds) * 1000), + budget_deadline, + ) + except Exception: + process_exit_observed = None + if job is not None and (created or not deadline_error): + try: + job_empty_observed = _wait_for_job_empty_with_budget( + kernel32, job, float(termination_grace_seconds), budget_deadline + ) + except Exception: + job_empty_observed = None + if assigned and job_empty_observed is True and process_exit_observed is not True: + try: + process_exit_observed, process_exit_code = _wait_for_process_exit_with_budget( + kernel32, + process, + int(float(termination_grace_seconds) * 1000), + budget_deadline, + ) + except Exception: + process_exit_observed = None + if not created and deadline_error: + containment = "not_started" + elif process_exit_observed is True: + containment = ( + "verified" + if assigned and job_empty_observed is True + else "assignment_failed_child_terminated" + ) + else: + containment = "uncertain" + if created and containment != "verified": + latch_trip_succeeded = _trip_fail_closed_latch(fail_closed_latch, reason) + if not latch_trip_succeeded: + status, reason = "supervisor_error", "containment_latch_trip_failed" + except Exception: + status, reason = "supervisor_error", "supervisor_process_error" + if kernel32 is not None and job is not None and process is not None: + job_termination_requested = True + try: + job_termination_call_succeeded = bool(kernel32.TerminateJobObject(job, 0xEE29)) + if process is not None: + process_exit_observed, process_exit_code = _wait_for_process_exit_with_budget( + kernel32, + process, + int(float(termination_grace_seconds) * 1000), + budget_deadline, + ) + job_empty_observed = _wait_for_job_empty_with_budget( + kernel32, job, float(termination_grace_seconds), budget_deadline + ) + if job_empty_observed is True and process_exit_observed is not True: + process_exit_observed, process_exit_code = _wait_for_process_exit_with_budget( + kernel32, + process, + int(float(termination_grace_seconds) * 1000), + budget_deadline, + ) + except Exception: + containment = "uncertain" + if ( + process_exit_observed is True + and job_empty_observed is True + and (not job_termination_requested or job_termination_call_succeeded is True) + ): + containment = "verified" + else: + containment = "uncertain" + if created and containment != "verified": + latch_trip_succeeded = _trip_fail_closed_latch(fail_closed_latch, reason) + if not latch_trip_succeeded: + reason = "containment_latch_trip_failed" + finally: + retain_control = ( + created + and containment != "verified" + and job is not None + and ( + process_exit_observed is not True + or job_empty_observed is not True + or (job_termination_requested and job_termination_call_succeeded is not True) + ) + ) + if retain_control: + retained_control = RetainedJobControl( + kernel32, + job, + process, + thread, + assigned, + resumed, + ) + job = None + process = None + thread = None + if thread and kernel32 is not None: + kernel32.CloseHandle(thread) + if reader is not None and reader.is_alive(): + reader_join_timeout = 0.1 + if budget_deadline is not None: + reader_join_timeout = _bounded_timeout_seconds( + reader_join_timeout, budget_deadline + ) + reader.join(timeout=reader_join_timeout) + if stream is not None: + try: + stream.close() + except Exception: + pass + if process and kernel32 is not None: + kernel32.CloseHandle(process) + if job and kernel32 is not None: + # KILL_ON_JOB_CLOSE is the final safety net; it is not counted as + # observed-empty evidence if the explicit query above failed. + kernel32.CloseHandle(job) + + if containment == "uncertain" and status not in { + "timed_out", + "pending_native_join", + "invalid_output", + "supervisor_error", + }: + status, reason = "containment_error", "process_tree_containment_unverified" + if budget_deadline is not None and _deadline_expired(budget_deadline) and status in { + "running", + "child_exited", + "pending_native_join", + }: + status, reason = "timed_out", _deadline_reason(relative_deadline, absolute_deadline) + evidence = _evidence( + created=created, + assigned=assigned, + resumed=resumed, + process_exit_observed=process_exit_observed, + process_exit_code=process_exit_code, + job_termination_requested=job_termination_requested, + job_termination_call_succeeded=job_termination_call_succeeded, + job_empty_observed=job_empty_observed, + containment=containment, + ) + return SupervisedResult(status, reason, receipt, evidence, retained_control) diff --git a/backtrader_runtime/ctp_sandbox_readonly_admission.py b/backtrader_runtime/ctp_sandbox_readonly_admission.py new file mode 100644 index 00000000..f77d9105 --- /dev/null +++ b/backtrader_runtime/ctp_sandbox_readonly_admission.py @@ -0,0 +1,913 @@ +"""Config-first CTP SimNow private-account read-only admission. + +This is a narrow prerequisite boundary for one code-owned CTP SimNow +private-account observation. It does not resolve credentials, import an SDK, +create a default factory, or grant any execution authority. A composition +root must supply the reviewed registration and an injected +:class:`CtpReadOnlySessionFactory` only after the runtime config has been +sealed against the exact registry. + +The factory receives the existing CTP read-only session request and must be +constructed with the same reviewed registration. Its SDK adapter uses the +exact configured front pair, neutral code-owned SimNow scope marker, and +instrument/exchange/hedge scope captured here. No set/profile is inferred from +the addresses. Environment variables and credentials cannot override them. +This admission never grants write authority. +""" + +from __future__ import annotations + +import hashlib +import hmac +import math +import re +import time +from dataclasses import dataclass, field +from typing import Any, Optional +from urllib.parse import urlsplit + +from .ctp_preflight import ( + CTP_PROVIDER, + CtpReadOnlyQuerySnapshot, + CtpReadOnlySession, + CtpReadOnlySessionFactory, + CtpReadOnlySessionIdentity, + CtpReadOnlySessionRequest, +) +from .errors import RuntimeConfigError +from .registry import ( + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + require_effective_runtime_config_seal, +) + + +_CTP_SIMNOW_ENVIRONMENT_RE = re.compile(r"^simnow(?:_set([12]))?$") +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") +_PROFILE_RE = re.compile(r"^(?:config_front_pair|set([12])_[a-z0-9_]{1,63})$") +_CTP_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_CTP_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") +_CTP_HEDGE_RE = re.compile(r"^[1-3]$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_CONFIGURED_SIMNOW_PROFILE = "config_front_pair" + + +class CtpSandboxReadOnlyAdmissionError(ValueError): + """Redacted fail-closed rejection for the sandbox read-only boundary.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise CtpSandboxReadOnlyAdmissionError(reason, message) + + +def _sha256(value: Any, field_name: str) -> str: + if type(value) is not str or not _SHA256_RE.fullmatch(value): + _reject("invalid_digest", "invalid {0}".format(field_name)) + return value + + +def _identifier(value: Any, field_name: str) -> str: + if type(value) is not str or value != value.strip() or not _IDENTIFIER_RE.fullmatch(value): + _reject("invalid_registration", "invalid {0}".format(field_name)) + return value + + +def _secrets_ref(value: Any) -> str: + if ( + type(value) is not str + or value == "none" + or value != value.strip() + or not value + or len(value) > 256 + or any(character.isspace() for character in value) + ): + _reject("invalid_registration", "invalid allowed secrets reference") + return value + + +def _environment_and_profile(environment: Any, sdk_profile: Any) -> tuple[str, str]: + if ( + type(environment) is not str + or environment != environment.strip() + or (environment_match := _CTP_SIMNOW_ENVIRONMENT_RE.fullmatch(environment)) is None + ): + _reject( + "environment_not_simnow", "CTP sandbox admission requires an exact SimNow environment" + ) + if type(sdk_profile) is not str or sdk_profile != sdk_profile.strip(): + _reject("invalid_registration", "invalid CTP SDK profile") + profile_match = _PROFILE_RE.fullmatch(sdk_profile) + if profile_match is None: + _reject("invalid_registration", "invalid CTP SDK profile") + environment_group = environment_match.group(1) + profile_group = profile_match.group(1) + if environment == "simnow": + if sdk_profile != _CONFIGURED_SIMNOW_PROFILE: + _reject( + "environment_profile_mismatch", + "configured SimNow fronts require the direct-pair SDK marker", + ) + elif profile_group != environment_group: + _reject( + "environment_profile_mismatch", + "CTP SDK profile does not match the registered SimNow environment", + ) + return environment, sdk_profile + + +def _ctp_front(value: Any, field_name: str) -> str: + """Validate a canonical TCP front without classifying or replacing it.""" + + if ( + type(value) is not str + or not value + or value != value.strip() + or len(value) > 128 + or any(character.isspace() or ord(character) < 0x20 for character in value) + ): + _reject("invalid_registration", "invalid CTP {0}".format(field_name)) + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + _reject("invalid_registration", "invalid CTP {0}".format(field_name)) + if ( + parsed.scheme != "tcp" + or not parsed.hostname + or port is None + or not 1 <= port <= 65535 + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or value != "tcp://{0}:{1}".format(parsed.hostname, port) + ): + _reject("invalid_registration", "invalid CTP {0}".format(field_name)) + return value + + +def _native_query_scope( + instrument_id: Any, + exchange_id: Any, + hedge_flag: Any, + *, + reason: str, +) -> tuple[str, str, str]: + """Validate the bounded native CTP query scope outside operator config.""" + + instrument_id = _identifier(instrument_id, "instrument_id") + exchange_id = _identifier(exchange_id, "exchange_id") + hedge_flag = _identifier(hedge_flag, "hedge_flag") + if not _CTP_INSTRUMENT_RE.fullmatch(instrument_id): + _reject(reason, "invalid instrument_id") + if not _CTP_EXCHANGE_RE.fullmatch(exchange_id): + _reject(reason, "invalid exchange_id") + if not _CTP_HEDGE_RE.fullmatch(hedge_flag): + _reject(reason, "invalid hedge_flag") + return instrument_id, exchange_id, hedge_flag + + +def _session_ttl(value: Any) -> float: + if type(value) not in (int, float): + _reject("invalid_registration", "invalid read-only session TTL") + try: + normalized = float(value) + except (OverflowError, TypeError, ValueError): + _reject("invalid_registration", "invalid read-only session TTL") + if not math.isfinite(normalized) or normalized <= 0.0 or normalized > 300.0: + _reject("invalid_registration", "invalid read-only session TTL") + return normalized + + +def _timestamp(value: Any, field_name: str) -> float: + if type(value) not in (int, float): + _reject("invalid_clock", "invalid {0}".format(field_name)) + try: + normalized = float(value) + except (OverflowError, TypeError, ValueError): + _reject("invalid_clock", "invalid {0}".format(field_name)) + if not math.isfinite(normalized): + _reject("invalid_clock", "invalid {0}".format(field_name)) + return 0.0 if normalized == 0.0 else normalized + + +@dataclass(frozen=True) +class CtpSandboxReadOnlyRegistration: + """One exact CTP private-read route and query scope. + + ``runtime_registration`` must be the same object registered in the trusted + registry. A static binding may set the scope in code; the restricted + config-driven binding derives it only from one sealed private SimNow + ``simulation/sandbox`` config. The exact TD/MD strings are preserved as + configured; this contract does not classify them by set or SDK profile. + The resulting admission always binds one exact scope and grants no write. + """ + + runtime_registration: RegisteredRuntime + environment: str + sdk_profile: str + account_fingerprint_sha256: str = field(repr=False) + allowed_secrets_ref: str + instrument_id: str + exchange_id: str + hedge_flag: str + td_front: str + md_front: str + session_ttl_seconds: float = 60.0 + + def __post_init__(self) -> None: + if type(self.runtime_registration) is not RegisteredRuntime: + _reject("invalid_registration", "invalid registered runtime binding") + environment, sdk_profile = _environment_and_profile(self.environment, self.sdk_profile) + object.__setattr__(self, "environment", environment) + object.__setattr__(self, "sdk_profile", sdk_profile) + object.__setattr__( + self, + "account_fingerprint_sha256", + _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256"), + ) + object.__setattr__(self, "allowed_secrets_ref", _secrets_ref(self.allowed_secrets_ref)) + instrument_id, exchange_id, hedge_flag = _native_query_scope( + self.instrument_id, + self.exchange_id, + self.hedge_flag, + reason="invalid_registration", + ) + object.__setattr__(self, "instrument_id", instrument_id) + object.__setattr__(self, "exchange_id", exchange_id) + object.__setattr__(self, "hedge_flag", hedge_flag) + object.__setattr__(self, "td_front", _ctp_front(self.td_front, "td_front")) + object.__setattr__(self, "md_front", _ctp_front(self.md_front, "md_front")) + object.__setattr__(self, "session_ttl_seconds", _session_ttl(self.session_ttl_seconds)) + + +@dataclass(frozen=True) +class CtpSandboxReadOnlyObservation: + """Digest-only private read evidence that cannot act as account authority. + + A native-certificate digest, when the shared snapshot contract carries + one, remains unverified metadata at this boundary. It cannot establish + SDK issuance, account acceptance, or execution authority. + """ + + runtime_id: str + strategy_id: str + effective_config_digest: str + registration_digest: str + provider: str + environment: str + sdk_profile: str + account_fingerprint_sha256: str = field(repr=False) + instrument_id: str + exchange_id: str + hedge_flag: str + trading_day: str + connection_generation: int + query_snapshot: CtpReadOnlyQuerySnapshot + native_certificate_sha256: Optional[str] = None + native_certificate_provenance: str = "LOCAL_ONLY" + read_only_route_admitted: bool = True + session_connected: bool = True + provider_preflight_started: bool = False + approval_required: bool = False + account_acceptance_established: bool = False + execution_authorized: bool = False + external_writes_authorized: bool = False + order_submission_authorized: bool = False + cancellation_authorized: bool = False + settlement_authorized: bool = False + arming_authorized: bool = False + external_write_requests: int = 0 + + def __post_init__(self) -> None: + runtime_id = _identifier(self.runtime_id, "runtime_id") + strategy_id = _identifier(self.strategy_id, "strategy_id") + effective_config_digest = _sha256(self.effective_config_digest, "effective_config_digest") + registration_digest = _sha256(self.registration_digest, "registration_digest") + if self.provider != CTP_PROVIDER: + _reject("provider_not_ctp", "CTP sandbox admission requires provider ctp") + environment, sdk_profile = _environment_and_profile(self.environment, self.sdk_profile) + account_fingerprint = _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256") + instrument_id, exchange_id, hedge_flag = _native_query_scope( + self.instrument_id, + self.exchange_id, + self.hedge_flag, + reason="invalid_observation", + ) + identity = _normalise_identity( + CtpReadOnlySessionIdentity( + provider=self.provider, + environment=environment, + account_fingerprint_sha256=account_fingerprint, + trading_day=self.trading_day, + connection_generation=self.connection_generation, + ), + "observation identity", + ) + snapshot = _normalise_snapshot(self.query_snapshot) + if snapshot.identity != identity: + _reject( + "snapshot_identity_mismatch", "CTP query snapshot identity does not match session" + ) + native_certificate_sha256 = _snapshot_native_certificate_sha256(snapshot) + # A snapshot digest alone is not proof of SDK-native issuance: an + # injected fake session can manufacture a SHA-256-shaped value. Only + # a future typed builder/factory composition root can make that claim. + expected_native_certificate_provenance = ( + "UNVERIFIED_DIGEST_ONLY" if native_certificate_sha256 is not None else "LOCAL_ONLY" + ) + if ( + self.native_certificate_sha256 != native_certificate_sha256 + or self.native_certificate_provenance != expected_native_certificate_provenance + ): + _reject( + "invalid_observation", + "native certificate provenance must be derived from the query snapshot", + ) + if ( + self.read_only_route_admitted is not True + or self.session_connected is not True + or self.provider_preflight_started is not False + or self.approval_required is not False + or self.account_acceptance_established is not False + or self.execution_authorized is not False + or self.external_writes_authorized is not False + or self.order_submission_authorized is not False + or self.cancellation_authorized is not False + or self.settlement_authorized is not False + or self.arming_authorized is not False + or type(self.external_write_requests) is not int + or self.external_write_requests != 0 + ): + _reject("invalid_observation", "CTP sandbox observation cannot grant authority") + object.__setattr__(self, "runtime_id", runtime_id) + object.__setattr__(self, "strategy_id", strategy_id) + object.__setattr__(self, "effective_config_digest", effective_config_digest) + object.__setattr__(self, "registration_digest", registration_digest) + object.__setattr__(self, "environment", environment) + object.__setattr__(self, "sdk_profile", sdk_profile) + object.__setattr__(self, "account_fingerprint_sha256", account_fingerprint) + object.__setattr__(self, "instrument_id", instrument_id) + object.__setattr__(self, "exchange_id", exchange_id) + object.__setattr__(self, "hedge_flag", hedge_flag) + object.__setattr__(self, "trading_day", identity.trading_day) + object.__setattr__(self, "connection_generation", identity.connection_generation) + object.__setattr__(self, "query_snapshot", snapshot) + object.__setattr__(self, "native_certificate_sha256", native_certificate_sha256) + object.__setattr__( + self, + "native_certificate_provenance", + expected_native_certificate_provenance, + ) + + def __bool__(self) -> bool: + raise TypeError( + "CtpSandboxReadOnlyObservation is a non-authority read-only observation; " + "do not use it as account acceptance" + ) + + def as_public_dict(self) -> dict[str, Any]: + """Return the read-only route without a reversible account hash.""" + + return { + "account_acceptance_established": self.account_acceptance_established, + "account_scope": "redacted", + "approval_required": self.approval_required, + "arming_authorized": self.arming_authorized, + "cancellation_authorized": self.cancellation_authorized, + "connection_generation": self.connection_generation, + "effective_config_digest": self.effective_config_digest, + "environment": self.environment, + "exchange_id": self.exchange_id, + "execution_authorized": self.execution_authorized, + "external_write_requests": self.external_write_requests, + "external_writes_authorized": self.external_writes_authorized, + "hedge_flag": self.hedge_flag, + "instrument_id": self.instrument_id, + "native_certificate_provenance": self.native_certificate_provenance, + "native_certificate_sha256": self.native_certificate_sha256, + "order_submission_authorized": self.order_submission_authorized, + "provider": self.provider, + "provider_preflight_started": self.provider_preflight_started, + "query_snapshot": self.query_snapshot.as_public_dict(), + "read_only_route_admitted": self.read_only_route_admitted, + "registration_digest": self.registration_digest, + "runtime_id": self.runtime_id, + "sdk_profile": self.sdk_profile, + "session_connected": self.session_connected, + "settlement_authorized": self.settlement_authorized, + "strategy_id": self.strategy_id, + "trading_day": self.trading_day, + } + + +def _normalise_registration(value: Any) -> CtpSandboxReadOnlyRegistration: + if type(value) is not CtpSandboxReadOnlyRegistration: + _reject("registration_required", "a code-owned CTP sandbox registration is required") + try: + return CtpSandboxReadOnlyRegistration( + runtime_registration=value.runtime_registration, + environment=value.environment, + sdk_profile=value.sdk_profile, + account_fingerprint_sha256=value.account_fingerprint_sha256, + allowed_secrets_ref=value.allowed_secrets_ref, + instrument_id=value.instrument_id, + exchange_id=value.exchange_id, + hedge_flag=value.hedge_flag, + td_front=value.td_front, + md_front=value.md_front, + session_ttl_seconds=value.session_ttl_seconds, + ) + except CtpSandboxReadOnlyAdmissionError: + raise + except Exception: + _reject("invalid_registration", "invalid code-owned CTP sandbox registration") + + +def _normalise_identity(value: Any, field_name: str) -> CtpReadOnlySessionIdentity: + if type(value) is not CtpReadOnlySessionIdentity: + _reject("invalid_session_identity", "invalid {0}".format(field_name)) + try: + return CtpReadOnlySessionIdentity( + provider=value.provider, + environment=value.environment, + account_fingerprint_sha256=value.account_fingerprint_sha256, + trading_day=value.trading_day, + connection_generation=value.connection_generation, + ) + except Exception: + _reject("invalid_session_identity", "invalid {0}".format(field_name)) + + +def _normalise_snapshot(value: Any) -> CtpReadOnlyQuerySnapshot: + if type(value) is not CtpReadOnlyQuerySnapshot: + _reject("invalid_query_snapshot", "invalid CTP query snapshot") + try: + normalized_fields = getattr(CtpReadOnlyQuerySnapshot, "__dataclass_fields__", {}) + arguments = { + "identity": value.identity, + "query_digests": value.query_digests, + "snapshot_sha256": value.snapshot_sha256, + } + # The reusable CTP contract starts as a local digest-only snapshot. + # A later SDK-backed extension may add a typed native-certificate + # digest. Preserve it only when it is part of that shared contract; + # this boundary still treats it as unverified because it does not + # establish factory provenance or SDK issuance on its own. + if "native_certificate_sha256" in normalized_fields: + arguments["native_certificate_sha256"] = getattr( + value, "native_certificate_sha256", None + ) + if "rate_exchange_scopes" in normalized_fields: + arguments["rate_exchange_scopes"] = getattr(value, "rate_exchange_scopes", ()) + return CtpReadOnlyQuerySnapshot(**arguments) + except Exception: + _reject("invalid_query_snapshot", "invalid CTP query snapshot") + + +def _snapshot_native_certificate_sha256(snapshot: CtpReadOnlyQuerySnapshot) -> Optional[str]: + """Read optional native provenance only from the shared snapshot contract.""" + + if "native_certificate_sha256" not in getattr( + CtpReadOnlyQuerySnapshot, "__dataclass_fields__", {} + ): + return None + try: + native_certificate_sha256 = snapshot.native_certificate_sha256 + except Exception: + _reject("invalid_query_snapshot", "invalid native CTP certificate provenance") + if native_certificate_sha256 is None: + return None + return _sha256(native_certificate_sha256, "native_certificate_sha256") + + +def _require_session_deadline(valid_until: float, monotonic_deadline: float) -> None: + if ( + _timestamp(time.time(), "now") >= valid_until + or _timestamp(time.monotonic(), "monotonic_now") >= monotonic_deadline + ): + _reject("session_deadline_expired", "CTP sandbox read-only session deadline has expired") + + +def require_ctp_sandbox_profile_registration( + registration: RegisteredRuntime, + registry: RuntimeRegistry, +) -> RuntimeProfile: + """Return the CTP sandbox profile eligible for this read-only preflight. + + The current preflight accepts one complete zero-write + ``simulation/sandbox`` profile tied to a code-owned config-driven CTP + binding. Any future change to profile capabilities needs its own reviewed + policy and dispatcher contract. + """ + + if type(registration) is not RegisteredRuntime or type(registry) is not RuntimeRegistry: + _reject("runtime_profile_mismatch", "registered CTP sandbox profile is invalid") + profiles = registration.profiles + profile = registration.profile_for("simulation", "sandbox") + unavailable = tuple( + (item.mode, item.preset, item.reason) for item in registration.unavailable_mode_profiles + ) + if ( + len(profiles) != 1 + or type(profile) is not RuntimeProfile + or profiles[0] is not profile + or profile.mode != "simulation" + or profile.preset != "sandbox" + or registration.allowed_presets != () + or registration.allowed_parameter_keys != () + or registration.allowed_secrets_refs != ("none",) + or registration.available_capabilities != () + or registration.offline_managed_execution is not False + or registration.sandbox_write_policy != "deny" + or registration.approval_receipt_digest is not None + or registration.runner_module is not None + or registration.runner_entrypoint != "run_runtime" + or registration.capability_modules != () + or registration.bootstrap_parameters != () + or profile.allowed_parameter_keys != () + or profile.allowed_secrets_refs != ("config_yaml",) + or profile.available_capabilities != () + or profile.approval_receipt_digest is not None + or profile.runner_module is not None + or profile.runner_entrypoint != "run_runtime" + or profile.capability_modules != () + or profile.offline_managed_execution is not False + or profile.sandbox_write_policy != "deny" + or unavailable + != (("live", "managed_live_direct", "managed_live_direct_profile_unavailable"),) + ): + _reject( + "runtime_profile_mismatch", + "registered profile is not the exact zero-write CTP sandbox route", + ) + try: + resolved = registry.require_runtime_dir(registration.runtime_dir) + binding = registry.require_ctp_simnow_readonly_binding(registration.runtime_id) + except Exception: + _reject("runtime_profile_mismatch", "registered CTP sandbox profile is unavailable") + # Import locally to avoid making the admission module depend on the + # operator composition at import time. + from .ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding + + if ( + resolved is not registration + or type(binding) is not CtpSimNowConfigReadOnlyBinding + or binding.runtime_id != registration.runtime_id + ): + _reject( + "runtime_profile_mismatch", + "registered profile is not bound to CTP config-driven read-only dispatch", + ) + return profile + + +def require_ctp_sandbox_profile_runtime( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, +) -> RuntimeProfile: + """Validate a sealed config against the exact profile preflight exception.""" + + if type(effective) is not EffectiveRuntimeConfig or type(registry) is not RuntimeRegistry: + _reject("effective_config_required", "a sealed CTP runtime configuration is required") + try: + require_effective_runtime_config_seal(effective, registry) + except Exception: + _reject("effective_config_mismatch", "sealed runtime configuration does not match registry") + profile = require_ctp_sandbox_profile_registration(effective.registration, registry) + from .config import CtpSimNowPrivateConfig + + private = effective.config.ctp_simnow + if ( + effective.profile is not profile + or effective.mode != "simulation" + or effective.preset != "sandbox" + or effective.config.strategy_id != effective.registration.strategy_id + or effective.config.secrets_ref != "config_yaml" + or type(private) is not CtpSimNowPrivateConfig + or type(private.front_pairs) is not tuple + or not private.front_pairs + or len(private.front_pairs) > 8 + or tuple(effective.parameters) != () + or effective.policy.name != "sandbox" + or effective.policy.mode != "simulation" + or effective.policy.environment != "sandbox" + or effective.order_route is not None + or effective.account_access != "sandbox_private_read" + or effective.required_capabilities != () + or effective.allows_network is not True + or effective.allows_external_writes is not False + or effective.allows_production_writes is not False + or effective.allows_hypothetical_fills is not False + or effective.requires_approval is not False + or effective.requires_live_confirmation is not False + ): + _reject("runtime_profile_mismatch", "sealed runtime is not the exact CTP sandbox profile") + return profile + + +def _require_runtime_contract( + effective: Any, + registry: Any, + admission_registration: Any, +) -> tuple[EffectiveRuntimeConfig, RuntimeRegistry, CtpSandboxReadOnlyRegistration]: + """Verify all sealed config and code-owned static constraints before I/O.""" + + if type(effective) is not EffectiveRuntimeConfig: + _reject("effective_config_required", "a sealed effective runtime configuration is required") + if type(registry) is not RuntimeRegistry: + _reject("registry_required", "a trusted runtime registry is required") + try: + require_effective_runtime_config_seal(effective, registry) + except RuntimeConfigError: + _reject("effective_config_mismatch", "sealed runtime configuration does not match registry") + except Exception: + _reject("effective_config_mismatch", "sealed runtime configuration does not match registry") + + admission = _normalise_registration(admission_registration) + try: + registered = registry.require_runtime_dir(effective.config.strategy_dir) + except Exception: + _reject("runtime_registration_mismatch", "registered runtime cannot be resolved") + if registered is not effective.registration or registered is not admission.runtime_registration: + _reject( + "runtime_registration_mismatch", + "sealed runtime does not match the code-owned CTP registration", + ) + if effective.profile is None: + # Keep the established legacy binding unchanged. Profile-backed + # runtimes have inert legacy fields, so their selected profile is + # validated separately below rather than inheriting from these fields. + if ( + registered.profiles + or registered.allowed_presets != ("sandbox",) + or registered.allowed_parameter_keys != () + or registered.allowed_secrets_refs != (admission.allowed_secrets_ref,) + or registered.sandbox_write_policy != "deny" + or registered.approval_receipt_digest is not None + or registered.available_capabilities != () + or registered.offline_managed_execution is not False + ): + _reject( + "runtime_policy_mismatch", + "registered runtime is not the exact zero-write CTP sandbox route", + ) + else: + require_ctp_sandbox_profile_runtime(effective, registry) + if admission.allowed_secrets_ref != "config_yaml": + _reject( + "runtime_policy_mismatch", + "profile-backed CTP sandbox reads require config_yaml credentials", + ) + private = effective.config.ctp_simnow + try: + configured_pairs = tuple( + (pair["td_front"], pair["md_front"]) for pair in private.front_pairs + ) + except Exception: + _reject("runtime_policy_mismatch", "sealed CTP profile has no exact front pair") + configured_account = hashlib.sha256( + "{0}:{1}".format(private.broker_id, private.user_id).encode("utf-8") + ).hexdigest() + if ( + admission.environment != "simnow" + or admission.sdk_profile != _CONFIGURED_SIMNOW_PROFILE + or (admission.td_front, admission.md_front) not in configured_pairs + or admission.instrument_id != private.instrument_id + or admission.exchange_id != private.exchange_id + or admission.hedge_flag != private.hedge_flag + or not hmac.compare_digest( + admission.account_fingerprint_sha256, + configured_account, + ) + ): + _reject( + "runtime_policy_mismatch", + "CTP profile admission does not match the sealed config scope", + ) + if ( + effective.config.strategy_id != registered.strategy_id + or effective.config.secrets_ref != admission.allowed_secrets_ref + or tuple(effective.parameters) != () + or effective.mode != "simulation" + or effective.preset != "sandbox" + or effective.policy.name != "sandbox" + or effective.policy.mode != "simulation" + or effective.policy.environment != "sandbox" + or effective.order_route is not None + or effective.account_access != "sandbox_private_read" + or effective.required_capabilities != () + or effective.allows_network is not True + or effective.allows_external_writes is not False + or effective.allows_production_writes is not False + or effective.allows_hypothetical_fills is not False + or effective.requires_approval is not False + or effective.requires_live_confirmation is not False + ): + _reject( + "runtime_policy_mismatch", + "sealed runtime is not the exact zero-write CTP sandbox route", + ) + return effective, registry, admission + + +def _matching_identity( + request: CtpReadOnlySessionRequest, + identity: CtpReadOnlySessionIdentity, +) -> bool: + return ( + identity.provider == request.provider + and identity.environment == request.environment + and hmac.compare_digest( + identity.account_fingerprint_sha256, request.account_fingerprint_sha256 + ) + ) + + +def require_ctp_sandbox_readonly_runtime_contract( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + admission_registration: CtpSandboxReadOnlyRegistration, +) -> CtpSandboxReadOnlyRegistration: + """Validate the sealed zero-write route before resolving secrets or an SDK. + + This is the composition-root extension point for constructing an injected + ``CtpReadOnlySessionFactory``. It has no access to a session factory, + secret resolver, provider client, or SDK. The reused runtime seal does + retain its required local directory-identity check before any caller can + resolve credentials or start a provider session. + """ + + _effective, _registry, admission = _require_runtime_contract( + effective, + registry, + admission_registration, + ) + del _effective, _registry + return admission + + +def admit_ctp_simnow_sandbox_readonly( + *, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + admission_registration: CtpSandboxReadOnlyRegistration, + session_factory: Optional[CtpReadOnlySessionFactory] = None, +) -> CtpSandboxReadOnlyObservation: + """Observe one exact CTP SimNow private account through a zero-write route. + + This function verifies the sealed runtime, exact registered secret route, + CTP SimNow environment, account fingerprint, and code-owned native-query + scope before it reads an attribute from ``session_factory``. Its result + is deliberately not account acceptance, provider approval, or authority + to submit, cancel, settle, arm, or write externally. + """ + + admission = require_ctp_sandbox_readonly_runtime_contract( + effective, + registry, + admission_registration, + ) + now = _timestamp(time.time(), "now") + valid_until = now + admission.session_ttl_seconds + monotonic_deadline = ( + _timestamp(time.monotonic(), "monotonic_now") + admission.session_ttl_seconds + ) + request = CtpReadOnlySessionRequest( + provider=CTP_PROVIDER, + environment=admission.environment, + account_fingerprint_sha256=admission.account_fingerprint_sha256, + valid_until=valid_until, + ) + _require_session_deadline(request.valid_until, monotonic_deadline) + if session_factory is None: + _reject( + "session_factory_required", + "CTP sandbox admission requires an injected read-only factory", + ) + try: + open_read_only = getattr(session_factory, "open_read_only", None) + except Exception: + _reject("invalid_session_factory", "invalid CTP read-only session factory") + if not callable(open_read_only): + _reject("invalid_session_factory", "invalid CTP read-only session factory") + + try: + session: Optional[CtpReadOnlySession] = open_read_only(request) + except CtpSandboxReadOnlyAdmissionError: + raise + except Exception: + _reject("session_factory_failed", "unable to open CTP read-only session") + if session is None: + _reject("invalid_session", "CTP read-only session factory returned no session") + + result: Optional[CtpSandboxReadOnlyObservation] = None + error: Optional[CtpSandboxReadOnlyAdmissionError] = None + close_read_only = None + try: + try: + close_read_only = getattr(session, "close_read_only", None) + if not callable(close_read_only): + _reject("invalid_session", "CTP read-only session has no close operation") + _require_session_deadline(request.valid_until, monotonic_deadline) + read_identity = getattr(session, "read_identity", None) + read_snapshot = getattr(session, "read_query_snapshot", None) + if not callable(read_identity) or not callable(read_snapshot): + _reject("invalid_session", "CTP read-only session has an invalid protocol") + + first_identity = _normalise_identity(read_identity(), "session identity") + _require_session_deadline(request.valid_until, monotonic_deadline) + if not _matching_identity(request, first_identity): + if first_identity.provider != CTP_PROVIDER: + _reject( + "session_provider_mismatch", + "CTP session provider does not match registration", + ) + if first_identity.environment != admission.environment: + _reject( + "session_environment_mismatch", + "CTP session environment does not match registration", + ) + _reject( + "session_account_mismatch", "CTP session account does not match registration" + ) + + snapshot = _normalise_snapshot(read_snapshot()) + _require_session_deadline(request.valid_until, monotonic_deadline) + if snapshot.identity != first_identity: + _reject( + "snapshot_identity_mismatch", + "CTP query snapshot identity does not match session", + ) + + final_identity = _normalise_identity(read_identity(), "final session identity") + _require_session_deadline(request.valid_until, monotonic_deadline) + if final_identity != first_identity: + _reject( + "session_identity_changed", "CTP session identity changed during observation" + ) + + native_certificate_sha256 = _snapshot_native_certificate_sha256(snapshot) + result = CtpSandboxReadOnlyObservation( + runtime_id=effective.registration.runtime_id or "", + strategy_id=effective.strategy_id, + effective_config_digest=effective.effective_digest, + registration_digest=effective.registration.digest, + provider=first_identity.provider, + environment=first_identity.environment, + sdk_profile=admission.sdk_profile, + account_fingerprint_sha256=first_identity.account_fingerprint_sha256, + instrument_id=admission.instrument_id, + exchange_id=admission.exchange_id, + hedge_flag=admission.hedge_flag, + trading_day=first_identity.trading_day, + connection_generation=first_identity.connection_generation, + query_snapshot=snapshot, + native_certificate_sha256=native_certificate_sha256, + native_certificate_provenance=( + "UNVERIFIED_DIGEST_ONLY" + if native_certificate_sha256 is not None + else "LOCAL_ONLY" + ), + ) + except CtpSandboxReadOnlyAdmissionError as caught: + error = caught + except Exception: + error = CtpSandboxReadOnlyAdmissionError( + "read_only_session_failed", "CTP sandbox read-only session failed" + ) + finally: + if callable(close_read_only): + try: + close_read_only() + except Exception: + if error is None: + error = CtpSandboxReadOnlyAdmissionError( + "session_close_failed", "unable to close CTP read-only session" + ) + if error is None: + try: + _require_session_deadline(request.valid_until, monotonic_deadline) + except CtpSandboxReadOnlyAdmissionError as caught: + error = caught + if error is not None: + raise error + if result is None: + _reject("read_only_session_failed", "CTP sandbox read-only session failed") + return result + + +__all__ = [ + "CtpSandboxReadOnlyAdmissionError", + "CtpSandboxReadOnlyObservation", + "CtpSandboxReadOnlyRegistration", + "admit_ctp_simnow_sandbox_readonly", + "require_ctp_sandbox_readonly_runtime_contract", +] diff --git a/backtrader_runtime/ctp_sdk_market_readonly.py b/backtrader_runtime/ctp_sdk_market_readonly.py new file mode 100644 index 00000000..b867ca28 --- /dev/null +++ b/backtrader_runtime/ctp_sdk_market_readonly.py @@ -0,0 +1,1311 @@ +"""Bounded CTP market-data probe with no trader or execution client. + +This module is intentionally separate from the private-account query session. +It accepts only the exact, already-reviewed SimNow admission produced by the +runtime route, and passes that admission's ``md_front`` value unchanged to the +SDK's ``MdClient``. The SDK profile map is validated by the admission type; +it is never used here to select or replace an endpoint. + +The probe subscribes to exactly one admitted instrument. Successful market +login and a successful subscription acknowledgement are required. Receiving +a tick is reported as an observation only because a quiet contract/session can +legitimately produce no tick during a short probe window. Its account lease +coordinates only other invocations of this probe; existing ``MdClient`` and +``CtpMarketStream`` sessions do not participate and must not run concurrently +for the same account during this probe. A tick observation requires the exact +instrument and exchange, finite positive LastPrice, and non-negative integer +Volume after the exact subscription acknowledgement and inside the optional +bounded observation window. Accepted callbacks are bound to the sealed +account/front/contract and connection generation. This remains local callback +evidence, not independent native-data QA or proof of real-market readiness. +""" + +from __future__ import annotations + +import hashlib +import hmac +import math +import os +import stat +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Protocol + +from .ctp_sandbox_readonly_admission import CtpSandboxReadOnlyRegistration + + +_MD_LOGIN_BROKER_ID_SHAPES = frozenset( + { + "not_observed", + "unreadable", + "empty", + "ascii_mismatch", + "nonascii_or_replacement", + "whitespace_or_control", + "exact_match", + } +) +_MD_LOGIN_USER_ID_SHAPES = frozenset( + { + "not_observed", + "unreadable", + "empty", + "ascii_mismatch", + "nonascii_or_replacement", + "whitespace_or_control", + "exact_match", + } +) +_MD_LOGIN_TRADING_DAY_SHAPES = frozenset( + { + "not_observed", + "unreadable", + "empty", + "invalid_format", + "invalid_calendar", + "valid", + } +) +_MD_LOGIN_NATIVE_FIELD_SHAPES = frozenset( + { + "not_observed", + "unreadable", + "empty", + "nonempty_terminated", + "unterminated", + } +) + + +class CtpSdkMarketReadOnlyError(ValueError): + """A redacted failure from the bounded market-data-only probe.""" + + def __init__( + self, + reason: str, + *, + close_state: str = "not_started", + primary_reason: str | None = None, + front_callback_observed: bool | None = None, + login_callback_count: int | None = None, + login_callback_disposition: str | None = None, + login_request_id_relation: str | None = None, + login_response_error_status: str | None = None, + login_failure_category: str | None = None, + login_broker_id_shape: str | None = None, + login_user_id_shape: str | None = None, + login_trading_day_shape: str | None = None, + native_broker_id_shape: str | None = None, + native_user_id_shape: str | None = None, + client_stop_returned: bool | None = None, + ) -> None: + self.reason = reason + self.close_state = close_state + self.primary_reason = primary_reason + self.front_callback_observed = front_callback_observed + self.login_callback_count = login_callback_count + self.login_callback_disposition = login_callback_disposition + self.login_request_id_relation = login_request_id_relation + self.login_response_error_status = login_response_error_status + self.login_failure_category = login_failure_category + self.login_broker_id_shape = ( + login_broker_id_shape + if type(login_broker_id_shape) is str + and login_broker_id_shape in _MD_LOGIN_BROKER_ID_SHAPES + else None + ) + self.login_user_id_shape = ( + login_user_id_shape + if type(login_user_id_shape) is str and login_user_id_shape in _MD_LOGIN_USER_ID_SHAPES + else None + ) + self.login_trading_day_shape = ( + login_trading_day_shape + if type(login_trading_day_shape) is str + and login_trading_day_shape in _MD_LOGIN_TRADING_DAY_SHAPES + else None + ) + self.native_broker_id_shape = ( + native_broker_id_shape + if type(native_broker_id_shape) is str + and native_broker_id_shape in _MD_LOGIN_NATIVE_FIELD_SHAPES + else None + ) + self.native_user_id_shape = ( + native_user_id_shape + if type(native_user_id_shape) is str + and native_user_id_shape in _MD_LOGIN_NATIVE_FIELD_SHAPES + else None + ) + self.client_stop_returned = ( + client_stop_returned if type(client_stop_returned) is bool else None + ) + super().__init__("CTP market-data read-only probe failed ({0})".format(reason)) + + +class CtpMarketCredentialSource(Protocol): + """Already-resolved credentials scoped to the admitted CTP account.""" + + def require_credential(self, name: str) -> str: + """Return one credential value without exposing it in diagnostics.""" + + +@dataclass(frozen=True) +class CtpSdkMarketTickBinding: + """Pathless identity binding for one or more accepted MD tick callbacks.""" + + account_fingerprint_sha256: str = field(repr=False) + md_front_sha256: str + instrument_id: str + exchange_id: str + connection_generation: int + + def as_public_dict(self) -> dict[str, Any]: + """Expose only the sealed identity scope, never raw provider values.""" + + return { + "account_bound": True, + "connection_generation": self.connection_generation, + "exchange_id": self.exchange_id, + "instrument_id": self.instrument_id, + "md_front_sha256": self.md_front_sha256, + } + + +@dataclass(frozen=True) +class CtpSdkMarketReadOnlyObservation: + """Non-authoritative MD readiness and optional first-tick observation. + + ``tick_observation_count`` counts callbacks received after the exact + subscription acknowledgement, during the bounded optional observation + window, and matching the admitted instrument/exchange with minimally valid + price/volume fields. ``tick_binding`` records the callback's account, front, + instrument, exchange, and connection generation. This is local SDK callback + evidence, not independent native-data QA or proof of real-market readiness. + + ``client_stop_returned`` means the SDK stop method returned. The account + lease is released only by an exact, complete, same-generation native stop + receipt. An incomplete or unavailable receipt keeps the client and lease + pinned until process exit; a Join thread exit alone is not proof that + native release completed. The lease does not coordinate legacy or + application-owned ``MdClient`` sessions. This value grants no account + acceptance or execution authority. + """ + + md_front_sha256: str + account_fingerprint_sha256: str = field(repr=False) + instrument_id: str + exchange_id: str + market_login_ready: bool + subscription_acknowledged: bool + tick_observation_count: int + tick_binding: CtpSdkMarketTickBinding | None + connection_generation: int | None + client_stop_returned: bool + native_join_pending: bool + + @property + def first_tick_observed(self) -> bool: + """Whether at least one matching tick callback was observed.""" + + return self.tick_observation_count > 0 + + @property + def probe_session_closed(self) -> bool: + """Whether stop returned and no native Join remains pending/unknown.""" + + return self.client_stop_returned and not self.native_join_pending + + @property + def market_path_ready(self) -> bool: + """Whether login and the exact single-instrument subscription passed.""" + + return self.market_login_ready and self.subscription_acknowledged + + @property + def order_submission_authorized(self) -> bool: + """This market-data-only observation never carries order authority.""" + + return False + + @property + def trading_writes(self) -> int: + """Market-data login/subscription performs no trading requests.""" + + return 0 + + @property + def settlement_writes(self) -> int: + """The market-data client has no settlement-write interface.""" + + return 0 + + def as_public_dict(self) -> dict[str, Any]: + """Expose no raw account ID, credential, front, or provider payload.""" + + return { + "client_stop_returned": self.client_stop_returned, + "connection_generation": self.connection_generation, + "account_probe_lock_scope": "cooperating_probe_invocations_only", + "account_scope_bound": bool(self.account_fingerprint_sha256), + "exchange_id": self.exchange_id, + "first_tick_observed": self.first_tick_observed, + "instrument_id": self.instrument_id, + "market_login_ready": self.market_login_ready, + "market_path_ready": self.market_path_ready, + "md_front_sha256": self.md_front_sha256, + "native_join_pending": self.native_join_pending, + "order_submission_authorized": False, + "probe_session_closed": self.probe_session_closed, + "subscription_acknowledged": self.subscription_acknowledged, + "settlement_writes": self.settlement_writes, + "tick_binding": ( + None if self.tick_binding is None else self.tick_binding.as_public_dict() + ), + "tick_observation_count": self.tick_observation_count, + "trading_writes": self.trading_writes, + } + + +_MAX_TIMEOUT_SECONDS = 60.0 +_MAX_NATIVE_JOIN_WAIT_SECONDS = 1.0 +_LOCK_DIRECTORY_NAME = "backtrader-runtime-ctp-md-probe" +_PROCESS_LOCKS: dict[str, threading.Lock] = {} +_PROCESS_LOCKS_GUARD = threading.Lock() +_PENDING_LEASES: dict[str, tuple["_AccountLease", Any, Any]] = {} +_PENDING_LEASES_GUARD = threading.Lock() +_MD_LOGIN_CALLBACK_DISPOSITIONS = frozenset( + { + "none", + "stale_spi", + "generation_mismatch", + "request_id_type_invalid", + "request_id_mismatch", + "nonterminal", + "accepted", + "provider_rejected", + "identity_rejected", + } +) +_MD_LOGIN_REQUEST_ID_RELATIONS = frozenset( + {"not_observed", "invalid", "zero", "lower", "equal", "higher"} +) +_MD_LOGIN_RESPONSE_ERROR_STATUSES = frozenset( + {"not_observed", "missing", "invalid", "zero", "nonzero"} +) + + +def _md_login_callback_diagnostic( + client: Any, +) -> tuple[int, str, str | None, str | None, str | None, str | None] | None: + """Copy only the installed SDK's fixed MD callback disposition and count. + + This is a diagnostic observation, never login or write admission. Unknown + SDK/fake shapes are omitted instead of exposing a provider payload. + """ + + try: + diagnostic = client.login_callback_diagnostic + count = diagnostic.callback_count + disposition = diagnostic.disposition.value + except Exception: + return None + if ( + type(count) is not int + or not 0 <= count <= 1_000_000 + or type(disposition) is not str + or disposition not in _MD_LOGIN_CALLBACK_DISPOSITIONS + ): + return None + # Missing or unknown callback classifications cannot be interpreted as + # provider evidence. + try: + relation = diagnostic.request_id_relation.value + error_status = diagnostic.response_error_status.value + except Exception: + relation = error_status = None + if ( + type(relation) is not str + or relation not in _MD_LOGIN_REQUEST_ID_RELATIONS + or type(error_status) is not str + or error_status not in _MD_LOGIN_RESPONSE_ERROR_STATUSES + ): + relation = error_status = None + native_shapes: list[str | None] = [] + for field_name in ("native_broker_id_shape", "native_user_id_shape"): + try: + shape = getattr(diagnostic, field_name).value + except Exception: + shape = None + if type(shape) is not str or shape not in _MD_LOGIN_NATIVE_FIELD_SHAPES: + shape = None + native_shapes.append(shape) + return count, disposition, relation, error_status, native_shapes[0], native_shapes[1] + + +def _reject( + reason: str, + *, + close_state: str = "not_started", + primary_reason: str | None = None, + front_callback_observed: bool | None = None, + login_callback_count: int | None = None, + login_callback_disposition: str | None = None, + login_request_id_relation: str | None = None, + login_response_error_status: str | None = None, + login_failure_category: str | None = None, + login_broker_id_shape: str | None = None, + login_user_id_shape: str | None = None, + login_trading_day_shape: str | None = None, + native_broker_id_shape: str | None = None, + native_user_id_shape: str | None = None, + client_stop_returned: bool | None = None, +) -> None: + raise CtpSdkMarketReadOnlyError( + reason, + close_state=close_state, + primary_reason=primary_reason, + front_callback_observed=front_callback_observed, + login_callback_count=login_callback_count, + login_callback_disposition=login_callback_disposition, + login_request_id_relation=login_request_id_relation, + login_response_error_status=login_response_error_status, + login_failure_category=login_failure_category, + login_broker_id_shape=login_broker_id_shape, + login_user_id_shape=login_user_id_shape, + login_trading_day_shape=login_trading_day_shape, + native_broker_id_shape=native_broker_id_shape, + native_user_id_shape=native_user_id_shape, + client_stop_returned=client_stop_returned, + ) + + +def _account_lock_root() -> Path: + """Return a stable, per-OS-user lock directory independent of TMP/TEMP. + + Environment-selected temporary directories are unsuitable for a process + lock: two invocations for the same user can inherit different TMP/TEMP + values and silently acquire different files. Resolve the account's home + directory from the OS account database on POSIX and its LocalAppData known + folder on Windows instead. + """ + + if os.name == "nt": + base = _windows_local_app_data() + base_info = os.lstat(str(base)) + if _is_reparse_or_link(base_info) or not stat.S_ISDIR(base_info.st_mode): + _reject("account_probe_lock_unavailable") + else: + import pwd + + uid = os.getuid() + home = pwd.getpwuid(uid).pw_dir + if type(home) is not str or not os.path.isabs(home): + _reject("account_probe_lock_unavailable") + base = Path(home) + base_info = os.lstat(str(base)) + if ( + _is_reparse_or_link(base_info) + or not stat.S_ISDIR(base_info.st_mode) + or base_info.st_uid != uid + or stat.S_IMODE(base_info.st_mode) & 0o022 + ): + _reject("account_probe_lock_unavailable") + return base / _LOCK_DIRECTORY_NAME + + +def _windows_local_app_data() -> Path: + """Read LocalAppData through Windows known-folder APIs, not environment.""" + + import ctypes + from ctypes import wintypes + + class _Guid(ctypes.Structure): + _fields_ = [ + ("Data1", wintypes.DWORD), + ("Data2", wintypes.WORD), + ("Data3", wintypes.WORD), + ("Data4", ctypes.c_ubyte * 8), + ] + + # FOLDERID_LocalAppData = {F1B32785-6FBA-4FCF-9D55-7B8E7F157091} + folder_id = _Guid( + 0xF1B32785, + 0x6FBA, + 0x4FCF, + (ctypes.c_ubyte * 8)(0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91), + ) + result = ctypes.c_wchar_p() + shell32 = ctypes.windll.shell32 + shell32.SHGetKnownFolderPath.argtypes = [ + ctypes.POINTER(_Guid), + wintypes.DWORD, + wintypes.HANDLE, + ctypes.POINTER(ctypes.c_wchar_p), + ] + shell32.SHGetKnownFolderPath.restype = ctypes.c_long + hr = shell32.SHGetKnownFolderPath(ctypes.byref(folder_id), 0, None, ctypes.byref(result)) + try: + if hr != 0 or not result.value: + _reject("account_probe_lock_unavailable") + return Path(result.value) + finally: + if result: + ctypes.windll.ole32.CoTaskMemFree(ctypes.cast(result, ctypes.c_void_p)) + + +def _validated_admission(value: Any) -> CtpSandboxReadOnlyRegistration: + """Revalidate the selected admission without deriving a route by profile. + + The composition root verifies that this exact TD/MD pair belongs to the + sealed config's candidate set before calling the market probe. This + adapter preserves the pair verbatim and does not select or normalize it. + """ + + if type(value) is not CtpSandboxReadOnlyRegistration: + _reject("admission_required") + try: + admission = CtpSandboxReadOnlyRegistration( + runtime_registration=value.runtime_registration, + environment=value.environment, + sdk_profile=value.sdk_profile, + account_fingerprint_sha256=value.account_fingerprint_sha256, + allowed_secrets_ref=value.allowed_secrets_ref, + instrument_id=value.instrument_id, + exchange_id=value.exchange_id, + hedge_flag=value.hedge_flag, + td_front=value.td_front, + md_front=value.md_front, + session_ttl_seconds=value.session_ttl_seconds, + ) + except Exception: + _reject("admission_invalid") + # Rebuild above validates the complete configured pair but intentionally + # does not canonicalize, remap, or replace either endpoint. + if admission.md_front != value.md_front or admission.instrument_id != value.instrument_id: + _reject("admission_invalid") + return admission + + +def _timeout(value: Any, *, allow_zero: bool = False) -> float: + if type(value) not in (int, float): + _reject("invalid_timeout") + try: + normalized = float(value) + except (OverflowError, TypeError, ValueError): + _reject("invalid_timeout") + minimum_ok = normalized >= 0.0 if allow_zero else normalized > 0.0 + if not math.isfinite(normalized) or not minimum_ok or normalized > _MAX_TIMEOUT_SECONDS: + _reject("invalid_timeout") + return normalized + + +def _read_credential(source: CtpMarketCredentialSource, name: str) -> str: + try: + value = source.require_credential(name) + except Exception: + _reject("credential_unavailable") + if type(value) is not str or not value or value != value.strip(): + _reject("credential_invalid") + return value + + +def _account_lock_key(account_fingerprint_sha256: str) -> str: + return hashlib.sha256( + ("ctp-md-probe-v1:" + account_fingerprint_sha256).encode("ascii") + ).hexdigest() + + +def _md_client_snapshot(client: Any) -> tuple[Any, ...] | None: + """Read the SDK's mutable MD binding/readiness under its state lock. + + The pinned ``MdClient`` has no public readiness snapshot, so the probe + reads the small set of fields maintained by its SPI while holding the + SDK's reentrant state lock. A changed or unrecognized client shape fails + closed rather than producing a login observation from stale callback + state. + """ + + try: + state_lock = getattr(client, "_state_lock") + with state_lock: + front = getattr(client, "front") + broker_id = getattr(client, "broker_id") + user_id = getattr(client, "user_id") + generation = getattr(client, "connection_generation") + connected = getattr(client, "_connected") + logged_in = getattr(client, "_loggedin") + except Exception: + return None + if ( + type(front) is not str + or type(broker_id) is not str + or type(user_id) is not str + or type(generation) is not int + or generation < 0 + or type(connected) is not bool + or type(logged_in) is not bool + ): + return None + return (front, broker_id, user_id, generation, connected, logged_in) + + +def _client_state_error( + snapshot: tuple[Any, ...] | None, + *, + front: str, + broker_id: str, + user_id: str, +) -> str | None: + if snapshot is None: + return "market_session_state_unavailable" + current_front, current_broker, current_user, generation, connected, logged_in = snapshot + if current_front != front: + return "market_front_binding_mismatch" + if current_broker != broker_id or current_user != user_id: + return "market_client_identity_mismatch" + if generation < 1 or connected is not True or logged_in is not True: + return "market_session_not_ready" + return None + + +class _AccountLease: + """Process and OS-level exclusive lease for one CTP MD flow directory.""" + + def __init__(self, account_key: str) -> None: + self.account_key = account_key + with _PROCESS_LOCKS_GUARD: + self._thread_lock = _PROCESS_LOCKS.setdefault(account_key, threading.Lock()) + self._fd: int | None = None + self._released = False + self._release_guard = threading.Lock() + + def acquire(self) -> None: + if not self._thread_lock.acquire(blocking=False): + _reject("account_probe_busy") + try: + root = _account_lock_root() + root.mkdir(mode=0o700, parents=True, exist_ok=True) + root_info = os.lstat(str(root)) + if _is_reparse_or_link(root_info) or not stat.S_ISDIR(root_info.st_mode): + _reject("account_probe_lock_unavailable") + if os.name != "nt": + if root_info.st_uid != os.getuid() or stat.S_IMODE(root_info.st_mode) & 0o077: + _reject("account_probe_lock_unavailable") + + lock_path = root / (self.account_key + ".lock") + try: + path_info = os.lstat(str(lock_path)) + except FileNotFoundError: + path_info = None + if path_info is not None and ( + _is_reparse_or_link(path_info) or not stat.S_ISREG(path_info.st_mode) + ): + _reject("account_probe_lock_unavailable") + flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_BINARY", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + fd = os.open(str(lock_path), flags, 0o600) + try: + opened_info = os.fstat(fd) + current_info = os.lstat(str(lock_path)) + if ( + _is_reparse_or_link(current_info) + or not stat.S_ISREG(opened_info.st_mode) + or not _same_file_identity(opened_info, current_info) + ): + _reject("account_probe_lock_unavailable") + if os.name != "nt" and ( + opened_info.st_uid != os.getuid() or stat.S_IMODE(opened_info.st_mode) & 0o077 + ): + _reject("account_probe_lock_unavailable") + if os.name == "nt": + if opened_info.st_size == 0: + os.write(fd, b"\0") + os.lseek(fd, 0, os.SEEK_SET) + import msvcrt + + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except CtpSdkMarketReadOnlyError: + os.close(fd) + raise + except (OSError, ImportError): + os.close(fd) + _reject("account_probe_busy") + self._fd = fd + except CtpSdkMarketReadOnlyError: + self._thread_lock.release() + raise + except Exception: + self._thread_lock.release() + _reject("account_probe_lock_unavailable") + + def release(self) -> None: + with self._release_guard: + if self._released: + return + self._released = True + fd = self._fd + self._fd = None + if fd is not None: + try: + os.close(fd) + except OSError: + pass + self._thread_lock.release() + + +def _same_file_identity(left: os.stat_result, right: os.stat_result) -> bool: + return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino) + + +def _is_reparse_or_link(value: os.stat_result) -> bool: + if stat.S_ISLNK(value.st_mode): + return True + attributes = getattr(value, "st_file_attributes", 0) + reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return bool(attributes & reparse) + + +def _hold_lease_unknown(lease: _AccountLease, client: Any, join_thread: Any) -> None: + """Pin the client and account lease until process exit if stop is uncertified.""" + + with _PENDING_LEASES_GUARD: + _PENDING_LEASES[lease.account_key] = (lease, client, join_thread) + + +def _native_stop_receipt_type() -> type[Any]: + """Load the SDK's public shutdown receipt only for clients that expose it.""" + + from bt_api_ctp.ctp.client import CtpNativeStopReceipt + + return CtpNativeStopReceipt + + +def _validated_native_stop_receipt( + receipt: Any, + *, + expected_generation: int | None, +) -> tuple[bool, bool | None] | None: + """Return ``(complete, thread_alive)`` for an exact, coherent SDK receipt. + + Unknown receipt types and malformed or contradictory field combinations + cannot release the account lease. Keep this import lazy so the legacy + source/editable SDK and offline fake clients remain usable. + """ + + try: + receipt_type = _native_stop_receipt_type() + except Exception: + return None + if type(receipt) is not receipt_type: + return None + try: + generation = receipt.connection_generation + join_required = receipt.join_required + join_completed = receipt.join_completed + native_released = receipt.native_released + thread_alive = receipt.thread_alive + timed_out = receipt.timed_out + complete = receipt.complete + except Exception: + return None + if ( + type(generation) is not int + or generation < 0 + or type(expected_generation) is not int + or generation != expected_generation + or type(join_required) is not bool + or type(join_completed) is not bool + or type(native_released) is not bool + or (thread_alive is not None and type(thread_alive) is not bool) + or type(timed_out) is not bool + or type(complete) is not bool + ): + return None + if join_completed and thread_alive is not False: + return None + derived_complete = ( + native_released and (not join_required or join_completed) and thread_alive is False + ) + if complete is not derived_complete or (timed_out and complete): + return None + return complete, thread_alive + + +def _error_id(response: Any) -> int: + if response is None: + return 0 + try: + return int(getattr(response, "ErrorID", 0) or 0) + except (TypeError, ValueError, OverflowError): + return -1 + + +def _instrument_text(response: Any) -> str: + if response is None: + return "" + try: + value = getattr(response, "InstrumentID", "") + except Exception: + return "" + if isinstance(value, bytes): + try: + value = value.decode("ascii") + except UnicodeDecodeError: + return "" + if type(value) is not str: + return "" + # Native fixed-width C fields may expose right padding; the configured + # instrument itself has already passed the strict admission validator. + return value.rstrip("\x00 ") + + +def _field_text(response: Any, name: str) -> str: + try: + value = getattr(response, name, "") + except Exception: + return "" + if isinstance(value, bytes): + try: + value = value.decode("ascii") + except UnicodeDecodeError: + return "" + if type(value) is not str: + return "" + return value.rstrip("\x00 ") + + +def _valid_tick(tick: Any, instrument: str, exchange: str) -> bool: + if _instrument_text(tick) != instrument or _field_text(tick, "ExchangeID") != exchange: + return False + try: + last_price = getattr(tick, "LastPrice", None) + volume = getattr(tick, "Volume", None) + except Exception: + return False + if type(last_price) not in (int, float) or type(volume) is not int or volume < 0: + return False + try: + return math.isfinite(float(last_price)) and last_price > 0 + except (OverflowError, TypeError, ValueError): + return False + + +def _probe_ctp_market_readonly( + *, + admission: CtpSandboxReadOnlyRegistration, + credential_source: CtpMarketCredentialSource, + client_type: Any, + timeout_seconds: float = 15.0, + tick_observation_seconds: float = 0.0, +) -> CtpSdkMarketReadOnlyObservation: + """Verify one admitted CTP market route with an injected client type. + + ``timeout_seconds`` is one monotonic deadline for login, subscription ack, + and any optional tick observation. A missing tick never causes address + selection or failure after login and subscription have succeeded. + + This private helper has no default SDK import or route of its own. Its + reviewed composition must inject the real ``MdClient`` only after sealed + registry/config, artifact provenance, and credential gates pass. Offline + callers inject a fake. The account lease serializes cooperating + invocations of this helper only. Call it in an isolated preflight process + while no legacy or application-owned market-data session for this account + is active. + """ + + admitted = _validated_admission(admission) + if not callable(client_type): + _reject("market_client_type_required") + timeout = _timeout(timeout_seconds) + tick_window = _timeout(tick_observation_seconds, allow_zero=True) + if tick_window > timeout: + _reject("invalid_timeout") + + front = admitted.md_front + instrument = admitted.instrument_id + exchange = admitted.exchange_id + deadline = time.monotonic() + timeout + lease = _AccountLease(_account_lock_key(admitted.account_fingerprint_sha256)) + lease.acquire() + + client = None + client_started = False + close_state = "not_started" + client_stop_returned = False + stop_failed = False + primary_error: str | None = None + observation: CtpSdkMarketReadOnlyObservation | None = None + join_thread = None + condition: threading.Condition | None = None + state: dict[str, Any] | None = None + initial_generation: int | None = None + front_callback_observed: bool | None = None + login_callback_diagnostic: tuple[int, str, str | None, str | None] | None = None + try: + broker_id = _read_credential(credential_source, "broker_id") + user_id = _read_credential(credential_source, "user_id") + password = _read_credential(credential_source, "password") + account_digest = hashlib.sha256( + "{0}:{1}".format(broker_id, user_id).encode("utf-8") + ).hexdigest() + if not hmac.compare_digest(account_digest, admitted.account_fingerprint_sha256): + _reject("credential_account_mismatch") + if time.monotonic() >= deadline: + _reject("probe_deadline_expired") + + # Pass the exact immutable admission string. Do not call a profile map, + # normalize the URL, probe alternatives, or fall back to environment. + client = client_type(front, broker_id, user_id, password) + initial_snapshot = _md_client_snapshot(client) + if initial_snapshot is None: + _reject("market_client_state_unavailable") + if initial_snapshot[0] != front: + _reject("market_front_binding_mismatch") + if initial_snapshot[1] != broker_id or initial_snapshot[2] != user_id: + _reject("market_client_identity_mismatch") + initial_generation = initial_snapshot[3] + condition = threading.Condition() + state = { + "closed": False, + "error": None, + "login": False, + "login_generation": None, + "subscription_ack": False, + "subscription_generation": None, + "tick_observation_deadline": None, + "tick_observation_count": 0, + "tick_binding": None, + } + + def record_error(reason: str) -> None: + with condition: + if not state["closed"] and state["error"] is None: + state["error"] = reason + condition.notify_all() + + def on_login(login_field: Any) -> None: + snapshot = _md_client_snapshot(client) + session_error = _client_state_error( + snapshot, + front=front, + broker_id=broker_id, + user_id=user_id, + ) + if session_error is not None: + record_error(session_error) + return + try: + login_broker_id = getattr(login_field, "BrokerID") + login_user_id = getattr(login_field, "UserID") + except Exception: + record_error("market_login_identity_unavailable") + return + if type(login_broker_id) is not str or type(login_user_id) is not str: + record_error("market_login_identity_unavailable") + return + if login_broker_id != broker_id or login_user_id != user_id: + record_error("market_login_identity_mismatch") + return + assert snapshot is not None + with condition: + if not state["closed"] and state["error"] is None: + state["login"] = True + state["login_generation"] = snapshot[3] + condition.notify_all() + + def on_error(response: Any) -> None: + error_id = _error_id(response) + if error_id != 0: + record_error("market_front_rejected") + return + # The pinned MD client can report a login identity mismatch via + # on_error with the original zero-error response. A callback in + # either pending phase is still a rejection signal; do not let an + # ErrorID of zero turn it into a successful readiness observation. + with condition: + if state["closed"] or state["error"] is not None: + return + if state["login"] and state["subscription_ack"]: + return + reason = ( + "market_login_identity_mismatch" + if not state["login"] + else "market_subscription_rejected" + ) + state["error"] = reason + condition.notify_all() + + def on_disconnect(_reason: Any) -> None: + with condition: + if not state["closed"]: + state["error"] = state["error"] or "market_front_disconnected" + condition.notify_all() + + def on_subscribe(specific_instrument: Any, response: Any) -> None: + if _instrument_text(specific_instrument) != instrument: + return + if _error_id(response) != 0: + record_error("market_subscription_rejected") + return + snapshot = _md_client_snapshot(client) + session_error = _client_state_error( + snapshot, + front=front, + broker_id=broker_id, + user_id=user_id, + ) + if session_error is not None: + record_error(session_error) + return + assert snapshot is not None + with condition: + if state["closed"] or state["error"] is not None: + return + login_generation = state["login_generation"] + if login_generation is None or snapshot[3] != login_generation: + state["error"] = "market_connection_generation_changed" + condition.notify_all() + return + if not state["closed"]: + if not state["subscription_ack"]: + state["subscription_ack"] = True + state["subscription_generation"] = snapshot[3] + if tick_window > 0.0: + state["tick_observation_deadline"] = min( + deadline, time.monotonic() + tick_window + ) + condition.notify_all() + + def on_tick(tick: Any) -> None: + if tick_window <= 0.0: + return + if not _valid_tick(tick, instrument, exchange): + return + snapshot = _md_client_snapshot(client) + session_error = _client_state_error( + snapshot, + front=front, + broker_id=broker_id, + user_id=user_id, + ) + if session_error is not None: + record_error(session_error) + return + assert snapshot is not None + with condition: + if state["closed"] or state["error"] is not None: + return + login_generation = state["login_generation"] + subscription_generation = state["subscription_generation"] + tick_deadline = state["tick_observation_deadline"] + if not state["subscription_ack"] or tick_deadline is None: + # Depth callbacks can race a subscription response. Such + # a tick is not evidence that the acknowledged scope was + # observed, so wait for a later post-ACK callback. + return + if ( + login_generation is None + or subscription_generation != login_generation + or snapshot[3] != login_generation + ): + state["error"] = "market_connection_generation_changed" + condition.notify_all() + return + if time.monotonic() > tick_deadline: + return + state["tick_observation_count"] += 1 + if state["tick_binding"] is None: + state["tick_binding"] = CtpSdkMarketTickBinding( + account_fingerprint_sha256=admitted.account_fingerprint_sha256, + md_front_sha256=hashlib.sha256(front.encode("utf-8")).hexdigest(), + instrument_id=instrument, + exchange_id=exchange, + connection_generation=snapshot[3], + ) + condition.notify_all() + + client.on_login = on_login + client.on_error = on_error + client.on_disconnect = on_disconnect + client.on_subscribe = on_subscribe + client.on_tick = on_tick + + # MdClient defers this single request until its login response. + client.subscribe([instrument]) + client.start(block=False) + client_started = True + + with condition: + # ``start`` is expected to return promptly, but it is a synchronous + # SDK call. Do not accept callbacks that only became visible + # after the single monotonic deadline elapsed while that call was + # in progress. + if time.monotonic() >= deadline: + _reject("probe_deadline_expired") + while not (state["login"] and state["subscription_ack"]): + if state["error"]: + _reject(state["error"]) + remaining = deadline - time.monotonic() + if remaining <= 0: + reason = ( + "market_login_timeout" if not state["login"] else "subscription_ack_timeout" + ) + _reject(reason) + condition.wait(remaining) + + tick_deadline = state["tick_observation_deadline"] + while tick_deadline is not None: + if state["error"]: + _reject(state["error"]) + remaining = tick_deadline - time.monotonic() + if remaining <= 0: + break + condition.wait(remaining) + + if state["error"]: + _reject(state["error"]) + snapshot = _md_client_snapshot(client) + session_error = _client_state_error( + snapshot, + front=front, + broker_id=broker_id, + user_id=user_id, + ) + if session_error is not None: + _reject(session_error) + assert snapshot is not None + login_generation = state["login_generation"] + subscription_generation = state["subscription_generation"] + if ( + login_generation is None + or subscription_generation != login_generation + or snapshot[3] != login_generation + ): + _reject("market_connection_generation_changed") + observation = CtpSdkMarketReadOnlyObservation( + md_front_sha256=hashlib.sha256(front.encode("utf-8")).hexdigest(), + account_fingerprint_sha256=admitted.account_fingerprint_sha256, + instrument_id=instrument, + exchange_id=exchange, + market_login_ready=True, + subscription_acknowledged=True, + tick_observation_count=state["tick_observation_count"], + tick_binding=state["tick_binding"], + connection_generation=snapshot[3], + client_stop_returned=False, + native_join_pending=False, + ) + # Define the observation point while callbacks are excluded; a + # later teardown callback cannot retroactively make a stale + # generation look ready. + state["closed"] = True + except CtpSdkMarketReadOnlyError as exc: + primary_error = exc.reason + except Exception: + # Never include provider exceptions: native bindings can embed account, + # front, or credential data in their messages. + primary_error = "market_probe_failed" + finally: + if client is not None: + final_snapshot = _md_client_snapshot(client) + if final_snapshot is not None and initial_generation is not None: + front_callback_observed = final_snapshot[3] > initial_generation + # Read before stop resets SDK state or a late teardown callback. + login_callback_diagnostic = _md_login_callback_diagnostic(client) + try: + join_thread = getattr(client, "_thread", None) + except Exception: + join_thread = None + stop_and_wait = None + stop_receipt_method_unknown = False + try: + stop_and_wait = getattr(client, "stop_and_wait", None) + except Exception: + stop_receipt_method_unknown = True + + stop_receipt_state: tuple[bool, bool | None] | None = None + try: + if condition is not None and state is not None: + with condition: + state["closed"] = True + if stop_receipt_method_unknown: + stop_failed = True + close_state = "native_stop_method_unknown" + elif stop_and_wait is not None: + if not callable(stop_and_wait): + stop_failed = True + close_state = "native_stop_method_invalid" + else: + # The public method calls stop internally. Do not call + # stop a second time or infer completion from a return + # that lacks its exact public receipt. + pre_stop_snapshot = _md_client_snapshot(client) + # Teardown needs its own bounded grace after a probe + # timeout. A spent login deadline must not turn the + # native Join observation into a zero-second poll. + join_wait = _MAX_NATIVE_JOIN_WAIT_SECONDS + receipt = stop_and_wait(timeout=join_wait) + client_stop_returned = True + close_state = "stop_returned" + stop_receipt_state = _validated_native_stop_receipt( + receipt, + expected_generation=( + None if pre_stop_snapshot is None else pre_stop_snapshot[3] + ), + ) + if stop_receipt_state is None: + stop_failed = True + close_state = "native_stop_receipt_unknown" + else: + # A stop() return and captured Join thread cannot certify + # native Release. Without an exact public receipt, retain + # the lease through process exit. + client.stop() + client_stop_returned = True + stop_failed = True + close_state = "native_stop_receipt_unknown" + except Exception: + close_state = "stop_failed" + stop_failed = True + + if stop_receipt_state is not None: + receipt_complete, receipt_thread_alive = stop_receipt_state + if receipt_complete: + close_state = "stop_returned" + lease.release() + elif receipt_thread_alive is True and join_thread is not None: + # The public receipt confirms Join remains live. Keep the + # client and lease pinned until that exact SDK thread exits. + try: + thread_alive = join_thread.is_alive() + except Exception: + thread_alive = None + if thread_alive is True: + stop_failed = True + close_state = "native_join_pending" + _hold_lease_unknown(lease, client, join_thread) + else: + stop_failed = True + close_state = "native_stop_receipt_inconsistent" + _hold_lease_unknown(lease, client, join_thread) + else: + # A dead/missing thread does not prove Release + Join when + # the receipt itself is incomplete or says liveness is + # unknown. Do not release the account lease. + stop_failed = True + close_state = ( + "native_join_pending" + if receipt_thread_alive is True + else "native_stop_incomplete" + ) + _hold_lease_unknown(lease, client, join_thread) + else: + pending = False + if join_thread is not None: + try: + join_remaining = _MAX_NATIVE_JOIN_WAIT_SECONDS + # Legacy stop() can return before its native Join + # observer exits. Give it a bounded opportunity to + # finish and keep the lease while it remains pending. + join_thread.join(join_remaining) + pending = bool(join_thread.is_alive()) + except Exception: + pending = True + try: + join_tracking_unknown = client_started and not hasattr(client, "_thread") + except Exception: + join_tracking_unknown = True + if pending: + close_state = "native_join_pending" + _hold_lease_unknown(lease, client, join_thread) + elif join_tracking_unknown: + close_state = "native_join_state_unknown" + _hold_lease_unknown(lease, client, join_thread) + elif stop_failed: + # Keep the client and lock alive indefinitely when shutdown + # is uncertain; another same-account MD API must fail closed. + _hold_lease_unknown(lease, client, join_thread) + else: + lease.release() + else: + lease.release() + + if stop_failed: + _reject( + "market_client_stop_failed", + close_state=close_state, + primary_reason=primary_error, + front_callback_observed=front_callback_observed, + login_callback_count=( + None if login_callback_diagnostic is None else login_callback_diagnostic[0] + ), + login_callback_disposition=( + None if login_callback_diagnostic is None else login_callback_diagnostic[1] + ), + login_request_id_relation=( + None if login_callback_diagnostic is None else login_callback_diagnostic[2] + ), + login_response_error_status=( + None if login_callback_diagnostic is None else login_callback_diagnostic[3] + ), + native_broker_id_shape=( + None if login_callback_diagnostic is None else login_callback_diagnostic[4] + ), + native_user_id_shape=( + None if login_callback_diagnostic is None else login_callback_diagnostic[5] + ), + client_stop_returned=client_stop_returned, + ) + if primary_error is not None: + _reject( + primary_error, + close_state=close_state, + front_callback_observed=front_callback_observed, + login_callback_count=( + None if login_callback_diagnostic is None else login_callback_diagnostic[0] + ), + login_callback_disposition=( + None if login_callback_diagnostic is None else login_callback_diagnostic[1] + ), + login_request_id_relation=( + None if login_callback_diagnostic is None else login_callback_diagnostic[2] + ), + login_response_error_status=( + None if login_callback_diagnostic is None else login_callback_diagnostic[3] + ), + native_broker_id_shape=( + None if login_callback_diagnostic is None else login_callback_diagnostic[4] + ), + native_user_id_shape=( + None if login_callback_diagnostic is None else login_callback_diagnostic[5] + ), + client_stop_returned=client_stop_returned, + ) + assert observation is not None + return CtpSdkMarketReadOnlyObservation( + md_front_sha256=observation.md_front_sha256, + account_fingerprint_sha256=observation.account_fingerprint_sha256, + instrument_id=observation.instrument_id, + exchange_id=observation.exchange_id, + market_login_ready=observation.market_login_ready, + subscription_acknowledged=observation.subscription_acknowledged, + tick_observation_count=observation.tick_observation_count, + tick_binding=observation.tick_binding, + connection_generation=observation.connection_generation, + client_stop_returned=client_stop_returned, + native_join_pending=close_state == "native_join_pending", + ) + + +__all__ = [ + "CtpSdkMarketReadOnlyError", + "CtpSdkMarketReadOnlyObservation", + "CtpSdkMarketTickBinding", +] diff --git a/backtrader_runtime/ctp_sdk_readonly.py b/backtrader_runtime/ctp_sdk_readonly.py new file mode 100644 index 00000000..ab55f5f1 --- /dev/null +++ b/backtrader_runtime/ctp_sdk_readonly.py @@ -0,0 +1,904 @@ +"""SDK-backed CTP read-only session for a sealed CTP sandbox account scope. + +This module has no import-time SDK dependency or operator-selected endpoint. +The caller must first validate a sealed ``simulation/sandbox`` runtime and +construct this factory from its code-owned account and instrument scope. +Opening a session performs authentication and native queries only; this module +has no order, cancellation, settlement, or execution-arm operation. +""" + +from __future__ import annotations + +import hashlib +import hmac +import math +import re +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Optional, Protocol, Tuple +from urllib.parse import urlsplit + +from .ctp_preflight import ( + CtpReadOnlyQuerySnapshot, + CtpReadOnlySessionIdentity, + CtpReadOnlySessionRequest, +) + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") +_HEDGE_RE = re.compile(r"^[1-3]$") +_ACCOUNT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_WRITE_REQUEST_KEYS = ("settlement_confirm", "order_insert", "order_action") +_QUERY_ORDER = ( + ("account", "query_account_result"), + ("positions", "query_positions_result"), + ("orders", "query_orders_result"), + ("trades", "query_trades_result"), + ("instruments", "query_instruments_result"), + ("margin_rate", "query_instrument_margin_rate_result"), + ("commission_rate", "query_instrument_commission_rate_result"), +) +_SNAPSHOT_NAMES = { + "margin_rate": "margin_rates", + "commission_rate": "commission_rates", +} +_RATE_QUERY_NAMES = frozenset(("margin_rate", "commission_rate")) +_NATIVE_CERTIFICATE_SCHEMA = "ctp_native_query_certificate.v5" +_RATE_EXCHANGE_SCOPE_VALUES = frozenset(("exact", "unverified")) +_MAX_CLOSE_JOIN_WAIT_SECONDS = 1.0 +_MISSING_STOP_AND_WAIT = object() +_CTP_REQUEST_COUNTER_KEYS = ( + "authenticate", + "login", + "settlement_confirm", + "order_insert", + "order_action", + "query_account", + "query_positions", + "query_orders", + "query_trades", + "query_instruments", + "query_margin_rate", + "query_commission_rate", + "query_depth_market_data", + "query_option_trade_cost", + "query_option_commission_rate", + "query_settlement_confirmation", +) + + +class CtpSdkReadOnlyError(ValueError): + """Redacted failure of one native read-only session.""" + + def __init__(self, reason: str, cleanup_reason: Optional[str] = None) -> None: + self.reason = reason + self.cleanup_reason = cleanup_reason + message = "CTP read-only SDK session rejected: {0}".format(reason) + if cleanup_reason is not None: + message += " (cleanup: {0})".format(cleanup_reason) + super().__init__(message) + + +@dataclass(frozen=True) +class CtpSdkReadOnlyCloseEvidence: + """Value-free projection of one read-only native-client close attempt. + + ``status`` is ``complete`` only when a trusted SDK stop receipt positively + proves native release and completed Join. ``native_join_pending`` is used + only when a structurally valid receipt explicitly reports a required, + incomplete Join. Missing, inconsistent, or exceptional evidence remains + ``unknown``. This projection describes shutdown only; it is not account, + settlement, query-snapshot, or trading-readiness evidence. + """ + + status: str + native_released: Optional[bool] = None + join_required: Optional[bool] = None + join_completed: Optional[bool] = None + thread_alive: Optional[bool] = None + timed_out: Optional[bool] = None + client_stop_returned: Optional[bool] = None + + def __post_init__(self) -> None: + if type(self.status) is not str or self.status not in ( + "complete", + "native_join_pending", + "unknown", + ): + raise ValueError("close_evidence_invalid") + for name in ( + "native_released", + "join_required", + "join_completed", + "thread_alive", + "timed_out", + "client_stop_returned", + ): + value = getattr(self, name) + if value is not None and type(value) is not bool: + raise ValueError("close_evidence_invalid") + if self.status == "complete" and not ( + self.native_released is True + and self.join_required in (True, False) + and (self.join_required is False or self.join_completed is True) + and self.thread_alive is False + and self.timed_out is False + and self.client_stop_returned is True + ): + raise ValueError("close_evidence_invalid") + if self.status == "native_join_pending" and not ( + self.join_required is True and self.join_completed is False + ): + raise ValueError("close_evidence_invalid") + + @property + def native_join_pending(self) -> bool: + return self.status == "native_join_pending" + + @property + def verified_complete(self) -> bool: + return self.status == "complete" + + +def _reject(reason: str) -> None: + raise CtpSdkReadOnlyError(reason) from None + + +def _close_ctp_sdk_client_read_only( + client: Any, + *, + stop_receipt_type: Optional[Any] = None, + expected_connection_generation: Optional[int] = None, +) -> None: + """Stop one native client and require positive bounded shutdown evidence.""" + + try: + stop_and_wait = getattr(client, "stop_and_wait", _MISSING_STOP_AND_WAIT) + except Exception: + _reject("session_close_method_unknown") + if stop_and_wait is not _MISSING_STOP_AND_WAIT and not callable(stop_and_wait): + try: + client.stop() + except Exception: + _reject("session_close_failed") + _reject("session_close_method_invalid") + if callable(stop_and_wait): + if stop_receipt_type is None: + try: + client.stop() + except Exception: + _reject("session_close_failed") + _reject("session_close_receipt_untrusted") + try: + receipt = stop_and_wait(timeout=_MAX_CLOSE_JOIN_WAIT_SECONDS) + except Exception: + _reject("session_close_failed") + if stop_receipt_type is None or type(receipt) is not stop_receipt_type: + _reject("session_close_receipt_untrusted") + try: + connection_generation = receipt.connection_generation + join_required = receipt.join_required + join_completed = receipt.join_completed + native_released = receipt.native_released + thread_alive = receipt.thread_alive + timed_out = receipt.timed_out + complete = receipt.complete + except Exception: + _reject("session_close_receipt_invalid") + if ( + type(connection_generation) is not int + or connection_generation < 0 + or ( + expected_connection_generation is not None + and connection_generation != expected_connection_generation + ) + or type(join_required) is not bool + or type(join_completed) is not bool + or type(native_released) is not bool + or (thread_alive is not None and type(thread_alive) is not bool) + or type(timed_out) is not bool + or type(complete) is not bool + ): + _reject("session_close_receipt_invalid") + if ( + (join_required and not join_completed and native_released) + or (join_completed and thread_alive is not False) + or (not join_required and thread_alive is True) + ): + _reject("session_close_receipt_invalid") + expected_complete = ( + native_released + and (not join_required or join_completed) + and thread_alive is False + and not timed_out + ) + if complete is not expected_complete: + _reject("session_close_receipt_invalid") + if not complete: + _reject("session_close_incomplete") + return + + join_state_known = True + join_thread = None + join_active = False + native_init_started = False + try: + has_join_thread = hasattr(client, "_thread") + join_thread = getattr(client, "_thread", None) + join_active = getattr(client, "_join_active", False) + native_init_started = getattr(client, "_native_init_started", False) + join_state_known = ( + has_join_thread and type(join_active) is bool and type(native_init_started) is bool + ) + if not join_state_known: + join_active = False + native_init_started = False + join_state_uncertain = ( + join_state_known and join_thread is None and (join_active or native_init_started) + ) + except Exception: + join_state_known = False + join_state_uncertain = True + join_thread = None + try: + client.stop() + except Exception: + _reject("session_close_failed") + if not join_state_known: + _reject("session_join_state_unknown") + if join_thread is not None: + try: + join_thread.join(_MAX_CLOSE_JOIN_WAIT_SECONDS) + if join_thread.is_alive(): + _reject("session_close_incomplete") + except CtpSdkReadOnlyError: + raise + except Exception: + _reject("session_join_state_unknown") + if join_state_uncertain: + _reject("session_close_incomplete") + try: + join_active = getattr(client, "_join_active", False) + native_init_started = getattr(client, "_native_init_started", False) + except Exception: + _reject("session_join_state_unknown") + if join_active is not False or native_init_started is not False: + _reject("session_close_incomplete") + + +def _close_ctp_sdk_client_read_only_with_evidence( + client: Any, + *, + stop_receipt_type: Optional[Any] = None, + expected_connection_generation: Optional[int] = None, +) -> CtpSdkReadOnlyCloseEvidence: + """Close once and retain only trustworthy, value-free shutdown facts.""" + + unknown = CtpSdkReadOnlyCloseEvidence("unknown") + try: + stop_and_wait = getattr(client, "stop_and_wait", _MISSING_STOP_AND_WAIT) + except Exception: + try: + client.stop() + except Exception: + pass + return unknown + + if not callable(stop_and_wait) or stop_receipt_type is None: + # Preserve the legacy best-effort teardown, but do not turn its private + # Python thread observations into native Release/Join evidence. + try: + _close_ctp_sdk_client_read_only( + client, + stop_receipt_type=stop_receipt_type, + expected_connection_generation=expected_connection_generation, + ) + except Exception: + pass + return unknown + + try: + receipt = stop_and_wait(timeout=_MAX_CLOSE_JOIN_WAIT_SECONDS) + except Exception: + return unknown + if type(receipt) is not stop_receipt_type: + return unknown + + try: + connection_generation = receipt.connection_generation + join_required = receipt.join_required + join_completed = receipt.join_completed + native_released = receipt.native_released + thread_alive = receipt.thread_alive + timed_out = receipt.timed_out + client_stop_returned = receipt.client_stop_returned + complete = receipt.complete + except Exception: + return unknown + + if ( + type(connection_generation) is not int + or connection_generation < 0 + or ( + expected_connection_generation is not None + and connection_generation != expected_connection_generation + ) + or type(join_required) is not bool + or type(join_completed) is not bool + or type(native_released) is not bool + or (thread_alive is not None and type(thread_alive) is not bool) + or type(timed_out) is not bool + or (client_stop_returned is not None and type(client_stop_returned) is not bool) + or type(complete) is not bool + or (join_required and not join_completed and native_released) + or (join_completed and thread_alive is not False) + or (not join_required and thread_alive is True) + ): + return unknown + + expected_complete = ( + native_released + and (not join_required or join_completed) + and thread_alive is False + and not timed_out + and client_stop_returned is True + ) + if complete is not expected_complete: + return unknown + + receipt_facts = { + "native_released": native_released, + "join_required": join_required, + "join_completed": join_completed, + "thread_alive": thread_alive, + "timed_out": timed_out, + "client_stop_returned": client_stop_returned, + } + if complete: + return CtpSdkReadOnlyCloseEvidence("complete", **receipt_facts) + if join_required and not join_completed: + return CtpSdkReadOnlyCloseEvidence("native_join_pending", **receipt_facts) + return CtpSdkReadOnlyCloseEvidence("unknown", **receipt_facts) + + +class CtpCredentialSource(Protocol): + """A scoped resolver result; this factory never stores raw credentials.""" + + def require_credential(self, name: str) -> str: + """Return one already scoped credential value.""" + + +@dataclass(frozen=True) +class CtpSdkReadOnlyScope: + """The exact CTP query target selected by the sealed runtime config.""" + + environment: str + sdk_profile: str + td_front: str + md_front: str + account_fingerprint_sha256: str = field(repr=False) + instrument_id: str + exchange_id: str + hedge_flag: str + + def __post_init__(self) -> None: + if self.environment != "simnow" or self.sdk_profile != "config_front_pair": + raise ValueError("read-only SDK scope needs the sealed SimNow config binding") + for name in ("td_front", "md_front"): + value = getattr(self, name) + if ( + type(value) is not str + or not value + or value != value.strip() + or len(value) > 128 + or any(character.isspace() or ord(character) < 0x20 for character in value) + ): + raise ValueError("read-only SDK scope has an invalid configured front") + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + parsed = None + port = None + if ( + parsed is None + or parsed.scheme != "tcp" + or not parsed.hostname + or port is None + or not 1 <= port <= 65535 + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or value != "tcp://{0}:{1}".format(parsed.hostname, port) + ): + raise ValueError("read-only SDK scope has an invalid configured front") + if self.td_front == self.md_front: + raise ValueError("read-only SDK scope needs distinct TD and MD fronts") + if type(self.account_fingerprint_sha256) is not str or not _SHA256_RE.fullmatch( + self.account_fingerprint_sha256 + ): + raise ValueError("invalid code-owned account fingerprint") + if type(self.instrument_id) is not str or not _INSTRUMENT_RE.fullmatch(self.instrument_id): + raise ValueError("invalid code-owned instrument") + if type(self.exchange_id) is not str or not _EXCHANGE_RE.fullmatch(self.exchange_id): + raise ValueError("invalid code-owned exchange") + if type(self.hedge_flag) is not str or not _HEDGE_RE.fullmatch(self.hedge_flag): + raise ValueError("invalid code-owned hedge flag") + + +def _default_sdk_components() -> Tuple[Any, ...]: + """Load the CTP SDK inside the composition root's capability-origin fence. + + This lazy import alone does not prove an installed or release-approved wheel. + """ + + from bt_api_ctp import CtpNativeQueryCertificateBuilder + from bt_api_ctp.ctp.client import TraderClient + + try: + from bt_api_ctp.ctp.client import CtpNativeStopReceipt + except ImportError: + # Reviewed older SDKs still provide the legacy stop() surface. Their + # shutdown is accepted only through the conservative captured-thread + # fallback below. + return TraderClient, CtpNativeQueryCertificateBuilder + + if ( + type(CtpNativeStopReceipt) is not type + or CtpNativeStopReceipt.__module__ != "bt_api_ctp.ctp.client" + or CtpNativeStopReceipt.__name__ != "CtpNativeStopReceipt" + ): + raise ImportError("untrusted CTP native stop receipt type") + + return TraderClient, CtpNativeQueryCertificateBuilder, CtpNativeStopReceipt + + +def _deadline_remaining(valid_until: float, monotonic_deadline: float) -> float: + wall_remaining = valid_until - time.time() + monotonic_remaining = monotonic_deadline - time.monotonic() + if not math.isfinite(wall_remaining) or not math.isfinite(monotonic_remaining): + _reject("session_clock_invalid") + remaining = min(wall_remaining, monotonic_remaining) + if remaining <= 0: + _reject("session_deadline_expired") + return remaining + + +def _credential(source: CtpCredentialSource, name: str) -> str: + try: + value = source.require_credential(name) + except Exception: + _reject("credential_unavailable") + if type(value) is not str or not value: + _reject("credential_unavailable") + if name in ("broker_id", "user_id") and not _ACCOUNT_ID_RE.fullmatch(value): + _reject("credential_identity_invalid") + return value + + +def _identity_from_client(client: Any, scope: CtpSdkReadOnlyScope) -> CtpReadOnlySessionIdentity: + try: + session_scope = client.get_query_session_scope() + if session_scope.read_only_ready is not True: + _reject("session_not_read_only_ready") + broker_id = session_scope.broker_id + investor_id = session_scope.investor_id + if ( + type(broker_id) is not str + or not _ACCOUNT_ID_RE.fullmatch(broker_id) + or type(investor_id) is not str + or not _ACCOUNT_ID_RE.fullmatch(investor_id) + ): + _reject("session_account_identity_invalid") + digest = hashlib.sha256( + "{0}:{1}".format(broker_id, investor_id).encode("utf-8") + ).hexdigest() + if not hmac.compare_digest(digest, scope.account_fingerprint_sha256): + _reject("session_account_mismatch") + front_reader = getattr(client, "get_front_binding_state", None) + if not callable(front_reader): + _reject("session_front_binding_unavailable") + front_state = front_reader() + if ( + type(front_state) is not dict + or front_state.get("configured_front") != scope.td_front + or front_state.get("registered_front") != scope.td_front + or front_state.get("connection_confirmed_front") != scope.td_front + or front_state.get("connected") is not True + or front_state.get("native_api_current") is not True + or front_state.get("bound_identity_current") is not True + or front_state.get("connection_generation") != session_scope.connection_generation + ): + _reject("session_front_binding_mismatch") + return CtpReadOnlySessionIdentity( + provider="ctp", + environment=scope.environment, + account_fingerprint_sha256=digest, + trading_day=session_scope.trading_day, + connection_generation=session_scope.connection_generation, + ) + except CtpSdkReadOnlyError: + raise + except Exception: + _reject("session_identity_unavailable") + + +def _certificate_rate_exchange_scopes(certificate: Any) -> Tuple[Tuple[str, str], ...]: + """Validate and retain the SDK's per-rate-query exchange-scope evidence.""" + + try: + public_reader = getattr(certificate, "as_public_dict") + public = public_reader() + certificate_sha256 = certificate.certificate_sha256 + query_digests = certificate.query_digests + except Exception: + _reject("native_rate_exchange_scope_unavailable") + if ( + not callable(public_reader) + or type(public) is not dict + or public.get("schema") != _NATIVE_CERTIFICATE_SCHEMA + or public.get("complete") is not True + or public.get("atomic_snapshot") is not False + or public.get("execution_authorized") is not False + or type(public.get("queries")) is not list + or type(query_digests) is not tuple + or type(certificate_sha256) is not str + or not _SHA256_RE.fullmatch(certificate_sha256) + or public.get("certificate_sha256") != certificate_sha256 + or public.get("query_digest") != certificate_sha256 + or len(public["queries"]) != len(_QUERY_ORDER) + ): + _reject("native_rate_exchange_scope_invalid") + + digests_by_name = {} + for pair in query_digests: + if type(pair) is not tuple or len(pair) != 2: + _reject("native_rate_exchange_scope_invalid") + name, digest = pair + if ( + type(name) is not str + or name not in {query_name for query_name, _method_name in _QUERY_ORDER} + or type(digest) is not str + or not _SHA256_RE.fullmatch(digest) + or name in digests_by_name + ): + _reject("native_rate_exchange_scope_invalid") + digests_by_name[name] = digest + expected_names = {query_name for query_name, _method_name in _QUERY_ORDER} + if set(digests_by_name) != expected_names: + _reject("native_rate_exchange_scope_invalid") + + rows_by_name = {} + for row in public["queries"]: + if type(row) is not dict: + _reject("native_rate_exchange_scope_invalid") + name = row.get("request_type") + if ( + type(name) is not str + or name not in expected_names + or name in rows_by_name + or row.get("records_sha256") != digests_by_name.get(name) + or "rate_exchange_scope" not in row + ): + _reject("native_rate_exchange_scope_invalid") + scope = row["rate_exchange_scope"] + if name in _RATE_QUERY_NAMES: + if type(scope) is not str or scope not in _RATE_EXCHANGE_SCOPE_VALUES: + _reject("native_rate_exchange_scope_invalid") + elif scope is not None: + _reject("native_rate_exchange_scope_invalid") + rows_by_name[name] = scope + if set(rows_by_name) != expected_names: + _reject("native_rate_exchange_scope_invalid") + return tuple(sorted((_SNAPSHOT_NAMES[name], rows_by_name[name]) for name in _RATE_QUERY_NAMES)) + + +class _CtpSdkReadOnlySession: + """Minimal wrapper hiding the native client's write-capable methods.""" + + def __init__( + self, + client: Any, + scope: CtpSdkReadOnlyScope, + builder_type: Any, + stop_receipt_type: Optional[Any], + connection_generation: int, + valid_until: float, + monotonic_deadline: float, + query_timeout: float, + ) -> None: + self._client = client + self._scope = CtpSdkReadOnlyScope( + environment=scope.environment, + sdk_profile=scope.sdk_profile, + td_front=scope.td_front, + md_front=scope.md_front, + account_fingerprint_sha256=scope.account_fingerprint_sha256, + instrument_id=scope.instrument_id, + exchange_id=scope.exchange_id, + hedge_flag=scope.hedge_flag, + ) + self._builder_type = builder_type + self._stop_receipt_type = stop_receipt_type + self._connection_generation = connection_generation + self._valid_until = valid_until + self._monotonic_deadline = monotonic_deadline + self._query_timeout = query_timeout + self._closed = False + self._close_complete = False + self._close_evidence: Optional[CtpSdkReadOnlyCloseEvidence] = None + + def _require_open(self) -> float: + if self._closed: + _reject("session_closed") + return _deadline_remaining(self._valid_until, self._monotonic_deadline) + + def _require_zero_writes(self) -> None: + try: + counts = self._client.get_request_counts() + if type(counts) is not dict or any( + type(counts.get(name)) is not int or counts[name] != 0 + for name in _WRITE_REQUEST_KEYS + ): + _reject("native_write_detected") + except CtpSdkReadOnlyError: + raise + except Exception: + _reject("native_write_counts_unavailable") + + def read_identity(self) -> CtpReadOnlySessionIdentity: + self._require_open() + identity = _identity_from_client(self._client, self._scope) + self._require_zero_writes() + self._require_open() + return identity + + def read_query_snapshot(self) -> CtpReadOnlyQuerySnapshot: + first_identity = self.read_identity() + try: + builder = self._builder_type( + self._client, + instrument_id=self._scope.instrument_id, + exchange_id=self._scope.exchange_id, + hedge_flag=self._scope.hedge_flag, + ) + for query_name, method_name in _QUERY_ORDER: + remaining = self._require_open() + method = getattr(self._client, method_name) + timeout = min(remaining, self._query_timeout) + if query_name == "instruments": + result = method( + instrument_id=self._scope.instrument_id, + exchange_id=self._scope.exchange_id, + timeout=timeout, + ) + elif query_name == "margin_rate": + result = method( + self._scope.instrument_id, + exchange_id=self._scope.exchange_id, + hedge_flag=self._scope.hedge_flag, + timeout=timeout, + ) + elif query_name == "commission_rate": + result = method( + self._scope.instrument_id, + exchange_id=self._scope.exchange_id, + timeout=timeout, + ) + else: + result = method(timeout=timeout) + # A result is checked immediately, within its SDK-issued TTL. + builder.add(result) + self._require_zero_writes() + self._require_open() + certificate = builder.finish() + final_identity = self.read_identity() + if final_identity != first_identity: + _reject("session_identity_changed") + query_digests = tuple( + (_SNAPSHOT_NAMES.get(name, name), digest) + for name, digest in certificate.query_digests + ) + rate_exchange_scopes = _certificate_rate_exchange_scopes(certificate) + return CtpReadOnlyQuerySnapshot.from_query_digests( + first_identity, + query_digests, + native_certificate_sha256=certificate.certificate_sha256, + rate_exchange_scopes=rate_exchange_scopes, + ) + except CtpSdkReadOnlyError: + raise + except Exception: + _reject("native_query_certificate_failed") + + def close_read_only(self) -> None: + if self._closed: + if not self._close_complete: + _reject("session_close_incomplete") + return + self._closed = True + # The legacy API intentionally exposes no receipt details. If a + # caller later asks for the projection, do not repeat native stop. + self._close_evidence = CtpSdkReadOnlyCloseEvidence("unknown") + _close_ctp_sdk_client_read_only( + self._client, + stop_receipt_type=self._stop_receipt_type, + expected_connection_generation=self._connection_generation, + ) + self._close_complete = True + + def close_read_only_with_evidence(self) -> CtpSdkReadOnlyCloseEvidence: + """Close once and return a strict, value-free native stop projection. + + Unlike ``close_read_only``, this method returns incomplete or unknown + outcomes so an outer Job supervisor can terminate and classify the + child. It never retries a stop attempt. Callers must accept only + ``verified_complete``; unknown evidence is not a successful close. + """ + + if self._close_evidence is not None: + return self._close_evidence + if self._closed: + return CtpSdkReadOnlyCloseEvidence("unknown") + self._closed = True + try: + self._close_evidence = _close_ctp_sdk_client_read_only_with_evidence( + self._client, + stop_receipt_type=self._stop_receipt_type, + expected_connection_generation=self._connection_generation, + ) + except Exception: + self._close_evidence = CtpSdkReadOnlyCloseEvidence("unknown") + self._close_complete = self._close_evidence.verified_complete + return self._close_evidence + + +class CtpSdkReadOnlySessionFactory: + """A single-session native factory with no trading-write interface.""" + + def __init__( + self, + scope: CtpSdkReadOnlyScope, + credential_source: CtpCredentialSource, + *, + connect_timeout: float = 15.0, + query_timeout: float = 5.0, + sdk_components_loader: Optional[Callable[[], Tuple[Any, ...]]] = None, + ) -> None: + if type(scope) is not CtpSdkReadOnlyScope: + raise TypeError("scope must be a CtpSdkReadOnlyScope") + if type(connect_timeout) not in (int, float) or not 0 < connect_timeout <= 60: + raise ValueError("invalid read-only connect timeout") + if type(query_timeout) not in (int, float) or not 0 < query_timeout <= 30: + raise ValueError("invalid read-only query timeout") + self._scope = CtpSdkReadOnlyScope( + environment=scope.environment, + sdk_profile=scope.sdk_profile, + td_front=scope.td_front, + md_front=scope.md_front, + account_fingerprint_sha256=scope.account_fingerprint_sha256, + instrument_id=scope.instrument_id, + exchange_id=scope.exchange_id, + hedge_flag=scope.hedge_flag, + ) + self._credential_source = credential_source + self._connect_timeout = float(connect_timeout) + self._query_timeout = float(query_timeout) + self._sdk_components_loader = sdk_components_loader or _default_sdk_components + + def __repr__(self) -> str: + return "CtpSdkReadOnlySessionFactory(environment={0!r}, credentials=)".format( + self._scope.environment + ) + + def open_read_only(self, request: CtpReadOnlySessionRequest) -> _CtpSdkReadOnlySession: + if type(request) is not CtpReadOnlySessionRequest: + raise TypeError("request must be a CtpReadOnlySessionRequest") + if request.provider != "ctp" or request.environment != self._scope.environment: + _reject("environment_mismatch") + if not hmac.compare_digest( + request.account_fingerprint_sha256, + self._scope.account_fingerprint_sha256, + ): + _reject("account_mismatch") + remaining = request.valid_until - time.time() + if not math.isfinite(remaining): + _reject("session_clock_invalid") + if remaining <= 0: + _reject("session_deadline_expired") + monotonic_deadline = time.monotonic() + remaining + _deadline_remaining(request.valid_until, monotonic_deadline) + + broker_id = _credential(self._credential_source, "broker_id") + user_id = _credential(self._credential_source, "user_id") + account_digest = hashlib.sha256( + "{0}:{1}".format(broker_id, user_id).encode("utf-8") + ).hexdigest() + if not hmac.compare_digest(account_digest, self._scope.account_fingerprint_sha256): + _reject("credential_account_mismatch") + password = _credential(self._credential_source, "password") + app_id = _credential(self._credential_source, "app_id") + auth_code = _credential(self._credential_source, "auth_code") + _deadline_remaining(request.valid_until, monotonic_deadline) + + client = None + stop_receipt_type = None + connection_generation = None + session = None + open_error = None + opened = False + try: + components = self._sdk_components_loader() + if type(components) is not tuple or len(components) not in (2, 3): + _reject("sdk_components_invalid") + client_type, builder_type = components[:2] + if len(components) == 3: + stop_receipt_type = components[2] + if not isinstance(stop_receipt_type, type): + _reject("sdk_components_invalid") + client = client_type( + self._scope.td_front, + broker_id, + user_id, + password, + app_id=app_id, + auth_code=auth_code, + auto_settlement_confirm=False, + ) + client.start(block=False) + remaining = _deadline_remaining(request.valid_until, monotonic_deadline) + if client.wait_ready(timeout=min(remaining, self._connect_timeout)) is not True: + _reject("session_not_read_only_ready") + _deadline_remaining(request.valid_until, monotonic_deadline) + if client.get_session_state().get("auto_settlement_confirm") is not False: + _reject("settlement_write_not_disabled") + identity = _identity_from_client(client, self._scope) + connection_generation = identity.connection_generation + session = _CtpSdkReadOnlySession( + client, + self._scope, + builder_type, + stop_receipt_type, + connection_generation, + request.valid_until, + monotonic_deadline, + self._query_timeout, + ) + session._require_zero_writes() + opened = True + except CtpSdkReadOnlyError as exc: + open_error = exc + except Exception: + open_error = CtpSdkReadOnlyError("session_open_failed") + if not opened and client is not None: + try: + _close_ctp_sdk_client_read_only( + client, + stop_receipt_type=stop_receipt_type, + expected_connection_generation=connection_generation, + ) + except CtpSdkReadOnlyError as cleanup_error: + if open_error is None: + open_error = cleanup_error + else: + open_error = CtpSdkReadOnlyError( + open_error.reason, + cleanup_reason=cleanup_error.reason, + ) + if open_error is not None: + raise open_error from None + if not opened or session is None: + _reject("session_open_failed") + return session + + +__all__ = [ + "CtpCredentialSource", + "CtpSdkReadOnlyError", + "CtpSdkReadOnlyCloseEvidence", + "CtpSdkReadOnlyScope", + "CtpSdkReadOnlySessionFactory", +] diff --git a/backtrader_runtime/ctp_simnow_managed_composition.py b/backtrader_runtime/ctp_simnow_managed_composition.py new file mode 100644 index 00000000..2c286777 --- /dev/null +++ b/backtrader_runtime/ctp_simnow_managed_composition.py @@ -0,0 +1,237 @@ +"""Opt-in construction helpers for the config-pair managed SimNow port. + +This module is deliberately absent from the default inventory and CLI. It +revalidates the registered, sealed ``config.yaml`` before reading its private +CTP block, and constructs an unconnected TraderClient. No SDK authority, +approval, evidence verifier, or account-wide writer fence is created here. + +The result is intended for a trusted runtime process. Python private +attributes are not isolation from an untrusted in-process strategy plugin; a +deployment that loads such plugins must keep credentials and the SDK client +behind a process or service boundary. +""" + +from __future__ import annotations + +import hashlib +from typing import Any, Callable, Mapping + +from .config import CtpSimNowPrivateConfig +from .ctp_simulation_execution import ( + CtpSimulationExecutionError, + CtpSimulationExecutionRegistration, + require_ctp_simulation_execution_admission, +) +from .ctp_artifact_provenance import ( + CtpArtifactProvenanceError, + verify_ctp_simnow_managed_artifact_provenance_for_fronts, +) +from .ctp_trader_client_port import ( + CtpSimulationTraderConfig, + CtpTraderClientSimulationPort, + create_ctp_trader_client_simulation_port, +) +from .capability_imports import trusted_installed_capability_import_context +from .registry import ( + EffectiveRuntimeConfig, + RuntimeRegistry, + require_effective_runtime_config_seal, + validate_runtime_config, +) + + +def _reject(reason: str) -> None: + raise CtpSimulationExecutionError(reason) + + +def _resolve_sealed_ctp_simnow_private_config( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + registration: CtpSimulationExecutionRegistration, +) -> CtpSimNowPrivateConfig: + """Validate current sealed routing scope without extracting credentials. + + The source config must be the registered ``simulation/sandbox`` runtime + with a same-file CTP private block (``secrets_ref: config_yaml``). Current + on-disk content is reparsed through the protected runtime-config loader; + all addresses and instrument/hedge scope must still match the code-owned + managed-execution registration. When ``front_pairs`` contains + several candidates, the registration's exact MD/TD pair is the explicit + selection; no first-entry or fallback selection is performed. The + returned private block is not used to read credential fields until the + artifact gate has passed. + """ + + try: + require_effective_runtime_config_seal(effective, registry) + require_ctp_simulation_execution_admission(effective, registry, registration) + current = validate_runtime_config(effective.registration.runtime_dir, registry) + require_effective_runtime_config_seal(current, registry) + require_ctp_simulation_execution_admission(current, registry, registration) + except CtpSimulationExecutionError: + raise + except Exception as exc: + raise CtpSimulationExecutionError("sealed_ctp_runtime_config_rejected") from exc + + private = current.config.ctp_simnow + if ( + current.registration is not effective.registration + or current.config.config_digest != effective.config.config_digest + or current.effective_digest != effective.effective_digest + ): + _reject("sealed_ctp_runtime_config_changed") + + if ( + type(private) is not CtpSimNowPrivateConfig + or current.config.secrets_ref != "config_yaml" + or registration.allowed_secrets_ref != "config_yaml" + ): + _reject("sealed_ctp_simnow_private_config_required") + if ( + private.instrument_id != registration.instrument_id + or private.exchange_id != registration.exchange_id + or private.hedge_flag != registration.hedge_flag + ): + _reject("sealed_ctp_simnow_instrument_scope_mismatch") + selected_pair = (registration.md_front, registration.td_front) + configured_pairs = tuple((pair["md_front"], pair["td_front"]) for pair in private.front_pairs) + if configured_pairs.count(selected_pair) != 1: + _reject("sealed_ctp_front_pair_registration_mismatch") + return private + + +def _trader_config_from_private( + private: CtpSimNowPrivateConfig, + registration: CtpSimulationExecutionRegistration, +) -> CtpSimulationTraderConfig: + """Extract sealed credential fields after the SDK artifact gate passes.""" + + config = CtpSimulationTraderConfig( + td_front=registration.td_front, + md_front=registration.md_front, + broker_id=private.broker_id, + user_id=private.user_id, + password=private.password, + auth_code=private.auth_code, + app_id=private.app_id, + auto_detect_fronts=False, + ) + config.validate(registration) + expected_fingerprint = hashlib.sha256( + f"{private.broker_id}:{private.user_id}".encode("utf-8") + ).hexdigest()[:16] + expected_account_digest = hashlib.sha256( + ("acct_" + expected_fingerprint).encode("ascii") + ).hexdigest() + if expected_account_digest != registration.account_fingerprint_sha256: + _reject("sealed_ctp_simnow_account_scope_mismatch") + return config + + +def _verify_selected_pair_artifacts( + private: CtpSimNowPrivateConfig, + registration: CtpSimulationExecutionRegistration, +) -> None: + """Verify provenance for the one sealed pair selected by code.""" + + selected_pair = (registration.md_front, registration.td_front) + configured_pairs = tuple((pair["md_front"], pair["td_front"]) for pair in private.front_pairs) + if configured_pairs.count(selected_pair) != 1: + _reject("sealed_ctp_front_pair_registration_mismatch") + try: + verify_ctp_simnow_managed_artifact_provenance_for_fronts( + td_front=registration.td_front, + md_front=registration.md_front, + ) + except CtpArtifactProvenanceError: + _reject("managed_simnow_artifact_provenance_rejected") + except Exception: + _reject("managed_simnow_artifact_provenance_rejected") + + +def resolve_sealed_ctp_simnow_trader_config( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + registration: CtpSimulationExecutionRegistration, +) -> CtpSimulationTraderConfig: + """Resolve exact sealed credentials only after artifact provenance passes.""" + + private = _resolve_sealed_ctp_simnow_private_config(effective, registry, registration) + _verify_selected_pair_artifacts(private, registration) + return _trader_config_from_private(private, registration) + + +def create_sealed_ctp_simnow_managed_port( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + registration: CtpSimulationExecutionRegistration, + *, + execution_capability: object, + runtime_admission_check: Callable[[], bool], + runtime_order_binding: Callable[[str, bool], Mapping[str, Any]], + runtime_credential_binding_factory: Callable[..., Any], + runtime_approval_verifier: Any, + sdk_approval_rechecker: Callable[[Any, Mapping[str, Any]], bool], + trader_client_factory: Callable[..., Any] | None = None, + order_field_factory: Callable[[], Any] | None = None, + action_field_factory: Callable[[], Any] | None = None, +) -> CtpTraderClientSimulationPort: + """Build the exact-config managed adapter without starting a provider. + + Every authority-bearing dependency is required and injected explicitly. + This function does not issue the SDK capability, create approvals, connect + the native API, install an action binding, or register a runnable route. + It only constructs the SDK client and configures its persistent gate in + the disarmed state. + """ + + required = ( + runtime_admission_check, + runtime_order_binding, + runtime_credential_binding_factory, + sdk_approval_rechecker, + ) + if execution_capability is None or any(not callable(item) for item in required): + _reject("explicit_managed_simnow_authorities_required") + if not callable(getattr(runtime_approval_verifier, "verify", None)): + _reject("runtime_write_approval_verifier_required") + private = _resolve_sealed_ctp_simnow_private_config(effective, registry, registration) + # This code-owned gate checks the exact pair selected by the sealed + # registration and the installed bt_api_base/bt_api_ctp artifacts before + # credentials are extracted or any SDK import/client factory can run. The + # managed gate includes bt_api_py because CTP lazily imports its managed + # approval and credential-binding contracts from that separate wheel. + # The injection seams below remain useful for offline tests, but they do + # not bypass this artifact check. + _verify_selected_pair_artifacts(private, registration) + config = _trader_config_from_private(private, registration) + + # Keep every SDK import triggered by the factory and port constructor + # pinned to the concrete installed packages that passed provenance. + try: + with trusted_installed_capability_import_context( + ("bt_api_base", "bt_api_ctp", "bt_api_py") + ): + return create_ctp_trader_client_simulation_port( + registration, + config, + trader_client_factory=trader_client_factory, + execution_capability=execution_capability, + runtime_admission_check=runtime_admission_check, + runtime_order_binding=runtime_order_binding, + runtime_credential_binding_factory=runtime_credential_binding_factory, + runtime_approval_verifier=runtime_approval_verifier, + sdk_approval_rechecker=sdk_approval_rechecker, + order_field_factory=order_field_factory, + action_field_factory=action_field_factory, + ) + except CtpSimulationExecutionError: + raise + except Exception: + _reject("managed_simnow_capability_import_rejected") + + +__all__ = [ + "create_sealed_ctp_simnow_managed_port", + "resolve_sealed_ctp_simnow_trader_config", +] diff --git a/backtrader_runtime/ctp_simnow_managed_md_bridge.py b/backtrader_runtime/ctp_simnow_managed_md_bridge.py new file mode 100644 index 00000000..929fe67b --- /dev/null +++ b/backtrader_runtime/ctp_simnow_managed_md_bridge.py @@ -0,0 +1,398 @@ +"""Lease-bound, read-only tick handoff for managed SimNow consumers. + +This module deliberately does not create a Store, native client, subscription, +or Backtrader Feed. A caller that already owns an authenticated MD stream may +adapt that stream to :class:`ManagedCtpMdTickSource`; this bridge pins its +selected scope and only yields strictly typed, fresh ticks from that scope. + +The bridge is an adapter contract, not a provider authorization boundary. The +source must remain owned by the managed runtime which owns the account lease. +""" + +from __future__ import annotations + +import math +import re +import threading +from dataclasses import dataclass +from typing import Optional, Protocol + +from .ctp_simnow_managed_operator import CtpSimNowManagedScopeSelection +from .ctp_simnow_managed_runtime import CtpSimNowNativeReadiness + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +class CtpSimNowManagedMdBridgeError(ValueError): + """Redacted fail-closed error from the managed MD tick bridge.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +@dataclass(frozen=True) +class ManagedCtpMdLeaseSnapshot: + """Owner-issued snapshot of one account-lease tenure. + + ``lease_generation`` must be a positive integer from an owner-maintained, + strictly increasing per-account sequence. The owner must advance it after + any lease loss, persist it across restarts, and never revive an old source + after that loss, even if the same account later reacquires a lease. This + bridge checks that contract but cannot independently authenticate the + owner's implementation. + """ + + account_fingerprint_sha256: str + lease_generation: int + active: bool + + +@dataclass(frozen=True) +class ManagedCtpMdSourceIdentity: + """Identity snapshot for one acknowledged stream and its readiness tick. + + The source adapter must take the generation, sequence, event-time and + receive-time watermarks from the same MD client generation whose exact + subscription ACK and first tick produced ``CtpSimNowNativeReadiness``. + ``lease_generation`` comes from the managed owner; it is not caller-chosen + and strictly increases on every lease grant after any prior loss. + """ + + config_digest: str + registration_digest: str + account_fingerprint_sha256: str + lease_generation: int + md_front: str + td_front: str + instrument_id: str + exchange_id: str + connection_generation: int + subscription_epoch: int + subscription_instrument_id: str + subscription_acknowledged: bool + first_tick_observed: bool + ready_tick_sequence: int + ready_event_timestamp: float + ready_received_monotonic_ns: int + + +@dataclass(frozen=True) +class ManagedCtpMdTick: + """Normalized single-instrument tick from the pinned managed MD stream. + + ``sequence`` must be greater than ``identity.ready_tick_sequence``; the + readiness tick is used only as a watermark and is never re-emitted. + """ + + identity: ManagedCtpMdSourceIdentity + sequence: int + event_timestamp: float + received_monotonic_ns: int + instrument_id: str + exchange_id: str + last_price: float + volume_delta: float + bid_price: float + ask_price: float + bid_volume: float + ask_volume: float + trading_day: str = "" + action_day: str = "" + update_time: str = "" + update_millisec: int = 0 + stale: bool = False + stale_reason: str = "" + + +class ManagedCtpMdTickSource(Protocol): + """Read-only view supplied by the owner of an already-started MD client.""" + + @property + def identity(self) -> ManagedCtpMdSourceIdentity: + """Return the current front/account/instrument/generation snapshot.""" + + @property + def lease_snapshot(self) -> ManagedCtpMdLeaseSnapshot: + """Return owner-issued active state and a non-reusable lease token. + + The owner must issue a strictly increasing per-account generation and + permanently invalidate an old source after lease loss. Boolean-only + checks do not satisfy this protocol. The snapshot and + ``identity.lease_generation`` must name the same lease tenure. + """ + + def poll_tick(self) -> Optional[ManagedCtpMdTick]: + """Return the next normalized tick without starting or subscribing.""" + + +def _reject(reason: str) -> None: + raise CtpSimNowManagedMdBridgeError(reason) + + +def _finite_number(value: object) -> bool: + return type(value) in (int, float) and math.isfinite(float(value)) + + +def _validate_lease_snapshot( + snapshot: object, account_fingerprint_sha256: str +) -> ManagedCtpMdLeaseSnapshot: + if type(snapshot) is not ManagedCtpMdLeaseSnapshot: + _reject("managed_md_lease_snapshot_unavailable") + assert isinstance(snapshot, ManagedCtpMdLeaseSnapshot) + if ( + type(snapshot.account_fingerprint_sha256) is not str + or not _SHA256_RE.fullmatch(snapshot.account_fingerprint_sha256) + or type(snapshot.lease_generation) is not int + or snapshot.lease_generation <= 0 + or type(snapshot.active) is not bool + ): + _reject("managed_md_lease_snapshot_invalid") + if ( + snapshot.account_fingerprint_sha256 != account_fingerprint_sha256 + or snapshot.active is not True + ): + _reject("managed_md_account_lease_lost") + return snapshot + + +def _read_lease_snapshot( + source: ManagedCtpMdTickSource, account_fingerprint_sha256: str +) -> ManagedCtpMdLeaseSnapshot: + try: + snapshot = source.lease_snapshot + except Exception: + _reject("managed_md_lease_snapshot_unavailable") + return _validate_lease_snapshot(snapshot, account_fingerprint_sha256) + + +def _validate_identity(identity: object) -> ManagedCtpMdSourceIdentity: + if type(identity) is not ManagedCtpMdSourceIdentity: + _reject("managed_md_source_identity_invalid") + assert isinstance(identity, ManagedCtpMdSourceIdentity) + for value in ( + identity.config_digest, + identity.registration_digest, + identity.account_fingerprint_sha256, + ): + if type(value) is not str or not _SHA256_RE.fullmatch(value): + _reject("managed_md_source_identity_invalid") + if type(identity.lease_generation) is not int or identity.lease_generation <= 0: + _reject("managed_md_source_identity_invalid") + for value in ( + identity.md_front, + identity.td_front, + identity.instrument_id, + identity.exchange_id, + identity.subscription_instrument_id, + ): + if type(value) is not str or not value or value != value.strip(): + _reject("managed_md_source_identity_invalid") + if ( + type(identity.connection_generation) is not int + or identity.connection_generation <= 0 + or type(identity.subscription_epoch) is not int + or identity.subscription_epoch <= 0 + or type(identity.ready_tick_sequence) is not int + or identity.ready_tick_sequence <= 0 + or not _finite_number(identity.ready_event_timestamp) + or float(identity.ready_event_timestamp) <= 0 + or type(identity.ready_received_monotonic_ns) is not int + or identity.ready_received_monotonic_ns <= 0 + or type(identity.subscription_acknowledged) is not bool + or identity.subscription_acknowledged is not True + or type(identity.first_tick_observed) is not bool + or identity.first_tick_observed is not True + or identity.subscription_instrument_id != identity.instrument_id + ): + _reject("managed_md_source_not_ready") + return identity + + +class CtpSimNowManagedMdTickBridge: + """Pin and poll a lease-owned MD source without taking client ownership. + + Construction requires native readiness for the exact selected scope and + a source identity that proves an exact subscription ACK plus a first tick. + Every poll rechecks the account lease and complete source identity before + and after reading. A source fault, scope/generation change, malformed tick, + or stale/out-of-order tick poisons the bridge permanently. + + ``poll_tick`` returns a typed event for a future Feed adapter. It does not + create or mutate a ``BtApiFeed`` because that Feed currently owns a Store + lifecycle and cannot safely adopt the already-started managed MD client. + """ + + def __init__( + self, + selection: CtpSimNowManagedScopeSelection, + readiness: CtpSimNowNativeReadiness, + source: ManagedCtpMdTickSource, + ) -> None: + self._selection = selection + self._source = source + self._lock = threading.Lock() + self._closed = False + self._fault: Optional[str] = None + self._last_sequence = 0 + self._last_event_timestamp = 0.0 + self._last_received_monotonic_ns = 0 + + if type(readiness) is not CtpSimNowNativeReadiness: + _reject("managed_md_readiness_required") + try: + matches = readiness.matches(selection) + except Exception: + matches = False + if matches is not True: + _reject("managed_md_readiness_scope_mismatch") + + try: + registration = selection.execution_registration + account_fingerprint = registration.account_fingerprint_sha256 + lease_snapshot = _read_lease_snapshot(source, account_fingerprint) + expected = { + "config_digest": selection.config_digest, + "registration_digest": registration.digest, + "account_fingerprint_sha256": account_fingerprint, + "md_front": registration.md_front, + "td_front": registration.td_front, + "instrument_id": registration.instrument_id, + "exchange_id": registration.exchange_id, + } + source_identity = _validate_identity(source.identity) + except CtpSimNowManagedMdBridgeError: + raise + except Exception: + _reject("managed_md_source_unavailable") + if any(getattr(source_identity, key) != value for key, value in expected.items()): + _reject("managed_md_source_scope_mismatch") + if source_identity.lease_generation != lease_snapshot.lease_generation: + _reject("managed_md_source_lease_generation_mismatch") + + self._identity = source_identity + self._lease_generation = lease_snapshot.lease_generation + self._last_sequence = source_identity.ready_tick_sequence + self._last_event_timestamp = float(source_identity.ready_event_timestamp) + self._last_received_monotonic_ns = source_identity.ready_received_monotonic_ns + self._assert_source_current() + + @property + def identity(self) -> ManagedCtpMdSourceIdentity: + """Return the pinned identity for diagnostics and downstream binding.""" + + with self._lock: + self._require_usable() + return self._identity + + def _require_usable(self) -> None: + if self._fault is not None: + _reject(self._fault) + if self._closed: + _reject("managed_md_bridge_closed") + + def _poison(self, reason: str) -> None: + self._fault = reason + _reject(reason) + + def _assert_source_current(self) -> None: + self._require_usable() + try: + lease_snapshot = _read_lease_snapshot( + self._source, self._identity.account_fingerprint_sha256 + ) + current = _validate_identity(self._source.identity) + except CtpSimNowManagedMdBridgeError as exc: + self._poison(exc.reason) + except Exception: + self._poison("managed_md_source_unavailable") + if ( + lease_snapshot.lease_generation != self._lease_generation + or current.lease_generation != self._lease_generation + ): + self._poison("managed_md_account_lease_changed") + if current != self._identity: + self._poison("managed_md_source_generation_or_scope_changed") + + def _validate_tick(self, tick: object) -> ManagedCtpMdTick: + if type(tick) is not ManagedCtpMdTick: + self._poison("managed_md_tick_shape_unknown") + assert isinstance(tick, ManagedCtpMdTick) + if tick.identity != self._identity: + self._poison("managed_md_tick_scope_or_generation_mismatch") + if ( + tick.instrument_id != self._identity.instrument_id + or tick.exchange_id != self._identity.exchange_id + ): + self._poison("managed_md_tick_instrument_mismatch") + if ( + type(tick.sequence) is not int + or tick.sequence <= self._last_sequence + or not _finite_number(tick.event_timestamp) + or float(tick.event_timestamp) <= 0 + or float(tick.event_timestamp) < self._last_event_timestamp + or type(tick.received_monotonic_ns) is not int + or tick.received_monotonic_ns <= self._last_received_monotonic_ns + or tick.stale is not False + or type(tick.stale_reason) is not str + or tick.stale_reason != "" + ): + self._poison("managed_md_tick_stale_or_out_of_order") + if ( + not _finite_number(tick.last_price) + or float(tick.last_price) <= 0 + or not _finite_number(tick.volume_delta) + or float(tick.volume_delta) < 0 + or not _finite_number(tick.bid_price) + or float(tick.bid_price) <= 0 + or not _finite_number(tick.ask_price) + or float(tick.ask_price) <= 0 + or float(tick.bid_price) > float(tick.ask_price) + or not _finite_number(tick.bid_volume) + or float(tick.bid_volume) < 0 + or not _finite_number(tick.ask_volume) + or float(tick.ask_volume) < 0 + or type(tick.update_millisec) is not int + or not 0 <= tick.update_millisec <= 999 + or any( + type(value) is not str + for value in (tick.trading_day, tick.action_day, tick.update_time) + ) + ): + self._poison("managed_md_tick_fields_invalid") + self._last_sequence = tick.sequence + self._last_event_timestamp = float(tick.event_timestamp) + self._last_received_monotonic_ns = tick.received_monotonic_ns + return tick + + def poll_tick(self) -> Optional[ManagedCtpMdTick]: + """Yield one validated tick, or ``None`` when the source is idle.""" + + with self._lock: + self._assert_source_current() + try: + tick = self._source.poll_tick() + except Exception: + self._poison("managed_md_source_poll_failed") + self._assert_source_current() + if tick is None: + return None + return self._validate_tick(tick) + + def retire(self) -> None: + """Stop yielding locally; the managed runtime retains client ownership.""" + + with self._lock: + if self._fault is None: + self._closed = True + + +__all__ = [ + "CtpSimNowManagedMdBridgeError", + "CtpSimNowManagedMdTickBridge", + "ManagedCtpMdLeaseSnapshot", + "ManagedCtpMdSourceIdentity", + "ManagedCtpMdTick", + "ManagedCtpMdTickSource", +] diff --git a/backtrader_runtime/ctp_simnow_managed_operator.py b/backtrader_runtime/ctp_simnow_managed_operator.py new file mode 100644 index 00000000..5bb77845 --- /dev/null +++ b/backtrader_runtime/ctp_simnow_managed_operator.py @@ -0,0 +1,514 @@ +"""Pure admission and configured-front selection for managed SimNow runs. + +This module does not resolve credentials, import a CTP SDK, open a provider +session, or submit orders. A future reviewed operator route must obtain its +immutable policy from code-owned inventory and must continue through the +separate approval, artifact, session, writer-fence, and reconciliation gates. +The only network-capable operation here is the bounded, credential-free TCP +probe of the exact candidate pairs in the sealed config. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import Any, Callable, Mapping, Optional, Sequence, Tuple + +from .config import CtpPrivateConfig +from .ctp_front_pair_probe import ( + CtpFrontPairProbeError, + CtpFrontPairSelection, + select_ctp_front_pair, +) +from .ctp_simulation_execution import ( + CtpSimulationExecutionError, + CtpSimulationExecutionRegistration, + require_ctp_simulation_execution_admission, +) +from .errors import RuntimeConfigError +from .registry import ( + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + require_effective_runtime_config_seal, + validate_runtime_config, +) + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_FRONT_PAIR_SET_DOMAIN = b"backtrader-ctp-simnow-front-pair-set-v1\0" +_MAX_FRONT_PAIRS = 8 +_REQUIRED_CAPABILITIES = ("execution", "risk", "monitor") +_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") + + +def _managed_selector_profile( + registration: RegisteredRuntime, secrets_ref: str +) -> Optional[RuntimeProfile]: + """Return the exact receipt-required profile, or validate legacy policy facts.""" + + if registration.profiles: + profile = registration.profile_for("simulation", "sandbox") + if ( + type(profile) is not RuntimeProfile + or profile.mode != "simulation" + or profile.preset != "sandbox" + or profile.allowed_parameter_keys != () + or profile.allowed_secrets_refs != (secrets_ref,) + or profile.available_capabilities != _REQUIRED_CAPABILITIES + or profile.approval_receipt_digest is None + or profile.offline_managed_execution is not False + or profile.sandbox_write_policy != "receipt_required" + ): + _reject("runtime_profile_policy_mismatch") + # Profile-scoped registrations deliberately keep these legacy policy + # fields inert. Never let them supply a fallback receipt or capability. + if ( + registration.allowed_presets != () + or registration.allowed_parameter_keys != () + or registration.allowed_secrets_refs != ("none",) + or registration.available_capabilities != () + or registration.offline_managed_execution is not False + or registration.sandbox_write_policy != "deny" + or registration.approval_receipt_digest is not None + ): + _reject("runtime_profile_legacy_policy_not_inert") + return profile + + if ( + registration.allowed_presets != ("sandbox",) + or registration.allowed_parameter_keys != () + or registration.allowed_secrets_refs != (secrets_ref,) + or registration.sandbox_write_policy != "receipt_required" + or registration.approval_receipt_digest is None + or registration.available_capabilities != _REQUIRED_CAPABILITIES + or registration.offline_managed_execution + ): + _reject("runtime_policy_mismatch") + return None + + +class CtpSimNowManagedOperatorError(ValueError): + """Redacted rejection from the offline SimNow managed-scope selector.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +def _reject(reason: str) -> None: + raise CtpSimNowManagedOperatorError(reason) + + +def ctp_simnow_front_pair_set_sha256( + front_pairs: Sequence[Mapping[str, str]], +) -> str: + """Digest the ordered, exact configured MD/TD pairs using the journal contract. + + The serialized value is ``[[md_front, td_front], ...]`` in config order, + encoded as compact ASCII JSON and prefixed with the versioned domain + separator. No profile, set label, calendar, or time-derived value enters + this identity. + """ + + if isinstance(front_pairs, (str, bytes)) or not isinstance(front_pairs, Sequence): + _reject("front_pair_set_invalid") + if not 1 <= len(front_pairs) <= _MAX_FRONT_PAIRS: + _reject("front_pair_set_invalid") + pairs = [] + seen = set() + for item in front_pairs: + if not isinstance(item, Mapping) or set(item) != {"md_front", "td_front"}: + _reject("front_pair_set_invalid") + md_front = item["md_front"] + td_front = item["td_front"] + if type(md_front) is not str or not md_front or type(td_front) is not str or not td_front: + _reject("front_pair_set_invalid") + pair = (md_front, td_front) + if pair in seen: + _reject("front_pair_set_invalid") + seen.add(pair) + pairs.append([md_front, td_front]) + serialized = json.dumps(pairs, ensure_ascii=True, separators=(",", ":")).encode("ascii") + return hashlib.sha256(_FRONT_PAIR_SET_DOMAIN + serialized).hexdigest() + + +@dataclass(frozen=True) +class CtpSimNowManagedExecutionPolicy: + """Code-owned runtime admission, approval identity, and hard risk envelope. + + Production code must source this value from a reviewed inventory entry; + account, configured front candidates, and contract scope are resolved from + the freshly sealed canonical ``ctp:`` block on each invocation. The policy + deliberately contains no account, endpoint, or contract values, so an + operator can update those values in ``config.yaml`` without a code change. + Its numeric bounds remain the code-owned hard execution envelope. + """ + + runtime_registration: RegisteredRuntime + allowed_sides: Tuple[str, ...] + quantity_step: int + max_quantity: int + max_gross_position: int + min_price: Decimal + max_price: Decimal + price_tick: Decimal + approval_key_id: str + environment: str = "simnow" + approval_ttl_seconds: float = 30.0 + allowed_secrets_ref: str = "config_yaml" + + def __post_init__(self) -> None: + if type(self.runtime_registration) is not RegisteredRuntime: + _reject("code_owned_registration_required") + registration = self.runtime_registration + if ( + registration.allowed_presets != ("sandbox",) + or registration.allowed_parameter_keys != () + or registration.allowed_secrets_refs != (self.allowed_secrets_ref,) + or registration.sandbox_write_policy != "receipt_required" + or registration.approval_receipt_digest is None + or registration.available_capabilities != _REQUIRED_CAPABILITIES + or registration.offline_managed_execution + ): + if registration.profiles: + _managed_selector_profile(registration, self.allowed_secrets_ref) + else: + _reject("runtime_policy_mismatch") + if self.allowed_secrets_ref != "config_yaml": + _reject("invalid_secrets_reference") + if self.environment != "simnow": + _reject("environment_policy_mismatch") + sides = tuple(self.allowed_sides) + if ( + not sides + or len(set(sides)) != len(sides) + or any(side not in ("BUY", "SELL") for side in sides) + ): + _reject("risk_policy_invalid") + object.__setattr__(self, "allowed_sides", sides) + if ( + type(self.quantity_step) is not int + or self.quantity_step <= 0 + or type(self.max_quantity) is not int + or self.max_quantity <= 0 + or self.max_quantity % self.quantity_step + or type(self.max_gross_position) is not int + or self.max_gross_position < self.max_quantity + ): + _reject("risk_policy_invalid") + try: + minimum = Decimal(str(self.min_price)) + maximum = Decimal(str(self.max_price)) + tick = Decimal(str(self.price_tick)) + except Exception: + _reject("risk_policy_invalid") + if ( + not minimum.is_finite() + or not maximum.is_finite() + or not tick.is_finite() + or minimum <= 0 + or maximum < minimum + or tick <= 0 + or minimum % tick + or maximum % tick + ): + _reject("risk_policy_invalid") + object.__setattr__(self, "min_price", minimum) + object.__setattr__(self, "max_price", maximum) + object.__setattr__(self, "price_tick", tick) + if ( + type(self.approval_key_id) is not str + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,63}", self.approval_key_id) + or isinstance(self.approval_ttl_seconds, bool) + or type(self.approval_ttl_seconds) not in (int, float) + or not math.isfinite(float(self.approval_ttl_seconds)) + or not 0 < float(self.approval_ttl_seconds) <= 60.0 + ): + _reject("risk_policy_invalid") + object.__setattr__(self, "approval_ttl_seconds", float(self.approval_ttl_seconds)) + + +@dataclass(frozen=True) +class CtpSimNowManagedScopeSelection: + """One immutable pair selection and exact run scope; not write authority.""" + + execution_registration: CtpSimulationExecutionRegistration + front_pair_selection: CtpFrontPairSelection + front_pair_set_sha256: str + config_digest: str + effective_digest: str + profile_digest: Optional[str] = None + + def __post_init__(self) -> None: + if type(self.execution_registration) is not CtpSimulationExecutionRegistration: + _reject("execution_registration_required") + if type(self.front_pair_selection) is not CtpFrontPairSelection: + _reject("front_pair_selection_required") + if ( + self.execution_registration.front_pair_set_sha256 != self.front_pair_set_sha256 + or self.execution_registration.config_digest != self.config_digest + or self.execution_registration.md_front != self.front_pair_selection.pair.md_front + or self.execution_registration.td_front != self.front_pair_selection.pair.td_front + or self.execution_registration.front_pair_set_sha256 is None + or self.execution_registration.config_digest is None + or self.execution_registration.effective_digest != self.effective_digest + or self.execution_registration.profile_digest != self.profile_digest + ): + _reject("selected_scope_binding_mismatch") + for value, name in ( + (self.front_pair_set_sha256, "front_pair_set_sha256"), + (self.config_digest, "config_digest"), + (self.effective_digest, "effective_digest"), + ): + if type(value) is not str or not _SHA256_RE.fullmatch(value): + _reject("invalid_" + name) + if self.profile_digest is not None and ( + type(self.profile_digest) is not str or not _SHA256_RE.fullmatch(self.profile_digest) + ): + _reject("invalid_profile_digest") + + +def _private_config(effective: EffectiveRuntimeConfig) -> CtpPrivateConfig: + """Return the freshly sealed, canonical mode-neutral ``ctp:`` block.""" + + private = getattr(effective.config, "ctp", None) + if type(private) is not CtpPrivateConfig: + _reject("sealed_canonical_ctp_private_config_required") + # In simulation mode the legacy attribute is only a compatibility view. + # It must refer to this exact parsed object and cannot supply an alternate + # private block. + legacy = getattr(effective.config, "ctp_simnow", None) + if legacy is not None and legacy is not private: + _reject("ctp_private_config_alias_mismatch") + return private + + +def _verify_sealed_runtime( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + policy: CtpSimNowManagedExecutionPolicy, +) -> Tuple[EffectiveRuntimeConfig, CtpPrivateConfig]: + if type(effective) is not EffectiveRuntimeConfig or type(registry) is not RuntimeRegistry: + _reject("sealed_runtime_required") + try: + require_effective_runtime_config_seal(effective, registry) + if effective.registration is not policy.runtime_registration: + _reject("runtime_registration_mismatch") + current = validate_runtime_config(effective.registration.runtime_dir, registry) + require_effective_runtime_config_seal(current, registry) + except CtpSimNowManagedOperatorError: + raise + except (RuntimeConfigError, Exception): + _reject("sealed_runtime_rejected") + if ( + current.registration is not effective.registration + or current.config.config_digest != effective.config.config_digest + or current.effective_digest != effective.effective_digest + ): + _reject("sealed_runtime_config_changed") + profile = _managed_selector_profile(current.registration, policy.allowed_secrets_ref) + profile_shape = ( + current.profile is profile + and profile is not None + and profile.digest == current.profile.digest + ) + legacy_shape = profile is None and current.profile is None + if not (profile_shape or legacy_shape): + _reject("effective_profile_mismatch") + if ( + current.mode != "simulation" + or current.preset != "sandbox" + or current.config.secrets_ref != policy.allowed_secrets_ref + or current.registration.runtime_id != policy.runtime_registration.runtime_id + or current.config.strategy_id != policy.runtime_registration.strategy_id + or current.policy.environment != "sandbox" + or current.policy.mode != "simulation" + or current.required_capabilities != _REQUIRED_CAPABILITIES + or current.allows_production_writes is not False + or current.allows_external_writes is not True + or current.order_route != "managed_execution" + or current.account_access != "sandbox_direct_provider" + or current.requires_approval is not True + or current.requires_live_confirmation is not False + ): + _reject("effective_policy_mismatch") + private = _private_config(current) + if ( + type(private.front_pairs) is not tuple + or not 1 <= len(private.front_pairs) <= _MAX_FRONT_PAIRS + ): + _reject("config_contract_or_candidate_mismatch") + if ( + type(private.instrument_id) is not str + or not _INSTRUMENT_RE.fullmatch(private.instrument_id) + or type(private.exchange_id) is not str + or not _EXCHANGE_RE.fullmatch(private.exchange_id) + or type(private.hedge_flag) is not str + or private.hedge_flag not in ("1", "2", "3") + ): + _reject("config_contract_or_candidate_mismatch") + # Validate the exact candidate set before probing. It remains sourced from + # the sealed config; its digest is added to the invocation registration. + ctp_simnow_front_pair_set_sha256(private.front_pairs) + return current, private + + +def select_ctp_simnow_managed_scope( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + policy: CtpSimNowManagedExecutionPolicy, + *, + connector: Optional[Callable[[str, int, float], Any]] = None, + process_factory: Optional[Callable[..., Any]] = None, + clock: Optional[Callable[[], float]] = None, + timeout_seconds: float = 3.0, + repeated_samples: int = 3, +) -> CtpSimNowManagedScopeSelection: + """Verify sealed SimNow scope, probe all candidates, and pin one run pair. + + Runtime, config contract, candidate, and hard risk checks precede probing. + The account fingerprint, ordered candidate digest, contract scope, and + measured pair are then bound into the invocation registration. Password, + app ID, and auth code are never accessed; credentials and the SDK are not + loaded here. Ties retain config order and no retry/reselection occurs after + this immutable result is produced. + """ + + if type(policy) is not CtpSimNowManagedExecutionPolicy: + _reject("code_owned_policy_required") + current, private = _verify_sealed_runtime(effective, registry, policy) + try: + selection = select_ctp_front_pair( + private.front_pairs, + timeout_seconds=timeout_seconds, + max_pairs=_MAX_FRONT_PAIRS, + repeated_samples=repeated_samples, + connector=connector, + process_factory=process_factory, + clock=clock, + ) + except CtpFrontPairProbeError as exc: + _reject("front_pair_probe_" + exc.reason) + + account_fingerprint = hashlib.sha256( + f"{private.broker_id}:{private.user_id}".encode("utf-8") + ).hexdigest()[:16] + account_digest = hashlib.sha256(("acct_" + account_fingerprint).encode("ascii")).hexdigest() + candidate_digest = ctp_simnow_front_pair_set_sha256(private.front_pairs) + + profile = current.profile + try: + registration = CtpSimulationExecutionRegistration( + runtime_registration=policy.runtime_registration, + environment=policy.environment, + sdk_profile="config_front_pair", + td_front=selection.pair.td_front, + md_front=selection.pair.md_front, + account_fingerprint_sha256=account_digest, + allowed_secrets_ref=policy.allowed_secrets_ref, + instrument_id=private.instrument_id, + exchange_id=private.exchange_id, + hedge_flag=private.hedge_flag, + allowed_sides=policy.allowed_sides, + quantity_step=policy.quantity_step, + max_quantity=policy.max_quantity, + max_gross_position=policy.max_gross_position, + min_price=policy.min_price, + max_price=policy.max_price, + price_tick=policy.price_tick, + approval_key_id=policy.approval_key_id, + approval_ttl_seconds=policy.approval_ttl_seconds, + front_pair_set_sha256=candidate_digest, + config_digest=current.config.config_digest, + effective_digest=current.effective_digest, + profile_digest=None if profile is None else profile.digest, + profile_approval_receipt_digest=( + None if profile is None else profile.approval_receipt_digest + ), + ) + if profile is None: + require_ctp_simulation_execution_admission(current, registry, registration) + else: + _verify_profile_selection_registration( + current, registry, profile, registration, private + ) + except CtpSimulationExecutionError as exc: + _reject("execution_admission_" + exc.reason) + except Exception: + _reject("execution_admission_rejected") + + return CtpSimNowManagedScopeSelection( + execution_registration=registration, + front_pair_selection=selection, + front_pair_set_sha256=candidate_digest, + config_digest=current.config.config_digest, + effective_digest=current.effective_digest, + profile_digest=None if profile is None else profile.digest, + ) + + +def _verify_profile_selection_registration( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + profile: RuntimeProfile, + registration: CtpSimulationExecutionRegistration, + private: CtpPrivateConfig, +) -> None: + """Bind this selector result to one sealed profile and its private CTP scope.""" + + try: + require_effective_runtime_config_seal(effective, registry) + except Exception: + _reject("sealed_runtime_rejected") + account_fingerprint = hashlib.sha256( + "{0}:{1}".format(private.broker_id, private.user_id).encode("utf-8") + ).hexdigest()[:16] + account_digest = hashlib.sha256(("acct_" + account_fingerprint).encode("ascii")).hexdigest() + selected = effective.registration.profile_for("simulation", "sandbox") + if ( + selected is not profile + or effective.profile is not profile + or profile.digest != registration.profile_digest + or profile.approval_receipt_digest != registration.profile_approval_receipt_digest + or profile.sandbox_write_policy != "receipt_required" + or profile.available_capabilities != _REQUIRED_CAPABILITIES + or profile.allowed_secrets_refs != (registration.allowed_secrets_ref,) + or profile.allowed_parameter_keys != () + or profile.offline_managed_execution is not False + or profile.approval_receipt_digest is None + or effective.config.secrets_ref != registration.allowed_secrets_ref + or effective.config.config_digest != registration.config_digest + or effective.effective_digest != registration.effective_digest + or effective.required_capabilities != _REQUIRED_CAPABILITIES + or effective.allows_external_writes is not True + or effective.allows_production_writes is not False + or effective.requires_approval is not True + or effective.order_route != "managed_execution" + or effective.account_access != "sandbox_direct_provider" + or registration.runtime_registration is not effective.registration + or registration.account_fingerprint_sha256 != account_digest + or registration.front_pair_set_sha256 + != ctp_simnow_front_pair_set_sha256(private.front_pairs) + or registration.instrument_id != private.instrument_id + or registration.exchange_id != private.exchange_id + or registration.hedge_flag != private.hedge_flag + or (registration.md_front, registration.td_front) + not in tuple((pair["md_front"], pair["td_front"]) for pair in private.front_pairs) + ): + _reject("profile_selection_binding_mismatch") + + +__all__ = [ + "CtpSimNowManagedExecutionPolicy", + "CtpSimNowManagedOperatorError", + "CtpSimNowManagedScopeSelection", + "ctp_simnow_front_pair_set_sha256", + "select_ctp_simnow_managed_scope", +] diff --git a/backtrader_runtime/ctp_simnow_managed_runtime.py b/backtrader_runtime/ctp_simnow_managed_runtime.py new file mode 100644 index 00000000..a19ebff8 --- /dev/null +++ b/backtrader_runtime/ctp_simnow_managed_runtime.py @@ -0,0 +1,719 @@ +"""Explicit, unregistered composition root for managed CTP SimNow execution. + +The caller supplies every authority and every provider-facing dependency. This +module selects one exact configured MD/TD pair once, delegates client creation +to the artifact-first composition, and opens the journaled execution session +under its account lease and external writer fence. It has no CLI registration, +approval signer, key source, or default provider client. + +Front selection proves bounded TCP reachability only. The injected native +client factory constructs one unstarted client for the exact pair it receives. +The readiness callback receives the port, exact selection, and that concrete +client as separate arguments. Managed readiness callbacks must accept the +market-client sink and failure-cleanup hook. They must hand off a fresh MD +client synchronously before starting it and must not retain the sink after the +callback returns. The owner seals that sink at callback return, then owns both +clients through startup, session use and shutdown. A failed login, query, +readiness check, or later uncertain write never triggers endpoint reselection. +""" + +from __future__ import annotations + +import inspect +import re +import threading +import time +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Optional + +from . import ctp_simulation_execution as _execution +from .ctp_simnow_managed_composition import create_sealed_ctp_simnow_managed_port +from .ctp_simnow_managed_operator import ( + CtpSimNowManagedExecutionPolicy, + CtpSimNowManagedScopeSelection, + select_ctp_simnow_managed_scope, +) +from .ctp_simulation_execution import ( + CtpSimulationExecutionError, + CtpSimulationExecutionRegistration, + CtpSimulationExecutionSession, + CtpSimulationSessionIdentity, + CtpSimulationQueryEvidenceVerifier, + CtpSimulationApprovalVerifier, +) +from .ctp_native_shutdown import stop_ctp_native_client +from .registry import EffectiveRuntimeConfig, RuntimeRegistry + +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$") + + +def _reject(reason: str) -> None: + raise CtpSimulationExecutionError(reason) + + +@dataclass(frozen=True) +class CtpSimNowManagedRuntime: + """The one selected scope, optional TD proof, and leased execution session.""" + + selection: CtpSimNowManagedScopeSelection + session: CtpSimulationExecutionSession + td_trading_readiness: Any = None + + def close(self) -> None: + self.session.close() + + def __enter__(self) -> "CtpSimNowManagedRuntime": + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + +class _ManagedNativeResources: + """Own native clients through readiness, execution open and final close. + + Stop is attempted at most once per client. A failed stop is remembered and + re-raised on later close calls without retrying an uncertain native call. + The raw trader is closed through its port once that port exists, preserving + the port's cleanup contract; before that point the captured client is + stopped directly. + """ + + def __init__(self) -> None: + self._lock = threading.RLock() + self._trader: Any = None + self._extra_traders: list[Any] = [] + self._port: Any = None + self._md_clients: list[Any] = [] + self._closed = False + self._close_failed = False + self._market_client_registration_sealed = False + + def set_trader(self, client: Any) -> None: + with self._lock: + if self._closed: + _reject("managed_simnow_native_client_after_close") + if self._trader is None: + self._trader = client + return + if self._trader is not client and not any( + existing is client for existing in self._extra_traders + ): + self._extra_traders.append(client) + _reject("managed_simnow_native_client_count_mismatch") + + @property + def market_client_count(self) -> int: + with self._lock: + return len(self._md_clients) + + def set_port(self, port: Any) -> None: + with self._lock: + if self._closed: + _reject("managed_simnow_native_port_after_close") + if self._port is not None and self._port is not port: + _reject("managed_simnow_native_port_changed") + self._port = port + + def register_market_client(self, client: Any) -> None: + if client is None: + _reject("managed_simnow_md_client_required") + with self._lock: + if client is self._trader or any(client is item for item in self._extra_traders): + _reject("managed_simnow_md_client_aliases_td") + if self._closed or self._market_client_registration_sealed: + # The readiness adapter must hand off a fresh MD client before + # starting it. A rejected late handoff never becomes owned and + # must not be started by the callback. + if not self._closed: + self._close_failed = True + _reject("managed_simnow_md_client_registration_closed") + if not any(existing is client for existing in self._md_clients): + self._md_clients.append(client) + if len(self._md_clients) > 1: + self._close_failed = True + _reject("managed_simnow_md_client_count_mismatch") + try: + fresh = ( + getattr(client, "is_ready") is False + and type(getattr(client, "connection_generation")) is int + and getattr(client, "connection_generation") == 0 + and getattr(client, "active_md_identity") is None + ) + except BaseException: + fresh = False + if not fresh: + # Keep a prematurely started client owned so cleanup can try + # it, but do not let startup continue with untracked history. + self._close_failed = True + _reject("managed_simnow_md_client_must_be_fresh_at_handoff") + + def seal_market_client_registration(self) -> None: + """Close the ownership sink when the synchronous readiness call ends.""" + with self._lock: + self._market_client_registration_sealed = True + if self._close_failed: + _reject("managed_simnow_md_client_registration_failed") + + def close(self) -> None: + with self._lock: + if self._closed: + if self._close_failed: + _reject("managed_simnow_native_close_failed") + return + self._closed = True + failed = False + # Stop MD before TD; attempt every owned client even if an earlier + # stop fails. The account lease remains held by execution.open or + # the live session until this entire method returns successfully. + for client in self._md_clients: + if not stop_ctp_native_client(client): + failed = True + + if self._port is not None: + try: + close = getattr(self._port, "close", None) + except BaseException: + close = None + failed = True + if not callable(close): + failed = True + else: + try: + close() + except BaseException: + failed = True + for client in self._extra_traders: + if not stop_ctp_native_client(client): + failed = True + if self._port is None and self._trader is not None: + if not stop_ctp_native_client(self._trader): + failed = True + self._close_failed = self._close_failed or failed + if self._close_failed: + _reject("managed_simnow_native_close_failed") + + +class _OwnedNativePort: + """Delegate the native protocol while extending its close ownership to MD.""" + + def __init__(self, port: Any, resources: _ManagedNativeResources) -> None: + self._port = port + self._resources = resources + + def close(self) -> None: + self._resources.close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._port, name) + + +def _supports_readiness_lifecycle(callback: Callable[..., Any]) -> bool: + """Whether an injected readiness adapter accepts the managed sink/hooks.""" + + try: + parameters = inspect.signature(callback).parameters + except (TypeError, ValueError): + return False + has_var_keyword = any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() + ) + if has_var_keyword: + return True + return all( + name in parameters + and parameters[name].kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + for name in ("market_client_sink", "failure_cleanup") + ) + + +@dataclass(frozen=True) +class CtpSimNowNativeReadiness: + """Exact pair/account evidence returned by a trusted startup adapter. + + The callback proves authenticated TD session identity and MD + login/subscription/tick readiness through its supported adapter surface. + These observations do not prove settlement confirmation, TD trading + readiness, or order authority. The typed result binds the observations to + the sealed config, account and exact selected pair; a plain boolean is + insufficient. + """ + + config_digest: str + registration_digest: str + account_fingerprint_sha256: str + md_front: str + td_front: str + td_ready: bool + md_ready: bool + + @property + def td_trading_ready(self) -> bool: + """Native login evidence never claims settlement/write readiness.""" + + return False + + @property + def order_submission_authorized(self) -> bool: + """Readiness observations never authorize an order submission.""" + + return False + + def matches(self, selection: CtpSimNowManagedScopeSelection) -> bool: + registration = selection.execution_registration + return bool( + self.config_digest == selection.config_digest + and self.registration_digest == registration.digest + and self.account_fingerprint_sha256 == registration.account_fingerprint_sha256 + and self.md_front == registration.md_front + and self.td_front == registration.td_front + and self.td_ready is True + and self.md_ready is True + and self.td_trading_ready is False + and self.order_submission_authorized is False + ) + + +class _FailedNativePort: + """Carry partial-client cleanup through execution.open's lease cleanup.""" + + def __init__( + self, + reason: str, + closer: Callable[[], None], + pending_base_exception: Optional[BaseException] = None, + ) -> None: + self._reason = reason + self._closer = closer + self._pending_base_exception = pending_base_exception + self._closed = False + + def get_execution_identity(self) -> CtpSimulationSessionIdentity: + if self._pending_base_exception is not None: + raise self._pending_base_exception + _reject(self._reason) + + def close(self) -> None: + if self._closed: + return + self._closer() + self._closed = True + + def __getattr__(self, _name: str) -> Any: + _reject(self._reason) + + +def _require_injected_authorities( + *, + execution_capability: object, + runtime_admission_check: Callable[[], bool], + runtime_order_binding: Callable[[str, bool], Mapping[str, Any]], + runtime_credential_binding_factory: Callable[..., Any], + approval_verifier: CtpSimulationApprovalVerifier, + sdk_approval_rechecker: Callable[[Any, Mapping[str, Any]], bool], + query_evidence_verifier: CtpSimulationQueryEvidenceVerifier, + writer_fence: Any, + native_client_factory: Callable[..., Any], + native_readiness_check: Callable[..., CtpSimNowNativeReadiness], +) -> None: + if execution_capability is None: + _reject("explicit_managed_simnow_authorities_required") + for callback in ( + runtime_admission_check, + runtime_order_binding, + runtime_credential_binding_factory, + sdk_approval_rechecker, + native_client_factory, + native_readiness_check, + ): + if not callable(callback): + _reject("explicit_managed_simnow_authorities_required") + if not callable(getattr(approval_verifier, "verify", None)): + _reject("approval_verifier_required") + if not callable(getattr(query_evidence_verifier, "verify", None)): + _reject("query_evidence_verifier_required") + if ( + not callable(getattr(writer_fence, "assert_active", None)) + or type(getattr(writer_fence, "environment", None)) is not str + or type(getattr(writer_fence, "account_fingerprint_sha256", None)) is not str + or type(getattr(writer_fence, "fence_id", None)) is not str + ): + _reject("account_writer_fence_required") + if writer_fence.environment != "simnow" or not _ID_RE.fullmatch(writer_fence.fence_id): + _reject("account_writer_fence_scope_mismatch") + try: + writer_fence.assert_active() + except Exception: + _reject("account_writer_fence_unavailable") + + +def _identity_matches_selection(identity: Any, selection: CtpSimNowManagedScopeSelection) -> bool: + registration = selection.execution_registration + return bool( + type(identity) is CtpSimulationSessionIdentity + and identity.environment == registration.environment == "simnow" + and identity.sdk_profile == registration.sdk_profile == "config_front_pair" + and identity.td_front == registration.td_front + and identity.md_front == registration.md_front + and identity.account_fingerprint_sha256 == registration.account_fingerprint_sha256 + and identity.production is False + and identity.native_gate_armed is False + and identity.native_simnow_managed_mode is True + ) + + +def _td_config_from_artifact_first_port( + port: Any, + registration: CtpSimulationExecutionRegistration, + config_type: type, +) -> Any: + """Build the non-secret readiness identity from the post-gate port config.""" + + try: + config = getattr(port, "config") + values = { + "md_front": getattr(config, "md_front"), + "td_front": getattr(config, "td_front"), + "broker_id": getattr(config, "broker_id"), + "user_id": getattr(config, "user_id"), + } + except Exception: + _reject("managed_simnow_td_config_unavailable") + if ( + values["md_front"] != registration.md_front + or values["td_front"] != registration.td_front + or type(values["broker_id"]) is not str + or not values["broker_id"] + or type(values["user_id"]) is not str + or not values["user_id"] + ): + _reject("managed_simnow_td_config_scope_mismatch") + try: + return config_type(**values) + except Exception: + _reject("managed_simnow_td_config_invalid") + + +def _require_current_td_trading_readiness( + trader_client: Any, + selection: CtpSimNowManagedScopeSelection, + native_readiness: CtpSimNowNativeReadiness, + td_readiness: Any, + config: Any, + td_readiness_type: type, +) -> None: + """Rebind the typed query result to the same live public TD session.""" + + registration = selection.execution_registration + if ( + type(td_readiness) is not td_readiness_type + or td_readiness.config_digest != selection.config_digest + or td_readiness.registration_digest != registration.digest + or td_readiness.front_pair_set_sha256 != selection.front_pair_set_sha256 + or td_readiness.account_fingerprint_sha256 != registration.account_fingerprint_sha256 + or td_readiness.md_front != registration.md_front + or td_readiness.td_front != registration.td_front + or td_readiness.td_trading_ready is not True + or td_readiness.md_ready is not True + or td_readiness.settlement_proof_source != "confirmation_query" + or td_readiness.settlement_readback_verified is not True + or td_readiness.execution_gate_armed is not False + or td_readiness.write_authority_granted is not False + or type(td_readiness.connection_generation) is not int + or td_readiness.connection_generation <= 0 + or type(td_readiness.trading_day) is not str + or len(td_readiness.trading_day) != 8 + or not td_readiness.trading_day.isascii() + or not td_readiness.trading_day.isdigit() + or type(td_readiness.settlement_query_request_id) is not int + or td_readiness.settlement_query_request_id <= 0 + or td_readiness.account_fingerprint_sha256 != native_readiness.account_fingerprint_sha256 + or config.md_front != registration.md_front + or config.td_front != registration.td_front + ): + _reject("managed_simnow_td_trading_readiness_scope_mismatch") + try: + time.strptime(td_readiness.trading_day, "%Y%m%d") + except (OverflowError, ValueError): + _reject("managed_simnow_td_trading_readiness_scope_mismatch") + try: + state = trader_client.get_session_state() + front = trader_client.get_front_binding_state() + scope = trader_client.get_query_session_scope() + counts = trader_client.get_request_counts() + except Exception: + _reject("managed_simnow_td_trading_readiness_state_unavailable") + if type(state) is not dict or type(front) is not dict or type(counts) is not dict: + _reject("managed_simnow_td_trading_readiness_state_unavailable") + generation = td_readiness.connection_generation + day = td_readiness.trading_day + short_account = td_readiness.account_fingerprint + if ( + type(short_account) is not str + or len(short_account) != 16 + or any(character not in "0123456789abcdef" for character in short_account) + or getattr(scope, "read_only_ready", None) is not True + or type(getattr(scope, "connection_generation", None)) is not int + or getattr(scope, "connection_generation", None) != generation + or getattr(scope, "trading_day", None) != day + or getattr(scope, "account_fingerprint", None) != short_account + or getattr(scope, "broker_id", None) != config.broker_id + or getattr(scope, "investor_id", None) != config.user_id + or state.get("connected") is not True + or state.get("read_only_ready") is not True + or state.get("trading_ready") is not True + or state.get("auto_settlement_confirm") is not False + or type(state.get("connection_generation")) is not int + or state.get("connection_generation") != generation + or state.get("trading_day") != day + or state.get("account_fingerprint") != short_account + or state.get("settlement_state") != "confirmed" + or state.get("settlement_readback_verified") is not True + or state.get("settlement_proof_source") != "confirmation_query" + or state.get("settlement_connection_generation") != generation + or state.get("settlement_account_fingerprint") != short_account + or state.get("settlement_trading_day") != day + or state.get("settlement_proof_query_request_id") + != td_readiness.settlement_query_request_id + or state.get("execution_gate_armed") is not False + or front.get("configured_front") != registration.td_front + or front.get("registered_front") != registration.td_front + or front.get("connection_confirmed_front") != registration.td_front + or front.get("connected") is not True + or front.get("native_api_current") is not True + or front.get("bound_identity_current") is not True + or type(front.get("connection_generation")) is not int + or front.get("connection_generation") != generation + ): + _reject("managed_simnow_td_trading_readiness_session_changed") + for name in ("settlement_confirm", "order_insert", "order_action"): + if type(counts.get(name)) is not int or counts[name] != 0: + _reject("managed_simnow_td_write_request_observed") + if ( + type(counts.get("query_settlement_confirmation")) is not int + or counts["query_settlement_confirmation"] != 1 + ): + _reject("managed_simnow_td_settlement_query_count_mismatch") + + +def open_ctp_simnow_managed_runtime( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + policy: CtpSimNowManagedExecutionPolicy, + *, + execution_capability: object, + runtime_admission_check: Callable[[], bool], + runtime_order_binding: Callable[[str, bool], Mapping[str, Any]], + runtime_credential_binding_factory: Callable[..., Any], + approval_verifier: CtpSimulationApprovalVerifier, + sdk_approval_rechecker: Callable[[Any, Mapping[str, Any]], bool], + query_evidence_verifier: CtpSimulationQueryEvidenceVerifier, + writer_fence: Any, + native_client_factory: Callable[..., Any], + native_readiness_check: Callable[..., CtpSimNowNativeReadiness], + td_trading_readiness_check: Optional[Callable[..., Any]] = None, + connector: Optional[Callable[[str, int, float], Any]] = None, + process_factory: Optional[Callable[..., Any]] = None, + clock: Optional[Callable[[], float]] = None, + timeout_seconds: float = 3.0, + repeated_samples: int = 3, + order_field_factory: Optional[Callable[[], Any]] = None, + action_field_factory: Optional[Callable[[], Any]] = None, +) -> CtpSimNowManagedRuntime: + """Open one explicitly authorized SimNow session with one pinned front pair. + + The caller's factory must start the client from the exact sealed arguments + it receives. No private client attribute is used as a start/connect seam. + Static dependencies and the external writer fence are checked before the + credential-free front probe. The selected scope is immutable and reused + unchanged for artifact verification, client creation, readiness checking, + and journal admission. Readiness failure closes the client and fails the + open; it does not try another configured pair. An optional controlled TD + trading-readiness callback runs after login/MD readiness and before + ``execution.open``. When supplied, its typed result must remain bound to + the same account, pair, generation, day, and settlement query. Omitting + the callback preserves read-only startup; the SDK still rejects any later + order write until its own current settlement readback is trading-ready. + """ + + if type(policy) is not CtpSimNowManagedExecutionPolicy: + _reject("code_owned_policy_required") + _require_injected_authorities( + execution_capability=execution_capability, + runtime_admission_check=runtime_admission_check, + runtime_order_binding=runtime_order_binding, + runtime_credential_binding_factory=runtime_credential_binding_factory, + approval_verifier=approval_verifier, + sdk_approval_rechecker=sdk_approval_rechecker, + query_evidence_verifier=query_evidence_verifier, + writer_fence=writer_fence, + native_client_factory=native_client_factory, + native_readiness_check=native_readiness_check, + ) + if td_trading_readiness_check is not None and not callable(td_trading_readiness_check): + _reject("managed_simnow_td_readiness_callback_invalid") + + try: + selection = select_ctp_simnow_managed_scope( + effective, + registry, + policy, + connector=connector, + process_factory=process_factory, + clock=clock, + timeout_seconds=timeout_seconds, + repeated_samples=repeated_samples, + ) + except CtpSimulationExecutionError: + raise + except Exception as exc: + raise CtpSimulationExecutionError("managed_simnow_scope_selection_failed") from exc + + registration = selection.execution_registration + if writer_fence.account_fingerprint_sha256 != registration.account_fingerprint_sha256: + _reject("account_writer_fence_scope_mismatch") + + td_readiness_results: list[Any] = [] + + def native_session_factory( + requested_registration: CtpSimulationExecutionRegistration, + lease: _execution.CtpAccountFlowLease, + ) -> Any: + if requested_registration is not registration: + _reject("managed_simnow_selected_registration_changed") + try: + lease.assert_held(registration.account_fingerprint_sha256) + except Exception: + _reject("account_flow_lease_required") + created_clients: list[Any] = [] + resources = _ManagedNativeResources() + port: Any = None + + def tracked_client_factory(*args: Any, **kwargs: Any) -> Any: + client = native_client_factory(*args, **kwargs) + created_clients.append(client) + resources.set_trader(client) + return client + + def close_created_resource() -> None: + resources.close() + + try: + port = create_sealed_ctp_simnow_managed_port( + effective, + registry, + registration, + execution_capability=execution_capability, + runtime_admission_check=runtime_admission_check, + runtime_order_binding=runtime_order_binding, + runtime_credential_binding_factory=runtime_credential_binding_factory, + runtime_approval_verifier=approval_verifier, + sdk_approval_rechecker=sdk_approval_rechecker, + trader_client_factory=tracked_client_factory, + order_field_factory=order_field_factory, + action_field_factory=action_field_factory, + ) + resources.set_port(port) + if getattr(port, "registration", None) is not registration: + raise CtpSimulationExecutionError("managed_simnow_port_registration_mismatch") + if len(created_clients) != 1: + raise CtpSimulationExecutionError("managed_simnow_native_client_count_mismatch") + # The port intentionally does not expose its write-capable SDK + # client. Pass the concrete client captured by the artifact-first + # factory as an explicit, separately reviewed readiness input. + if not _supports_readiness_lifecycle(native_readiness_check): + resources.seal_market_client_registration() + raise CtpSimulationExecutionError( + "managed_simnow_native_readiness_lifecycle_required" + ) + try: + readiness = native_readiness_check( + port, + selection, + created_clients[0], + market_client_sink=resources.register_market_client, + failure_cleanup=resources.close, + ) + finally: + resources.seal_market_client_registration() + if resources.market_client_count != 1: + raise CtpSimulationExecutionError("managed_simnow_md_client_ownership_missing") + if type(readiness) is not CtpSimNowNativeReadiness or not readiness.matches(selection): + raise CtpSimulationExecutionError("managed_simnow_native_readiness_rejected") + if td_trading_readiness_check is not None: + # Import after module initialization: the adapter's typed + # inputs refer back to CtpSimNowNativeReadiness in this module. + from .ctp_simnow_td_trading_readiness import ( + CtpSimNowTdTradingReadiness, + CtpSimNowTdTradingReadinessConfig, + ) + + td_config = _td_config_from_artifact_first_port( + port, + registration, + CtpSimNowTdTradingReadinessConfig, + ) + td_readiness = td_trading_readiness_check( + created_clients[0], + selection, + td_config, + native_readiness=readiness, + failure_cleanup=resources.close, + ) + _require_current_td_trading_readiness( + created_clients[0], + selection, + readiness, + td_readiness, + td_config, + CtpSimNowTdTradingReadiness, + ) + td_readiness_results.append(td_readiness) + identity = port.get_execution_identity() + if not _identity_matches_selection(identity, selection): + raise CtpSimulationExecutionError("managed_simnow_native_identity_mismatch") + return _OwnedNativePort(port, resources) + except BaseException as exc: + reason = ( + exc.reason + if isinstance(exc, CtpSimulationExecutionError) + else "managed_simnow_native_start_failed" + ) + # Return a fail-closed adapter so open_ctp_simulation_execution + # performs cleanup while it still owns the account lease. If close + # fails, its standard path poisons the owner and retains the lease. + failed = _FailedNativePort( + str(reason), + close_created_resource, + None if isinstance(exc, Exception) else exc, + ) + return failed + + try: + session = _execution.open_ctp_simulation_execution( + effective=effective, + registry=registry, + registration=registration, + native_session_factory=native_session_factory, + approval_verifier=approval_verifier, + query_evidence_verifier=query_evidence_verifier, + writer_fence=writer_fence, + ) + except CtpSimulationExecutionError: + raise + except Exception as exc: + raise CtpSimulationExecutionError("managed_simnow_execution_open_failed") from exc + + return CtpSimNowManagedRuntime( + selection=selection, + session=session, + td_trading_readiness=(td_readiness_results[0] if td_readiness_results else None), + ) + + +__all__ = [ + "CtpSimNowManagedRuntime", + "CtpSimNowNativeReadiness", + "open_ctp_simnow_managed_runtime", +] diff --git a/backtrader_runtime/ctp_simnow_md_diagnostic.py b/backtrader_runtime/ctp_simnow_md_diagnostic.py new file mode 100644 index 00000000..5d3e226a --- /dev/null +++ b/backtrader_runtime/ctp_simnow_md_diagnostic.py @@ -0,0 +1,429 @@ +"""One-shot, source-tree-only CTP SimNow market-data diagnostic. + +This is intentionally not a registered runtime or a CLI route. It exists to +inspect the MD login/subscription/tick leg independently after an operator has +stopped the separate TD diagnostic process. It resolves only the registered +Iteration 41 private runtime and its sealed ``config.yaml``. The exact pair is +chosen by the same credential-free selector as the registered preflight, then +checked against the installed SDK pins before credentials are released. + +The probe uses ``MdClient`` only and has no TD, order, cancel, settlement, or +strategy interface. Run it in a supervised child process: the SDK has +synchronous native calls whose wall-clock duration cannot be bounded reliably +inside this Python process. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from contextlib import contextmanager +from typing import Iterator, Optional, Sequence + +from .capability_imports import trusted_installed_capability_import_context +from .ctp_artifact_provenance import ( + CtpArtifactProvenanceError, + verify_ctp_sdk_artifact_provenance_for_fronts, +) +from .credential_resolver import ( + CredentialResolutionError, + require_resolved_runtime_credentials_seal, + resolve_runtime_credentials, +) +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from .ctp_sdk_market_readonly import ( + _MD_LOGIN_CALLBACK_DISPOSITIONS, + _MD_LOGIN_REQUEST_ID_RELATIONS, + _MD_LOGIN_RESPONSE_ERROR_STATUSES, + CtpSdkMarketReadOnlyError, +) +from .inventory import ( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + iteration41_runtime_registry, +) +from .registry import require_effective_runtime_config_seal, validate_runtime_config + + +_MARKET_FAILURE_REASONS = frozenset( + { + "account_probe_busy", + "account_probe_lock_unavailable", + "admission_invalid", + "admission_required", + "credential_account_mismatch", + "credential_invalid", + "credential_unavailable", + "invalid_timeout", + "market_client_identity_mismatch", + "market_client_state_unavailable", + "market_client_stop_failed", + "market_client_type_required", + "market_connection_generation_changed", + "market_front_binding_mismatch", + "market_front_disconnected", + "market_front_rejected", + "market_login_identity_mismatch", + "market_login_identity_unavailable", + "market_login_timeout", + "market_probe_failed", + "market_session_not_ready", + "market_session_state_unavailable", + "market_subscription_rejected", + "native_join_pending", + "native_join_state_unknown", + "probe_deadline_expired", + "subscription_ack_timeout", + } +) +_CLOSE_STATES_WITH_UNCERTAIN_NATIVE_SHUTDOWN = frozenset( + { + "native_join_pending", + "native_join_state_unknown", + "native_stop_incomplete", + "native_stop_method_invalid", + "native_stop_method_unknown", + "native_stop_receipt_inconsistent", + "native_stop_receipt_unknown", + "stop_failed", + } +) + + +def _emit( + *, + status: str, + reason: str, + stage: str, + market_login_ready: bool = False, + subscription_acknowledged: bool = False, + matching_tick_observed: bool = False, + client_stop_returned: bool = False, + probe_session_closed: bool = False, + native_join_pending: bool = False, + native_shutdown_uncertain: bool = False, + probe_primary_reason: str | None = None, + front_callback_observed: bool | None = None, + login_callback_count: int | None = None, + login_callback_disposition: str | None = None, + login_request_id_relation: str | None = None, + login_response_error_status: str | None = None, + login_failure_category: str | None = None, + include_login_failure_category: bool = False, +) -> None: + """Write a fixed, value-free JSON projection to stdout.""" + + payload = { + "client_stop_returned": client_stop_returned, + "market_login_ready": market_login_ready, + "matching_tick_observed": matching_tick_observed, + "native_join_pending": native_join_pending, + "native_shutdown_uncertain": native_shutdown_uncertain, + "order_submission_authorized": False, + "probe_primary_reason": probe_primary_reason, + "front_callback_observed": front_callback_observed, + "login_callback_count": login_callback_count, + "login_callback_disposition": login_callback_disposition, + "login_request_id_relation": login_request_id_relation, + "login_response_error_status": login_response_error_status, + "probe_session_closed": probe_session_closed, + "reason": reason, + "settlement_writes": 0, + "stage": stage, + "status": status, + "subscription_acknowledged": subscription_acknowledged, + "trading_writes": 0, + } + if include_login_failure_category: + payload["login_failure_category"] = ( + login_failure_category + if type(login_failure_category) is str + and login_failure_category + in { + "request_id_invalid", + "request_id_mismatch", + "response_nonterminal", + "provider_rejected", + "response_invalid", + "broker_id_mismatch", + "user_id_mismatch", + "trading_day_invalid", + } + else None + ) + sys.stdout.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +@contextmanager +def _discard_provider_process_output() -> Iterator[None]: + """Keep SDK/native diagnostics off the operator's stdout and stderr.""" + + sys.stdout.flush() + sys.stderr.flush() + stdout_fd = sys.stdout.fileno() + stderr_fd = sys.stderr.fileno() + saved_stdout_fd = os.dup(stdout_fd) + saved_stderr_fd = os.dup(stderr_fd) + null_fd = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(null_fd, stdout_fd) + os.dup2(null_fd, stderr_fd) + yield + finally: + sys.stdout.flush() + sys.stderr.flush() + os.dup2(saved_stdout_fd, stdout_fd) + os.dup2(saved_stderr_fd, stderr_fd) + os.close(null_fd) + os.close(saved_stdout_fd) + os.close(saved_stderr_fd) + + +def _reason_for_exception(error: BaseException, *, stage: str) -> str: + """Map exceptions to fixed categories; never print exception text.""" + + if isinstance(error, CtpSdkMarketReadOnlyError): + return error.reason if error.reason in _MARKET_FAILURE_REASONS else "market_probe_rejected" + if isinstance(error, CtpArtifactProvenanceError): + return "sdk_artifact_rejected" + if isinstance(error, CredentialResolutionError): + return "credential_rejected" + if isinstance(error, RuntimeConfigError): + return { + "configuration": "configuration_rejected", + "front_selection": "front_selection_rejected", + }.get(stage, "runtime_policy_rejected") + return { + "configuration": "configuration_rejected", + "front_selection": "front_selection_rejected", + "sdk_artifact": "sdk_artifact_rejected", + "credentials": "credential_rejected", + "market_data": "market_probe_rejected", + }.get(stage, "diagnostic_rejected") + + +def _close_projection(error: BaseException) -> dict[str, bool]: + """Keep process/SDK close fields distinct in the fixed JSON projection.""" + + close_state = ( + error.close_state if isinstance(error, CtpSdkMarketReadOnlyError) else "not_started" + ) + stop_returned = ( + error.client_stop_returned + if isinstance(error, CtpSdkMarketReadOnlyError) and type(error.client_stop_returned) is bool + else False + ) + return { + "client_stop_returned": stop_returned, + "probe_session_closed": close_state == "stop_returned" and stop_returned, + "native_join_pending": close_state == "native_join_pending", + "native_shutdown_uncertain": close_state in _CLOSE_STATES_WITH_UNCERTAIN_NATIVE_SHUTDOWN, + } + + +def run_diagnostic(argv: Optional[Sequence[str]] = None) -> int: + """Run the exact registered MD-only diagnostic; accept no caller scope.""" + + arguments = tuple(sys.argv[1:] if argv is None else argv) + if arguments: + _emit(status="rejected", reason="arguments_not_allowed", stage="arguments") + return 2 + # The helper is a one-shot operator process. Keep SDK logging from adding + # provider-specific values beside the fixed JSON result. + logging.disable(logging.CRITICAL) + + stage = "configuration" + try: + registry = iteration41_runtime_registry() + effective = validate_runtime_config( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + registry, + ) + require_effective_runtime_config_seal(effective, registry) + + from .ctp_simnow_operator import ( + CtpSimNowConfigReadOnlyBinding, + _select_configured_front_pair, + ) + + registration = registry.require_runtime_dir(effective.config.strategy_dir) + if ( + registration is not effective.registration + or registration.runtime_id != ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the configured runtime registration changed", + field_path="runtime.preset", + reason="runtime_registration_mismatch", + ) + binding = registry.require_ctp_simnow_readonly_binding(registration.runtime_id) + if type(binding) is not CtpSimNowConfigReadOnlyBinding: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime does not have the config-driven CTP binding", + field_path="runtime.preset", + reason="ctp_simnow_preflight_front_policy_required", + ) + _, _, front_pairs = binding._sealed_private_config(effective, registry) + + stage = "front_selection" + selection = _select_configured_front_pair(front_pairs) + admission, scope = binding._route( + effective, + registry, + selected_front_pair=selection.pair, + ) + + stage = "sdk_artifact" + with trusted_installed_capability_import_context(("bt_api_base", "bt_api_ctp")): + verify_ctp_sdk_artifact_provenance_for_fronts( + td_front=admission.td_front, + md_front=admission.md_front, + ) + + stage = "credentials" + credentials = resolve_runtime_credentials(effective, registry, scope) + require_resolved_runtime_credentials_seal( + credentials, + effective, + registry, + scope, + ) + + # Importing the wrapper does not load the SDK. It rechecks the + # credential seal immediately before each individual value read. + from .ctp_simnow_readonly_runtime import _SealedCtpCredentialSource + from .ctp_sdk_market_readonly import _probe_ctp_market_readonly + + credential_source = _SealedCtpCredentialSource( + credentials, + effective, + registry, + scope, + ) + stage = "market_data" + with _discard_provider_process_output(): + from bt_api_ctp.ctp.client import MdClient + + observation = _probe_ctp_market_readonly( + admission=admission, + credential_source=credential_source, + client_type=MdClient, + timeout_seconds=15.0, + tick_observation_seconds=5.0, + ) + + tick_observed = observation.first_tick_observed + session_closed = observation.probe_session_closed + join_pending = observation.native_join_pending + if join_pending or not session_closed: + _emit( + status="incomplete", + reason="native_join_pending" if join_pending else "native_shutdown_uncertain", + stage="market_data", + market_login_ready=observation.market_login_ready, + subscription_acknowledged=observation.subscription_acknowledged, + matching_tick_observed=tick_observed, + client_stop_returned=observation.client_stop_returned, + probe_session_closed=session_closed, + native_join_pending=join_pending, + native_shutdown_uncertain=True, + ) + return 3 + if not observation.market_path_ready: + _emit( + status="rejected", + reason="market_readiness_incomplete", + stage="market_data", + market_login_ready=observation.market_login_ready, + subscription_acknowledged=observation.subscription_acknowledged, + matching_tick_observed=tick_observed, + client_stop_returned=observation.client_stop_returned, + probe_session_closed=session_closed, + ) + return 2 + if not tick_observed: + _emit( + status="incomplete", + reason="matching_tick_not_observed", + stage="market_data", + market_login_ready=observation.market_login_ready, + subscription_acknowledged=observation.subscription_acknowledged, + matching_tick_observed=False, + client_stop_returned=observation.client_stop_returned, + probe_session_closed=session_closed, + ) + return 3 + _emit( + status="diagnostic_complete", + reason="matching_tick_observed", + stage="market_data", + market_login_ready=True, + subscription_acknowledged=True, + matching_tick_observed=True, + client_stop_returned=observation.client_stop_returned, + probe_session_closed=session_closed, + native_join_pending=False, + native_shutdown_uncertain=False, + ) + return 0 + except Exception as error: + _emit( + status="rejected", + reason=_reason_for_exception(error, stage=stage), + stage=stage, + probe_primary_reason=( + error.primary_reason + if isinstance(error, CtpSdkMarketReadOnlyError) + and error.primary_reason in _MARKET_FAILURE_REASONS + else None + ), + front_callback_observed=( + error.front_callback_observed + if isinstance(error, CtpSdkMarketReadOnlyError) + and type(error.front_callback_observed) is bool + else None + ), + login_callback_count=( + error.login_callback_count + if isinstance(error, CtpSdkMarketReadOnlyError) + and type(error.login_callback_count) is int + and 0 <= error.login_callback_count <= 1_000_000 + else None + ), + login_callback_disposition=( + error.login_callback_disposition + if isinstance(error, CtpSdkMarketReadOnlyError) + and error.login_callback_disposition in _MD_LOGIN_CALLBACK_DISPOSITIONS + else None + ), + login_request_id_relation=( + error.login_request_id_relation + if isinstance(error, CtpSdkMarketReadOnlyError) + and error.login_request_id_relation in _MD_LOGIN_REQUEST_ID_RELATIONS + else None + ), + login_response_error_status=( + error.login_response_error_status + if isinstance(error, CtpSdkMarketReadOnlyError) + and error.login_response_error_status in _MD_LOGIN_RESPONSE_ERROR_STATUSES + else None + ), + **_close_projection(error), + ) + return 2 + + +def main() -> int: + return run_diagnostic() + + +if __name__ == "__main__": # pragma: no cover - exercised by the operator process + raise SystemExit(main()) + + +__all__ = ["main", "run_diagnostic"] diff --git a/backtrader_runtime/ctp_simnow_operational_window.py b/backtrader_runtime/ctp_simnow_operational_window.py new file mode 100644 index 00000000..0b898339 --- /dev/null +++ b/backtrader_runtime/ctp_simnow_operational_window.py @@ -0,0 +1,599 @@ +"""Non-authorizing contracts for a proposed bounded SimNow G6-S window. + +These DTOs are deliberately separate from :mod:`ctp_f14_external_admission`. +They bind a proposed one-action approval and a single native query stream to a +SimNow simulation scope. They do not establish an account-wide writer fence, +a common account snapshot, a cryptographic signature, or write authority. +There is no production/CLI/Store/SDK integration or default verifier here. + +An injected reviewer/verifier protocol is useful for fake-only contract tests. +Its return values are not trusted evidence until a future code-owned, +independently reviewed integration authenticates the review source and holds +the required local lease at its actual call sites. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Optional, Protocol, Tuple + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_ACTION_TTL_SECONDS = 60.0 +_QUERY_KINDS = frozenset( + ("account_funds", "open_orders", "positions", "trades", "settlement_confirmation") +) +_SCOPE_DOMAIN = b"backtrader-ctp-simnow-operational-window-scope-v1\0" +_ACTION_DOMAIN = b"backtrader-ctp-simnow-operational-window-action-v1\0" +_QUERY_DOMAIN = b"backtrader-ctp-simnow-single-query-v1\0" + + +class CtpSimNowOperationalWindowError(ValueError): + """Redacted failure from this non-authorizing contract seam.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason.replace("_", " ")) + + +def _reject(reason: str) -> None: + raise CtpSimNowOperationalWindowError(reason) from None + + +def _require_id(value: object, field_name: str) -> str: + if type(value) is not str or _ID_RE.fullmatch(value) is None: + _reject("invalid_" + field_name) + return value + + +def _require_digest(value: object, field_name: str) -> str: + if type(value) is not str or _SHA256_RE.fullmatch(value) is None: + _reject("invalid_" + field_name) + return value + + +def _timestamp(value: object, field_name: str) -> float: + if type(value) not in (int, float) or not math.isfinite(float(value)): + _reject("invalid_" + field_name) + return float(value) + + +def _canonical_digest(value: object, domain: bytes) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(domain + encoded.encode("ascii")).hexdigest() + + +def _decimal_text(value: Decimal) -> str: + return format(value, "f") + + +@dataclass(frozen=True, repr=False) +class CtpSimNowOperationalRiskLimits: + """Risk ceiling for the single bounded operational test order.""" + + max_order_quantity: int + max_gross_position_quantity: int + max_live_test_orders: int + max_order_notional: Decimal = field(repr=False) + + def __post_init__(self) -> None: + if ( + type(self.max_order_quantity) is not int + or self.max_order_quantity != 1 + or type(self.max_gross_position_quantity) is not int + or self.max_gross_position_quantity != 1 + or type(self.max_live_test_orders) is not int + or self.max_live_test_orders != 1 + or type(self.max_order_notional) is not Decimal + or not self.max_order_notional.is_finite() + or self.max_order_notional <= 0 + ): + _reject("operational_risk_limits_invalid") + + @property + def digest(self) -> str: + return _canonical_digest( + { + "max_gross_position_quantity": self.max_gross_position_quantity, + "max_live_test_orders": self.max_live_test_orders, + "max_order_notional": _decimal_text(self.max_order_notional), + "max_order_quantity": self.max_order_quantity, + }, + _SCOPE_DOMAIN, + ) + + +@dataclass(frozen=True, repr=False) +class CtpSimNowOperationalWindowScope: + """Exact simulation-only identity for one named operational test window.""" + + window_id: str + runtime_id: str + environment: str + mode: str + preset: str + account_fingerprint_sha256: str = field(repr=False) + config_digest: str + effective_digest: str + registration_digest: str + artifact_set_digest: str + session_id: str + trading_day: str + connection_generation: int + session_identity_digest: str + selected_front_pair: Tuple[str, str] = field(repr=False) + instrument_id: str + exchange_id: str + hedge_flag: str + risk_limits: CtpSimNowOperationalRiskLimits = field(repr=False) + + def __post_init__(self) -> None: + _require_id(self.window_id, "window_id") + _require_id(self.runtime_id, "runtime_id") + if (self.environment, self.mode, self.preset) != ("simnow", "simulation", "sandbox"): + _reject("operational_window_must_be_simnow_sandbox") + for name in ( + "account_fingerprint_sha256", + "config_digest", + "effective_digest", + "registration_digest", + "artifact_set_digest", + "session_identity_digest", + ): + _require_digest(getattr(self, name), name) + _require_id(self.session_id, "session_id") + if type(self.trading_day) is not str or re.fullmatch(r"[0-9]{8}", self.trading_day) is None: + _reject("invalid_trading_day") + if type(self.connection_generation) is not int or self.connection_generation <= 0: + _reject("invalid_connection_generation") + if ( + type(self.selected_front_pair) is not tuple + or len(self.selected_front_pair) != 2 + or any( + type(front) is not str or not front or front != front.strip() + for front in self.selected_front_pair + ) + or self.selected_front_pair[0] == self.selected_front_pair[1] + ): + _reject("selected_front_pair_invalid") + for name in ("instrument_id", "exchange_id"): + value = getattr(self, name) + if type(value) is not str or not value or value != value.strip(): + _reject("invalid_" + name) + if self.hedge_flag not in ("1", "2", "3"): + _reject("invalid_hedge_flag") + if type(self.risk_limits) is not CtpSimNowOperationalRiskLimits: + _reject("operational_risk_limits_required") + + @property + def selected_front_pair_sha256(self) -> str: + return _canonical_digest(self.selected_front_pair, _SCOPE_DOMAIN) + + @property + def scope_digest(self) -> str: + return _canonical_digest( + { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "artifact_set_digest": self.artifact_set_digest, + "config_digest": self.config_digest, + "effective_digest": self.effective_digest, + "environment": self.environment, + "exchange_id": self.exchange_id, + "hedge_flag": self.hedge_flag, + "instrument_id": self.instrument_id, + "mode": self.mode, + "preset": self.preset, + "registration_digest": self.registration_digest, + "risk_limits_digest": self.risk_limits.digest, + "runtime_id": self.runtime_id, + "selected_front_pair": self.selected_front_pair, + "session": { + "connection_generation": self.connection_generation, + "identity_digest": self.session_identity_digest, + "session_id": self.session_id, + "trading_day": self.trading_day, + }, + "window_id": self.window_id, + }, + _SCOPE_DOMAIN, + ) + + @property + def account_writer_exclusive(self) -> bool: + """Always false: local/operational scope is not a broker fence.""" + + return False + + @property + def common_snapshot_verified(self) -> bool: + """Always false: this scope contains no cross-query snapshot claim.""" + + return False + + +@dataclass(frozen=True, repr=False) +class CtpSimNowOperationalActionRequest: + """One exact proposed test action bound to a named SimNow window.""" + + scope: CtpSimNowOperationalWindowScope = field(repr=False) + action_kind: str + action_id: str + action_digest: str + approval_digest: str + requested_quantity: Optional[int] = None + requested_notional: Optional[Decimal] = field(default=None, repr=False) + target_digest: Optional[str] = None + target_remaining_quantity: Optional[int] = None + + def __post_init__(self) -> None: + if type(self.scope) is not CtpSimNowOperationalWindowScope: + _reject("operational_window_scope_required") + if type(self.action_kind) is not str or self.action_kind not in ("SUBMIT", "CANCEL"): + _reject("operational_action_kind_invalid") + _require_id(self.action_id, "action_id") + for name in ("action_digest", "approval_digest"): + _require_digest(getattr(self, name), name) + if self.action_kind == "SUBMIT": + if ( + type(self.requested_quantity) is not int + or self.requested_quantity <= 0 + or self.requested_quantity > self.scope.risk_limits.max_order_quantity + or self.requested_quantity > self.scope.risk_limits.max_gross_position_quantity + or type(self.requested_notional) is not Decimal + or not self.requested_notional.is_finite() + or self.requested_notional <= 0 + or self.requested_notional > self.scope.risk_limits.max_order_notional + or self.target_digest is not None + or self.target_remaining_quantity is not None + ): + _reject("operational_submit_exceeds_or_misses_risk_scope") + elif ( + self.requested_quantity is not None + or self.requested_notional is not None + or self.target_digest is None + or self.target_remaining_quantity is None + or type(self.target_remaining_quantity) is not int + or self.target_remaining_quantity <= 0 + or self.target_remaining_quantity > self.scope.risk_limits.max_order_quantity + ): + _reject("operational_cancel_target_invalid") + else: + _require_digest(self.target_digest, "target_digest") + + @property + def request_digest(self) -> str: + return _canonical_digest( + { + "action_digest": self.action_digest, + "action_id": self.action_id, + "action_kind": self.action_kind, + "approval_digest": self.approval_digest, + "requested_notional": ( + _decimal_text(self.requested_notional) + if self.requested_notional is not None + else None + ), + "requested_quantity": self.requested_quantity, + "scope_digest": self.scope.scope_digest, + "target_digest": self.target_digest, + "target_remaining_quantity": self.target_remaining_quantity, + }, + _ACTION_DOMAIN, + ) + + +@dataclass(frozen=True, repr=False) +class CtpSimNowOperationalWindowPermit: + """Non-authorizing, short-lived review record for exactly one action. + + ``review_digest`` is a reference digest only. This type does not verify a + signature or an operator identity. Its fixed false properties prevent it + from representing strict F14 writer exclusion or a common snapshot. + """ + + binding: CtpSimNowOperationalActionRequest = field(repr=False) + permit_id: str + review_authority_id: str + review_digest: str + revocation_epoch: int + issued_at_utc: float + expires_at_utc: float + + def __post_init__(self) -> None: + if type(self.binding) is not CtpSimNowOperationalActionRequest: + _reject("operational_permit_binding_invalid") + for name in ("permit_id", "review_authority_id"): + _require_id(getattr(self, name), name) + _require_digest(self.review_digest, "review_digest") + if type(self.revocation_epoch) is not int or self.revocation_epoch <= 0: + _reject("operational_revocation_epoch_invalid") + issued = _timestamp(self.issued_at_utc, "issued_at_utc") + expires = _timestamp(self.expires_at_utc, "expires_at_utc") + if expires <= issued or expires - issued > _ACTION_TTL_SECONDS: + _reject("operational_permit_expiry_invalid") + + @property + def scope_digest(self) -> str: + return self.binding.scope.scope_digest + + @property + def request_digest(self) -> str: + return self.binding.request_digest + + @property + def permit_digest(self) -> str: + """Stable summary of validity/revocation fields; not a signature.""" + + return _canonical_digest( + { + "binding_digest": self.binding.request_digest, + "expires_at_utc": float(self.expires_at_utc), + "issued_at_utc": float(self.issued_at_utc), + "permit_id": self.permit_id, + "revocation_epoch": self.revocation_epoch, + "review_authority_id": self.review_authority_id, + "review_digest": self.review_digest, + }, + _ACTION_DOMAIN, + ) + + @property + def account_writer_exclusive(self) -> bool: + return False + + @property + def common_snapshot_verified(self) -> bool: + return False + + @property + def cryptographic_signature_verified(self) -> bool: + return False + + @property + def write_authorized(self) -> bool: + return False + + +class CtpSimNowOperationalWindowReviewer(Protocol): + """Unimplemented review/revocation source; local fakes are non-authorizing.""" + + def issue_action_review( + self, request: CtpSimNowOperationalActionRequest + ) -> CtpSimNowOperationalWindowPermit: + """Return an independently reviewed, exact-scope record.""" + + ... + + def assert_action_review_current( + self, + permit: CtpSimNowOperationalWindowPermit, + *, + request: CtpSimNowOperationalActionRequest, + ) -> bool: + """Return literal True only while the review is unrevoked and current.""" + + ... + + +class CtpSimNowQueryKind: + """Names for one request stream; these names imply no account coverage.""" + + ACCOUNT_FUNDS = "account_funds" + OPEN_ORDERS = "open_orders" + POSITIONS = "positions" + TRADES = "trades" + SETTLEMENT_CONFIRMATION = "settlement_confirmation" + + +@dataclass(frozen=True, repr=False) +class CtpSimNowQueryRequest: + """One native query request bound to a SimNow operational window.""" + + scope: CtpSimNowOperationalWindowScope = field(repr=False) + query_id: str + query_kind: str + request_id: int + filters_digest: str + + def __post_init__(self) -> None: + if type(self.scope) is not CtpSimNowOperationalWindowScope: + _reject("query_window_scope_required") + _require_id(self.query_id, "query_id") + if type(self.query_kind) is not str or self.query_kind not in _QUERY_KINDS: + _reject("query_kind_invalid") + if type(self.request_id) is not int or self.request_id <= 0: + _reject("query_request_id_invalid") + _require_digest(self.filters_digest, "query_filters_digest") + + @property + def request_digest(self) -> str: + return _canonical_digest( + { + "filters_digest": self.filters_digest, + "query_id": self.query_id, + "query_kind": self.query_kind, + "request_id": self.request_id, + "scope_digest": self.scope.scope_digest, + }, + _QUERY_DOMAIN, + ) + + +@dataclass(frozen=True, repr=False) +class CtpSimNowSingleQueryObservation: + """Evidence facts for exactly one native request/response stream.""" + + binding: CtpSimNowQueryRequest = field(repr=False) + b_is_last: bool + response_error_code: int + callback_count: int + late_callback_count: int + record_count: int + records_digest: str + source_evidence_digest: str + started_at_utc: float + terminal_at_utc: float + + def __post_init__(self) -> None: + if type(self.binding) is not CtpSimNowQueryRequest: + _reject("single_query_binding_invalid") + if type(self.b_is_last) is not bool: + _reject("single_query_terminal_flag_invalid") + if type(self.response_error_code) is not int: + _reject("single_query_error_code_invalid") + for name in ("callback_count", "late_callback_count", "record_count"): + value = getattr(self, name) + if type(value) is not int or value < 0: + _reject("single_query_count_invalid") + for name in ("records_digest", "source_evidence_digest"): + _require_digest(getattr(self, name), name) + started = _timestamp(self.started_at_utc, "query_started_at_utc") + terminal = _timestamp(self.terminal_at_utc, "query_terminal_at_utc") + if terminal < started: + _reject("single_query_time_order_invalid") + + @property + def request_digest(self) -> str: + return self.binding.request_digest + + @property + def account_writer_exclusive(self) -> bool: + return False + + @property + def common_snapshot_verified(self) -> bool: + return False + + @property + def account_coverage_verified(self) -> bool: + return False + + +class CtpSimNowSingleQueryVerifier(Protocol): + """Proposed native-source verifier; no implementation is supplied here.""" + + def verify_single_query( + self, + observation: CtpSimNowSingleQueryObservation, + *, + expected_request: CtpSimNowQueryRequest, + ) -> bool: + """Verify source, exact request, session, filters and this stream's terminal callback.""" + + ... + + +def require_simnow_operational_window_permit( + reviewer: CtpSimNowOperationalWindowReviewer, + request: CtpSimNowOperationalActionRequest, + *, + now_utc: float, +) -> CtpSimNowOperationalWindowPermit: + """Fetch and validate a fake-testable review record; this grants no route.""" + + if type(request) is not CtpSimNowOperationalActionRequest: + _reject("operational_action_request_required") + now = _timestamp(now_utc, "now_utc") + issue = getattr(reviewer, "issue_action_review", None) + check = getattr(reviewer, "assert_action_review_current", None) + if not callable(issue) or not callable(check): + _reject("operational_window_reviewer_required") + try: + permit = issue(request) + except Exception: + _reject("operational_window_review_unavailable") + if type(permit) is not CtpSimNowOperationalWindowPermit or permit.binding != request: + _reject("operational_window_review_scope_mismatch") + if now < permit.issued_at_utc or now >= permit.expires_at_utc: + _reject("operational_window_review_expired") + try: + active = check(permit, request=request) + except Exception: + _reject("operational_window_revocation_check_unavailable") + if active is not True: + _reject("operational_window_review_revoked") + return permit + + +def require_active_simnow_operational_window_permit( + reviewer: CtpSimNowOperationalWindowReviewer, + permit: CtpSimNowOperationalWindowPermit, + request: CtpSimNowOperationalActionRequest, + *, + now_utc: float, +) -> None: + """Recheck exact binding, expiry and external revocation state.""" + + if ( + type(permit) is not CtpSimNowOperationalWindowPermit + or type(request) is not CtpSimNowOperationalActionRequest + or permit.binding != request + ): + _reject("operational_window_review_scope_mismatch") + now = _timestamp(now_utc, "now_utc") + if now < permit.issued_at_utc or now >= permit.expires_at_utc: + _reject("operational_window_review_expired") + check = getattr(reviewer, "assert_action_review_current", None) + if not callable(check): + _reject("operational_window_reviewer_required") + try: + active = check(permit, request=request) + except Exception: + _reject("operational_window_revocation_check_unavailable") + if active is not True: + _reject("operational_window_review_revoked") + + +def require_simnow_single_query_observation( + verifier: CtpSimNowSingleQueryVerifier, + observation: CtpSimNowSingleQueryObservation, + *, + expected_request: CtpSimNowQueryRequest, +) -> None: + """Accept one verified terminal stream only; never assemble a snapshot.""" + + if ( + type(observation) is not CtpSimNowSingleQueryObservation + or type(expected_request) is not CtpSimNowQueryRequest + or observation.binding != expected_request + ): + _reject("single_query_scope_mismatch") + if ( + observation.b_is_last is not True + or observation.response_error_code != 0 + or observation.callback_count <= 0 + or observation.late_callback_count != 0 + ): + _reject("single_query_terminal_evidence_incomplete") + verify = getattr(verifier, "verify_single_query", None) + if not callable(verify): + _reject("single_query_verifier_required") + try: + verified = verify(observation, expected_request=expected_request) + except Exception: + _reject("single_query_source_verification_unavailable") + if verified is not True: + _reject("single_query_source_unverified") + + +__all__ = [ + "CtpSimNowOperationalActionRequest", + "CtpSimNowOperationalRiskLimits", + "CtpSimNowOperationalWindowError", + "CtpSimNowOperationalWindowPermit", + "CtpSimNowOperationalWindowReviewer", + "CtpSimNowOperationalWindowScope", + "CtpSimNowQueryKind", + "CtpSimNowQueryRequest", + "CtpSimNowSingleQueryObservation", + "CtpSimNowSingleQueryVerifier", + "require_active_simnow_operational_window_permit", + "require_simnow_operational_window_permit", + "require_simnow_single_query_observation", +] diff --git a/backtrader_runtime/ctp_simnow_operator.py b/backtrader_runtime/ctp_simnow_operator.py new file mode 100644 index 00000000..824e97c4 --- /dev/null +++ b/backtrader_runtime/ctp_simnow_operator.py @@ -0,0 +1,547 @@ +"""Operator dispatch for a code-registered CTP SimNow read-only route. + +The public CLI accepts only a registered runtime directory. The legacy +static binding remains constructible for compatibility checks, but dispatch +rejects it. A code-owned policy binding consumes the TD/MD front pair selected +from one sealed private config block without an endpoint allowlist or +set/profile selection. It passes both strings unchanged into the private-read +admission. No calendar, clock, or environment variable selects an endpoint; +the TCP probe selects only among configured candidates and authorizes nothing. +No binding grants execution authority. + +The default inventory has one config-driven binding for the reserved private +runtime. It grants no runner or execution authority. Preflight probes only the +ordered exact pairs in sealed config and binds one selected pair unchanged. A +passing transport probe does not release credentials: artifact provenance, +scoped credentials, account-bound TD queries, MD login/subscription/tick +callbacks, and zero-write checks remain separate gates. Constructing a binding +does not itself contact SimNow or grant account/execution authority. +""" + +from __future__ import annotations + +import hashlib +import math +import re +from dataclasses import dataclass, field +from typing import Any + +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from .registry import ( + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeRegistry, + require_effective_runtime_config_seal, +) + + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_SECRET_REF_RE = re.compile( + r"^(?:runtime_secrets|os_secret_store:[A-Za-z0-9][A-Za-z0-9._-]{0,127})$" +) +_SIMNOW_ENVIRONMENT_RE = re.compile(r"^simnow_set[12]$") +_OFFICIAL_SIMNOW_PROFILES = frozenset({"set1_group1", "set1_group2", "set2_7x24"}) +_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") +_CTP_EXCHANGES = frozenset({"CZCE", "DCE", "SHFE", "INE", "CFFEX", "GFEX"}) +_CONFIG_YAML_REF = "config_yaml" +_FRONT_PROBE_TIMEOUT_SECONDS = 3.0 +_FRONT_PROBE_MAX_PAIRS = 8 +_FRONT_PROBE_REPEATED_SAMPLES = 3 + + +def _invalid_binding() -> ValueError: + return ValueError("invalid code-owned CTP SimNow read-only binding") + + +@dataclass(frozen=True) +class CtpSimNowReadOnlyBinding: + """Legacy static metadata retained for validation compatibility only. + + This is not an approval receipt. It carries no credential values, SDK + loader, provider object, order/cancel capability, or production setting. + The operator dispatcher never uses it to open a private-read route. + ``account_fingerprint_sha256`` is private in repr and is never included in + operator errors. + """ + + runtime_id: str + environment: str + sdk_profile: str + account_fingerprint_sha256: str = field(repr=False) + secrets_ref: str = field(repr=False) + instrument_id: str + exchange_id: str + hedge_flag: str + session_ttl_seconds: float = 60.0 + + def __post_init__(self) -> None: + if ( + type(self.runtime_id) is not str + or not self.runtime_id.strip() + or not _IDENTIFIER_RE.fullmatch(self.runtime_id) + ): + raise _invalid_binding() + if type(self.environment) is not str or not _SIMNOW_ENVIRONMENT_RE.fullmatch( + self.environment + ): + raise _invalid_binding() + if ( + type(self.sdk_profile) is not str + or self.sdk_profile not in _OFFICIAL_SIMNOW_PROFILES + or self.sdk_profile[3] != self.environment[-1] + ): + raise _invalid_binding() + if type(self.account_fingerprint_sha256) is not str or not _SHA256_RE.fullmatch( + self.account_fingerprint_sha256 + ): + raise _invalid_binding() + if ( + type(self.secrets_ref) is not str + or not _SECRET_REF_RE.fullmatch(self.secrets_ref) + or self.secrets_ref == "none" + ): + raise _invalid_binding() + if type(self.instrument_id) is not str or not _INSTRUMENT_RE.fullmatch(self.instrument_id): + raise _invalid_binding() + if type(self.exchange_id) is not str or not _EXCHANGE_RE.fullmatch(self.exchange_id): + raise _invalid_binding() + if type(self.hedge_flag) is not str or self.hedge_flag not in ("1", "2", "3"): + raise _invalid_binding() + if type(self.session_ttl_seconds) not in (int, float): + raise _invalid_binding() + try: + ttl = float(self.session_ttl_seconds) + except (OverflowError, TypeError, ValueError): + raise _invalid_binding() from None + if not math.isfinite(ttl) or not 0.0 < ttl <= 300.0: + raise _invalid_binding() + object.__setattr__(self, "session_ttl_seconds", ttl) + + def _admission(self, registration: RegisteredRuntime) -> Any: + """Create the fixed admission only after registry identity is checked.""" + + from .ctp_sandbox_readonly_admission import CtpSandboxReadOnlyRegistration + from .ctp_artifact_provenance import simnow_fronts_for_profile + + td_front, md_front = simnow_fronts_for_profile(self.sdk_profile) + + return CtpSandboxReadOnlyRegistration( + runtime_registration=registration, + environment=self.environment, + sdk_profile=self.sdk_profile, + account_fingerprint_sha256=self.account_fingerprint_sha256, + allowed_secrets_ref=self.secrets_ref, + instrument_id=self.instrument_id, + exchange_id=self.exchange_id, + hedge_flag=self.hedge_flag, + td_front=td_front, + md_front=md_front, + session_ttl_seconds=self.session_ttl_seconds, + ) + + def _credential_scope(self, effective: EffectiveRuntimeConfig) -> Any: + """Derive authentication scope from the sealed config and this binding.""" + + from .credential_resolver import CTP_AUTHENTICATION_CREDENTIAL_KEYS, RuntimeCredentialScope + + return RuntimeCredentialScope( + runtime_id=self.runtime_id, + strategy_id=effective.strategy_id, + provider="ctp", + provider_environment=self.environment, + policy_environment="sandbox", + mode="simulation", + preset="sandbox", + account_access="sandbox_private_read", + account_fingerprint_sha256=self.account_fingerprint_sha256, + secrets_ref=self.secrets_ref, + credential_keys=CTP_AUTHENTICATION_CREDENTIAL_KEYS, + effective_config_digest=effective.effective_digest, + registration_digest=effective.registration.digest, + ) + + +def _front_error() -> RuntimeConfigError: + return _operator_error( + "ctp_simnow_preflight_front_rejected", + "the sealed SimNow config must provide a valid configured TD/MD front pair", + ) + + +def _select_configured_front_pair(front_pairs: tuple[Any, ...]) -> Any: + """Probe only sealed configured pairs and redact endpoint-specific failures.""" + + from .ctp_front_pair_probe import CtpFrontPairProbeError, select_ctp_front_pair + + try: + return select_ctp_front_pair( + front_pairs, + timeout_seconds=_FRONT_PROBE_TIMEOUT_SECONDS, + max_pairs=_FRONT_PROBE_MAX_PAIRS, + repeated_samples=_FRONT_PROBE_REPEATED_SAMPLES, + ) + except CtpFrontPairProbeError: + raise _operator_error( + "ctp_simnow_preflight_front_probe_rejected", + "configured CTP front TCP probing failed; SDK, credential resolution, and account queries were not started", + ) from None + except Exception: + # Keep the operator boundary redacted even if the selector itself is + # unavailable or returns malformed internal state. + raise _operator_error( + "ctp_simnow_preflight_front_probe_rejected", + "configured CTP front TCP probing failed; SDK, credential resolution, and account queries were not started", + ) from None + + +def _resolve_selected_front_pair(front_pairs: tuple[Any, ...], *, selected_front_pair: Any) -> Any: + """Require a selector result to be exactly one member of sealed config.""" + + from .ctp_front_pair_probe import CtpConfiguredFrontPair + + if selected_front_pair is None: + if len(front_pairs) != 1: + raise _operator_error( + "ctp_simnow_preflight_front_selection_required", + "multiple configured CTP front pairs require an explicit probe selection", + ) + candidate = front_pairs[0] + md_front = candidate.get("md_front") if hasattr(candidate, "get") else None + td_front = candidate.get("td_front") if hasattr(candidate, "get") else None + selected_front_pair = CtpConfiguredFrontPair(md_front=md_front, td_front=td_front) + if ( + type(selected_front_pair) is not CtpConfiguredFrontPair + or type(selected_front_pair.md_front) is not str + or type(selected_front_pair.td_front) is not str + ): + raise _operator_error( + "ctp_simnow_preflight_front_selection_required", + "the configured CTP front selector did not return one exact address pair", + ) + if not any( + hasattr(candidate, "get") + and candidate.get("md_front") == selected_front_pair.md_front + and candidate.get("td_front") == selected_front_pair.td_front + for candidate in front_pairs + ): + raise _operator_error( + "ctp_simnow_preflight_front_selection_mismatch", + "the selected CTP front pair is not present in the sealed config", + ) + return selected_front_pair + + +def _validate_selected_config_index( + front_pairs: tuple[Any, ...], *, selected_front_pair: Any, selected_config_index: Any +) -> int | None: + """Bind an optional selector index to the exact sealed config pair.""" + + if selected_config_index is None: + return None + if ( + type(selected_config_index) is not int + or selected_config_index < 0 + or selected_config_index >= len(front_pairs) + or selected_config_index > 7 + ): + raise _operator_error( + "ctp_simnow_preflight_front_selection_mismatch", + "the selected CTP config index is outside the sealed candidate set", + ) + candidate = front_pairs[selected_config_index] + if ( + not hasattr(candidate, "get") + or candidate.get("md_front") != selected_front_pair.md_front + or candidate.get("td_front") != selected_front_pair.td_front + ): + raise _operator_error( + "ctp_simnow_preflight_front_selection_mismatch", + "the selected CTP config index does not identify the admitted front pair", + ) + return selected_config_index + + +@dataclass(frozen=True) +class CtpSimNowConfigReadOnlyBinding: + """Code-owned policy for one sealed, config-driven CTP read-only route. + + Instrument, exchange, hedge flag, credentials, and both front strings come + from the sealed private config. The front strings pass through unchanged; + this binding does not consult a static endpoint allowlist or derive a + profile from the address pair. This binding contains no account or + credential value and has no time-based endpoint policy. + """ + + runtime_id: str + session_ttl_seconds: float = 60.0 + + def __post_init__(self) -> None: + if type(self.runtime_id) is not str or not _IDENTIFIER_RE.fullmatch(self.runtime_id): + raise _invalid_binding() + if type(self.session_ttl_seconds) not in (int, float): + raise _invalid_binding() + try: + ttl = float(self.session_ttl_seconds) + except (OverflowError, TypeError, ValueError): + raise _invalid_binding() from None + if not math.isfinite(ttl) or not 0.0 < ttl <= 300.0: + raise _invalid_binding() + object.__setattr__(self, "session_ttl_seconds", ttl) + + @property + def secrets_ref(self) -> str: + return _CONFIG_YAML_REF + + def _route( + self, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + *, + selected_front_pair: Any = None, + selected_config_index: Any = None, + ) -> tuple[Any, Any]: + from .credential_resolver import CTP_AUTHENTICATION_CREDENTIAL_KEYS, RuntimeCredentialScope + from .ctp_sandbox_readonly_admission import CtpSandboxReadOnlyRegistration + + private, registration, front_pairs = self._sealed_private_config(effective, registry) + # Scope is an explicit operator choice in the sealed config. Validate + # the supported CTP shape here, but do not silently replace it with a + # code-selected instrument or exchange. + if ( + type(private.instrument_id) is not str + or not _INSTRUMENT_RE.fullmatch(private.instrument_id) + or type(private.exchange_id) is not str + or private.exchange_id not in _CTP_EXCHANGES + or type(private.hedge_flag) is not str + or private.hedge_flag not in ("1", "2", "3") + ): + raise _operator_error( + "ctp_simnow_preflight_query_scope_rejected", + "the configured CTP instrument, exchange, or hedge scope is invalid", + ) + selected_front_pair = _resolve_selected_front_pair( + front_pairs, selected_front_pair=selected_front_pair + ) + selected_config_index = _validate_selected_config_index( + front_pairs, + selected_front_pair=selected_front_pair, + selected_config_index=selected_config_index, + ) + try: + td_front = selected_front_pair.td_front + md_front = selected_front_pair.md_front + except Exception: + raise _operator_error( + "ctp_simnow_preflight_front_selection_required", + "the sealed config must resolve to one explicit CTP front pair", + ) from None + try: + # These are policy labels, not selector inputs. The endpoints stay + # exactly as sealed in config and no source/env field can replace + # them or select a SimNow set. + environment = "simnow" + sdk_profile = "config_front_pair" + account_fingerprint = hashlib.sha256( + "{0}:{1}".format(private.broker_id, private.user_id).encode("utf-8") + ).hexdigest() + admission = CtpSandboxReadOnlyRegistration( + runtime_registration=registration, + environment=environment, + sdk_profile=sdk_profile, + account_fingerprint_sha256=account_fingerprint, + allowed_secrets_ref=_CONFIG_YAML_REF, + instrument_id=private.instrument_id, + exchange_id=private.exchange_id, + hedge_flag=private.hedge_flag, + td_front=td_front, + md_front=md_front, + session_ttl_seconds=self.session_ttl_seconds, + ) + scope = RuntimeCredentialScope( + runtime_id=self.runtime_id, + strategy_id=effective.strategy_id, + provider="ctp", + provider_environment=environment, + policy_environment="sandbox", + mode="simulation", + preset="sandbox", + account_access="sandbox_private_read", + account_fingerprint_sha256=account_fingerprint, + secrets_ref=_CONFIG_YAML_REF, + credential_keys=CTP_AUTHENTICATION_CREDENTIAL_KEYS, + effective_config_digest=effective.effective_digest, + registration_digest=registration.digest, + ) + except RuntimeConfigError: + raise + except Exception: + raise _front_error() from None + return admission, scope + + def _sealed_private_config( + self, effective: EffectiveRuntimeConfig, registry: RuntimeRegistry + ) -> tuple[Any, RegisteredRuntime, tuple[Any, ...]]: + """Validate the sealed route before any credential-free network probe.""" + + from .config import CtpSimNowPrivateConfig + + require_effective_runtime_config_seal(effective, registry) + registration = effective.registration + private = effective.config.ctp_simnow + front_pairs = getattr(private, "front_pairs", None) + if ( + type(private) is not CtpSimNowPrivateConfig + or effective.mode != "simulation" + or effective.preset != "sandbox" + or effective.config.secrets_ref != _CONFIG_YAML_REF + or registration.runtime_id != self.runtime_id + or type(front_pairs) is not tuple + or not front_pairs + or len(front_pairs) > _FRONT_PROBE_MAX_PAIRS + ): + raise _operator_error( + "ctp_simnow_preflight_private_config_required", + "the registered CTP route requires its sealed private config.yaml block", + ) + # Validate non-endpoint scope before spending the bounded probe budget. + if ( + type(private.instrument_id) is not str + or not _INSTRUMENT_RE.fullmatch(private.instrument_id) + or type(private.exchange_id) is not str + or private.exchange_id not in _CTP_EXCHANGES + or type(private.hedge_flag) is not str + or private.hedge_flag not in ("1", "2", "3") + ): + raise _operator_error( + "ctp_simnow_preflight_query_scope_rejected", + "the configured CTP instrument, exchange, or hedge scope is invalid", + ) + return private, registration, front_pairs + + +def _operator_error(reason: str, message: str) -> RuntimeConfigError: + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + message, + field_path="runtime.preset", + reason=reason, + ) + + +def dispatch_registered_ctp_simnow_readonly_preflight( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> Any: + """Run one registered read-only account preflight through the composition root. + + This function accepts no scope or SDK injection. It resolves the route + binding from the same trusted registry that sealed ``config.yaml`` and + calls the real read-only composition API. The default registry resolves + the config-driven binding; missing config fails at config loading, and the + scoped SDK provenance gate runs before credential resolution or connection. + """ + + if type(effective) is not EffectiveRuntimeConfig or type(registry) is not RuntimeRegistry: + raise _operator_error( + "ctp_simnow_preflight_binding_required", + "a sealed runtime configuration and trusted registry are required", + ) + require_effective_runtime_config_seal(effective, registry) + if effective.profile is not None or effective.registration.profiles: + from .ctp_sandbox_readonly_admission import ( + CtpSandboxReadOnlyAdmissionError, + require_ctp_sandbox_profile_runtime, + ) + + try: + require_ctp_sandbox_profile_runtime(effective, registry) + except CtpSandboxReadOnlyAdmissionError: + raise _operator_error( + "ctp_simnow_preflight_profile_dispatch_unavailable", + "the profiled CTP route is not the exact zero-write sandbox binding", + ) from None + try: + registration = registry.require_runtime_dir(effective.config.strategy_dir) + if registration is not effective.registration: + raise ValueError("registration mismatch") + binding = registry.require_ctp_simnow_readonly_binding(registration.runtime_id) + except RuntimeConfigError: + raise + except Exception: + raise _operator_error( + "ctp_simnow_preflight_route_unregistered", + "no reviewed CTP SimNow read-only route is registered for this runtime", + ) from None + + try: + if type(binding) is CtpSimNowReadOnlyBinding: + raise _operator_error( + "ctp_simnow_preflight_front_policy_required", + "CTP SimNow preflight requires sealed config.yaml front addresses", + ) + if type(binding) is CtpSimNowConfigReadOnlyBinding: + _, _, front_pairs = binding._sealed_private_config(effective, registry) + selection = _select_configured_front_pair(front_pairs) + selected_config_index = getattr(selection, "config_index", None) + if selected_config_index is None: + # Keep compatibility with older selector adapters that expose + # the exact pair but not its candidate index. Config pairs are + # unique, so deriving the index from that exact pair is safe. + selected_config_index = next( + index + for index, candidate in enumerate(front_pairs) + if candidate.get("md_front") == selection.pair.md_front + and candidate.get("td_front") == selection.pair.td_front + ) + admission, scope = binding._route( + effective, + registry, + selected_front_pair=selection.pair, + selected_config_index=selected_config_index, + ) + else: + raise _invalid_binding() + except RuntimeConfigError: + raise + except Exception: + raise _operator_error( + "ctp_simnow_preflight_binding_invalid", + "the registered CTP SimNow read-only route is invalid", + ) from None + + # Import the provider-capable composition only after the sealed config and + # exact code-owned route binding have both been resolved. + from .ctp_simnow_readonly_runtime import ( + CtpSimNowReadOnlyRuntimeError, + open_ctp_simnow_readonly_runtime, + ) + + try: + return open_ctp_simnow_readonly_runtime( + effective=effective, + registry=registry, + admission_registration=admission, + credential_scope=scope, + selected_config_index=selected_config_index, + ) + except CtpSimNowReadOnlyRuntimeError as error: + reason = "ctp_simnow_preflight_{0}".format(error.reason) + raise _operator_error( + reason, + "the registered CTP SimNow read-only preflight was rejected", + ) from None + except RuntimeConfigError: + raise + except Exception: + raise _operator_error( + "ctp_simnow_preflight_rejected", + "the registered CTP SimNow read-only preflight was rejected", + ) from None + + +__all__ = [ + "CtpSimNowConfigReadOnlyBinding", + "CtpSimNowReadOnlyBinding", + "dispatch_registered_ctp_simnow_readonly_preflight", +] diff --git a/backtrader_runtime/ctp_simnow_readonly_runtime.py b/backtrader_runtime/ctp_simnow_readonly_runtime.py new file mode 100644 index 00000000..909ec530 --- /dev/null +++ b/backtrader_runtime/ctp_simnow_readonly_runtime.py @@ -0,0 +1,672 @@ +"""One sealed, CTP SimNow private-account read-only composition entry point. + +This is deliberately a narrow composition root, not a registered runtime, +CLI command, provider admission, or execution route. It joins four already +separate contracts in a fixed order: + +1. validate the exact sealed Iteration 41 ``simulation/sandbox`` admission; +2. validate the exact sealed CTP credential scope before any secret I/O; +3. validate the installed CTP SDK import origin before any secret I/O; +4. resolve and re-seal one credential payload; and +5. run the seven-query TD read-only observation and close that client; +6. only then load the pinned installation's MdClient and verify one market + login, one exact single-instrument subscription, and a bounded matching + tick observation; and +7. return both non-authoritative observations. + +There is no order, cancellation, settlement, arm, or external-write API here. +A successful observation proves only that this process completed one bounded +seven-query TD account session and one exact-contract MD login/subscription/ +tick check through the installed SDK import boundary. It is not independent native +market-data QA, provider account acceptance, an approval receipt, a trading +permit, or a production route. +""" + +from __future__ import annotations + +import hashlib +import hmac +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Tuple + +from .capability_imports import trusted_installed_capability_import_context +from .ctp_artifact_provenance import ( + CtpArtifactProvenanceError, + verify_ctp_sdk_artifact_provenance_for_fronts, +) +from .credential_resolver import ( + CTP_AUTHENTICATION_CREDENTIAL_KEYS, + CredentialResolutionError, + RuntimeCredentialScope, + require_resolved_runtime_credentials_seal, + resolve_runtime_credentials, +) +from .ctp_sandbox_readonly_admission import ( + CtpSandboxReadOnlyAdmissionError, + CtpSandboxReadOnlyObservation, + CtpSandboxReadOnlyRegistration, + admit_ctp_simnow_sandbox_readonly, + require_ctp_sandbox_readonly_runtime_contract, +) +from .ctp_sdk_readonly import ( + CtpSdkReadOnlyError, + CtpSdkReadOnlyScope, + CtpSdkReadOnlySessionFactory, + _default_sdk_components as _ctp_sdk_default_components, +) +from .ctp_sdk_market_readonly import ( + CtpSdkMarketReadOnlyError, + CtpSdkMarketReadOnlyObservation, + _probe_ctp_market_readonly, +) +from .errors import RuntimeConfigError +from .registry import EffectiveRuntimeConfig, RuntimeRegistry + + +_MD_TICK_OBSERVATION_SECONDS = 5.0 +_MAX_CONFIGURED_FRONT_PAIRS = 8 + + +class CtpSimNowReadOnlyRuntimeError(ValueError): + """Redacted composition failure with no credentials or opaque refs.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise CtpSimNowReadOnlyRuntimeError(reason, message) + + +def _normalise_credential_scope(value: Any) -> RuntimeCredentialScope: + """Snapshot exact scope data without accepting subclasses or dynamic fields.""" + + if type(value) is not RuntimeCredentialScope: + _reject( + "credential_scope_required", + "a code-owned CTP credential scope is required for the read-only route", + ) + try: + return RuntimeCredentialScope( + runtime_id=value.runtime_id, + strategy_id=value.strategy_id, + provider=value.provider, + provider_environment=value.provider_environment, + policy_environment=value.policy_environment, + mode=value.mode, + preset=value.preset, + account_access=value.account_access, + account_fingerprint_sha256=value.account_fingerprint_sha256, + secrets_ref=value.secrets_ref, + credential_keys=value.credential_keys, + effective_config_digest=value.effective_config_digest, + registration_digest=value.registration_digest, + ) + except Exception: + _reject( + "credential_scope_invalid", "the CTP credential scope is not a valid code-owned binding" + ) + raise AssertionError("unreachable") + + +def _require_matching_ctp_scope( + admission: CtpSandboxReadOnlyRegistration, + effective: EffectiveRuntimeConfig, + scope: RuntimeCredentialScope, +) -> RuntimeCredentialScope: + """Reject a scope that could select another CTP account or environment. + + This gate deliberately occurs before ``resolve_runtime_credentials`` so a + bad admission or code-owned scope cannot trigger Credential Manager/file + access. The resolver repeats the sealed effective-config binding before + it reads the selected secret source. + """ + + snapshot = _normalise_credential_scope(scope) + from .config import CtpSimNowPrivateConfig + + private = effective.config.ctp_simnow + if type(private) is not CtpSimNowPrivateConfig: + _reject( + "credential_scope_mismatch", + "the CTP credential scope does not match the sealed sandbox admission", + ) + try: + configured_pairs = tuple( + (pair["md_front"], pair["td_front"]) for pair in private.front_pairs + ) + except Exception: + _reject( + "credential_scope_mismatch", + "the CTP credential scope does not match the sealed sandbox admission", + ) + selected_pair = (admission.md_front, admission.td_front) + if ( + selected_pair not in configured_pairs + or admission.environment != "simnow" + or admission.sdk_profile != "config_front_pair" + or snapshot.provider != "ctp" + or snapshot.provider_environment != "simnow" + or snapshot.policy_environment != "sandbox" + or snapshot.mode != "simulation" + or snapshot.preset != "sandbox" + or snapshot.account_access != "sandbox_private_read" + or snapshot.secrets_ref != admission.allowed_secrets_ref + or snapshot.credential_keys != CTP_AUTHENTICATION_CREDENTIAL_KEYS + or snapshot.runtime_id != effective.registration.runtime_id + or snapshot.strategy_id != effective.strategy_id + or snapshot.effective_config_digest != effective.effective_digest + or snapshot.registration_digest != effective.registration.digest + or not hmac.compare_digest( + snapshot.account_fingerprint_sha256, + admission.account_fingerprint_sha256, + ) + ): + _reject( + "credential_scope_mismatch", + "the CTP credential scope does not match the sealed sandbox admission", + ) + return snapshot + + +def _sdk_scope_from_admission(admission: CtpSandboxReadOnlyRegistration) -> CtpSdkReadOnlyScope: + """Make the SDK query scope solely from the exact sealed admission object.""" + + try: + return CtpSdkReadOnlyScope( + environment=admission.environment, + sdk_profile=admission.sdk_profile, + account_fingerprint_sha256=admission.account_fingerprint_sha256, + instrument_id=admission.instrument_id, + exchange_id=admission.exchange_id, + hedge_flag=admission.hedge_flag, + td_front=admission.td_front, + md_front=admission.md_front, + ) + except Exception: + _reject( + "sdk_scope_invalid", + "the code-owned CTP sandbox admission has no exact SDK read-only scope", + ) + raise AssertionError("unreachable") + + +def _default_sdk_components() -> Tuple[Any, ...]: + """Use the adapter's fixed loader; this private name is a unit-test seam. + + The public composition API deliberately accepts no caller-provided SDK + loader. Keeping the seam private prevents an operator from producing a + native-looking observation with a substituted client or endpoint. + """ + + return _ctp_sdk_default_components() + + +class _SealedCtpCredentialSource: + """Revalidate resolver provenance immediately before each SDK credential read. + + The SDK factory receives this narrow protocol object rather than the raw + resolver result. A seal checked only once at factory construction could + become stale before a later ``require_credential`` call, so each access + repeats the exact effective-config, registry, and scope binding. + """ + + __slots__ = ("_credentials", "_effective", "_registry", "_scope") + + def __init__( + self, + credentials: Any, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + scope: RuntimeCredentialScope, + ) -> None: + self._credentials = credentials + self._effective = effective + self._registry = registry + self._scope = scope + + def __repr__(self) -> str: + return "_SealedCtpCredentialSource(credentials=)" + + def require_credential(self, name: str) -> str: + if type(name) is not str or name not in CTP_AUTHENTICATION_CREDENTIAL_KEYS: + raise CredentialResolutionError( + "credential_name_not_available", "the requested CTP credential is not available" + ) + require_resolved_runtime_credentials_seal( + self._credentials, + self._effective, + self._registry, + self._scope, + ) + return self._credentials.require_credential(name) + + +@dataclass(frozen=True) +class CtpSimNowFrontSelectionAudit: + """Opt-in, endpoint-free identity for one selected sealed config pair.""" + + selected_config_index: int + candidate_count: int + selected_front_pair_sha256: str = field(repr=False) + + def __post_init__(self) -> None: + if ( + type(self.selected_config_index) is not int + or type(self.candidate_count) is not int + or not 1 <= self.candidate_count <= _MAX_CONFIGURED_FRONT_PAIRS + or not 0 <= self.selected_config_index < self.candidate_count + or self.selected_config_index > 7 + or type(self.selected_front_pair_sha256) is not str + or len(self.selected_front_pair_sha256) != 64 + or any( + character not in "0123456789abcdef" for character in self.selected_front_pair_sha256 + ) + ): + raise ValueError("invalid CTP SimNow front selection audit") + + def as_public_dict(self) -> Dict[str, Any]: + """Return pair comparability without exposing configured endpoints.""" + + return { + "schema_version": 1, + "selected_config_index": self.selected_config_index, + "candidate_count": self.candidate_count, + "selected_front_pair_sha256": self.selected_front_pair_sha256, + } + + +@dataclass(frozen=True) +class CtpSimNowReadOnlyRuntimeObservation: + """A redacted read-only result that intentionally grants no authority.""" + + observation: CtpSandboxReadOnlyObservation + market_data_observation: CtpSdkMarketReadOnlyObservation + market_data_trading_writes: int = 0 + credentials_resolved: bool = True + provider_connected: bool = False + account_identity_verified: bool = False + preflight_authorized: bool = False + execution_authorized: bool = False + external_writes_authorized: bool = False + order_submission_authorized: bool = False + cancellation_authorized: bool = False + settlement_authorized: bool = False + arming_authorized: bool = False + external_write_requests: int = 0 + front_selection_audit: Optional[CtpSimNowFrontSelectionAudit] = field(default=None, repr=False) + + def __post_init__(self) -> None: + if type(self.observation) is not CtpSandboxReadOnlyObservation: + raise TypeError("observation must be a CtpSandboxReadOnlyObservation") + if type(self.market_data_observation) is not CtpSdkMarketReadOnlyObservation: + raise TypeError("market_data_observation must be a CtpSdkMarketReadOnlyObservation") + if ( + self.front_selection_audit is not None + and type(self.front_selection_audit) is not CtpSimNowFrontSelectionAudit + ): + raise TypeError("front_selection_audit must be a CtpSimNowFrontSelectionAudit") + if ( + self.credentials_resolved is not True + or self.provider_connected is not False + or self.account_identity_verified is not False + or self.preflight_authorized is not False + or self.execution_authorized is not False + or self.external_writes_authorized is not False + or self.order_submission_authorized is not False + or self.cancellation_authorized is not False + or self.settlement_authorized is not False + or self.arming_authorized is not False + or type(self.external_write_requests) is not int + or self.external_write_requests != 0 + or type(self.market_data_trading_writes) is not int + or self.market_data_trading_writes != 0 + ): + raise ValueError("the CTP SimNow read-only result cannot grant authority") + if ( + self.observation.execution_authorized is not False + or self.observation.external_writes_authorized is not False + or self.observation.order_submission_authorized is not False + or self.observation.cancellation_authorized is not False + or self.observation.settlement_authorized is not False + or self.observation.arming_authorized is not False + or self.observation.external_write_requests != 0 + ): + raise ValueError("the nested CTP observation must remain zero-write") + if ( + self.market_data_observation.market_login_ready is not True + or self.market_data_observation.subscription_acknowledged is not True + or self.market_data_observation.tick_observation_count < 1 + or self.market_data_observation.probe_session_closed is not True + or self.market_data_observation.account_fingerprint_sha256 + != self.observation.account_fingerprint_sha256 + or self.market_data_observation.instrument_id != self.observation.instrument_id + or self.market_data_observation.exchange_id != self.observation.exchange_id + or self.market_data_observation.order_submission_authorized is not False + or self.market_data_observation.trading_writes != 0 + or self.market_data_observation.settlement_writes != 0 + ): + raise ValueError( + "the market-data probe must be bound, observed, closed, and zero-write" + ) + tick_binding = self.market_data_observation.tick_binding + if ( + tick_binding is None + or tick_binding.account_fingerprint_sha256 + != self.market_data_observation.account_fingerprint_sha256 + or tick_binding.md_front_sha256 != self.market_data_observation.md_front_sha256 + or tick_binding.instrument_id != self.market_data_observation.instrument_id + or tick_binding.exchange_id != self.market_data_observation.exchange_id + or tick_binding.connection_generation + != self.market_data_observation.connection_generation + ): + raise ValueError("the market-data tick binding must match the closed MD observation") + + def __bool__(self) -> bool: + raise TypeError( + "CtpSimNowReadOnlyRuntimeObservation is not account, preflight, or execution authority; " + "do not use it as a boolean" + ) + + def as_public_dict(self) -> Dict[str, Any]: + """Return only read-only facts and non-authority state.""" + + return { + "credentials_resolved": self.credentials_resolved, + "provider_connected": self.provider_connected, + "account_identity_verified": self.account_identity_verified, + "preflight_authorized": self.preflight_authorized, + "execution_authorized": self.execution_authorized, + "external_writes_authorized": self.external_writes_authorized, + "order_submission_authorized": self.order_submission_authorized, + "cancellation_authorized": self.cancellation_authorized, + "settlement_authorized": self.settlement_authorized, + "arming_authorized": self.arming_authorized, + "external_write_requests": self.external_write_requests, + "market_data_trading_writes": self.market_data_trading_writes, + "read_only_observation": self.observation.as_public_dict(), + "market_data_observation": self.market_data_observation.as_public_dict(), + } + + def as_front_selection_audit_dict(self) -> Dict[str, Any]: + """Return opt-in configured-pair identity, separate from frozen I2 evidence.""" + + if self.front_selection_audit is None: + raise ValueError("this observation has no selected config index audit") + return self.front_selection_audit.as_public_dict() + + +def _front_selection_audit( + effective: EffectiveRuntimeConfig, + admission: CtpSandboxReadOnlyRegistration, + selected_config_index: Any, +) -> Optional[CtpSimNowFrontSelectionAudit]: + """Validate an optional config index before any credential or SDK access.""" + + if selected_config_index is None: + return None + from .config import CtpSimNowPrivateConfig + + private = effective.config.ctp_simnow + front_pairs = getattr(private, "front_pairs", None) + if ( + type(private) is not CtpSimNowPrivateConfig + or type(front_pairs) is not tuple + or not 1 <= len(front_pairs) <= _MAX_CONFIGURED_FRONT_PAIRS + or type(selected_config_index) is not int + or selected_config_index < 0 + or selected_config_index >= len(front_pairs) + or selected_config_index > 7 + ): + _reject( + "configured_front_selection_invalid", + "the selected CTP config index is outside the sealed candidate set", + ) + try: + selected_pair = front_pairs[selected_config_index] + md_front = selected_pair["md_front"] + td_front = selected_pair["td_front"] + except Exception: + _reject( + "configured_front_selection_invalid", + "the selected CTP config index does not identify one sealed front pair", + ) + if (admission.md_front, admission.td_front) != (md_front, td_front): + _reject( + "configured_front_selection_mismatch", + "the admitted CTP front pair does not match the selected sealed config index", + ) + md_bytes = md_front.encode("utf-8") + td_bytes = td_front.encode("utf-8") + pair_digest = hashlib.sha256( + b"backtrader.ctp.simnow.front-pair-audit.v1\0" + + len(md_bytes).to_bytes(4, "big") + + md_bytes + + len(td_bytes).to_bytes(4, "big") + + td_bytes + ).hexdigest() + try: + return CtpSimNowFrontSelectionAudit( + selected_config_index=selected_config_index, + candidate_count=len(front_pairs), + selected_front_pair_sha256=pair_digest, + ) + except Exception: + _reject( + "configured_front_selection_invalid", + "the selected CTP config index could not be represented as a safe audit", + ) + raise AssertionError("unreachable") + + +def _load_installed_md_client_type() -> Any: + """Load MdClient lazily inside the already validated capability fence.""" + + from bt_api_ctp.ctp.client import MdClient + + return MdClient + + +def open_ctp_simnow_readonly_runtime( + *, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + admission_registration: CtpSandboxReadOnlyRegistration, + credential_scope: RuntimeCredentialScope, + connect_timeout: float = 15.0, + query_timeout: float = 5.0, + selected_config_index: Optional[int] = None, +) -> CtpSimNowReadOnlyRuntimeObservation: + """Run one bounded CTP SimNow private query with no execution interface. + + The strict admission and credential-scope gates happen before secret I/O. + An installed-root capability-origin gate then runs before credential + resolution. The resolver's result is re-sealed before the SDK factory + exists, so a caller cannot substitute a manually constructed credential + object. The seven-query TraderClient pass and a separate single-contract + MdClient pass must both succeed, including a matching post-ACK tick. The MD client is imported only after the + TD observation completes, while still inside the same artifact-verified + installed-capability context. This preflight should run in an isolated + process with no other same-account market-data client active; its probe + lease coordinates only cooperating probe invocations. The returned object + contains no credential source, secret values, client, session handle, or + caller-controlled SDK loader. + """ + + try: + admission = require_ctp_sandbox_readonly_runtime_contract( + effective, + registry, + admission_registration, + ) + except CtpSandboxReadOnlyAdmissionError: + _reject( + "runtime_admission_rejected", + "the sealed CTP SimNow sandbox admission was rejected before credential access", + ) + except Exception: + _reject( + "runtime_admission_rejected", + "the sealed CTP SimNow sandbox admission was rejected before credential access", + ) + + scope = _require_matching_ctp_scope(admission, effective, credential_scope) + front_selection_audit = _front_selection_audit(effective, admission, selected_config_index) + sdk_scope = _sdk_scope_from_admission(admission) + + # Resolve the exact installed package before Credential Manager/file I/O. + # This strict context rejects CWD, source-root, absolute ``PYTHONPATH``, + # cached, and editable-meta-path substitutions. It is an origin fence, + # not a wheel signature, release-provenance, dependency-lock, or arbitrary + # trusted-in-process-code verifier. The private test loader seam need not + # import a capability, but it remains inside this fixed one-name boundary. + try: + # The CTP package's public initializer imports container types from + # bt_api_base, so both installed distributions are part of this fixed + # read-only capability boundary. + with trusted_installed_capability_import_context(("bt_api_base", "bt_api_ctp")): + # Artifact, RECORD, and canonical selected-front checks run before + # any Credential Manager or file-backed secret access. The import + # context has already excluded source/editable/CWD package origins. + try: + verify_ctp_sdk_artifact_provenance_for_fronts( + td_front=admission.td_front, + md_front=admission.md_front, + ) + except CtpArtifactProvenanceError: + _reject( + "capability_provenance_rejected", + "the installed CTP SDK artifact or selected front pair was rejected before credential access", + ) + + try: + credentials = resolve_runtime_credentials(effective, registry, scope) + require_resolved_runtime_credentials_seal(credentials, effective, registry, scope) + except CredentialResolutionError: + _reject( + "credential_resolution_rejected", + "the CTP credential source was rejected before native session setup", + ) + except Exception: + _reject( + "credential_resolution_rejected", + "the CTP credential source was rejected before native session setup", + ) + + credential_source = _SealedCtpCredentialSource(credentials, effective, registry, scope) + try: + factory = CtpSdkReadOnlySessionFactory( + sdk_scope, + credential_source, + connect_timeout=connect_timeout, + query_timeout=query_timeout, + sdk_components_loader=_default_sdk_components, + ) + observation = admit_ctp_simnow_sandbox_readonly( + effective=effective, + registry=registry, + admission_registration=admission, + session_factory=factory, + ) + except CtpSandboxReadOnlyAdmissionError: + _reject( + "read_only_observation_rejected", "the CTP read-only observation was rejected" + ) + except CtpSdkReadOnlyError: + _reject( + "native_readonly_session_rejected", + "the native CTP read-only session was rejected", + ) + except Exception: + _reject( + "native_readonly_session_rejected", + "the native CTP read-only session was rejected", + ) + # Do not load or construct MdClient until the registry/config, + # artifact and credential gates have passed and all seven TD + # queries have completed with the TraderClient closed. + try: + md_client_type = _load_installed_md_client_type() + market_observation = _probe_ctp_market_readonly( + admission=admission, + credential_source=credential_source, + client_type=md_client_type, + timeout_seconds=connect_timeout, + tick_observation_seconds=min(_MD_TICK_OBSERVATION_SECONDS, connect_timeout), + ) + except CtpSdkMarketReadOnlyError as exc: + _reject( + exc.reason, + "the exact CTP market-data login or single-instrument subscription was rejected", + ) + except CtpSimNowReadOnlyRuntimeError: + raise + except Exception: + _reject( + "market_readonly_observation_rejected", + "the exact CTP market-data login or single-instrument subscription was rejected", + ) + if ( + market_observation.md_front_sha256 + != hashlib.sha256(admission.md_front.encode("utf-8")).hexdigest() + or market_observation.account_fingerprint_sha256 + != admission.account_fingerprint_sha256 + or market_observation.instrument_id != admission.instrument_id + or market_observation.exchange_id != admission.exchange_id + or market_observation.market_login_ready is not True + or market_observation.subscription_acknowledged is not True + ): + _reject( + "market_observation_mismatch", + "the CTP market-data observation did not match the sealed route", + ) + if market_observation.tick_observation_count < 1: + _reject( + "market_tick_not_observed", + "the exact CTP market-data scope produced no matching tick in the bounded window", + ) + tick_binding = market_observation.tick_binding + if ( + tick_binding is None + or tick_binding.account_fingerprint_sha256 != admission.account_fingerprint_sha256 + or tick_binding.md_front_sha256 + != hashlib.sha256(admission.md_front.encode("utf-8")).hexdigest() + or tick_binding.instrument_id != admission.instrument_id + or tick_binding.exchange_id != admission.exchange_id + or tick_binding.connection_generation != market_observation.connection_generation + ): + _reject( + "market_tick_binding_mismatch", + "the observed CTP tick did not match the sealed market-data scope", + ) + if market_observation.probe_session_closed is not True: + _reject( + "market_client_close_incomplete", + "the CTP market-data client did not prove completed shutdown", + ) + except CtpSimNowReadOnlyRuntimeError: + raise + except RuntimeConfigError: + _reject( + "capability_origin_rejected", + "the installed CTP capability import origin was rejected before credential access", + ) + except Exception: + _reject( + "capability_origin_rejected", + "the installed CTP capability import origin was rejected before credential access", + ) + return CtpSimNowReadOnlyRuntimeObservation( + observation=observation, + market_data_observation=market_observation, + front_selection_audit=front_selection_audit, + ) + + +__all__ = [ + "CtpSimNowFrontSelectionAudit", + "CtpSimNowReadOnlyRuntimeError", + "CtpSimNowReadOnlyRuntimeObservation", + "open_ctp_simnow_readonly_runtime", +] diff --git a/backtrader_runtime/ctp_simnow_signed_review.py b/backtrader_runtime/ctp_simnow_signed_review.py new file mode 100644 index 00000000..a92af688 --- /dev/null +++ b/backtrader_runtime/ctp_simnow_signed_review.py @@ -0,0 +1,1196 @@ +"""Offline HMAC review verification for the proposed SimNow action window. + +This module is intentionally not imported by the runtime registry, CLI, +managed operator, Store, gateway, or SDK composition. It verifies an injected +signed review envelope against injected trust, UTC, and replay services. A +successful verification remains a review contract only: it does not create a +writer fence, authorize execution, or change the governing NO_WRITE decision. + +The existing :mod:`ctp_simulation_execution` HMAC approval is not reused here: +it does not bind the operational window's session/day/generation, exact risk +limits, revocation epoch, issuer policy, or replay guard. +""" + +from __future__ import annotations + +import ctypes +import hashlib +import hmac +import json +import math +import ntpath +import os +import re +import sqlite3 +import stat +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, FrozenSet, Optional, Protocol + +from .ctp_simnow_operational_window import ( + CtpSimNowOperationalActionRequest, + CtpSimNowOperationalWindowError, + CtpSimNowOperationalWindowPermit, +) + + +_DOMAIN = b"backtrader-ctp-simnow-operational-review-hmac-v1\0" +_REPLAY_DOMAIN = b"backtrader-ctp-simnow-operational-review-replay-v1\0" +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_SIGNATURE_RE = re.compile(r"^[0-9a-f]{64}$") +_ACTIONS = frozenset(("SUBMIT", "CANCEL")) +_MAX_TTL_SECONDS = 60.0 + + +def _reject(reason: str) -> None: + raise CtpSimNowOperationalWindowError(reason) from None + + +def _canonical_bytes(value: object) -> bytes: + try: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError): + _reject("operational_review_payload_invalid") + + +def _finite_utc(value: object) -> float: + if type(value) not in (int, float) or not math.isfinite(float(value)): + _reject("operational_review_trusted_utc_invalid") + return float(value) + + +def _normalized_set(value: object, field_name: str, pattern: re.Pattern[str]) -> FrozenSet[str]: + if isinstance(value, (str, bytes)): + _reject("operational_review_policy_invalid") + try: + items = frozenset(value) # type: ignore[arg-type] + except TypeError: + _reject("operational_review_policy_invalid") + if not items or any(type(item) is not str or pattern.fullmatch(item) is None for item in items): + _reject("operational_review_policy_invalid") + return items + + +def _is_link_or_reparse(result: os.stat_result) -> bool: + attributes = getattr(result, "st_file_attributes", 0) + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return stat.S_ISLNK(result.st_mode) or bool(attributes & reparse_point) + + +def _file_identity(result: os.stat_result) -> tuple[int, int]: + return int(result.st_dev), int(result.st_ino) + + +_WINDOWS_UNTRUSTED_WRITE_MASK = ( + 0x00000040 # FILE_DELETE_CHILD + | 0x00010000 # DELETE + | 0x00040000 # WRITE_DAC + | 0x00080000 # WRITE_OWNER + | 0x10000000 # GENERIC_ALL + | 0x40000000 # GENERIC_WRITE +) + + +def _windows_drive_type(root: str) -> int: + """Return the Win32 volume type for a normalized drive root.""" + + try: + get_drive_type = ctypes.WinDLL("Kernel32", use_last_error=True).GetDriveTypeW + get_drive_type.argtypes = (ctypes.c_wchar_p,) + get_drive_type.restype = ctypes.c_uint + return int(get_drive_type(root)) + except Exception: + return 0 # DRIVE_UNKNOWN; callers fail closed. + + +def _require_windows_local_volume( + path: Path, + *, + drive_type_getter: Optional[Callable[[str], int]] = None, +) -> None: + """Require a drive-letter path on a known fixed local Windows volume.""" + + raw_path = os.fspath(path) + drive, _tail = ntpath.splitdrive(raw_path) + if not re.fullmatch(r"[A-Za-z]:", drive): + raise RuntimeError("operational_review_replay_local_volume_required") + root = drive + "\\" + get_drive_type = drive_type_getter or _windows_drive_type + if get_drive_type(root) != 3: # DRIVE_FIXED; excludes remote/unknown volumes. + raise RuntimeError("operational_review_replay_local_volume_required") + + +def _validate_windows_private_acl( + owner_sid: str, + current_user_sid: str, + is_protected: bool, + entries: tuple[tuple[int, int, int, str], ...], +) -> None: + """Require an explicitly protected current-user ACL for stored state.""" + + allowed_sids = {current_user_sid, "S-1-5-18", "S-1-5-32-544"} + if owner_sid != current_user_sid or not is_protected: + raise RuntimeError("operational_review_replay_windows_acl_invalid") + for ace_type, ace_flags, _mask, trustee_sid in entries: + if ace_type not in (0, 1) or ace_flags & 0x10 or trustee_sid not in allowed_sids: + raise RuntimeError("operational_review_replay_windows_acl_invalid") + + +def _validate_windows_ancestor_acl( + owner_sid: str, + current_user_sid: str, + entries: tuple[tuple[int, int, int, str], ...], +) -> None: + """Reject path ancestors that another principal could rename or modify.""" + + trusted_sids = { + current_user_sid, + "S-1-5-18", # Local System + "S-1-5-32-544", # Builtin Administrators + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", # TrustedInstaller + } + if owner_sid not in trusted_sids: + raise RuntimeError("operational_review_replay_windows_ancestor_acl_invalid") + for ace_type, ace_flags, mask, trustee_sid in entries: + if ace_type not in (0, 1) or ace_flags & ~0x1F: + raise RuntimeError("operational_review_replay_windows_ancestor_acl_invalid") + if ace_type == 0 and not ace_flags & 0x08 and trustee_sid not in trusted_sids: + if mask & _WINDOWS_UNTRUSTED_WRITE_MASK: + raise RuntimeError("operational_review_replay_windows_ancestor_acl_invalid") + + +def _validate_windows_sidecar_acl( + owner_sid: str, + current_user_sid: str, + entries: tuple[tuple[int, int, int, str], ...], +) -> None: + """Accept only safe inherited ACEs from the already protected DB folder.""" + + allowed_sids = {current_user_sid, "S-1-5-18", "S-1-5-32-544"} + if owner_sid != current_user_sid: + raise RuntimeError("operational_review_replay_windows_sidecar_acl_invalid") + for ace_type, ace_flags, _mask, trustee_sid in entries: + if ( + ace_type not in (0, 1) + or ace_flags & ~0x1F + or ace_flags & 0x08 + or trustee_sid not in allowed_sids + ): + raise RuntimeError("operational_review_replay_windows_sidecar_acl_invalid") + if not any( + ace_type == 0 and trustee == current_user_sid for ace_type, _, _, trustee in entries + ): + raise RuntimeError("operational_review_replay_windows_sidecar_acl_invalid") + + +def _windows_acl_details_for_handle( + handle: int, +) -> tuple[str, str, bool, tuple[tuple[int, int, int, str], ...]]: + """Read an object's owner, DACL protection bit, and simple ACEs by handle.""" + + try: + from ctypes import wintypes + + from .credential_resolver import _current_windows_user_sid, _windows_sid_to_string + + advapi32 = ctypes.WinDLL("Advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + get_security_info = advapi32.GetSecurityInfo + get_security_info.argtypes = ( + wintypes.HANDLE, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ) + get_security_info.restype = wintypes.DWORD + owner_sid = ctypes.c_void_p() + group_sid = ctypes.c_void_p() + dacl = ctypes.c_void_p() + sacl = ctypes.c_void_p() + descriptor = ctypes.c_void_p() + result = get_security_info( + wintypes.HANDLE(handle), + 1, + 0x00000001 | 0x00000004, + ctypes.byref(owner_sid), + ctypes.byref(group_sid), + ctypes.byref(dacl), + ctypes.byref(sacl), + ctypes.byref(descriptor), + ) + if result != 0 or not descriptor: + raise RuntimeError("operational_review_replay_windows_acl_unavailable") + try: + get_control = advapi32.GetSecurityDescriptorControl + get_control.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.WORD), + ctypes.POINTER(wintypes.DWORD), + ) + get_control.restype = wintypes.BOOL + control = wintypes.WORD() + revision = wintypes.DWORD() + if not get_control(descriptor, ctypes.byref(control), ctypes.byref(revision)): + raise RuntimeError("operational_review_replay_windows_acl_unavailable") + + get_owner = advapi32.GetSecurityDescriptorOwner + get_owner.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.BOOL), + ) + get_owner.restype = wintypes.BOOL + owner_defaulted = wintypes.BOOL() + if ( + not get_owner(descriptor, ctypes.byref(owner_sid), ctypes.byref(owner_defaulted)) + or owner_defaulted.value + or not owner_sid + ): + raise RuntimeError("operational_review_replay_windows_acl_invalid") + + get_dacl = advapi32.GetSecurityDescriptorDacl + get_dacl.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(wintypes.BOOL), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.BOOL), + ) + get_dacl.restype = wintypes.BOOL + dacl_present = wintypes.BOOL() + dacl_defaulted = wintypes.BOOL() + if ( + not get_dacl( + descriptor, + ctypes.byref(dacl_present), + ctypes.byref(dacl), + ctypes.byref(dacl_defaulted), + ) + or not dacl_present.value + or dacl_defaulted.value + or not dacl + ): + raise RuntimeError("operational_review_replay_windows_acl_invalid") + + class _AclSizeInformation(ctypes.Structure): + _fields_ = ( + ("AceCount", wintypes.DWORD), + ("AclBytesInUse", wintypes.DWORD), + ("AclBytesFree", wintypes.DWORD), + ) + + get_acl_information = advapi32.GetAclInformation + get_acl_information.argtypes = ( + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ) + get_acl_information.restype = wintypes.BOOL + size_info = _AclSizeInformation() + if not get_acl_information(dacl, ctypes.byref(size_info), ctypes.sizeof(size_info), 2): + raise RuntimeError("operational_review_replay_windows_acl_unavailable") + + get_ace = advapi32.GetAce + get_ace.argtypes = ( + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ) + get_ace.restype = wintypes.BOOL + is_valid_sid = advapi32.IsValidSid + is_valid_sid.argtypes = (ctypes.c_void_p,) + is_valid_sid.restype = wintypes.BOOL + get_length_sid = advapi32.GetLengthSid + get_length_sid.argtypes = (ctypes.c_void_p,) + get_length_sid.restype = wintypes.DWORD + entries = [] + for index in range(int(size_info.AceCount)): + ace_pointer = ctypes.c_void_p() + if not get_ace(dacl, index, ctypes.byref(ace_pointer)) or not ace_pointer: + raise RuntimeError("operational_review_replay_windows_acl_unavailable") + header = ctypes.string_at(ace_pointer, 4) + ace_type = header[0] + ace_flags = header[1] + ace_size = int.from_bytes(header[2:4], byteorder="little") + if ace_size < 8 or ace_size > int(size_info.AclBytesInUse): + raise RuntimeError("operational_review_replay_windows_acl_invalid") + mask = int.from_bytes(ctypes.string_at(ace_pointer.value + 4, 4), "little") + trustee_sid = "" + if ace_type in (0, 1): + if ace_size < 12: + raise RuntimeError("operational_review_replay_windows_acl_invalid") + sid = ctypes.c_void_p(ace_pointer.value + 8) + if not is_valid_sid(sid) or get_length_sid(sid) > ace_size - 8: + raise RuntimeError("operational_review_replay_windows_acl_invalid") + trustee_sid = _windows_sid_to_string(advapi32, kernel32, sid) + entries.append((ace_type, ace_flags, mask, trustee_sid)) + + owner_text = _windows_sid_to_string(advapi32, kernel32, owner_sid) + current_user = _current_windows_user_sid(advapi32, kernel32) + return ( + owner_text, + current_user, + bool(control.value & 0x1000), + tuple(entries), + ) + finally: + local_free = kernel32.LocalFree + local_free.argtypes = (ctypes.c_void_p,) + local_free.restype = ctypes.c_void_p + local_free(descriptor) + except RuntimeError: + raise + except Exception: + raise RuntimeError("operational_review_replay_windows_acl_unavailable") from None + + +def _protect_windows_path_acl(path: Path, *, is_directory: bool) -> None: + """Install a protected current-user ACL through an identity-checked handle.""" + + before = os.lstat(str(path)) + expected_type = stat.S_ISDIR if is_directory else stat.S_ISREG + if _is_link_or_reparse(before) or not expected_type(before.st_mode): + raise RuntimeError("operational_review_replay_path_invalid") + try: + from ctypes import wintypes + + import msvcrt + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + create_file.restype = wintypes.HANDLE + desired_access = 0x00020000 | 0x00040000 | 0x00080000 | 0x00000080 + share_read_write = 0x00000001 | 0x00000002 + open_reparse_point = 0x00200000 + backup_semantics = 0x02000000 if is_directory else 0 + handle = create_file( + str(path), + desired_access, + share_read_write, + None, + 3, + open_reparse_point | backup_semantics, + None, + ) + invalid_handle = ctypes.c_void_p(-1).value + if handle == invalid_handle or handle == -1: + raise RuntimeError("operational_review_replay_windows_acl_unavailable") + descriptor = None + try: + descriptor = msvcrt.open_osfhandle( + handle, + os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0), + ) + opened = os.fstat(descriptor) + if _file_identity(opened) != _file_identity(before): + raise RuntimeError("operational_review_replay_path_changed") + from .ctp_private_config_setup import _set_windows_owner_acl + + _set_windows_owner_acl(descriptor, directory=is_directory) + from .credential_resolver import _windows_os_handle + + owner_sid, current_user_sid, protected, entries = _windows_acl_details_for_handle( + _windows_os_handle(descriptor) + ) + _validate_windows_private_acl(owner_sid, current_user_sid, protected, entries) + after = os.lstat(str(path)) + if _is_link_or_reparse(after) or _file_identity(after) != _file_identity(opened): + raise RuntimeError("operational_review_replay_path_changed") + finally: + if descriptor is not None: + os.close(descriptor) + else: + kernel32.CloseHandle.argtypes = (wintypes.HANDLE,) + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle(handle) + except RuntimeError: + raise + except Exception: + raise RuntimeError("operational_review_replay_windows_acl_unavailable") from None + + +@dataclass(frozen=True, repr=False) +class CtpSimNowOperationalReviewKeyPolicy: + """One resolved HMAC key plus the exact review authority it may exercise. + + The resolver is the trust boundary. This value must be constructed from + code-owned policy and protected key material by the eventual caller; tests + use an explicit fake resolver and disposable key. + """ + + key_id: str + review_authority_id: str + key_bytes: bytes = field(repr=False) + current_revocation_epoch: int + allowed_account_fingerprints: FrozenSet[str] + allowed_window_ids: FrozenSet[str] + allowed_session_identity_digests: FrozenSet[str] + allowed_trading_days: FrozenSet[str] + allowed_risk_limits_digests: FrozenSet[str] + allowed_action_kinds: FrozenSet[str] + revoked: bool = False + max_ttl_seconds: float = _MAX_TTL_SECONDS + + def __post_init__(self) -> None: + for name in ("key_id", "review_authority_id"): + value = getattr(self, name) + if type(value) is not str or _ID_RE.fullmatch(value) is None: + _reject("operational_review_policy_invalid") + if type(self.key_bytes) is not bytes or len(self.key_bytes) < 32: + _reject("operational_review_key_unavailable") + if type(self.current_revocation_epoch) is not int or self.current_revocation_epoch <= 0: + _reject("operational_review_policy_invalid") + if type(self.revoked) is not bool: + _reject("operational_review_policy_invalid") + + normalizers = { + "allowed_account_fingerprints": _SHA256_RE, + "allowed_window_ids": _ID_RE, + "allowed_session_identity_digests": _SHA256_RE, + "allowed_trading_days": re.compile(r"^[0-9]{8}$"), + "allowed_risk_limits_digests": _SHA256_RE, + "allowed_action_kinds": re.compile(r"^(SUBMIT|CANCEL)$"), + } + for name, pattern in normalizers.items(): + object.__setattr__(self, name, _normalized_set(getattr(self, name), name, pattern)) + if not self.allowed_action_kinds.issubset(_ACTIONS): + _reject("operational_review_policy_invalid") + if ( + type(self.max_ttl_seconds) not in (int, float) + or not math.isfinite(float(self.max_ttl_seconds)) + or not 0 < float(self.max_ttl_seconds) <= _MAX_TTL_SECONDS + ): + _reject("operational_review_policy_invalid") + object.__setattr__(self, "max_ttl_seconds", float(self.max_ttl_seconds)) + + +@dataclass(frozen=True, repr=False) +class CtpSimNowSignedOperationalReview: + """HMAC envelope around one exact operational action review.""" + + permit: CtpSimNowOperationalWindowPermit = field(repr=False) + key_id: str + signature_hex: str = field(repr=False) + + def __post_init__(self) -> None: + if type(self.permit) is not CtpSimNowOperationalWindowPermit: + _reject("operational_review_envelope_invalid") + if type(self.key_id) is not str or _ID_RE.fullmatch(self.key_id) is None: + _reject("operational_review_envelope_invalid") + if ( + type(self.signature_hex) is not str + or _SIGNATURE_RE.fullmatch(self.signature_hex) is None + ): + _reject("operational_review_envelope_invalid") + + def signed_payload(self) -> bytes: + """Return the domain-separated canonical payload covered by HMAC.""" + + permit = self.permit + request = permit.binding + scope = request.scope + payload = { + "version": 1, + "key_id": self.key_id, + "review_authority_id": permit.review_authority_id, + "review_digest": permit.review_digest, + "permit_id": permit.permit_id, + "revocation_epoch": permit.revocation_epoch, + "issued_at_utc": float(permit.issued_at_utc), + "expires_at_utc": float(permit.expires_at_utc), + "account_fingerprint_sha256": scope.account_fingerprint_sha256, + "window_id": scope.window_id, + "session_id": scope.session_id, + "trading_day": scope.trading_day, + "connection_generation": scope.connection_generation, + "session_identity_digest": scope.session_identity_digest, + "risk_limits_digest": scope.risk_limits.digest, + "scope_digest": scope.scope_digest, + "action_kind": request.action_kind, + "action_id": request.action_id, + "action_digest": request.action_digest, + "approval_digest": request.approval_digest, + "requested_quantity": request.requested_quantity, + "requested_notional": ( + None + if request.requested_notional is None + else format(request.requested_notional, "f") + ), + "target_digest": request.target_digest, + "target_remaining_quantity": request.target_remaining_quantity, + "request_digest": request.request_digest, + } + return _DOMAIN + _canonical_bytes(payload) + + +class CtpSimNowOperationalReviewSource(Protocol): + """Source of independently signed review envelopes; no source is bundled.""" + + def issue_action_review( + self, request: CtpSimNowOperationalActionRequest + ) -> CtpSimNowSignedOperationalReview: ... + + +class CtpSimNowOperationalReviewKeyResolver(Protocol): + """Resolve active key and issuer policy by key ID; missing means deny.""" + + def resolve(self, key_id: str) -> Optional[CtpSimNowOperationalReviewKeyPolicy]: ... + + +class CtpSimNowOperationalReviewReplayGuard(Protocol): + """Atomically reserve both permit ID and scope-bound action replay key.""" + + def claim_once(self, permit_id: str, action_replay_key: str, request_digest: str) -> bool: + """Return literal True only for the first durable claim of either key.""" + + +class LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard: + """Durably reserve review replay keys in an explicitly supplied local DB. + + This is a local-only storage primitive. Windows paths must be drive-letter + paths on a volume reported as fixed local storage. Construction and import + install no path, trust material, review source, or execution route. The + caller must choose a database under an existing private directory and + inject this instance into the reviewer. Claims use one SQLite transaction + across both replay keys; any lock, permission, identity, or database error + fails closed. + """ + + LOCAL_ONLY = True + NO_WRITE = True + + _SCHEMA_VERSION = 1 + _SCHEMA_MARKER = "simnow-operational-review-replay-local-only-v1" + _BUSY_TIMEOUT_SECONDS = 0.5 + _META_TABLE = "simnow_review_replay_meta" + _PERMIT_TABLE = "simnow_review_permit_claims" + _ACTION_TABLE = "simnow_review_action_claims" + + def __init__(self, path: Path) -> None: + if not isinstance(path, Path) or not path.is_absolute() or ".." in path.parts: + raise ValueError("operational_review_replay_path_invalid") + if not path.name: + raise ValueError("operational_review_replay_path_invalid") + if os.name == "nt": + try: + _require_windows_local_volume(path) + except RuntimeError as exc: + raise ValueError(str(exc)) from None + self._path = path + self._lock = threading.RLock() + + def claim_once(self, permit_id: str, action_replay_key: str, request_digest: str) -> bool: + """Atomically claim both replay keys, returning False on either duplicate.""" + + if type(permit_id) is not str or _ID_RE.fullmatch(permit_id) is None: + raise ValueError("operational_review_replay_claim_invalid") + if ( + type(action_replay_key) is not str + or _SHA256_RE.fullmatch(action_replay_key) is None + or type(request_digest) is not str + or _SHA256_RE.fullmatch(request_digest) is None + ): + raise ValueError("operational_review_replay_claim_invalid") + + with self._lock: + identity = self._prepare_database_file() + connection = None + windows_handles = None + try: + if os.name == "nt": + windows_handles = self._open_windows_storage_handles(identity) + self._secure_windows_sqlite_sidecars() + connection = sqlite3.connect( + str(self._path), + timeout=self._BUSY_TIMEOUT_SECONDS, + isolation_level=None, + ) + connection.execute( + "PRAGMA busy_timeout = " + str(int(self._BUSY_TIMEOUT_SECONDS * 1000)) + ) + connection.execute("PRAGMA foreign_keys = ON") + if connection.execute("PRAGMA foreign_keys").fetchone() != (1,): + raise RuntimeError("operational_review_replay_database_invalid") + if connection.execute("PRAGMA journal_mode").fetchone() != ("delete",): + raise RuntimeError("operational_review_replay_database_invalid") + connection.execute("PRAGMA synchronous = FULL") + self._require_current_identity(identity, windows_handles) + self._check_database_integrity(connection) + connection.execute("BEGIN IMMEDIATE") + self._ensure_schema(connection) + self._require_current_identity(identity, windows_handles) + try: + connection.execute( + "INSERT INTO " + self._PERMIT_TABLE + " (permit_id, request_digest) " + "VALUES (?, ?)", + (permit_id, request_digest), + ) + connection.execute( + "INSERT INTO " + + self._ACTION_TABLE + + " (action_replay_key, permit_id, request_digest) VALUES (?, ?, ?)", + (action_replay_key, permit_id, request_digest), + ) + except sqlite3.IntegrityError: + if os.name == "nt": + self._secure_windows_sqlite_sidecars() + connection.execute("ROLLBACK") + return False + if os.name == "nt": + self._secure_windows_sqlite_sidecars() + self._require_current_identity(identity, windows_handles) + connection.execute("COMMIT") + self._require_current_identity(identity, windows_handles) + return True + except (OSError, sqlite3.Error, RuntimeError) as exc: + if connection is not None and connection.in_transaction: + try: + connection.execute("ROLLBACK") + except sqlite3.Error: + pass + raise RuntimeError("operational_review_replay_unavailable") from exc + finally: + if connection is not None: + connection.close() + if windows_handles is not None: + for descriptor in reversed(windows_handles): + os.close(descriptor) + + def _prepare_database_file(self) -> tuple[tuple[tuple[str, int, int], ...], tuple[int, int]]: + parent_identity = self._verify_parent_directory() + try: + entry = os.lstat(str(self._path)) + except FileNotFoundError: + flags = os.O_CREAT | os.O_EXCL | os.O_RDWR + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(str(self._path), flags, 0o600) + except FileExistsError: + descriptor = None + if descriptor is not None: + try: + if hasattr(os, "fchmod"): + os.fchmod(descriptor, 0o600) + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode): + raise RuntimeError("operational_review_replay_path_invalid") + finally: + os.close(descriptor) + entry = os.lstat(str(self._path)) + if _is_link_or_reparse(entry) or not stat.S_ISREG(entry.st_mode): + raise RuntimeError("operational_review_replay_path_invalid") + if getattr(entry, "st_nlink", 1) != 1: + raise RuntimeError("operational_review_replay_path_invalid") + self._require_private_file(entry) + if os.name == "nt": + # Another process may observe a newly created DB before its creator + # finishes hardening the ACL. The already verified private parent + # excludes other users from creating or replacing this entry, so + # applying the same protected owner ACL here makes initialization + # idempotent across simultaneous first claims. + _protect_windows_path_acl(self._path, is_directory=False) + entry = os.lstat(str(self._path)) + if _is_link_or_reparse(entry) or not stat.S_ISREG(entry.st_mode): + raise RuntimeError("operational_review_replay_path_invalid") + if getattr(entry, "st_nlink", 1) != 1: + raise RuntimeError("operational_review_replay_path_invalid") + identity = _file_identity(entry) + if parent_identity != self._verify_parent_directory(): + raise RuntimeError("operational_review_replay_path_changed") + if identity != _file_identity(os.lstat(str(self._path))): + raise RuntimeError("operational_review_replay_path_changed") + return parent_identity, identity + + def _verify_parent_directory(self) -> tuple[tuple[str, int, int], ...]: + if os.name == "nt": + _require_windows_local_volume(self._path) + absolute = Path(os.path.abspath(str(self._path.parent))) + parts = [Path(absolute.anchor)] + current = parts[0] + for part in absolute.parts: + if part == absolute.anchor: + continue + current = current / part + parts.append(current) + + identities = [] + for directory in parts: + try: + entry = os.lstat(str(directory)) + except OSError as exc: + raise RuntimeError("operational_review_replay_path_unavailable") from exc + if _is_link_or_reparse(entry) or not stat.S_ISDIR(entry.st_mode): + raise RuntimeError("operational_review_replay_path_invalid") + identities.append((os.path.normcase(str(directory)),) + _file_identity(entry)) + + if os.name != "nt": + private_directory = os.lstat(str(self._path.parent)) + effective_uid = os.geteuid() + if ( + private_directory.st_uid != effective_uid + or stat.S_IMODE(private_directory.st_mode) & 0o077 + or stat.S_IMODE(private_directory.st_mode) & stat.S_IRUSR == 0 + or stat.S_IMODE(private_directory.st_mode) & stat.S_IWUSR == 0 + or stat.S_IMODE(private_directory.st_mode) & stat.S_IXUSR == 0 + ): + raise RuntimeError("operational_review_replay_permissions_invalid") + else: + for directory in parts[:-1]: + self._verify_windows_ancestor_directory(directory) + descriptor, _opened = self._verify_windows_private_path(absolute, is_directory=True) + os.close(descriptor) + return tuple(identities) + + def _require_private_file(self, entry: os.stat_result) -> None: + if os.name == "nt": + return + mode = stat.S_IMODE(entry.st_mode) + if ( + entry.st_uid != os.geteuid() + or mode & 0o077 + or mode & stat.S_IRUSR == 0 + or mode & stat.S_IWUSR == 0 + ): + raise RuntimeError("operational_review_replay_permissions_invalid") + + def _require_current_identity( + self, + identity: tuple[tuple[tuple[str, int, int], ...], tuple[int, int]], + windows_handles: Optional[tuple[int, int]] = None, + ) -> None: + parent_identity, database_identity = identity + if parent_identity != self._verify_parent_directory(): + raise RuntimeError("operational_review_replay_path_changed") + try: + entry = os.lstat(str(self._path)) + except OSError as exc: + raise RuntimeError("operational_review_replay_path_unavailable") from exc + if ( + _is_link_or_reparse(entry) + or not stat.S_ISREG(entry.st_mode) + or database_identity != _file_identity(entry) + ): + raise RuntimeError("operational_review_replay_path_changed") + self._require_private_file(entry) + if os.name == "nt": + self._verify_windows_private_handles(identity, windows_handles) + + def _verify_windows_ancestor_directory(self, directory: Path) -> None: + from .credential_resolver import _open_windows_metadata_handle, _windows_os_handle + + before = os.lstat(str(directory)) + if _is_link_or_reparse(before) or not stat.S_ISDIR(before.st_mode): + raise RuntimeError("operational_review_replay_path_invalid") + descriptor = _open_windows_metadata_handle(directory, is_directory=True) + try: + opened = os.fstat(descriptor) + if _file_identity(opened) != _file_identity(before): + raise RuntimeError("operational_review_replay_path_changed") + owner, current_user, _protected, entries = _windows_acl_details_for_handle( + _windows_os_handle(descriptor) + ) + _validate_windows_ancestor_acl(owner, current_user, entries) + after = os.lstat(str(directory)) + if _is_link_or_reparse(after) or _file_identity(after) != _file_identity(opened): + raise RuntimeError("operational_review_replay_path_changed") + finally: + os.close(descriptor) + + def _verify_windows_private_path( + self, + path: Path, + *, + is_directory: bool, + expected_identity: Optional[tuple[int, int]] = None, + ) -> tuple[int, os.stat_result]: + from .credential_resolver import _open_windows_metadata_handle, _windows_os_handle + + before = os.lstat(str(path)) + expected_type = stat.S_ISDIR if is_directory else stat.S_ISREG + if _is_link_or_reparse(before) or not expected_type(before.st_mode): + raise RuntimeError("operational_review_replay_path_invalid") + if not is_directory and getattr(before, "st_nlink", 1) != 1: + raise RuntimeError("operational_review_replay_path_invalid") + descriptor = _open_windows_metadata_handle(path, is_directory=is_directory) + try: + opened = os.fstat(descriptor) + if _file_identity(opened) != _file_identity(before) or ( + expected_identity is not None and expected_identity != _file_identity(opened) + ): + raise RuntimeError("operational_review_replay_path_changed") + owner, current_user, protected, entries = _windows_acl_details_for_handle( + _windows_os_handle(descriptor) + ) + _validate_windows_private_acl(owner, current_user, protected, entries) + after = os.lstat(str(path)) + if _is_link_or_reparse(after) or _file_identity(after) != _file_identity(opened): + raise RuntimeError("operational_review_replay_path_changed") + return descriptor, opened + except BaseException: + os.close(descriptor) + raise + + def _open_windows_storage_handles( + self, identity: tuple[tuple[tuple[str, int, int], ...], tuple[int, int]] + ) -> tuple[int, int]: + parent_identity, database_identity = identity + parent_descriptor, _parent_stat = self._verify_windows_private_path( + self._path.parent, + is_directory=True, + expected_identity=parent_identity[-1][1:], + ) + try: + database_descriptor, _database_stat = self._verify_windows_private_path( + self._path, + is_directory=False, + expected_identity=database_identity, + ) + except BaseException: + os.close(parent_descriptor) + raise + return parent_descriptor, database_descriptor + + def _verify_windows_private_handles( + self, + identity: tuple[tuple[tuple[str, int, int], ...], tuple[int, int]], + handles: Optional[tuple[int, int]], + ) -> None: + if handles is None: + raise RuntimeError("operational_review_replay_windows_acl_unavailable") + from .credential_resolver import _windows_os_handle + + parent_identity, database_identity = identity + for descriptor, path, expected in ( + (handles[0], self._path.parent, parent_identity[-1][1:]), + (handles[1], self._path, database_identity), + ): + opened = os.fstat(descriptor) + if _file_identity(opened) != expected: + raise RuntimeError("operational_review_replay_path_changed") + owner, current_user, protected, entries = _windows_acl_details_for_handle( + _windows_os_handle(descriptor) + ) + _validate_windows_private_acl(owner, current_user, protected, entries) + current = os.lstat(str(path)) + if _is_link_or_reparse(current) or _file_identity(current) != expected: + raise RuntimeError("operational_review_replay_path_changed") + + def _secure_windows_sqlite_sidecars(self) -> None: + """Verify rollback journals and reject unsupported WAL sidecars.""" + + journal_path = Path(str(self._path) + "-journal") + for suffix in ("-wal", "-shm"): + unsupported = Path(str(self._path) + suffix) + try: + entry = os.lstat(str(unsupported)) + except FileNotFoundError: + continue + if _is_link_or_reparse(entry) or not stat.S_ISREG(entry.st_mode): + raise RuntimeError("operational_review_replay_sidecar_invalid") + raise RuntimeError("operational_review_replay_wal_unsupported") + + try: + journal_entry = os.lstat(str(journal_path)) + except FileNotFoundError: + return + if ( + _is_link_or_reparse(journal_entry) + or not stat.S_ISREG(journal_entry.st_mode) + or getattr(journal_entry, "st_nlink", 1) != 1 + ): + raise RuntimeError("operational_review_replay_sidecar_invalid") + if os.name == "nt": + self._verify_windows_sidecar_path(journal_path, journal_entry) + elif stat.S_IMODE(journal_entry.st_mode) & 0o077: + raise RuntimeError("operational_review_replay_permissions_invalid") + + def _verify_windows_sidecar_path(self, path: Path, before: os.stat_result) -> None: + from .credential_resolver import _open_windows_metadata_handle, _windows_os_handle + + descriptor = _open_windows_metadata_handle(path, is_directory=False) + try: + opened = os.fstat(descriptor) + if _file_identity(opened) != _file_identity(before): + raise RuntimeError("operational_review_replay_path_changed") + owner, current_user, _protected, entries = _windows_acl_details_for_handle( + _windows_os_handle(descriptor) + ) + _validate_windows_sidecar_acl(owner, current_user, entries) + after = os.lstat(str(path)) + if ( + _is_link_or_reparse(after) + or not stat.S_ISREG(after.st_mode) + or _file_identity(after) != _file_identity(opened) + ): + raise RuntimeError("operational_review_replay_path_changed") + finally: + os.close(descriptor) + + def _check_database_integrity(self, connection: sqlite3.Connection) -> None: + try: + rows = connection.execute("PRAGMA quick_check").fetchall() + except sqlite3.Error as exc: + raise RuntimeError("operational_review_replay_corrupt") from exc + if rows != [("ok",)]: + raise RuntimeError("operational_review_replay_corrupt") + + def _ensure_schema(self, connection: sqlite3.Connection) -> None: + expected = {self._META_TABLE, self._PERMIT_TABLE, self._ACTION_TABLE} + all_objects = connection.execute( + "SELECT type, name FROM sqlite_master ORDER BY type, name" + ).fetchall() + internal_objects = [row for row in all_objects if row[1].startswith("sqlite_")] + if any( + object_type != "index" or not name.startswith("sqlite_autoindex_") + for object_type, name in internal_objects + ): + raise RuntimeError("operational_review_replay_schema_invalid") + objects = [row for row in all_objects if not row[1].startswith("sqlite_")] + if not objects: + connection.execute( + "CREATE TABLE " + + self._META_TABLE + + " (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), " + "schema_version INTEGER NOT NULL, marker TEXT NOT NULL)" + ) + connection.execute( + "CREATE TABLE " + + self._PERMIT_TABLE + + " (permit_id TEXT PRIMARY KEY NOT NULL, request_digest TEXT NOT NULL)" + ) + connection.execute( + "CREATE TABLE " + + self._ACTION_TABLE + + " (action_replay_key TEXT PRIMARY KEY NOT NULL, " + "permit_id TEXT NOT NULL UNIQUE, request_digest TEXT NOT NULL, " + "FOREIGN KEY (permit_id) REFERENCES " + self._PERMIT_TABLE + " (permit_id))" + ) + connection.execute( + "INSERT INTO " + + self._META_TABLE + + " (singleton, schema_version, marker) VALUES (1, ?, ?)", + (self._SCHEMA_VERSION, self._SCHEMA_MARKER), + ) + return + + if set(objects) != {("table", name) for name in expected} or len(objects) != len(expected): + raise RuntimeError("operational_review_replay_schema_invalid") + metadata = connection.execute( + "SELECT singleton, schema_version, marker FROM " + self._META_TABLE + ).fetchall() + if metadata != [(1, self._SCHEMA_VERSION, self._SCHEMA_MARKER)]: + raise RuntimeError("operational_review_replay_schema_invalid") + expected_columns = { + self._META_TABLE: ( + ("singleton", "INTEGER", 0, 1), + ("schema_version", "INTEGER", 1, 0), + ("marker", "TEXT", 1, 0), + ), + self._PERMIT_TABLE: ( + ("permit_id", "TEXT", 1, 1), + ("request_digest", "TEXT", 1, 0), + ), + self._ACTION_TABLE: ( + ("action_replay_key", "TEXT", 1, 1), + ("permit_id", "TEXT", 1, 0), + ("request_digest", "TEXT", 1, 0), + ), + } + for table, expected_column_rows in expected_columns.items(): + actual_column_rows = connection.execute("PRAGMA table_info(" + table + ")").fetchall() + actual_columns = tuple( + (row[1], row[2].upper(), row[3], row[5]) for row in actual_column_rows + ) + if actual_columns != expected_column_rows: + raise RuntimeError("operational_review_replay_schema_invalid") + + action_unique_indexes = [] + for _sequence, index_name, is_unique, origin, is_partial in connection.execute( + "PRAGMA index_list(" + self._ACTION_TABLE + ")" + ): + if is_unique: + index_columns = tuple( + row[2] for row in connection.execute("PRAGMA index_info(" + index_name + ")") + ) + action_unique_indexes.append((index_columns, origin, is_partial)) + if set(action_unique_indexes) != { + (("action_replay_key",), "pk", 0), + (("permit_id",), "u", 0), + }: + raise RuntimeError("operational_review_replay_schema_invalid") + foreign_keys = connection.execute( + "PRAGMA foreign_key_list(" + self._ACTION_TABLE + ")" + ).fetchall() + if len(foreign_keys) != 1 or ( + foreign_keys[0][2], + foreign_keys[0][3], + foreign_keys[0][4], + ) != (self._PERMIT_TABLE, "permit_id", "permit_id"): + raise RuntimeError("operational_review_replay_schema_invalid") + if connection.execute("PRAGMA foreign_key_check").fetchall(): + raise RuntimeError("operational_review_replay_corrupt") + + +class HmacCtpSimNowOperationalWindowReviewer: + """Verify signed SimNow reviews with injected trust, UTC, and replay state. + + There are no default keys, policy, UTC source, review source, or replay + store. The signed claims bind the whole operational scope and action. + A valid result is still not an execution capability or F14 writer fence. + """ + + def __init__( + self, + *, + review_source: CtpSimNowOperationalReviewSource, + key_resolver: CtpSimNowOperationalReviewKeyResolver, + trusted_utc: Callable[[], object], + replay_guard: CtpSimNowOperationalReviewReplayGuard, + ) -> None: + if not callable(getattr(review_source, "issue_action_review", None)): + _reject("operational_review_source_required") + if not callable(getattr(key_resolver, "resolve", None)): + _reject("operational_review_key_resolver_required") + if not callable(trusted_utc): + _reject("operational_review_trusted_utc_required") + if not callable(getattr(replay_guard, "claim_once", None)): + _reject("operational_review_replay_guard_required") + self._review_source = review_source + self._key_resolver = key_resolver + self._trusted_utc = trusted_utc + self._replay_guard = replay_guard + self._lock = threading.RLock() + self._accepted: dict[str, CtpSimNowSignedOperationalReview] = {} + + def issue_action_review( + self, request: CtpSimNowOperationalActionRequest + ) -> CtpSimNowOperationalWindowPermit: + if type(request) is not CtpSimNowOperationalActionRequest: + _reject("operational_action_request_required") + try: + envelope = self._review_source.issue_action_review(request) + except Exception: + _reject("operational_window_review_unavailable") + if ( + type(envelope) is not CtpSimNowSignedOperationalReview + or envelope.permit.binding != request + ): + _reject("operational_window_review_scope_mismatch") + self._verify_envelope(envelope, request) + + permit = envelope.permit + action_replay_key = hashlib.sha256( + _REPLAY_DOMAIN + + _canonical_bytes( + {"scope_digest": request.scope.scope_digest, "action_id": request.action_id} + ) + ).hexdigest() + with self._lock: + if permit.permit_id in self._accepted: + _reject("operational_window_review_replayed") + try: + claimed = self._replay_guard.claim_once( + permit.permit_id, action_replay_key, request.request_digest + ) + except Exception: + _reject("operational_window_replay_check_unavailable") + if claimed is not True: + _reject("operational_window_review_replayed") + self._accepted[permit.permit_id] = envelope + return permit + + def assert_action_review_current( + self, + permit: CtpSimNowOperationalWindowPermit, + *, + request: CtpSimNowOperationalActionRequest, + ) -> bool: + if ( + type(permit) is not CtpSimNowOperationalWindowPermit + or type(request) is not CtpSimNowOperationalActionRequest + or permit.binding != request + ): + _reject("operational_window_review_scope_mismatch") + with self._lock: + envelope = self._accepted.get(permit.permit_id) + if envelope is None or envelope.permit != permit: + _reject("operational_window_review_unavailable") + self._verify_envelope(envelope, request) + return True + + def _verify_envelope( + self, + envelope: CtpSimNowSignedOperationalReview, + request: CtpSimNowOperationalActionRequest, + ) -> None: + if envelope.permit.binding != request: + _reject("operational_window_review_scope_mismatch") + try: + policy = self._key_resolver.resolve(envelope.key_id) + except Exception: + _reject("operational_review_key_unavailable") + if ( + type(policy) is not CtpSimNowOperationalReviewKeyPolicy + or policy.key_id != envelope.key_id + or policy.revoked + ): + _reject("operational_review_key_unavailable") + permit = envelope.permit + scope = request.scope + if ( + permit.review_authority_id != policy.review_authority_id + or permit.revocation_epoch != policy.current_revocation_epoch + or scope.account_fingerprint_sha256 not in policy.allowed_account_fingerprints + or scope.window_id not in policy.allowed_window_ids + or scope.session_identity_digest not in policy.allowed_session_identity_digests + or scope.trading_day not in policy.allowed_trading_days + or scope.risk_limits.digest not in policy.allowed_risk_limits_digests + or request.action_kind not in policy.allowed_action_kinds + ): + _reject("operational_review_issuer_policy_rejected") + issued = permit.issued_at_utc + expires = permit.expires_at_utc + ttl = expires - issued + if ttl <= 0 or ttl > policy.max_ttl_seconds: + _reject("operational_window_review_expired") + try: + now = _finite_utc(self._trusted_utc()) + except Exception: + _reject("operational_review_trusted_utc_unavailable") + if now < issued or now >= expires: + _reject("operational_window_review_expired") + expected = hmac.new(policy.key_bytes, envelope.signed_payload(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, envelope.signature_hex): + _reject("operational_window_review_signature_invalid") + + +__all__ = [ + "LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard", + "CtpSimNowOperationalReviewKeyPolicy", + "CtpSimNowOperationalReviewKeyResolver", + "CtpSimNowOperationalReviewReplayGuard", + "CtpSimNowOperationalReviewSource", + "CtpSimNowSignedOperationalReview", + "HmacCtpSimNowOperationalWindowReviewer", +] diff --git a/backtrader_runtime/ctp_simnow_td_trading_readiness.py b/backtrader_runtime/ctp_simnow_td_trading_readiness.py new file mode 100644 index 00000000..d020e60b --- /dev/null +++ b/backtrader_runtime/ctp_simnow_td_trading_readiness.py @@ -0,0 +1,203 @@ +"""Fail-closed gate for unregistered SimNow TD settlement readiness. + +The available readiness adapter only consumes a SettlementInfoConfirm query. +Its native row has ConfirmDate and SettlementID, but no TradingDay. A trusted +current-session TradingDay therefore cannot be associated with the confirmed +settlement without a separately verified SettlementInfo query joined by +account and SettlementID. This module does not have that query pair and +refuses before issuing a provider query. + +The offline join contract lives in +``ctp_simnow_settlement_callback_candidate``. It validates shape only and does +not establish provider provenance or trading readiness. Nothing here grants +write authority or registers a runtime route. +""" + +from __future__ import annotations + +import hashlib +import hmac +import math +from dataclasses import dataclass +from typing import Any, Callable + +from .ctp_simnow_managed_operator import CtpSimNowManagedScopeSelection +from .ctp_simnow_managed_runtime import ( + CtpSimNowNativeReadiness, + CtpSimulationExecutionError, +) +from .ctp_native_shutdown import stop_ctp_native_client +from .ctp_simulation_execution import CtpSimulationExecutionRegistration + + +class CtpSimNowTdTradingReadinessError(CtpSimulationExecutionError): + """Redacted failure while checking current TD settlement readiness.""" + + def __init__(self, reason: str, *, close_state: str = "not_started") -> None: + self.close_state = close_state + super().__init__(reason) + + +@dataclass(frozen=True) +class CtpSimNowTdTradingReadinessConfig: + """Non-secret startup identity used to bind the public TD observation.""" + + md_front: str + td_front: str + broker_id: str + user_id: str + + +@dataclass(frozen=True) +class CtpSimNowTdTradingReadiness: + """Conservative DTO; this adapter currently cannot issue a positive result.""" + + config_digest: str + registration_digest: str + front_pair_set_sha256: str + account_fingerprint_sha256: str + account_fingerprint: str + md_front: str + td_front: str + connection_generation: int + trading_day: str + settlement_query_request_id: int + settlement_proof_source: str = "none" + settlement_readback_verified: bool = False + td_trading_ready: bool = False + md_ready: bool = True + execution_gate_armed: bool = False + write_authority_granted: bool = False + + +def _reject(reason: str) -> None: + raise CtpSimNowTdTradingReadinessError(reason) + + +def _account_fingerprints(broker_id: str, user_id: str) -> tuple[str, str]: + short = hashlib.sha256("{0}:{1}".format(broker_id, user_id).encode("utf-8")).hexdigest()[:16] + return short, hashlib.sha256(("acct_" + short).encode("ascii")).hexdigest() + + +def _validate_selection( + trader_client: Any, + selection: CtpSimNowManagedScopeSelection, + config: CtpSimNowTdTradingReadinessConfig, + native_readiness: CtpSimNowNativeReadiness, + timeout_seconds: float, +) -> CtpSimulationExecutionRegistration: + if type(selection) is not CtpSimNowManagedScopeSelection: + _reject("managed_simnow_selection_required") + registration = selection.execution_registration + if type(registration) is not CtpSimulationExecutionRegistration: + _reject("managed_simnow_registration_required") + if type(config) is not CtpSimNowTdTradingReadinessConfig: + _reject("managed_simnow_td_config_required") + if type(native_readiness) is not CtpSimNowNativeReadiness: + _reject("managed_simnow_prior_native_readiness_required") + if ( + type(timeout_seconds) not in (int, float) + or not math.isfinite(float(timeout_seconds)) + or not 0 < float(timeout_seconds) <= 60 + ): + _reject("managed_simnow_td_timeout_invalid") + identity_values = (config.md_front, config.td_front, config.broker_id, config.user_id) + if any( + type(value) is not str or not value or value != value.strip() for value in identity_values + ): + _reject("managed_simnow_td_config_invalid") + if ( + registration.environment != "simnow" + or registration.sdk_profile != "config_front_pair" + or registration.config_digest != selection.config_digest + or registration.front_pair_set_sha256 != selection.front_pair_set_sha256 + or registration.effective_digest != selection.effective_digest + or registration.md_front != selection.front_pair_selection.pair.md_front + or registration.td_front != selection.front_pair_selection.pair.td_front + or config.md_front != registration.md_front + or config.td_front != registration.td_front + ): + _reject("managed_simnow_td_selected_scope_mismatch") + if ( + native_readiness.config_digest != selection.config_digest + or native_readiness.registration_digest != registration.digest + or native_readiness.account_fingerprint_sha256 != registration.account_fingerprint_sha256 + or native_readiness.md_front != registration.md_front + or native_readiness.td_front != registration.td_front + or native_readiness.td_ready is not True + or native_readiness.md_ready is not True + or native_readiness.td_trading_ready is not False + ): + _reject("managed_simnow_td_prior_readiness_scope_mismatch") + _, account_digest = _account_fingerprints(config.broker_id, config.user_id) + if not hmac.compare_digest(account_digest, registration.account_fingerprint_sha256): + _reject("managed_simnow_td_account_mismatch") + for name in ( + "get_session_state", + "get_front_binding_state", + "get_query_session_scope", + "get_request_counts", + "verify_settlement_confirmation", + "stop", + ): + if not callable(getattr(trader_client, name, None)): + _reject("managed_simnow_td_public_api_unavailable") + return registration + + +def _close_after_failure(trader_client: Any, failure_cleanup: Callable[[], None] | None) -> bool: + if callable(failure_cleanup): + try: + failure_cleanup() + except BaseException: + # Do not retry a native stop after the shared owner had an + # incomplete close result. + return False + return True + stop_ctp_native_client(trader_client) + return False + + +def verify_ctp_simnow_td_trading_readiness( + trader_client: Any, + selection: CtpSimNowManagedScopeSelection, + config: CtpSimNowTdTradingReadinessConfig, + *, + native_readiness: CtpSimNowNativeReadiness, + failure_cleanup: Callable[[], None] | None = None, + timeout_seconds: float = 5.0, +) -> CtpSimNowTdTradingReadiness: + """Reject confirmation-only evidence before issuing a provider query. + + A SettlementInfoConfirm row's ``ConfirmDate`` is not the active session's + ``TradingDay``. The current adapter has no separately attested + SettlementInfo query to join by exact ``SettlementID``, account, and + session generation. Until that source path exists, this readiness gate + closes the owned session and remains unavailable. + """ + + try: + if not callable(failure_cleanup): + _reject("managed_simnow_td_failure_cleanup_required") + _validate_selection( + trader_client, + selection, + config, + native_readiness, + timeout_seconds, + ) + _reject("managed_simnow_td_settlement_day_binding_unavailable") + except CtpSimNowTdTradingReadinessError as exc: + closed = _close_after_failure(trader_client, failure_cleanup) + raise CtpSimNowTdTradingReadinessError( + exc.reason, + close_state="closed" if closed else "close_failed", + ) from None + + +__all__ = [ + "CtpSimNowTdTradingReadiness", + "CtpSimNowTdTradingReadinessConfig", + "CtpSimNowTdTradingReadinessError", + "verify_ctp_simnow_td_trading_readiness", +] diff --git a/backtrader_runtime/ctp_simulation_execution.py b/backtrader_runtime/ctp_simulation_execution.py new file mode 100644 index 00000000..03e23f1d --- /dev/null +++ b/backtrader_runtime/ctp_simulation_execution.py @@ -0,0 +1,2612 @@ +"""Narrow, receipt-bound CTP SimNow execution boundary. + +This module is deliberately not registered by the default runtime inventory. +Callers must supply a sealed sandbox runtime, a code-owned registration, an +approval verifier, an account-wide writer fence, a native query evidence +verifier and a native session factory. The factory is called only after static +admission and acquisition of the local cross-process account flow lease. +Production environments are rejected. + +The native factory adapter is responsible for mapping the typed requests to +the SDK's gated ``submit_order_insert`` / ``submit_order_action`` methods. +``CtpSimulationQueryResult.complete`` is only a local data contract: a true +value is not native provenance, complete pagination, or a terminal callback +certificate. The injected evidence verifier must bind native request history, +account, TradingDay, connection generation and full query coverage, then +recheck late callbacks after the last query. An opt-in ``TraderClient`` port +is available in ``ctp_trader_client_port.py``, but the default inventory/CLI +does not construct it and no trusted evidence verifier is provided. A request +return code is never treated as an exchange acknowledgement. Ambiguous +dispatches are journaled as UNKNOWN and can only be cleared by verified order, +trade and position queries. After restart, absent volatile cancel callback +history may resolve a pending intent only when those verified queries prove the +exact target is CANCELED and its trade/position deltas match. The journal then +records ``TARGET_TERMINAL``; it does not claim the cancel request was accepted. + +The local lease protects cooperating processes on the same host, OS user and +state root. It does not fence another OS user, host or SDK process. Before a +native adapter can be used, its injected account-wide writer fence must provide +the single-writer authority for the whole account; the SDK currently derives +the TD flow directory from broker/user and can share it between processes. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import math +import os +import re +import sqlite3 +import stat +import threading +import time +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, Callable, List, NoReturn, Optional, Protocol, Tuple, cast +from urllib.parse import urlsplit + +from .config import CtpPrivateConfig +from .errors import RuntimeConfigError +from .registry import ( + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + require_effective_runtime_config_seal, + validate_runtime_config, +) + +if os.name == "nt": + import msvcrt +else: + import fcntl + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$") +_ORDER_CLIENT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") +_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_EXCHANGE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,15}$") +_SIMNOW_ENVIRONMENT = "simnow" +_SIMNOW_SDK_PROFILE = "config_front_pair" +_MANAGED_CAPABILITIES = ("execution", "risk", "monitor") +_SIDES = frozenset(("BUY", "SELL")) +_ORDER_STATES = frozenset( + ("DISPATCHING", "UNKNOWN", "PENDING", "OPEN", "PARTIAL", "CANCEL_PENDING") +) +_PENDING_CANCEL_STATES = frozenset(("DISPATCHING", "UNKNOWN", "CANCEL_PENDING")) +_TERMINAL_STATUSES = frozenset(("FILLED", "CANCELED", "REJECTED")) +_JOURNAL_SCOPE_SCHEMA_VERSION = 2 +_MAX_JOURNAL_SCOPE_HISTORY = 64 +_FRONT_PAIR_SET_DOMAIN = b"backtrader-ctp-simnow-front-pair-set-v1\0" +_TERMINAL_JOURNAL_STATES = frozenset( + ("FILLED", "CANCELED", "REJECTED", "TARGET_TERMINAL") +) +_POISONED_LEASES: List["CtpAccountFlowLease"] = [] +_POISONED_LEASES_LOCK = threading.Lock() + + +class CtpSimulationExecutionError(ValueError): + """A redacted, fail-closed simulation execution rejection.""" + + def __init__(self, reason: str, message: Optional[str] = None) -> None: + self.reason = reason + super().__init__(message or reason.replace("_", " ")) + + +def _reject(reason: str, message: Optional[str] = None) -> NoReturn: + raise CtpSimulationExecutionError(reason, message) + + +def _digest(value: Any, name: str) -> str: + if type(value) is not str or not _SHA256_RE.fullmatch(value): + _reject("invalid_" + name) + return value + + +def _decimal(value: Any, name: str) -> Decimal: + if type(value) not in (str, int, float, Decimal): + _reject("invalid_" + name) + try: + result = Decimal(str(value)) + except (InvalidOperation, ValueError): + _reject("invalid_" + name) + if not result.is_finite(): + _reject("invalid_" + name) + return result + + +def _canonical(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode( + "ascii" + ) + + +def _front_pair_set_digest(front_pairs: Tuple[Tuple[str, str], ...]) -> str: + """Hash the ordered MD/TD candidate set using the selector contract.""" + + serialized = json.dumps( + [[md_front, td_front] for md_front, td_front in front_pairs], + ensure_ascii=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(_FRONT_PAIR_SET_DOMAIN + serialized).hexdigest() + + +def _validate_front(value: Any, name: str) -> str: + """Require one canonical TCP address without exposing malformed input.""" + + if type(value) is not str or not value or value != value.strip(): + _reject("invalid_" + name) + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + parsed = None + port = None + if ( + parsed is None + or parsed.scheme != "tcp" + or not parsed.hostname + or port is None + or not 1 <= port <= 65535 + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or any(character.isspace() or ord(character) < 0x20 for character in value) + or value != "tcp://{0}:{1}".format(parsed.hostname, port) + ): + _reject("invalid_" + name) + return value + + +def _default_state_root() -> Path: + """One OS-user-owned state root shared by all CTP execution runtimes.""" + + return Path.home() / ".backtrader" / "ctp_simnow_execution" + + +def _prepare_state_root() -> Path: + root = _default_state_root() + root.mkdir(parents=True, exist_ok=True, mode=0o700) + if root.is_symlink(): + _reject("execution_state_root_symlink") + if os.name != "nt": + os.chmod(str(root), 0o700) + return root + + +def _retain_poisoned_lease(lease: "CtpAccountFlowLease") -> None: + """Keep a failed-close owner's OS lock until supervised process exit.""" + + if lease._fd is None: + return + with _POISONED_LEASES_LOCK: + if not any(current is lease for current in _POISONED_LEASES): + _POISONED_LEASES.append(lease) + + +@dataclass(frozen=True) +class CtpSimulationExecutionRegistration: + """One exact code-owned sandbox route and bounded order envelope.""" + + runtime_registration: RegisteredRuntime + environment: str + sdk_profile: str + td_front: str + md_front: str + account_fingerprint_sha256: str + allowed_secrets_ref: str + instrument_id: str + exchange_id: str + hedge_flag: str + allowed_sides: Tuple[str, ...] + quantity_step: int + max_quantity: int + max_gross_position: int + min_price: Decimal + max_price: Decimal + price_tick: Decimal + approval_key_id: str + approval_ttl_seconds: float = 30.0 + front_pair_set_sha256: Optional[str] = None + config_digest: Optional[str] = None + effective_digest: Optional[str] = None + # Profile-backed execution binds the exact selected profile and its + # receipt into this identity; neither value is read from inert legacy + # registration fields. + profile_digest: Optional[str] = None + profile_approval_receipt_digest: Optional[str] = None + + def __post_init__(self) -> None: + if type(self.runtime_registration) is not RegisteredRuntime: + _reject("registration_required") + if self.environment != _SIMNOW_ENVIRONMENT or self.sdk_profile != _SIMNOW_SDK_PROFILE: + _reject("environment_profile_mismatch") + object.__setattr__(self, "td_front", _validate_front(self.td_front, "td_front")) + object.__setattr__(self, "md_front", _validate_front(self.md_front, "md_front")) + _digest(self.account_fingerprint_sha256, "account_fingerprint_sha256") + if ( + type(self.allowed_secrets_ref) is not str + or not self.allowed_secrets_ref + or self.allowed_secrets_ref != self.allowed_secrets_ref.strip() + or any(char.isspace() for char in self.allowed_secrets_ref) + ): + _reject("invalid_secrets_reference") + if type(self.instrument_id) is not str or not _INSTRUMENT_RE.fullmatch(self.instrument_id): + _reject("invalid_instrument") + if type(self.exchange_id) is not str or not _EXCHANGE_RE.fullmatch(self.exchange_id): + _reject("invalid_exchange") + if type(self.hedge_flag) is not str or self.hedge_flag not in ("1", "2", "3"): + _reject("invalid_hedge_flag") + sides = tuple(self.allowed_sides) + if not sides or len(set(sides)) != len(sides) or any(side not in _SIDES for side in sides): + _reject("invalid_allowed_sides") + object.__setattr__(self, "allowed_sides", sides) + if ( + type(self.quantity_step) is not int + or self.quantity_step <= 0 + or type(self.max_quantity) is not int + or self.max_quantity <= 0 + or self.max_quantity % self.quantity_step + or type(self.max_gross_position) is not int + or self.max_gross_position < self.max_quantity + ): + _reject("invalid_quantity_limits") + minimum = _decimal(self.min_price, "minimum_price") + maximum = _decimal(self.max_price, "maximum_price") + tick = _decimal(self.price_tick, "price_tick") + if minimum <= 0 or maximum < minimum or tick <= 0: + _reject("invalid_price_limits") + if (minimum / tick) != (minimum / tick).to_integral_value() or (maximum / tick) != ( + maximum / tick + ).to_integral_value(): + _reject("price_limits_not_tick_aligned") + object.__setattr__(self, "min_price", minimum) + object.__setattr__(self, "max_price", maximum) + object.__setattr__(self, "price_tick", tick) + if type(self.approval_key_id) is not str or not _ID_RE.fullmatch(self.approval_key_id): + _reject("invalid_approval_key_id") + if self.front_pair_set_sha256 is not None: + _digest(self.front_pair_set_sha256, "front_pair_set_sha256") + if self.config_digest is not None: + _digest(self.config_digest, "config_digest") + if self.effective_digest is not None: + _digest(self.effective_digest, "effective_digest") + if self.profile_digest is not None: + _digest(self.profile_digest, "profile_digest") + if self.profile_approval_receipt_digest is not None: + _digest(self.profile_approval_receipt_digest, "profile_approval_receipt_digest") + if (self.profile_digest is None) != (self.profile_approval_receipt_digest is None): + _reject("profile_approval_binding_incomplete") + if ( + type(self.approval_ttl_seconds) not in (int, float) + or not math.isfinite(float(self.approval_ttl_seconds)) + or not 0 < float(self.approval_ttl_seconds) <= 60.0 + ): + _reject("invalid_approval_ttl") + object.__setattr__(self, "approval_ttl_seconds", float(self.approval_ttl_seconds)) + + @property + def digest(self) -> str: + return hashlib.sha256( + _canonical( + { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "allowed_secrets_ref": self.allowed_secrets_ref, + "allowed_sides": self.allowed_sides, + "approval_key_id": self.approval_key_id, + "approval_receipt_digest": ( + self.profile_approval_receipt_digest + if self.profile_digest is not None + else self.runtime_registration.approval_receipt_digest + ), + "config_digest": self.config_digest, + "environment": self.environment, + "effective_digest": self.effective_digest, + "exchange_id": self.exchange_id, + "hedge_flag": self.hedge_flag, + "instrument_id": self.instrument_id, + "max_price": str(self.max_price), + "max_quantity": self.max_quantity, + "max_gross_position": self.max_gross_position, + "min_price": str(self.min_price), + "price_tick": str(self.price_tick), + "profile_digest": self.profile_digest, + "front_pair_set_sha256": self.front_pair_set_sha256, + "quantity_step": self.quantity_step, + "runtime_id": self.runtime_registration.runtime_id, + "sdk_profile": self.sdk_profile, + "td_front": self.td_front, + "md_front": self.md_front, + } + ) + ).hexdigest() + + +def require_ctp_simulation_execution_admission( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + registration: CtpSimulationExecutionRegistration, +) -> CtpSimulationExecutionRegistration: + """Validate a sandbox receipt-required route before factory/credential I/O.""" + + if type(effective) is not EffectiveRuntimeConfig or type(registry) is not RuntimeRegistry: + _reject("sealed_runtime_required") + try: + require_effective_runtime_config_seal(effective, registry) + registered = registry.require_runtime_dir(effective.config.strategy_dir) + except (RuntimeConfigError, Exception): + _reject("sealed_runtime_rejected") + if type(registration) is not CtpSimulationExecutionRegistration: + _reject("code_owned_registration_required") + profile = effective.profile + if registration.effective_digest is not None and registration.effective_digest != effective.effective_digest: + _reject("registration_effective_digest_mismatch") + if ( + registered is not effective.registration + or registered is not registration.runtime_registration + ): + _reject("runtime_registration_mismatch") + if profile is None: + if ( + registration.profile_digest is not None + or registration.profile_approval_receipt_digest is not None + or registered.profiles + or registered.allowed_presets != ("sandbox",) + or registered.allowed_parameter_keys != () + or registered.allowed_secrets_refs != (registration.allowed_secrets_ref,) + or registered.sandbox_write_policy != "receipt_required" + or registered.approval_receipt_digest is None + or registered.available_capabilities != _MANAGED_CAPABILITIES + or registered.offline_managed_execution + ): + _reject("runtime_policy_mismatch") + else: + if ( + type(profile) is not RuntimeProfile + or profile is not registered.profile_for("simulation", "sandbox") + or profile.mode != "simulation" + or profile.preset != "sandbox" + ): + _reject("effective_profile_mismatch") + if ( + registered.allowed_presets != () + or registered.allowed_parameter_keys != () + or registered.allowed_secrets_refs != ("none",) + or registered.available_capabilities != () + or registered.offline_managed_execution is not False + or registered.sandbox_write_policy != "deny" + or registered.approval_receipt_digest is not None + or profile.allowed_parameter_keys != () + or profile.allowed_secrets_refs != (registration.allowed_secrets_ref,) + or profile.available_capabilities != _MANAGED_CAPABILITIES + or profile.offline_managed_execution is not False + or profile.sandbox_write_policy != "receipt_required" + or registration.allowed_secrets_ref != "config_yaml" + or profile.approval_receipt_digest is None + ): + _reject("runtime_profile_policy_mismatch") + if ( + registration.profile_digest != profile.digest + or registration.profile_approval_receipt_digest != profile.approval_receipt_digest + ): + _reject("profile_scope_binding_mismatch") + if ( + registration.effective_digest != effective.effective_digest + or registration.config_digest != effective.config.config_digest + ): + _reject("profile_scope_digest_mismatch") + if ( + effective.config.strategy_id != registered.strategy_id + or effective.config.secrets_ref != registration.allowed_secrets_ref + or tuple(effective.parameters) != () + or effective.mode != "simulation" + or effective.preset != "sandbox" + or effective.policy.environment != "sandbox" + or effective.policy.mode != "simulation" + or effective.required_capabilities != _MANAGED_CAPABILITIES + or effective.allows_production_writes is not False + or effective.allows_external_writes is not True + or effective.order_route != "managed_execution" + or effective.account_access != "sandbox_direct_provider" + or effective.requires_approval is not True + or effective.requires_live_confirmation is not False + ): + _reject("effective_policy_mismatch") + + # The account selector intentionally stays out of config_digest. Refresh + # the protected source before native factory I/O so both an originally + # private config and a stale effective config observe the current CTP block. + try: + current = validate_runtime_config(registered.runtime_dir, registry) + require_effective_runtime_config_seal(current, registry) + except Exception: + _reject("sealed_ctp_runtime_config_rejected") + if ( + current.registration is not registered + or current.config.config_digest != effective.config.config_digest + or current.effective_digest != effective.effective_digest + ): + _reject("sealed_ctp_runtime_config_changed") + if profile is None: + private = current.config.ctp_simnow or current.config.ctp + if private is not None: + _validate_registration_private_ctp_scope( + private, + registration, + config_digest=current.config.config_digest, + ) + else: + current_profile = current.profile + private = getattr(current.config, "ctp", None) + legacy_private = getattr(current.config, "ctp_simnow", None) + if ( + type(current_profile) is not RuntimeProfile + or current_profile.digest != registration.profile_digest + or current_profile.approval_receipt_digest + != registration.profile_approval_receipt_digest + or type(private) is not CtpPrivateConfig + or (legacy_private is not None and legacy_private is not private) + ): + _reject("sealed_canonical_ctp_profile_scope_mismatch") + _validate_registration_private_ctp_scope( + private, + registration, + config_digest=current.config.config_digest, + ) + return registration + + +def _validate_registration_private_ctp_scope( + private: Any, + registration: CtpSimulationExecutionRegistration, + *, + config_digest: str, +) -> None: + """Bind execution identity and contract fields to a fresh sealed CTP block.""" + + broker_id = getattr(private, "broker_id", None) + user_id = getattr(private, "user_id", None) + if type(broker_id) is not str or type(user_id) is not str: + _reject("sealed_ctp_account_scope_invalid") + expected_fingerprint = hashlib.sha256( + "{0}:{1}".format(broker_id, user_id).encode("utf-8") + ).hexdigest()[:16] + expected_account_digest = hashlib.sha256( + ("acct_" + expected_fingerprint).encode("ascii") + ).hexdigest() + if not hmac.compare_digest( + registration.account_fingerprint_sha256, expected_account_digest + ): + _reject("sealed_ctp_account_scope_mismatch") + + if ( + getattr(private, "instrument_id", None) != registration.instrument_id + or getattr(private, "exchange_id", None) != registration.exchange_id + or getattr(private, "hedge_flag", None) != registration.hedge_flag + ): + _reject("sealed_ctp_contract_scope_mismatch") + + selected_pair = (registration.md_front, registration.td_front) + raw_pairs = getattr(private, "front_pairs", None) + if raw_pairs is None: + md_front = getattr(private, "md_front", None) + td_front = getattr(private, "td_front", None) + raw_pairs = (({"md_front": md_front, "td_front": td_front}),) + try: + configured_pairs = tuple( + ( + _validate_front(pair["md_front"], "md_front"), + _validate_front(pair["td_front"], "td_front"), + ) + for pair in raw_pairs + ) + except (KeyError, TypeError): + _reject("sealed_ctp_front_pair_set_invalid") + if not configured_pairs or configured_pairs.count(selected_pair) != 1: + _reject("sealed_ctp_front_pair_registration_mismatch") + candidate_digest = _front_pair_set_digest(configured_pairs) + if ( + registration.front_pair_set_sha256 is not None + and registration.front_pair_set_sha256 != candidate_digest + ): + _reject("sealed_ctp_front_pair_set_mismatch") + if ( + registration.config_digest is not None + and registration.config_digest != config_digest + ): + _reject("sealed_ctp_config_scope_mismatch") + + +@dataclass(frozen=True) +class CtpSimulationWriteRequest: + """One exact submit or cancel action covered by a short-lived approval.""" + + action: str + client_order_id: str + instrument_id: str + exchange_id: str + side: str + quantity: int + limit_price: Decimal + offset: str = "OPEN" + hedge_flag: str = "1" + target_order_sys_id: Optional[str] = None + target_order_ref: Optional[str] = None + target_front_id: Optional[int] = None + target_session_id: Optional[int] = None + + def __post_init__(self) -> None: + if self.action not in ("SUBMIT", "CANCEL"): + _reject("invalid_action") + if type(self.client_order_id) is not str or not _ORDER_CLIENT_ID_RE.fullmatch( + self.client_order_id + ): + _reject("invalid_client_order_id") + if type(self.instrument_id) is not str or not _INSTRUMENT_RE.fullmatch(self.instrument_id): + _reject("invalid_instrument") + if type(self.exchange_id) is not str or not _EXCHANGE_RE.fullmatch(self.exchange_id): + _reject("invalid_exchange") + if self.side not in _SIDES: + _reject("invalid_side") + if self.offset != "OPEN": + _reject("execution_slice_only_supports_opening_orders") + if self.hedge_flag not in ("1", "2", "3"): + _reject("invalid_hedge_flag") + if type(self.quantity) is not int or self.quantity <= 0: + _reject("invalid_quantity") + object.__setattr__(self, "limit_price", _decimal(self.limit_price, "limit_price")) + if self.action == "CANCEL": + if type(self.target_order_sys_id) is not str or not _ID_RE.fullmatch( + self.target_order_sys_id + ): + _reject("cancel_target_required") + if type(self.target_order_ref) is not str or not _ID_RE.fullmatch( + self.target_order_ref + ): + _reject("cancel_order_ref_required") + if type(self.target_front_id) is not int or self.target_front_id <= 0: + _reject("cancel_front_id_required") + if type(self.target_session_id) is not int or self.target_session_id <= 0: + _reject("cancel_session_id_required") + elif any( + value is not None + for value in ( + self.target_order_sys_id, + self.target_order_ref, + self.target_front_id, + self.target_session_id, + ) + ): + _reject("submit_target_forbidden") + + @property + def digest(self) -> str: + return hashlib.sha256( + _canonical( + { + "action": self.action, + "client_order_id": self.client_order_id, + "exchange_id": self.exchange_id, + "instrument_id": self.instrument_id, + "limit_price": str(self.limit_price), + "offset": self.offset, + "hedge_flag": self.hedge_flag, + "quantity": self.quantity, + "side": self.side, + "target_order_sys_id": self.target_order_sys_id, + "target_order_ref": self.target_order_ref, + "target_front_id": self.target_front_id, + "target_session_id": self.target_session_id, + } + ) + ).hexdigest() + + +@dataclass(frozen=True) +class CtpSimulationWriteApproval: + """Signed one-shot approval for one action payload.""" + + approval_id: str + key_id: str + registration_digest: str + receipt_digest: str + account_fingerprint_sha256: str + environment: str + td_front: str + md_front: str + request_digest: str + issued_at: float + expires_at: float + signature_hex: str + + def __post_init__(self) -> None: + for name in ( + "approval_id", + "key_id", + ): + value = getattr(self, name) + if type(value) is not str or not _ID_RE.fullmatch(value): + _reject("invalid_approval") + for name in ( + "registration_digest", + "receipt_digest", + "account_fingerprint_sha256", + "request_digest", + ): + _digest(getattr(self, name), name) + if self.environment != _SIMNOW_ENVIRONMENT: + _reject("approval_requires_simnow_environment") + _validate_front(self.td_front, "td_front") + _validate_front(self.md_front, "md_front") + for value in (self.issued_at, self.expires_at): + if type(value) not in (int, float) or not math.isfinite(float(value)): + _reject("invalid_approval_time") + if self.expires_at <= self.issued_at: + _reject("invalid_approval_window") + if type(self.signature_hex) is not str or not re.fullmatch( + r"[0-9a-f]{64}", self.signature_hex + ): + _reject("invalid_approval_signature") + + def signed_payload(self) -> bytes: + return _canonical( + { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "approval_id": self.approval_id, + "environment": self.environment, + "td_front": self.td_front, + "md_front": self.md_front, + "expires_at": float(self.expires_at), + "issued_at": float(self.issued_at), + "key_id": self.key_id, + "receipt_digest": self.receipt_digest, + "registration_digest": self.registration_digest, + "request_digest": self.request_digest, + } + ) + + +class CtpSimulationApprovalVerifier(Protocol): + """Verify approvals against an independently held operator key.""" + + def verify(self, approval: CtpSimulationWriteApproval) -> bool: ... + + +class HmacCtpSimulationApprovalVerifier: + """HMAC verifier; callers load its key from an OS secret store.""" + + def __init__(self, key_id: str, key: bytes) -> None: + if type(key_id) is not str or not _ID_RE.fullmatch(key_id): + _reject("invalid_approval_key_id") + if type(key) is not bytes or len(key) < 32: + _reject("approval_key_unavailable") + self._key_id = key_id + self._key = bytes(key) + + def verify(self, approval: CtpSimulationWriteApproval) -> bool: + if type(approval) is not CtpSimulationWriteApproval or approval.key_id != self._key_id: + return False + expected = hmac.new(self._key, approval.signed_payload(), hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, approval.signature_hex) + + +@dataclass(frozen=True) +class CtpSimulationSessionIdentity: + """Identity and gate state freshly reported by the native adapter.""" + + environment: str + sdk_profile: str + td_front: str + md_front: str + account_fingerprint_sha256: str + trading_day: str + connection_generation: int + production: bool + native_gate_armed: bool + native_simnow_managed_mode: bool = False + + def __post_init__(self) -> None: + if self.environment != _SIMNOW_ENVIRONMENT or self.sdk_profile != _SIMNOW_SDK_PROFILE: + _reject("session_profile_mismatch") + _validate_front(self.td_front, "session_td_front") + _validate_front(self.md_front, "session_md_front") + _digest(self.account_fingerprint_sha256, "session_account_fingerprint") + if type(self.trading_day) is not str or not re.fullmatch(r"[0-9]{8}", self.trading_day): + _reject("invalid_trading_day") + if type(self.connection_generation) is not int or self.connection_generation <= 0: + _reject("invalid_connection_generation") + if self.production is not False: + _reject("production_disabled") + if type(self.native_gate_armed) is not bool: + _reject("native_execution_gate_state_invalid") + if type(self.native_simnow_managed_mode) is not bool: + _reject("native_simnow_managed_mode_invalid") + if self.native_gate_armed and self.native_simnow_managed_mode: + _reject("native_execution_authorization_mode_invalid") + + +@dataclass(frozen=True) +class CtpSimulationDispatchReceipt: + """Exact local CTP submission result bound to one managed intent. + + ``QUEUED`` means only that the native API accepted the request into its + local dispatch path. It is not a provider acknowledgement. ``REJECTED`` + is reserved for a negative native submit code whose SDK evidence confirms + the same request, account, session and target with no callback received. + Any malformed or unproven result is ``UNKNOWN`` and must freeze writes + until reconciliation. + """ + + operation: str + outcome: str + request_digest: str + client_order_id: str + managed_intent_id: str + action_id: Optional[str] + request_id: int + submit_code: Optional[int] + identity: CtpSimulationSessionIdentity + local_rejection_verified: bool = False + + def __post_init__(self) -> None: + if self.operation not in ("SUBMIT", "CANCEL"): + _reject("native_dispatch_receipt_operation_invalid") + if self.outcome not in ("QUEUED", "REJECTED", "UNKNOWN"): + _reject("native_dispatch_receipt_outcome_invalid") + _digest(self.request_digest, "native_dispatch_receipt_request_digest") + if type(self.client_order_id) is not str or not _ORDER_CLIENT_ID_RE.fullmatch( + self.client_order_id + ): + _reject("native_dispatch_receipt_order_identity_invalid") + if ( + type(self.managed_intent_id) is not str + or not self.managed_intent_id + or self.managed_intent_id != self.managed_intent_id.strip() + or len(self.managed_intent_id) > 256 + or not self.managed_intent_id.isascii() + or any( + not (character.isalnum() or character in "._:-") + for character in self.managed_intent_id + ) + ): + _reject("native_dispatch_receipt_intent_identity_invalid") + if self.operation == "SUBMIT": + if self.action_id is not None: + _reject("native_dispatch_receipt_action_id_invalid") + elif type(self.action_id) is not str or not _ID_RE.fullmatch(self.action_id): + _reject("native_dispatch_receipt_action_id_invalid") + if type(self.request_id) is not int or self.request_id <= 0: + _reject("native_dispatch_receipt_request_id_invalid") + if self.submit_code is not None and type(self.submit_code) is not int: + _reject("native_dispatch_receipt_submit_code_invalid") + if type(self.local_rejection_verified) is not bool: + _reject("native_dispatch_receipt_rejection_proof_invalid") + if type(self.identity) is not CtpSimulationSessionIdentity: + _reject("native_dispatch_receipt_session_identity_invalid") + if self.outcome == "QUEUED" and ( + self.submit_code != 0 or self.local_rejection_verified + ): + _reject("native_dispatch_receipt_queue_result_invalid") + if self.outcome == "REJECTED" and not ( + type(self.submit_code) is int + and self.submit_code < 0 + and self.local_rejection_verified is True + ): + _reject("native_dispatch_receipt_rejection_unproven") + if self.outcome == "UNKNOWN" and self.local_rejection_verified: + _reject("native_dispatch_receipt_unknown_has_rejection_proof") + + @property + def provider_acknowledged(self) -> bool: + """A local submission receipt never proves a provider acknowledgement.""" + + return False + + +class CtpSimulationAccountWriterFence(Protocol): + """External, account-wide single-writer authority for a native session. + + This must fence every process/user/host that can access the SDK account and + its CTP flow directory. The local file lease below cannot provide that + guarantee by itself. + """ + + environment: str + account_fingerprint_sha256: str + fence_id: str + + def assert_active(self) -> None: ... + + +class CtpSimulationQueryEvidenceVerifier(Protocol): + """Verify native request history, scope, coverage and callback quiescence. + + Returning true asserts the result came from the expected native query + generation/account/TradingDay, all pages and terminal conditions are + covered, and callbacks were rechecked after the final query. For + ``account_open_orders`` it must prove the entire account's open-order set, + not only the registered instrument. For ``cancel_requests`` it must prove + terminal native request outcomes for every requested action ID. A restarted + session may instead resolve a pending cancel intent from verified exact + order, trade and position queries when the adapter explicitly reports its + volatile cancel history absent; that outcome is persisted as + ``TARGET_TERMINAL``, never as cancel-request acceptance. This module has no + implementation that can make those assertions for TraderClient. + """ + + def verify( + self, + query_kind: str, + result: "CtpSimulationQueryResult", + *, + expected_identity: CtpSimulationSessionIdentity, + registration_digest: str, + ) -> bool: ... + + +@dataclass(frozen=True) +class CtpSimulationOrderSnapshot: + client_order_id: str + instrument_id: str + exchange_id: str + side: str + quantity: int + limit_price: Decimal + traded_quantity: int + status: str + order_ref: str + order_sys_id: str + front_id: int + session_id: int + + def __post_init__(self) -> None: + if type(self.client_order_id) is not str or not _ORDER_CLIENT_ID_RE.fullmatch( + self.client_order_id + ): + _reject("invalid_native_client_order_id") + if type(self.instrument_id) is not str or not _INSTRUMENT_RE.fullmatch(self.instrument_id): + _reject("invalid_native_instrument") + if type(self.exchange_id) is not str or not _EXCHANGE_RE.fullmatch(self.exchange_id): + _reject("invalid_native_exchange") + if type(self.side) is not str or self.side not in _SIDES: + _reject("invalid_native_side") + if type(self.quantity) is not int or self.quantity <= 0: + _reject("invalid_native_quantity") + object.__setattr__(self, "limit_price", _decimal(self.limit_price, "native_limit_price")) + if type(self.traded_quantity) is not int or not 0 <= self.traded_quantity <= self.quantity: + _reject("invalid_native_traded_quantity") + if type(self.status) is not str or self.status not in ( + frozenset(("OPEN", "PARTIAL")) | _TERMINAL_STATUSES + ): + _reject("invalid_native_order_status") + if type(self.order_ref) is not str or not _ID_RE.fullmatch(self.order_ref): + _reject("invalid_native_order_ref") + if type(self.order_sys_id) is not str or not _ID_RE.fullmatch(self.order_sys_id): + _reject("invalid_native_order_sys_id") + if type(self.front_id) is not int or self.front_id <= 0: + _reject("invalid_native_front_id") + if type(self.session_id) is not int or self.session_id <= 0: + _reject("invalid_native_session_id") + + +@dataclass(frozen=True) +class CtpSimulationTradeSnapshot: + client_order_id: str + trade_id: str + quantity: int + instrument_id: str + exchange_id: str + side: str + + def __post_init__(self) -> None: + if type(self.client_order_id) is not str or not _ORDER_CLIENT_ID_RE.fullmatch( + self.client_order_id + ): + _reject("invalid_native_trade_client_order_id") + if type(self.trade_id) is not str or not _ID_RE.fullmatch(self.trade_id): + _reject("invalid_native_trade_id") + if type(self.quantity) is not int or self.quantity <= 0: + _reject("invalid_native_trade_quantity") + if type(self.instrument_id) is not str or not _INSTRUMENT_RE.fullmatch(self.instrument_id): + _reject("invalid_native_trade_instrument") + if type(self.exchange_id) is not str or not _EXCHANGE_RE.fullmatch(self.exchange_id): + _reject("invalid_native_trade_exchange") + if type(self.side) is not str or self.side not in _SIDES: + _reject("invalid_native_trade_side") + + +@dataclass(frozen=True) +class CtpSimulationPositionSnapshot: + instrument_id: str + exchange_id: str + side: str + quantity: int + + def __post_init__(self) -> None: + if type(self.instrument_id) is not str or not _INSTRUMENT_RE.fullmatch(self.instrument_id): + _reject("invalid_native_position_instrument") + if type(self.exchange_id) is not str or not _EXCHANGE_RE.fullmatch(self.exchange_id): + _reject("invalid_native_position_exchange") + if type(self.side) is not str or self.side not in _SIDES: + _reject("invalid_native_position_side") + if type(self.quantity) is not int or self.quantity < 0: + _reject("invalid_native_position_quantity") + + +@dataclass(frozen=True) +class CtpSimulationCancelRequestSnapshot: + """Terminal or unresolved native evidence for one submitted cancel action.""" + + action_id: str + client_order_id: str + target_order_sys_id: str + target_order_ref: str + target_front_id: int + target_session_id: int + status: str + + def __post_init__(self) -> None: + for name in ("action_id", "target_order_sys_id", "target_order_ref"): + value = getattr(self, name) + if type(value) is not str or not _ID_RE.fullmatch(value): + _reject("invalid_native_cancel_request_identity") + if type(self.client_order_id) is not str or not _ORDER_CLIENT_ID_RE.fullmatch( + self.client_order_id + ): + _reject("invalid_native_cancel_request_identity") + if type(self.target_front_id) is not int or self.target_front_id <= 0: + _reject("invalid_native_cancel_front_id") + if type(self.target_session_id) is not int or self.target_session_id <= 0: + _reject("invalid_native_cancel_session_id") + if type(self.status) is not str or self.status not in ( + "PENDING", + "CANCELED", + "REJECTED", + "UNKNOWN", + ): + _reject("invalid_native_cancel_request_status") + + +@dataclass(frozen=True) +class CtpSimulationQueryResult: + complete: bool + identity: CtpSimulationSessionIdentity + records: Tuple[Any, ...] + # Keep the SDK-owned QueryResult (including its private provenance source) + # available to a composition-owned verifier. Flattening it to ``records`` + # would discard request id, filter, generation, and callback evidence. + native_evidence: Any = field(default=None, repr=False, compare=False) + + +class CtpSimulationNativePort(Protocol): + """Small adapter over TraderClient's gated writes and native query APIs.""" + + def get_execution_identity(self) -> CtpSimulationSessionIdentity: ... + + def submit_order_insert( + self, request: CtpSimulationWriteRequest + ) -> CtpSimulationDispatchReceipt: ... + + def authorize_write( + self, + request: CtpSimulationWriteRequest, + approval: CtpSimulationWriteApproval, + *, + action_id: Optional[str] = None, + ) -> None: + """Stage the already verified one-shot approval at the native boundary.""" + ... + + def query_orders(self, client_order_id: str) -> CtpSimulationQueryResult: ... + + def query_trades(self, client_order_id: str) -> CtpSimulationQueryResult: ... + + def query_positions(self, instrument_id: str, exchange_id: str) -> CtpSimulationQueryResult: ... + + def query_account_open_orders(self) -> CtpSimulationQueryResult: + """Return every native open order on the account, across all contracts.""" + ... + + def query_cancel_requests(self, action_ids: Tuple[str, ...]) -> CtpSimulationQueryResult: + """Return native callback outcomes for every action ID, including unresolved ones.""" + ... + + def submit_order_action( + self, snapshot: CtpSimulationOrderSnapshot, action_id: str + ) -> CtpSimulationDispatchReceipt: + """Submit action with a caller-stable ID for native callback correlation.""" + ... + + def close(self) -> None: ... + + +class CtpAccountFlowLease: + """Cross-process exclusive owner of one account's shared CTP flow directory.""" + + def __init__(self, account_fingerprint_sha256: str, lock_root: Path) -> None: + self.account_fingerprint_sha256 = _digest( + account_fingerprint_sha256, "account_fingerprint_sha256" + ) + if not isinstance(lock_root, (str, os.PathLike)) or not Path(lock_root).is_absolute(): + _reject("flow_lock_root_must_be_absolute") + self._root = Path(lock_root) + self._fd: Optional[int] = None + self._thread_lock = threading.Lock() + + def acquire(self) -> "CtpAccountFlowLease": + if self._fd is not None: + _reject("flow_lease_already_acquired") + self._root.mkdir(parents=True, exist_ok=True) + if self._root.is_symlink(): + _reject("flow_lock_root_symlink") + path = self._root / (self.account_fingerprint_sha256 + ".lock") + flags = os.O_CREAT | os.O_RDWR + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(str(path), flags, 0o600) + try: + if not os.path.isfile(str(path)) or os.path.islink(str(path)): + _reject("flow_lock_file_invalid") + if os.name == "nt": + if os.fstat(fd).st_size == 0: + os.write(fd, b"\0") + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) # type: ignore[attr-defined] + except CtpSimulationExecutionError: + os.close(fd) + raise + except OSError: + os.close(fd) + _reject("account_flow_already_owned") + self._fd = fd + return self + + def assert_held(self, account_fingerprint_sha256: str) -> None: + if self._fd is None or not hmac.compare_digest( + self.account_fingerprint_sha256, account_fingerprint_sha256 + ): + _reject("account_flow_lease_required") + + def release(self) -> None: + with self._thread_lock: + fd, self._fd = self._fd, None + if fd is None: + return + try: + if os.name == "nt": + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(fd, fcntl.LOCK_UN) # type: ignore[attr-defined] + finally: + os.close(fd) + + def __enter__(self) -> "CtpAccountFlowLease": + return self.acquire() + + def __exit__(self, *_: Any) -> None: + self.release() + + +@dataclass(frozen=True) +class _ExecutionJournalScope: + runtime_id: str + account_fingerprint_sha256: str + config_digest: str + front_pair_set_sha256: str + selected_md_front: str + selected_td_front: str + profile_digest: Optional[str] = None + + def as_dict(self) -> dict[str, Any]: + scope = { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "config_digest": self.config_digest, + "front_pair_set_sha256": self.front_pair_set_sha256, + "runtime_id": self.runtime_id, + "schema_version": _JOURNAL_SCOPE_SCHEMA_VERSION, + "selected_md_front": self.selected_md_front, + "selected_td_front": self.selected_td_front, + } + if self.profile_digest is not None: + scope["profile_digest"] = self.profile_digest + return scope + + @property + def digest(self) -> str: + return hashlib.sha256(_canonical(self.as_dict())).hexdigest() + + +def _execution_journal_scope( + effective: EffectiveRuntimeConfig, + registration: CtpSimulationExecutionRegistration, +) -> _ExecutionJournalScope: + """Bind a journal to the sealed config's ordered candidate set and selection.""" + + config = effective.config + config_digest = config.config_digest + if registration.config_digest is not None and registration.config_digest != config_digest: + _reject("execution_journal_config_scope_mismatch") + profile = effective.profile + if profile is None: + if registration.profile_digest is not None or registration.profile_approval_receipt_digest is not None: + _reject("execution_journal_profile_scope_mismatch") + elif ( + type(profile) is not RuntimeProfile + or profile is not registration.runtime_registration.profile_for("simulation", "sandbox") + or profile.digest != registration.profile_digest + or profile.approval_receipt_digest != registration.profile_approval_receipt_digest + ): + _reject("execution_journal_profile_scope_mismatch") + + selected_pair = (registration.md_front, registration.td_front) + if profile is not None: + private = getattr(config, "ctp", None) + legacy_private = getattr(config, "ctp_simnow", None) + if ( + type(private) is not CtpPrivateConfig + or (legacy_private is not None and legacy_private is not private) + ): + _reject("execution_journal_canonical_ctp_scope_required") + else: + private = getattr(config, "ctp_simnow", None) + if private is None: + private = getattr(config, "ctp", None) + configured_pairs = getattr(private, "front_pairs", None) if private is not None else None + if configured_pairs is None: + # Older callers can still use a single explicitly registered pair. New + # multi-pair selectors always carry their candidate list in the sealed + # SimNow block and the registration carries its digest. + front_pairs = (selected_pair,) + else: + try: + front_pairs = tuple( + ( + _validate_front(pair["md_front"], "md_front"), + _validate_front(pair["td_front"], "td_front"), + ) + for pair in configured_pairs + ) + except (KeyError, TypeError): + _reject("execution_journal_front_pair_set_invalid") + if not front_pairs or len(set(front_pairs)) != len(front_pairs): + _reject("execution_journal_front_pair_set_invalid") + if front_pairs.count(selected_pair) != 1: + _reject("execution_journal_selected_front_pair_mismatch") + candidate_digest = _front_pair_set_digest(front_pairs) + if ( + registration.front_pair_set_sha256 is not None + and registration.front_pair_set_sha256 != candidate_digest + ): + _reject("execution_journal_front_pair_set_mismatch") + + runtime_id = registration.runtime_registration.runtime_id + if type(runtime_id) is not str or not runtime_id: + _reject("execution_journal_runtime_scope_invalid") + return _ExecutionJournalScope( + runtime_id=runtime_id, + account_fingerprint_sha256=registration.account_fingerprint_sha256, + config_digest=config_digest, + front_pair_set_sha256=candidate_digest, + selected_md_front=registration.md_front, + selected_td_front=registration.td_front, + profile_digest=None if profile is None else profile.digest, + ) + + +_LEGACY_EXECUTION_JOURNAL_TABLES = frozenset( + {"ctp_sim_orders", "ctp_sim_journal_metadata", "ctp_sim_journal_scopes"} +) +_LEGACY_ORDER_COLUMNS = frozenset( + { + "client_order_id", + "approval_id", + "request_digest", + "request_json", + "state", + "order_ref", + "order_sys_id", + "front_id", + "session_id", + "status", + "traded_quantity", + "position_digest", + "updated_at", + } +) + + +def _preflight_legacy_execution_journal_path(path: Path) -> None: + """Reject a foreign SQLite ledger before the legacy journal changes it. + + The new execution Store shares this code-owned file path. Its V19/V20 + schema must never be opened by this journal: even ``PRAGMA + journal_mode=WAL`` can mutate the file before ``_initialize_schema`` gets a + chance to notice foreign tables. Only a new empty file or the known narrow + ``ctp_sim_*`` table signatures may proceed. Unknown, partial, and + uncertain SQLite states fail closed. + """ + + # Prefer the execution package's public, read-only ledger classifier when + # the current package exposes it. Older installations can still use the + # exact legacy SQLite signature check below; no SDK-private SQL is used. + try: + from bt_api_execution.store import ( + CtpAccountStoreFileInspection, + SqliteExecutionStore, + ) + except Exception: + CtpAccountStoreFileInspection = None + SqliteExecutionStore = None + inspect_file = getattr(SqliteExecutionStore, "inspect_ctp_account_store_file", None) + if callable(inspect_file): + try: + inspection = inspect_file(str(path)) + if type(inspection) is not CtpAccountStoreFileInspection or inspection.kind not in { + "MISSING", + "LEGACY_CTP_JOURNAL", + }: + _reject("execution_journal_foreign_store") + except CtpSimulationExecutionError: + raise + except Exception: + # Some older but otherwise recognizable legacy journals (notably + # a populated orders-only schema) are intentionally rejected by + # the newer Store classifier because their scope cannot be proven. + # Continue with this module's exact read-only legacy signature + # check so it can retain the established, specific + # ``legacy_scope_missing`` diagnosis without opening SQLite for + # writing. Any other incomplete or unknown schema still fails in + # that exact check below. + pass + + sidecars = (Path(str(path) + "-wal"), Path(str(path) + "-shm")) + try: + if not os.path.lexists(str(path)): + if any(os.path.lexists(str(sidecar)) for sidecar in sidecars): + _reject("execution_journal_foreign_store") + return + info = os.lstat(path) + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + _reject("execution_journal_foreign_store") + if info.st_size == 0: + if any(os.path.lexists(str(sidecar)) for sidecar in sidecars): + _reject("execution_journal_foreign_store") + return + connection = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True, timeout=2.0) + try: + rows = connection.execute( + "SELECT type, name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + ).fetchall() + tables = {name for object_type, name in rows if object_type == "table"} + if len(tables) != len(rows) or tables not in ( + {"ctp_sim_orders"}, + _LEGACY_EXECUTION_JOURNAL_TABLES, + ): + _reject("execution_journal_foreign_store") + if "ctp_sim_orders" in tables: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(ctp_sim_orders)") + } + if not _LEGACY_ORDER_COLUMNS.issubset( + columns + ) or columns - _LEGACY_ORDER_COLUMNS - {"execution_scope_sha256"}: + _reject("execution_journal_foreign_store") + order_count = int( + connection.execute("SELECT COUNT(*) FROM ctp_sim_orders").fetchone()[0] + ) + if order_count: + metadata_count = 0 + if "ctp_sim_journal_metadata" in tables: + metadata_count = int( + connection.execute( + "SELECT COUNT(*) FROM ctp_sim_journal_metadata WHERE singleton=1" + ).fetchone()[0] + ) + if metadata_count != 1: + _reject("execution_journal_legacy_scope_missing") + for table, expected in ( + ( + "ctp_sim_journal_metadata", + {"singleton", "schema_version", "scope_json", "scope_sha256"}, + ), + ("ctp_sim_journal_scopes", {"scope_sha256", "schema_version", "scope_json"}), + ): + if table in tables: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(" + table + ")") + } + if columns != expected: + _reject("execution_journal_foreign_store") + finally: + connection.close() + except CtpSimulationExecutionError: + raise + except Exception: + _reject("execution_journal_foreign_store") + + +class _ExecutionJournal: + def __init__(self, path: Path, scope: _ExecutionJournalScope) -> None: + if not path.is_absolute(): + _reject("journal_path_must_be_absolute") + path.parent.mkdir(parents=True, exist_ok=True) + _preflight_legacy_execution_journal_path(path) + self._db = sqlite3.connect(str(path), timeout=2.0, check_same_thread=False) + self._db.execute("PRAGMA journal_mode=WAL") + self._scope = scope + self._scope_digest = scope.digest + try: + self._initialize_schema() + # A process can die after native dispatch but before persisting its + # response. Never retry such an intent automatically. + self._db.execute( + "UPDATE ctp_sim_orders SET state='UNKNOWN', updated_at=? WHERE state='DISPATCHING'", + (time.time(),), + ) + self._db.commit() + except BaseException: + self._db.rollback() + self._db.close() + raise + self._lock = threading.RLock() + + def _initialize_schema(self) -> None: + """Initialize scope history and permit rollover only after terminal intents.""" + + self._db.execute("BEGIN IMMEDIATE") + self._db.execute( + """CREATE TABLE IF NOT EXISTS ctp_sim_orders ( + client_order_id TEXT PRIMARY KEY, + approval_id TEXT NOT NULL UNIQUE, + request_digest TEXT NOT NULL, + request_json TEXT NOT NULL, + state TEXT NOT NULL, + order_ref TEXT, + order_sys_id TEXT, + front_id INTEGER, + session_id INTEGER, + status TEXT, + traded_quantity INTEGER NOT NULL DEFAULT 0, + position_digest TEXT, + updated_at REAL NOT NULL, + execution_scope_sha256 TEXT NOT NULL + )""" + ) + order_columns = { + row[1] for row in self._db.execute("PRAGMA table_info(ctp_sim_orders)").fetchall() + } + order_count = int(self._db.execute("SELECT COUNT(*) FROM ctp_sim_orders").fetchone()[0]) + if "execution_scope_sha256" not in order_columns: + if order_count: + _reject("execution_journal_legacy_scope_missing") + self._db.execute( + "ALTER TABLE ctp_sim_orders ADD COLUMN execution_scope_sha256 TEXT" + ) + + self._db.execute( + """CREATE TABLE IF NOT EXISTS ctp_sim_journal_metadata ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + schema_version INTEGER NOT NULL, + scope_json TEXT NOT NULL, + scope_sha256 TEXT NOT NULL + )""" + ) + self._db.execute( + """CREATE TABLE IF NOT EXISTS ctp_sim_journal_scopes ( + scope_sha256 TEXT PRIMARY KEY, + schema_version INTEGER NOT NULL, + scope_json TEXT NOT NULL + )""" + ) + metadata_rows = self._db.execute( + "SELECT schema_version, scope_json, scope_sha256 " + "FROM ctp_sim_journal_metadata WHERE singleton=1" + ).fetchall() + history_rows = self._db.execute( + "SELECT scope_sha256, schema_version, scope_json FROM ctp_sim_journal_scopes" + ).fetchall() + known_scopes: dict[str, dict[str, Any]] = {} + for history_digest, history_version, history_json in history_rows: + try: + history_scope = json.loads(history_json) + history_canonical = _canonical(history_scope).decode("ascii") + except (TypeError, ValueError): + _reject("execution_journal_metadata_corrupt") + if ( + history_version != _JOURNAL_SCOPE_SCHEMA_VERSION + or history_canonical != history_json + or hashlib.sha256(history_canonical.encode("ascii")).hexdigest() + != history_digest + or history_digest in known_scopes + ): + _reject("execution_journal_metadata_corrupt") + known_scopes[history_digest] = history_scope + + if not metadata_rows: + if order_count: + _reject("execution_journal_legacy_scope_missing") + if history_rows: + _reject("execution_journal_metadata_corrupt") + scope_json = _canonical(self._scope.as_dict()).decode("ascii") + self._db.execute( + "INSERT INTO ctp_sim_journal_scopes(scope_sha256, schema_version, scope_json) " + "VALUES(?, ?, ?)", + (self._scope_digest, _JOURNAL_SCOPE_SCHEMA_VERSION, scope_json), + ) + self._db.execute( + "INSERT INTO ctp_sim_journal_metadata" + "(singleton, schema_version, scope_json, scope_sha256) VALUES(1, ?, ?, ?)", + (_JOURNAL_SCOPE_SCHEMA_VERSION, scope_json, self._scope_digest), + ) + elif len(metadata_rows) != 1: + _reject("execution_journal_metadata_corrupt") + else: + schema_version, stored_json, stored_digest = metadata_rows[0] + try: + stored_scope = json.loads(stored_json) + stored_canonical = _canonical(stored_scope).decode("ascii") + except (TypeError, ValueError): + _reject("execution_journal_metadata_corrupt") + if stored_canonical != stored_json or hashlib.sha256( + stored_canonical.encode("ascii") + ).hexdigest() != stored_digest: + _reject("execution_journal_metadata_corrupt") + if schema_version == 1: + # Version 1 had a single active scope and stamped every row + # with it. Preserve that history when upgrading the metadata. + row_scope_mismatch = int( + self._db.execute( + "SELECT COUNT(*) FROM ctp_sim_orders " + "WHERE execution_scope_sha256 IS NULL OR execution_scope_sha256 != ?", + (stored_digest,), + ).fetchone()[0] + ) + if row_scope_mismatch: + _reject("execution_journal_metadata_corrupt") + if history_rows: + _reject("execution_journal_metadata_corrupt") + self._db.execute( + "INSERT INTO ctp_sim_journal_scopes" + "(scope_sha256, schema_version, scope_json) VALUES(?, ?, ?)", + (stored_digest, _JOURNAL_SCOPE_SCHEMA_VERSION, stored_json), + ) + self._db.execute( + "UPDATE ctp_sim_journal_metadata SET schema_version=? WHERE singleton=1", + (_JOURNAL_SCOPE_SCHEMA_VERSION,), + ) + schema_version = _JOURNAL_SCOPE_SCHEMA_VERSION + known_scopes[stored_digest] = stored_scope + if schema_version != _JOURNAL_SCOPE_SCHEMA_VERSION: + _reject("execution_journal_schema_unsupported") + if known_scopes.get(stored_digest) != stored_scope: + _reject("execution_journal_metadata_corrupt") + + dangling_row_count = int( + self._db.execute( + "SELECT COUNT(*) FROM ctp_sim_orders AS orders " + "LEFT JOIN ctp_sim_journal_scopes AS scopes " + "ON scopes.scope_sha256=orders.execution_scope_sha256 " + "WHERE orders.execution_scope_sha256 IS NULL OR scopes.scope_sha256 IS NULL" + ).fetchone()[0] + ) + if dangling_row_count: + _reject("execution_journal_row_scope_mismatch") + + if stored_digest != self._scope_digest or stored_scope != self._scope.as_dict(): + unresolved_count = int( + self._db.execute( + "SELECT COUNT(*) FROM ctp_sim_orders WHERE state NOT IN (?, ?, ?, ?)", + tuple(sorted(_TERMINAL_JOURNAL_STATES)), + ).fetchone()[0] + ) + if unresolved_count: + _reject("execution_journal_scope_mismatch") + candidate_json = _canonical(self._scope.as_dict()).decode("ascii") + prior_candidate = known_scopes.get(self._scope_digest) + if prior_candidate is not None and prior_candidate != self._scope.as_dict(): + _reject("execution_journal_metadata_corrupt") + if prior_candidate is None: + if len(known_scopes) >= _MAX_JOURNAL_SCOPE_HISTORY: + _reject("execution_journal_scope_history_full") + self._db.execute( + "INSERT INTO ctp_sim_journal_scopes" + "(scope_sha256, schema_version, scope_json) VALUES(?, ?, ?)", + ( + self._scope_digest, + _JOURNAL_SCOPE_SCHEMA_VERSION, + candidate_json, + ), + ) + self._db.execute( + "UPDATE ctp_sim_journal_metadata " + "SET schema_version=?, scope_json=?, scope_sha256=? WHERE singleton=1", + ( + _JOURNAL_SCOPE_SCHEMA_VERSION, + candidate_json, + self._scope_digest, + ), + ) + + def states(self) -> Tuple[Tuple[Any, ...], ...]: + with self._lock: + return cast( + Tuple[Tuple[Any, ...], ...], + tuple( + self._db.execute( + "SELECT client_order_id, request_digest, request_json, state, order_ref, " + "order_sys_id, front_id, session_id, status " + "FROM ctp_sim_orders ORDER BY rowid" + ).fetchall() + ), + ) + + def get(self, client_order_id: str) -> Optional[Tuple[Any, ...]]: + with self._lock: + return cast( + Optional[Tuple[Any, ...]], + self._db.execute( + "SELECT client_order_id, approval_id, request_digest, request_json, state, " + "order_ref, order_sys_id, front_id, session_id, status, traded_quantity " + "FROM ctp_sim_orders " + "WHERE client_order_id=?", + (client_order_id,), + ).fetchone(), + ) + + def cancel_rows_for(self, client_order_id: str) -> Tuple[Tuple[Any, ...], ...]: + with self._lock: + rows = self._db.execute( + "SELECT client_order_id, approval_id, request_digest, request_json, state " + "FROM ctp_sim_orders" + ).fetchall() + result = [] + for row in rows: + try: + payload = json.loads(row[3]) + except (TypeError, ValueError): + _reject("execution_journal_corrupt") + if ( + payload.get("action") == "CANCEL" + and payload.get("client_order_id") == client_order_id + ): + result.append(tuple(row)) + return cast(Tuple[Tuple[Any, ...], ...], tuple(result)) + + def reserve( + self, + request: CtpSimulationWriteRequest, + approval_id: str, + *, + journal_id: Optional[str] = None, + position_baseline: Optional[dict[str, int]] = None, + ) -> None: + stored_id = journal_id or request.client_order_id + with self._lock: + try: + self._db.execute( + "INSERT INTO ctp_sim_orders(client_order_id, approval_id, request_digest, " + "request_json, state, updated_at, execution_scope_sha256) " + "VALUES(?,?,?,?,?,?,?)", + ( + stored_id, + approval_id, + request.digest, + json.dumps( + { + "action": request.action, + "client_order_id": request.client_order_id, + "exchange_id": request.exchange_id, + "instrument_id": request.instrument_id, + "limit_price": str(request.limit_price), + "offset": request.offset, + "hedge_flag": request.hedge_flag, + "position_baseline": position_baseline, + "quantity": request.quantity, + "side": request.side, + "target_order_sys_id": request.target_order_sys_id, + "target_order_ref": request.target_order_ref, + "target_front_id": request.target_front_id, + "target_session_id": request.target_session_id, + }, + sort_keys=True, + ), + "DISPATCHING", + time.time(), + self._scope_digest, + ), + ) + self._db.commit() + except sqlite3.IntegrityError: + self._db.rollback() + _reject("order_or_approval_already_used") + + def set_state( + self, + client_order_id: str, + state: str, + *, + snapshot: Optional[CtpSimulationOrderSnapshot] = None, + position_digest: Optional[str] = None, + ) -> None: + with self._lock: + values = ( + state, + snapshot.order_ref if snapshot else None, + snapshot.order_sys_id if snapshot else None, + snapshot.front_id if snapshot else None, + snapshot.session_id if snapshot else None, + snapshot.status if snapshot else None, + snapshot.traded_quantity if snapshot else 0, + position_digest, + time.time(), + client_order_id, + ) + self._db.execute( + "UPDATE ctp_sim_orders SET state=?, order_ref=COALESCE(?,order_ref), " + "order_sys_id=COALESCE(?,order_sys_id), front_id=COALESCE(?,front_id), " + "session_id=COALESCE(?,session_id), status=COALESCE(?,status), " + "traded_quantity=?, position_digest=COALESCE(?,position_digest), updated_at=? " + "WHERE client_order_id=?", + values, + ) + self._db.commit() + + def close(self) -> None: + with self._lock: + self._db.close() + + +@dataclass(frozen=True) +class CtpSimulationReconciliation: + client_order_id: str + status: str + traded_quantity: int + trade_count: int + position_digest: str + complete: bool + + +def _request_from_row(row: Tuple[Any, ...]) -> CtpSimulationWriteRequest: + payload = json.loads(row[3]) + return CtpSimulationWriteRequest( + action=payload["action"], + client_order_id=payload["client_order_id"], + instrument_id=payload["instrument_id"], + exchange_id=payload["exchange_id"], + side=payload["side"], + quantity=payload["quantity"], + limit_price=Decimal(payload["limit_price"]), + offset=payload["offset"], + hedge_flag=payload["hedge_flag"], + target_order_sys_id=payload["target_order_sys_id"], + target_order_ref=payload["target_order_ref"], + target_front_id=payload["target_front_id"], + target_session_id=payload["target_session_id"], + ) + + +class CtpSimulationExecutionSession: + """Single-account, one-outstanding-order managed execution session.""" + + def __init__( + self, + registration: CtpSimulationExecutionRegistration, + native: CtpSimulationNativePort, + approval_verifier: CtpSimulationApprovalVerifier, + query_evidence_verifier: CtpSimulationQueryEvidenceVerifier, + writer_fence: CtpSimulationAccountWriterFence, + lease: CtpAccountFlowLease, + journal_path: Path, + *, + effective: Optional[EffectiveRuntimeConfig] = None, + registry: Optional[RuntimeRegistry] = None, + journal: Optional[_ExecutionJournal] = None, + journal_scope: Optional[_ExecutionJournalScope] = None, + ) -> None: + self.registration = registration + if registration.profile_digest is not None and ( + type(effective) is not EffectiveRuntimeConfig + or type(registry) is not RuntimeRegistry + ): + _reject("profile_session_admission_context_required") + self._effective = effective + self._registry = registry + self._native = native + self._verifier = approval_verifier + self._query_evidence_verifier = query_evidence_verifier + self._writer_fence = writer_fence + self._lease = lease + self._lease.assert_held(registration.account_fingerprint_sha256) + self._assert_writer_fence() + self._identity = self._require_identity() + if journal is not None: + self._journal = journal + elif journal_scope is not None: + self._journal = _ExecutionJournal(journal_path, journal_scope) + else: + _reject("execution_journal_scope_required") + self._lock = threading.RLock() + # The native adapter only retains cancel callback correlation in + # memory. A verified terminal order snapshot can recover pending + # journal intents after a process restart, but never substitute for a + # callback that should still be available in this process. + self._volatile_cancel_action_ids: set[str] = set() + self._state = "OPEN" + + def _assert_writer_fence(self) -> None: + fence = self._writer_fence + if ( + getattr(fence, "environment", None) != self.registration.environment + or getattr(fence, "account_fingerprint_sha256", None) + != self.registration.account_fingerprint_sha256 + or type(getattr(fence, "fence_id", None)) is not str + or not _ID_RE.fullmatch(fence.fence_id) + or not callable(getattr(fence, "assert_active", None)) + ): + _reject("account_writer_fence_scope_mismatch") + try: + fence.assert_active() + except Exception: + _reject("account_writer_fence_unavailable") + + def _require_identity(self) -> CtpSimulationSessionIdentity: + try: + identity = self._native.get_execution_identity() + except Exception: + _reject("native_session_identity_unavailable") + if type(identity) is not CtpSimulationSessionIdentity: + _reject("native_session_identity_invalid") + registration = self.registration + if ( + identity.environment != registration.environment + or identity.sdk_profile != registration.sdk_profile + or identity.td_front != registration.td_front + or identity.md_front != registration.md_front + or not hmac.compare_digest( + identity.account_fingerprint_sha256, + registration.account_fingerprint_sha256, + ) + or (identity.native_simnow_managed_mode and identity.native_gate_armed) + or (not identity.native_simnow_managed_mode and identity.native_gate_armed is not True) + ): + _reject("native_session_scope_or_authorization_mode_mismatch") + return identity + + def _require_open(self) -> CtpSimulationSessionIdentity: + if self._state != "OPEN": + _reject("session_poisoned" if self._state == "POISONED" else "session_closed") + self._lease.assert_held(self.registration.account_fingerprint_sha256) + self._assert_writer_fence() + identity = self._require_identity() + if identity != self._identity: + _reject("native_session_generation_changed") + return identity + + def _revalidate_profile_scope(self) -> None: + """Refresh a profile-backed scope before reserving or dispatching a write.""" + + if self.registration.profile_digest is None: + return + if ( + type(self._effective) is not EffectiveRuntimeConfig + or type(self._registry) is not RuntimeRegistry + ): + _reject("profile_session_admission_context_required") + require_ctp_simulation_execution_admission( + self._effective, self._registry, self.registration + ) + + def _revalidate_profile_scope_after_reserve(self, *journal_ids: str) -> None: + """Fence a reserved intent as UNKNOWN if its profile scope has gone stale.""" + + try: + self._revalidate_profile_scope() + except CtpSimulationExecutionError as exc: + self._mark_unknown(*journal_ids) + raise CtpSimulationExecutionError( + "profile_scope_changed_before_dispatch" + ) from exc + + def _verify_query_evidence(self, query_kind: str, result: CtpSimulationQueryResult) -> None: + if ( + type(result) is not CtpSimulationQueryResult + or result.complete is not True + or type(result.records) is not tuple + or result.identity != self._identity + ): + _reject("native_query_result_incomplete") + try: + verified = self._query_evidence_verifier.verify( + query_kind, + result, + expected_identity=self._identity, + registration_digest=self.registration.digest, + ) + except Exception: + verified = False + if verified is not True: + _reject("native_query_evidence_unverified") + + def _validate_request(self, request: CtpSimulationWriteRequest) -> None: + registration = self.registration + if ( + request.instrument_id != registration.instrument_id + or request.exchange_id != registration.exchange_id + or request.side not in registration.allowed_sides + or request.hedge_flag != registration.hedge_flag + or request.offset != "OPEN" + ): + _reject("order_scope_mismatch") + if ( + request.quantity > registration.max_quantity + or request.quantity % registration.quantity_step + ): + _reject("quantity_out_of_bounds") + if ( + request.limit_price < registration.min_price + or request.limit_price > registration.max_price + or (request.limit_price / registration.price_tick) + != (request.limit_price / registration.price_tick).to_integral_value() + ): + _reject("price_out_of_bounds") + + def _verify_approval( + self, request: CtpSimulationWriteRequest, approval: CtpSimulationWriteApproval + ) -> float: + now = time.time() + registration = self.registration + expected_receipt = ( + registration.profile_approval_receipt_digest + if registration.profile_digest is not None + else registration.runtime_registration.approval_receipt_digest + ) + if ( + type(approval) is not CtpSimulationWriteApproval + or approval.key_id != registration.approval_key_id + or approval.registration_digest != registration.digest + or approval.receipt_digest != expected_receipt + or approval.account_fingerprint_sha256 != registration.account_fingerprint_sha256 + or approval.environment != registration.environment + or approval.td_front != registration.td_front + or approval.md_front != registration.md_front + or approval.request_digest != request.digest + or approval.expires_at - approval.issued_at > registration.approval_ttl_seconds + or approval.issued_at > now + 1.0 + or approval.expires_at <= now + ): + _reject("approval_scope_or_expiry_mismatch") + try: + valid = self._verifier.verify(approval) + except Exception: + valid = False + if valid is not True: + _reject("approval_signature_rejected") + remaining = approval.expires_at - now + if remaining <= 0: + _reject("approval_expired") + return time.monotonic() + remaining + + def _has_unresolved(self) -> bool: + return any(row[3] in _ORDER_STATES for row in self._journal.states()) + + def _position_totals(self, result: CtpSimulationQueryResult) -> dict[str, int]: + if type(result) is not CtpSimulationQueryResult or result.complete is not True: + _reject("position_snapshot_incomplete") + if result.identity != self._identity: + _reject("position_snapshot_identity_mismatch") + totals = {"BUY": 0, "SELL": 0} + for position in result.records: + if ( + type(position) is not CtpSimulationPositionSnapshot + or position.instrument_id != self.registration.instrument_id + or position.exchange_id != self.registration.exchange_id + or position.side not in _SIDES + or type(position.quantity) is not int + or position.quantity < 0 + ): + _reject("position_snapshot_scope_mismatch") + totals[position.side] += position.quantity + return totals + + def _reject_unmanaged_open_orders(self, result: CtpSimulationQueryResult) -> None: + seen = set() + for order in result.records: + if type(order) is not CtpSimulationOrderSnapshot: + _reject("account_open_order_snapshot_invalid") + key = ( + order.client_order_id, + order.order_sys_id, + order.order_ref, + order.front_id, + order.session_id, + ) + if key in seen: + _reject("account_open_order_snapshot_ambiguous") + seen.add(key) + if ( + order.instrument_id == self.registration.instrument_id + and order.exchange_id == self.registration.exchange_id + and order.status not in _TERMINAL_STATUSES + ): + _reject("unmanaged_open_order_blocks_submission") + + def _mark_unknown(self, *journal_ids: str) -> None: + for journal_id in journal_ids: + self._journal.set_state(journal_id, "UNKNOWN") + + def _verified_cancel_outcomes( + self, + cancel_rows: Tuple[Tuple[Any, ...], ...], + result: CtpSimulationQueryResult, + order: CtpSimulationOrderSnapshot, + ) -> dict[str, str]: + expected_ids = tuple(row[0] for row in cancel_rows) + records = result.records + if len(records) != len(expected_ids) or any( + type(record) is not CtpSimulationCancelRequestSnapshot for record in records + ): + _reject("native_cancel_request_readback_incomplete") + by_id = {record.action_id: record for record in records} + if len(by_id) != len(records) or set(by_id) != set(expected_ids): + _reject("native_cancel_request_readback_ambiguous") + outcomes: dict[str, str] = {} + for row in cancel_rows: + action_id = row[0] + request = _request_from_row(row) + record = by_id[action_id] + if ( + record.client_order_id != request.client_order_id + or record.target_order_sys_id != request.target_order_sys_id + or record.target_order_ref != request.target_order_ref + or record.target_front_id != request.target_front_id + or record.target_session_id != request.target_session_id + ): + _reject("native_cancel_request_scope_mismatch") + if record.status not in ("CANCELED", "REJECTED"): + _reject("native_cancel_request_outcome_unknown") + outcomes[action_id] = record.status + canceled_actions = any(status == "CANCELED" for status in outcomes.values()) + if (order.status == "CANCELED") != canceled_actions: + _reject("native_cancel_order_action_mismatch") + return outcomes + + @staticmethod + def _cancel_target_matches_order( + row: Tuple[Any, ...], order: CtpSimulationOrderSnapshot + ) -> bool: + request = _request_from_row(row) + return ( + request.action == "CANCEL" + and request.client_order_id == order.client_order_id + and request.target_order_sys_id == order.order_sys_id + and request.target_order_ref == order.order_ref + and request.target_front_id == order.front_id + and request.target_session_id == order.session_id + ) + + def _dispatch_outcome( + self, + receipt: Any, + request: CtpSimulationWriteRequest, + *, + action_id: Optional[str] = None, + ) -> str: + """Accept only a typed receipt bound to this exact request and session.""" + if ( + type(receipt) is not CtpSimulationDispatchReceipt + or receipt.operation != request.action + or receipt.request_digest != request.digest + or receipt.client_order_id != request.client_order_id + or receipt.action_id != action_id + or receipt.identity != self._identity + or receipt.provider_acknowledged is not False + or ( + receipt.outcome == "QUEUED" + and (receipt.submit_code != 0 or receipt.local_rejection_verified) + ) + or ( + receipt.outcome == "REJECTED" + and ( + type(receipt.submit_code) is not int + or receipt.submit_code >= 0 + or receipt.local_rejection_verified is not True + ) + ) + or ( + receipt.outcome == "UNKNOWN" and receipt.local_rejection_verified + ) + ): + return "UNKNOWN" + return receipt.outcome + + def submit_order( + self, + *, + client_order_id: str, + instrument_id: str, + exchange_id: str, + side: str, + quantity: int, + limit_price: Decimal, + approval: CtpSimulationWriteApproval, + ) -> str: + request = CtpSimulationWriteRequest( + action="SUBMIT", + client_order_id=client_order_id, + instrument_id=instrument_id, + exchange_id=exchange_id, + side=side, + quantity=quantity, + limit_price=limit_price, + offset="OPEN", + hedge_flag=self.registration.hedge_flag, + ) + with self._lock: + self._require_open() + self._validate_request(request) + if self._has_unresolved(): + _reject("unresolved_order_freezes_new_writes") + approval_deadline = self._verify_approval(request, approval) + try: + self._assert_writer_fence() + account_open_orders = self._native.query_account_open_orders() + self._assert_writer_fence() + position_result = self._native.query_positions( + self.registration.instrument_id, self.registration.exchange_id + ) + except Exception: + _reject("account_exposure_snapshot_unavailable") + try: + self._verify_query_evidence("account_open_orders", account_open_orders) + self._verify_query_evidence("positions", position_result) + except Exception: + _reject("account_exposure_snapshot_unavailable") + self._reject_unmanaged_open_orders(account_open_orders) + try: + position_baseline = self._position_totals(position_result) + except Exception: + _reject("account_exposure_snapshot_unavailable") + if sum(position_baseline.values()) + request.quantity > ( + self.registration.max_gross_position + ): + _reject("gross_position_limit_exceeded") + # Re-read profile-backed config immediately before the durable + # reservation. A stale scope must not consume an approval or + # create a dispatch intent. + self._revalidate_profile_scope() + self._journal.reserve( + request, approval.approval_id, position_baseline=position_baseline + ) + if min(approval.expires_at - time.time(), approval_deadline - time.monotonic()) <= 0: + self._journal.set_state(client_order_id, "UNKNOWN") + _reject("approval_expired_before_dispatch") + try: + self._require_open() + except CtpSimulationExecutionError as exc: + self._mark_unknown(client_order_id) + raise CtpSimulationExecutionError( + "native_submit_session_changed_before_dispatch" + ) from exc + if min(approval.expires_at - time.time(), approval_deadline - time.monotonic()) <= 0: + self._mark_unknown(client_order_id) + _reject("approval_expired_before_dispatch") + self._revalidate_profile_scope_after_reserve(client_order_id) + try: + authorize_write = getattr(self._native, "authorize_write", None) + if callable(authorize_write): + authorize_write(request, approval) + # Authorization may perform blocking external work. Recheck + # the account fence after it returns so a fence lost during + # that work cannot still reach the native dispatch call. + self._assert_writer_fence() + except Exception: + self._journal.set_state(client_order_id, "UNKNOWN") + _reject("native_submit_outcome_unknown") + # Authorization can block while config or profile code changes. + # Refresh again directly before the native submit call. + self._revalidate_profile_scope_after_reserve(client_order_id) + try: + receipt = self._native.submit_order_insert(request) + self._require_open() + except Exception: + self._journal.set_state(client_order_id, "UNKNOWN") + _reject("native_submit_outcome_unknown") + outcome = self._dispatch_outcome(receipt, request) + if outcome == "REJECTED": + self._journal.set_state(client_order_id, "REJECTED") + return "REJECTED" + if outcome != "QUEUED": + self._journal.set_state(client_order_id, "UNKNOWN") + _reject("native_submit_outcome_unknown") + self._journal.set_state(client_order_id, "PENDING") + return "PENDING" + + def cancel_order(self, client_order_id: str, approval: CtpSimulationWriteApproval) -> str: + with self._lock: + self._require_open() + row = self._journal.get(client_order_id) + if row is None or row[4] not in ("OPEN", "PARTIAL"): + _reject("cancel_requires_reconciled_open_order") + original = _request_from_row(row) + snapshot = CtpSimulationOrderSnapshot( + client_order_id=client_order_id, + instrument_id=original.instrument_id, + exchange_id=original.exchange_id, + side=original.side, + quantity=original.quantity, + limit_price=original.limit_price, + traded_quantity=int(row[10] or 0), + status=str(row[9] or "OPEN"), + order_ref=str(row[5] or ""), + order_sys_id=str(row[6] or ""), + front_id=int(row[7] or 0), + session_id=int(row[8] or 0), + ) + if ( + not snapshot.order_sys_id + or not snapshot.order_ref + or snapshot.front_id <= 0 + or snapshot.session_id <= 0 + ): + _reject("cancel_native_order_identity_incomplete") + request = CtpSimulationWriteRequest( + action="CANCEL", + client_order_id=client_order_id, + instrument_id=original.instrument_id, + exchange_id=original.exchange_id, + side=original.side, + quantity=original.quantity, + limit_price=original.limit_price, + offset=original.offset, + hedge_flag=original.hedge_flag, + target_order_sys_id=snapshot.order_sys_id, + target_order_ref=snapshot.order_ref, + target_front_id=snapshot.front_id, + target_session_id=snapshot.session_id, + ) + self._validate_request(request) + approval_deadline = self._verify_approval(request, approval) + cancel_journal_id = ( + "cancel-" + + hashlib.sha256( + (client_order_id + "\0" + approval.approval_id).encode("ascii") + ).hexdigest()[:48] + ) + try: + self._revalidate_profile_scope() + except CtpSimulationExecutionError: + # The original order was reconciled under an obsolete scope; + # keep it fenced even though no cancel intent was reserved. + self._journal.set_state(client_order_id, "UNKNOWN") + raise + self._journal.reserve( + request, + approval.approval_id, + journal_id=cancel_journal_id, + ) + if min(approval.expires_at - time.time(), approval_deadline - time.monotonic()) <= 0: + self._journal.set_state(cancel_journal_id, "REJECTED") + _reject("approval_expired_before_dispatch") + try: + self._require_open() + except CtpSimulationExecutionError as exc: + self._mark_unknown(cancel_journal_id, client_order_id) + raise CtpSimulationExecutionError( + "native_cancel_session_changed_before_dispatch" + ) from exc + if min(approval.expires_at - time.time(), approval_deadline - time.monotonic()) <= 0: + self._mark_unknown(cancel_journal_id, client_order_id) + _reject("approval_expired_before_dispatch") + self._volatile_cancel_action_ids.add(cancel_journal_id) + self._revalidate_profile_scope_after_reserve( + cancel_journal_id, client_order_id + ) + try: + authorize_write = getattr(self._native, "authorize_write", None) + if callable(authorize_write): + authorize_write(request, approval, action_id=cancel_journal_id) + # Keep cancel dispatch behind the same post-authorization + # fence check as submit dispatch. + self._assert_writer_fence() + except Exception: + # Freeze the original target too; no cancel outcome is known. + self._journal.set_state(cancel_journal_id, "UNKNOWN") + self._journal.set_state(client_order_id, "UNKNOWN") + _reject("native_cancel_outcome_unknown") + self._revalidate_profile_scope_after_reserve( + cancel_journal_id, client_order_id + ) + try: + receipt = self._native.submit_order_action(snapshot, cancel_journal_id) + self._require_open() + except Exception: + # Freeze the original target too; the cancel may have reached CTP. + self._journal.set_state(cancel_journal_id, "UNKNOWN") + self._journal.set_state(client_order_id, "UNKNOWN") + _reject("native_cancel_outcome_unknown") + outcome = self._dispatch_outcome( + receipt, request, action_id=cancel_journal_id + ) + if outcome == "REJECTED": + self._journal.set_state(cancel_journal_id, "REJECTED") + return "REJECTED" + if outcome != "QUEUED": + # The action may have reached CTP even if the receipt is + # malformed, stale, or bound to another managed intent. + self._journal.set_state(cancel_journal_id, "UNKNOWN") + self._journal.set_state(client_order_id, "UNKNOWN") + _reject("native_cancel_outcome_unknown") + self._journal.set_state(cancel_journal_id, "CANCEL_PENDING") + self._journal.set_state(client_order_id, "CANCEL_PENDING") + return "CANCEL_PENDING" + + def reconcile(self, client_order_id: str) -> CtpSimulationReconciliation: + with self._lock: + self._require_open() + row = self._journal.get(client_order_id) + if row is None: + _reject("order_not_journaled") + request = _request_from_row(row) + cancel_rows = tuple( + cancel_row + for cancel_row in self._journal.cancel_rows_for(client_order_id) + if cancel_row[4] in _PENDING_CANCEL_STATES + ) + cancel_ids = tuple(cancel_row[0] for cancel_row in cancel_rows) + try: + self._assert_writer_fence() + orders = self._native.query_orders(client_order_id) + self._assert_writer_fence() + trades = self._native.query_trades(client_order_id) + self._assert_writer_fence() + positions = self._native.query_positions( + self.registration.instrument_id, self.registration.exchange_id + ) + self._verify_query_evidence("orders", orders) + self._verify_query_evidence("trades", trades) + self._verify_query_evidence("positions", positions) + except Exception: + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_reconciliation_incomplete") + cancel_requests = None + cancel_history_verified = False + cancel_history_absent = False + if cancel_ids: + try: + self._assert_writer_fence() + cancel_requests = self._native.query_cancel_requests(cancel_ids) + if ( + type(cancel_requests) is CtpSimulationQueryResult + and cancel_requests.complete is False + and cancel_requests.identity == self._identity + and type(cancel_requests.records) is tuple + and cancel_requests.records == () + ): + # TraderClient's action callback history is volatile. + # An explicit empty/incomplete result means it has no + # local correlation for these intents; it is not + # evidence that a cancel request was accepted. + cancel_history_absent = True + else: + self._verify_query_evidence("cancel_requests", cancel_requests) + cancel_history_verified = True + except Exception: + # Keep the distinction between absent volatile history + # and failed or untrusted evidence. Only the former can + # use the terminal-target recovery below. + pass + if ( + len(orders.records) != 1 + or type(orders.records[0]) is not CtpSimulationOrderSnapshot + ): + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_order_readback_ambiguous") + snapshot = orders.records[0] + if ( + snapshot.client_order_id != client_order_id + or snapshot.instrument_id != request.instrument_id + or snapshot.exchange_id != request.exchange_id + or snapshot.side != request.side + or snapshot.quantity != request.quantity + or _decimal(snapshot.limit_price, "native_order_limit_price") != request.limit_price + or type(snapshot.traded_quantity) is not int + or not 0 <= snapshot.traded_quantity <= request.quantity + or snapshot.status not in frozenset(("OPEN", "PARTIAL")) | _TERMINAL_STATUSES + or not snapshot.order_ref + or not snapshot.order_sys_id + ): + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_order_readback_mismatch") + if any(type(trade) is not CtpSimulationTradeSnapshot for trade in trades.records): + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_trade_readback_invalid") + trade_ids = [trade.trade_id for trade in trades.records] + if ( + len(set(trade_ids)) != len(trade_ids) + or any( + trade.client_order_id != client_order_id + or trade.instrument_id != request.instrument_id + or trade.exchange_id != request.exchange_id + or trade.side != request.side + or type(trade.quantity) is not int + or trade.quantity <= 0 + for trade in trades.records + ) + or sum(trade.quantity for trade in trades.records) != snapshot.traded_quantity + ): + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_trade_volume_mismatch") + try: + position_totals = self._position_totals(positions) + except CtpSimulationExecutionError: + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_position_readback_mismatch") + payload = json.loads(row[3]) + baseline = payload.get("position_baseline") + if ( + type(baseline) is not dict + or set(baseline) != _SIDES + or any(type(value) is not int or value < 0 for value in baseline.values()) + ): + self._mark_unknown(client_order_id, *cancel_ids) + _reject("position_baseline_missing") + expected_positions = dict(baseline) + if request.action == "SUBMIT": + expected_positions[request.side] += snapshot.traded_quantity + if position_totals != expected_positions: + self._mark_unknown(client_order_id, *cancel_ids) + _reject("position_delta_mismatch") + state = snapshot.status + cancel_outcomes = {} + if cancel_rows: + if cancel_history_verified: + try: + cancel_outcomes = self._verified_cancel_outcomes( + cancel_rows, cancel_requests, snapshot + ) + except CtpSimulationExecutionError: + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_cancel_request_outcome_unverified") + elif ( + cancel_history_absent + and snapshot.status == "CANCELED" + and not any( + cancel_id in self._volatile_cancel_action_ids for cancel_id in cancel_ids + ) + and all( + self._cancel_target_matches_order(cancel_row, snapshot) + for cancel_row in cancel_rows + ) + ): + # The order, trades and positions above are independently + # verified and identify the exact canceled target. Record + # that terminal target fact without claiming any cancel + # request callback or acceptance. + cancel_outcomes = dict.fromkeys(cancel_ids, "TARGET_TERMINAL") + else: + self._mark_unknown(client_order_id, *cancel_ids) + _reject("native_cancel_request_outcome_unverified") + position_digest = hashlib.sha256( + _canonical( + { + "BUY": position_totals["BUY"], + "SELL": position_totals["SELL"], + } + ) + ).hexdigest() + self._journal.set_state( + client_order_id, + state, + snapshot=snapshot, + position_digest=position_digest, + ) + for cancel_id, cancel_state in cancel_outcomes.items(): + self._journal.set_state(cancel_id, cancel_state) + return CtpSimulationReconciliation( + client_order_id=client_order_id, + status=state, + traded_quantity=snapshot.traded_quantity, + trade_count=len(trades.records), + position_digest=position_digest, + complete=True, + ) + + def close(self) -> None: + with self._lock: + if self._state == "CLOSED": + return + if self._state == "POISONED": + _reject("session_poisoned") + self._state = "CLOSING" + failures = [] + pending_base_exception: Optional[BaseException] = None + try: + self._native.close() + except BaseException as exc: + failures.append("native session") + if not isinstance(exc, Exception): + pending_base_exception = exc + try: + self._journal.close() + except BaseException as exc: + failures.append("execution journal") + if pending_base_exception is None and not isinstance(exc, Exception): + pending_base_exception = exc + if failures: + self._state = "POISONED" + _retain_poisoned_lease(self._lease) + if pending_base_exception is not None: + raise pending_base_exception + _reject( + "execution_close_failed", + "Execution cleanup failed for: " + + ", ".join(failures) + + "; the owner is poisoned and its flow lease is retained until process exit", + ) + try: + self._lease.release() + except BaseException as exc: + self._state = "POISONED" + _retain_poisoned_lease(self._lease) + if not isinstance(exc, Exception): + raise + _reject( + "execution_close_failed", + "Account flow lease release failed; the owner is poisoned", + ) + self._state = "CLOSED" + + def __enter__(self) -> "CtpSimulationExecutionSession": + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + +def open_ctp_simulation_execution( + *, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + registration: CtpSimulationExecutionRegistration, + native_session_factory: Callable[ + [CtpSimulationExecutionRegistration, CtpAccountFlowLease], CtpSimulationNativePort + ], + approval_verifier: CtpSimulationApprovalVerifier, + query_evidence_verifier: CtpSimulationQueryEvidenceVerifier, + writer_fence: CtpSimulationAccountWriterFence, +) -> CtpSimulationExecutionSession: + """Open one explicitly supplied native session after admission and account lock.""" + + require_ctp_simulation_execution_admission(effective, registry, registration) + journal_scope = _execution_journal_scope(effective, registration) + if not callable(native_session_factory): + _reject("native_session_factory_required") + if not callable(getattr(approval_verifier, "verify", None)): + _reject("approval_verifier_required") + if not callable(getattr(query_evidence_verifier, "verify", None)): + _reject("query_evidence_verifier_required") + if ( + getattr(writer_fence, "environment", None) != registration.environment + or getattr(writer_fence, "account_fingerprint_sha256", None) + != registration.account_fingerprint_sha256 + or type(getattr(writer_fence, "fence_id", None)) is not str + or not _ID_RE.fullmatch(writer_fence.fence_id) + or not callable(getattr(writer_fence, "assert_active", None)) + ): + _reject("account_writer_fence_scope_mismatch") + try: + writer_fence.assert_active() + except Exception: + _reject("account_writer_fence_unavailable") + state_root = _prepare_state_root() + lease = CtpAccountFlowLease( + registration.account_fingerprint_sha256, state_root / "flow-locks" + ).acquire() + journal_path = state_root / "journals" / (registration.account_fingerprint_sha256 + ".sqlite3") + native = None + journal: Optional[_ExecutionJournal] = None + try: + # Scope validation and safe empty-legacy migration happen under the + # account lease and before credentials, SDK import or client creation. + require_ctp_simulation_execution_admission(effective, registry, registration) + journal_scope = _execution_journal_scope(effective, registration) + journal = _ExecutionJournal(journal_path, journal_scope) + # The lock is acquired before this call. SDK TraderClient currently + # derives its TD flow directory from broker/user, so every writer for + # this account must use this owner lease until SDK flow paths are + # independently unique. + require_ctp_simulation_execution_admission(effective, registry, registration) + native = native_session_factory(registration, lease) + writer_fence.assert_active() + session = CtpSimulationExecutionSession( + registration, + native, + approval_verifier, + query_evidence_verifier, + writer_fence, + lease, + journal_path, + effective=effective, + registry=registry, + journal=journal, + ) + journal = None # ownership transferred to the returned session + return session + except BaseException as open_error: + cleanup_failures = [] + native_close_failed = False + native_close_base_exception: Optional[BaseException] = None + try: + if journal is not None: + try: + journal.close() + except BaseException as exc: + cleanup_failures.append("execution journal") + if not isinstance(exc, Exception): + raise + if native is not None: + try: + native.close() + except BaseException as exc: + native_close_failed = True + cleanup_failures.append("native session") + if not isinstance(exc, Exception): + native_close_base_exception = exc + finally: + if native_close_failed: + _retain_poisoned_lease(lease) + else: + try: + lease.release() + except BaseException as exc: + cleanup_failures.append("account flow lease") + if not isinstance(exc, Exception): + raise + if cleanup_failures: + if native_close_base_exception is not None: + raise native_close_base_exception from open_error + error = CtpSimulationExecutionError( + "execution_open_cleanup_failed", + "Execution open cleanup failed for: " + + ", ".join(cleanup_failures) + + ( + "; the owner is poisoned and its flow lease is retained until process exit" + if native_close_failed + else "" + ), + ) + raise error from open_error + raise + + +__all__ = [ + "CtpAccountFlowLease", + "CtpSimulationApprovalVerifier", + "CtpSimulationAccountWriterFence", + "CtpSimulationExecutionError", + "CtpSimulationExecutionRegistration", + "CtpSimulationExecutionSession", + "CtpSimulationDispatchReceipt", + "CtpSimulationOrderSnapshot", + "CtpSimulationPositionSnapshot", + "CtpSimulationQueryResult", + "CtpSimulationQueryEvidenceVerifier", + "CtpSimulationReconciliation", + "CtpSimulationSessionIdentity", + "CtpSimulationTradeSnapshot", + "CtpSimulationWriteApproval", + "CtpSimulationWriteRequest", + "HmacCtpSimulationApprovalVerifier", + "open_ctp_simulation_execution", + "require_ctp_simulation_execution_admission", +] diff --git a/backtrader_runtime/ctp_trader_client_port.py b/backtrader_runtime/ctp_trader_client_port.py new file mode 100644 index 00000000..ce8413a6 --- /dev/null +++ b/backtrader_runtime/ctp_trader_client_port.py @@ -0,0 +1,1499 @@ +"""Explicit-front, gated adapter from the SimNow session to CTP TraderClient. + +This adapter is intentionally not connected to the runtime inventory or CLI. +Its factory constructs a ``TraderClient`` with the exact sealed TD front and +never starts it. The internal SDK policy profile is always ``config_front_pair``; +the exact TD/MD pair comes from the sealed config and code-owned registration. +Managed writes keep the general execution gate disarmed and require an explicitly injected +runtime approval verifier, durable order identity resolver, per-action SDK +binding factory, fresh SDK approval rechecker, and runtime admission check. +The SDK checks the immutable operation scope under its lock at the final +ReqOrderInsert/ReqOrderAction boundary. + +The port preserves native QueryResult objects for an injected evidence +verifier. It does not claim that a terminal query alone proves account-wide +coverage, and cancellation outcomes remain UNKNOWN after restart because the +SDK currently keeps action callback history only in memory. +""" + +from __future__ import annotations + +import hashlib +import threading +import time +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from typing import Any, Callable, Mapping + +from .ctp_simulation_execution import ( + CtpSimulationCancelRequestSnapshot, + CtpSimulationDispatchReceipt, + CtpSimulationExecutionError, + CtpSimulationExecutionRegistration, + CtpSimulationNativePort, + CtpSimulationOrderSnapshot, + CtpSimulationPositionSnapshot, + CtpSimulationQueryResult, + CtpSimulationSessionIdentity, + CtpSimulationTradeSnapshot, + CtpSimulationWriteApproval, + CtpSimulationWriteRequest, +) +from .ctp_native_shutdown import stop_ctp_native_client + + +_SIMNOW_ENVIRONMENT = "simnow" +_SIMNOW_SDK_PROFILE = "config_front_pair" +_SIDE_BY_DIRECTION = {"0": "BUY", "1": "SELL"} + + +def _reject(reason: str) -> None: + raise CtpSimulationExecutionError(reason) + + +def _text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("ascii", errors="ignore").rstrip("\x00 ") + return str(value).rstrip("\x00 ") + + +def _value(row: Any, *names: str) -> Any: + if isinstance(row, Mapping): + for name in names: + if name in row: + return row[name] + for name in names: + result = getattr(row, name, None) + if result is not None: + return result + return None + + +def _int_field(row: Any, *names: str, required: bool = True) -> int: + value = _value(row, *names) + if isinstance(value, bool): + _reject("native_query_integer_invalid") + try: + result = int(value) + except (TypeError, ValueError, OverflowError): + if not required and value in (None, ""): + return 0 + _reject("native_query_integer_invalid") + return result + + +def _decimal_field(row: Any, *names: str) -> Decimal: + value = _value(row, *names) + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + _reject("native_query_decimal_invalid") + if not result.is_finite(): + _reject("native_query_decimal_invalid") + return result + + +def _sdk_simnow_binding_type() -> type | None: + """Return the installed SDK's exact typed SimNow binding, when available.""" + + try: + from bt_api_ctp.ctp.client import CtpRuntimeSimNowCredentialBinding + except Exception: + return None + return CtpRuntimeSimNowCredentialBinding + + +def _sdk_trader_client_type() -> type | None: + try: + from bt_api_ctp.ctp.client import TraderClient + except Exception: + return None + return TraderClient + + +def _require_sdk_simnow_binding( + binding: Any, profile: str, config: "CtpSimulationTraderConfig" +) -> None: + binding_type = _sdk_simnow_binding_type() + if binding_type is None or type(binding) is not binding_type: + _reject("native_runtime_credential_binding_unavailable") + if ( + getattr(binding, "td_front", None) != config.td_front + or getattr(binding, "md_front", None) != config.md_front + or getattr(binding, "environment_profile", None) != profile + or not callable(getattr(binding, "write_intent_verifier", None)) + ): + _reject("native_runtime_credential_binding_scope_mismatch") + + +@dataclass(frozen=True) +class CtpSimulationTraderConfig: + """Private CTP inputs resolved by the sealed runtime before this factory. + + Callers must supply every field. This object does not read environment + variables, choose a profile, or select a reachable endpoint. Its exact + TD/MD pair must equal the code-owned registration's explicitly selected + pair. The SDK receives the neutral ``config_front_pair`` policy profile; + endpoint reachability or a SimNow set label never changes that pair. + """ + + td_front: str + md_front: str + broker_id: str + user_id: str + password: str = field(repr=False) + auth_code: str = field(repr=False) + app_id: str = field(repr=False) + auto_detect_fronts: bool = False + + def validate( + self, + registration: CtpSimulationExecutionRegistration, + ) -> str: + if type(registration) is not CtpSimulationExecutionRegistration: + _reject("code_owned_registration_required") + if self.auto_detect_fronts is not False: + _reject("ctp_front_auto_selection_forbidden") + values = ( + self.td_front, + self.md_front, + self.broker_id, + self.user_id, + self.password, + self.auth_code, + self.app_id, + ) + if any(type(value) is not str or not value or value != value.strip() for value in values): + _reject("sealed_ctp_configuration_incomplete") + if ( + registration.environment != _SIMNOW_ENVIRONMENT + or registration.sdk_profile != _SIMNOW_SDK_PROFILE + or self.td_front != registration.td_front + or self.md_front != registration.md_front + ): + _reject("sealed_ctp_front_pair_registration_mismatch") + return _SIMNOW_SDK_PROFILE + + def feed_kwargs( + self, + registration: CtpSimulationExecutionRegistration, + ) -> dict[str, Any]: + """Return explicit constructor inputs for a future feed composition.""" + profile = self.validate(registration) + return { + "broker_id": self.broker_id, + "user_id": self.user_id, + "password": self.password, + "auth_code": self.auth_code, + "app_id": self.app_id, + "td_front": self.td_front, + "md_front": self.md_front, + "ctp_env_profile": profile, + "auto_detect_fronts": False, + "auto_settlement_confirm": False, + } + + def assert_resolved_feed( + self, + feed: Any, + registration: CtpSimulationExecutionRegistration, + ) -> None: + """Check the constructed feed still carries the exact sealed fronts. + + A managed composition must call this after constructing a feed from + :meth:`feed_kwargs` and before ``connect()`` creates a native client. + The readiness marker must say the pair was explicit; TCP probing or a + mixed/environment-selected pair is insufficient for this route. + """ + profile = self.validate(registration) + expected = { + "ctp_env_profile": profile, + "td_front": self.td_front, + "md_front": self.md_front, + "_execution_bound_profile": profile, + "_execution_bound_td_front": self.td_front, + "_execution_bound_md_front": self.md_front, + "_execution_bound_broker_id": self.broker_id, + "_execution_bound_user_id": self.user_id, + } + if any(_text(getattr(feed, name, "")) != value for name, value in expected.items()): + _reject("resolved_ctp_feed_binding_mismatch") + if getattr(feed, "ctp_env_readiness", None) != "explicit_config_pair": + _reject("resolved_ctp_feed_pair_not_explicit") + if getattr(feed, "auto_settlement_confirm", None) is not False: + _reject("ctp_automatic_settlement_confirmation_forbidden") + + def create_verified_feed( + self, + feed_factory: Callable[..., Any], + registration: CtpSimulationExecutionRegistration, + ) -> Any: + """Build an unconnected feed with explicit inputs, then pin-check it.""" + if not callable(feed_factory): + _reject("ctp_feed_factory_required") + try: + feed = feed_factory(**self.feed_kwargs(registration)) + except Exception as exc: + raise CtpSimulationExecutionError("ctp_feed_construction_failed") from exc + self.assert_resolved_feed(feed, registration) + return feed + + +class CtpTraderClientSimulationPort(CtpSimulationNativePort): + """Map the managed SimNow protocol to an already constructed TraderClient. + + The constructor does not start or connect the client. ``write_admitted`` + remains false unless the caller supplies the SDK's opaque capability, + durable order identity resolver, runtime approval verifier, per-action + binding factory, and current SDK approval rechecker. SimNow keeps the + general gate disarmed; each managed insert/cancel instead needs + a distinct typed SDK binding whose immutable final-write scope is checked + immediately before native dispatch. + """ + + def __init__( + self, + trader_client: Any, + registration: CtpSimulationExecutionRegistration, + config: CtpSimulationTraderConfig, + *, + execution_capability: object | None = None, + runtime_admission_check: Callable[[], bool] | None = None, + runtime_order_binding: Callable[[str, bool], Mapping[str, Any]] | None = None, + runtime_credential_binding: Any | None = None, + runtime_credential_binding_factory: Callable[..., Any] | None = None, + runtime_approval_verifier: Any | None = None, + sdk_approval_rechecker: Callable[[Any, Mapping[str, Any]], bool] | None = None, + order_field_factory: Callable[[], Any] | None = None, + action_field_factory: Callable[[], Any] | None = None, + ) -> None: + self.profile = config.validate(registration) + if trader_client is None: + _reject("native_trader_client_required") + if ( + _text(getattr(trader_client, "_bound_front", "")) != config.td_front + or _text(getattr(trader_client, "_bound_md_front", "")) != config.md_front + or _text(getattr(trader_client, "_bound_broker_id", "")) != config.broker_id + or _text(getattr(trader_client, "_bound_user_id", "")) != config.user_id + or getattr(trader_client, "auto_settlement_confirm", None) is not False + ): + _reject("native_trader_client_front_or_account_mismatch") + account_fingerprint = _text(getattr(trader_client, "_account_fingerprint", "")) + expected_account_fingerprint = hashlib.sha256( + f"{config.broker_id}:{config.user_id}".encode("utf-8") + ).hexdigest()[:16] + account_digest = hashlib.sha256( + ("acct_" + expected_account_fingerprint).encode("ascii") + ).hexdigest() + if ( + account_fingerprint != expected_account_fingerprint + or account_digest != registration.account_fingerprint_sha256 + ): + _reject("native_trader_client_account_mismatch") + factories = (order_field_factory, action_field_factory) + if any(factory is not None and not callable(factory) for factory in factories): + _reject("native_ctp_field_factory_invalid") + if runtime_credential_binding_factory is not None and not callable( + runtime_credential_binding_factory + ): + _reject("native_runtime_credential_binding_factory_invalid") + if sdk_approval_rechecker is not None and not callable(sdk_approval_rechecker): + _reject("native_sdk_approval_rechecker_invalid") + if runtime_credential_binding is not None and runtime_credential_binding_factory is None: + _reject("native_runtime_credential_binding_factory_required") + self._trader = trader_client + self.registration = registration + self.config = config + self._execution_capability = execution_capability + self._runtime_admission_check = runtime_admission_check + self._runtime_order_binding = runtime_order_binding + self._runtime_credential_binding = runtime_credential_binding + self._runtime_credential_binding_generation: int | None = None + self._runtime_credential_binding_factory = runtime_credential_binding_factory + self._runtime_approval_verifier = runtime_approval_verifier + self._sdk_approval_rechecker = sdk_approval_rechecker + self._write_lock = threading.RLock() + self._staged_write: ( + tuple[CtpSimulationWriteRequest, CtpSimulationWriteApproval, str | None] | None + ) = None + self._pending_sdk_intent: dict[str, Any] | None = None + self._pending_sdk_intent_consumed = False + self._sdk_write_intent_callback = self._verify_sdk_write_intent + self._order_field_factory = order_field_factory + self._action_field_factory = action_field_factory + self._actions: dict[str, tuple[int, CtpSimulationOrderSnapshot]] = {} + self._closed = False + self._close_failed = False + if execution_capability is not None: + client_type = _sdk_trader_client_type() + configure_gate = getattr(trader_client, "configure_execution_gate", None) + if ( + client_type is None + or type(trader_client) is not client_type + or not callable(configure_gate) + ): + _reject("native_managed_trader_client_unavailable") + try: + state = configure_gate(execution_capability) + except Exception as exc: + raise CtpSimulationExecutionError("native_execution_capability_rejected") from exc + if not isinstance(state, Mapping) or state.get("armed") is not False: + _reject("native_simnow_managed_gate_must_start_disarmed") + if runtime_credential_binding is not None: + # A static binding cannot carry a request-specific, single-use + # runtime verifier. It is deliberately never installed here. + _reject("native_per_action_credential_binding_required") + + @property + def write_admitted(self) -> bool: + """Report admission only when both native and runtime gates are present.""" + if ( + self._execution_capability is None + or self._runtime_admission_check is None + or self._runtime_order_binding is None + or self._runtime_credential_binding_factory is None + or self._runtime_credential_binding is None + or self._runtime_approval_verifier is None + or not callable(getattr(self._runtime_approval_verifier, "verify", None)) + or self._sdk_approval_rechecker is None + or self.profile != _SIMNOW_SDK_PROFILE + ): + return False + try: + session = self._trader.get_session_state() + # A valid read-only CTP session is not proof that settlement has + # been confirmed or that a trading request may be sent. + if not isinstance(session, Mapping) or session.get("trading_ready") is not True: + return False + self.get_execution_identity() + self._require_installed_runtime_binding() + admitted = self._runtime_admission_check() + except CtpSimulationExecutionError: + return False + except Exception: + return False + return admitted is True + + def _new_order_field(self) -> Any: + if self._order_field_factory is not None: + return self._order_field_factory() + try: + from bt_api_ctp.ctp.ctp_structs_order import CThostFtdcInputOrderField + + return CThostFtdcInputOrderField() + except Exception as exc: + raise CtpSimulationExecutionError("native_ctp_order_field_unavailable") from exc + + def _new_action_field(self) -> Any: + if self._action_field_factory is not None: + return self._action_field_factory() + try: + from bt_api_ctp.ctp.ctp_structs_order import CThostFtdcInputOrderActionField + + return CThostFtdcInputOrderActionField() + except Exception as exc: + raise CtpSimulationExecutionError("native_ctp_action_field_unavailable") from exc + + def _require_write(self) -> CtpSimulationSessionIdentity: + if self.profile != _SIMNOW_SDK_PROFILE: + _reject("native_simnow_config_front_pair_required") + self._require_td_trading_ready() + if not self.write_admitted: + _reject("native_execution_gate_not_admitted") + return self.get_execution_identity() + + def _require_td_trading_ready(self) -> None: + """Keep authenticated read-only identity separate from write readiness.""" + + try: + state = self._trader.get_session_state() + except Exception: + _reject("native_trader_session_state_unavailable") + if not isinstance(state, Mapping) or state.get("trading_ready") is not True: + _reject("native_trader_session_not_trading_ready") + + def _verify_runtime_approval( + self, + request: CtpSimulationWriteRequest, + approval: CtpSimulationWriteApproval, + ) -> None: + verifier = self._runtime_approval_verifier + registration = self.registration + now = time.time() + if ( + type(request) is not CtpSimulationWriteRequest + or type(approval) is not CtpSimulationWriteApproval + or approval.key_id != registration.approval_key_id + or approval.registration_digest != registration.digest + or approval.receipt_digest != registration.runtime_registration.approval_receipt_digest + or approval.account_fingerprint_sha256 != registration.account_fingerprint_sha256 + or approval.environment != registration.environment + or approval.td_front != registration.td_front + or approval.md_front != registration.md_front + or approval.request_digest != request.digest + or approval.expires_at - approval.issued_at > registration.approval_ttl_seconds + or approval.issued_at > now + 1.0 + or approval.expires_at <= now + or verifier is None + ): + _reject("native_runtime_write_approval_scope_mismatch") + try: + verified = verifier.verify(approval) + except Exception: + verified = False + if verified is not True: + _reject("native_runtime_write_approval_rejected") + + def _install_action_binding( + self, + request: CtpSimulationWriteRequest, + approval: CtpSimulationWriteApproval, + action_id: str | None, + identity: CtpSimulationSessionIdentity, + ) -> Any: + factory = self._runtime_credential_binding_factory + if not callable(factory): + _reject("native_per_action_credential_binding_required") + if request.action == "CANCEL" and ( + type(action_id) is not str or not action_id or action_id != action_id.strip() + ): + _reject("native_cancel_action_id_invalid") + if request.action == "SUBMIT" and action_id is not None: + _reject("native_submit_action_id_forbidden") + try: + binding = factory( + operation="insert" if request.action == "SUBMIT" else "cancel", + request=request, + runtime_approval=approval, + action_id=action_id, + identity=identity, + write_intent_verifier=self._sdk_write_intent_callback, + ) + except Exception as exc: + raise CtpSimulationExecutionError("native_sdk_write_binding_creation_failed") from exc + _require_sdk_simnow_binding(binding, self.profile, self.config) + if getattr(binding, "write_intent_verifier", None) is not self._sdk_write_intent_callback: + _reject("native_sdk_write_verifier_not_bound") + configure_binding = getattr( + self._trader, "configure_runtime_simnow_credential_binding", None + ) + if not callable(configure_binding): + _reject("native_runtime_credential_binding_unavailable") + try: + result = configure_binding(self._execution_capability, binding) + except Exception as exc: + raise CtpSimulationExecutionError("native_runtime_credential_binding_rejected") from exc + if ( + not isinstance(result, Mapping) + or result.get("configured") is not True + or result.get("environment_profile") != self.profile + or result.get("connection_generation") != identity.connection_generation + ): + _reject("native_runtime_credential_binding_result_invalid") + self._runtime_credential_binding = binding + self._runtime_credential_binding_generation = identity.connection_generation + self._require_installed_runtime_binding() + return binding + + def authorize_write( + self, + request: CtpSimulationWriteRequest, + approval: CtpSimulationWriteApproval, + *, + action_id: str | None = None, + ) -> None: + """Stage one signed managed intent before its typed SDK dispatch. + + The execution session calls this only after it reserves the durable + journal row and verifies the runtime approval. This adapter repeats + signature and scope checks, installs the matching typed SDK entry or + recovery binding, and gives the SDK one exact callback scope to consume. + """ + + with self._write_lock: + if self._staged_write is not None or self._pending_sdk_intent is not None: + _reject("native_managed_write_already_staged") + self._require_td_trading_ready() + identity = self.get_execution_identity() + if not identity.native_simnow_managed_mode: + _reject("native_simnow_managed_capability_unproven") + self._verify_runtime_approval(request, approval) + if self._runtime_admission_check is None: + _reject("native_runtime_admission_verifier_required") + try: + admitted = self._runtime_admission_check() + except Exception: + admitted = False + if admitted is not True: + _reject("native_runtime_admission_rejected") + binding = self._install_action_binding(request, approval, action_id, identity) + self._staged_write = (request, approval, action_id) + # Keep this exact SDK binding with the staged approval. A later + # callback cannot switch the SDK verifier or the recovery approval. + if self._runtime_credential_binding is not binding: + self._staged_write = None + _reject("native_runtime_credential_binding_changed") + + def _require_staged_write( + self, + request: CtpSimulationWriteRequest, + *, + action_id: str | None = None, + ) -> tuple[CtpSimulationWriteApproval, Any]: + with self._write_lock: + staged = self._staged_write + if staged is None or staged[0] != request or staged[2] != action_id: + _reject("native_managed_write_not_authorized") + approval = staged[1] + binding = self._require_installed_runtime_binding() + self._verify_runtime_approval(request, approval) + self._staged_write = None + return approval, binding + + def _set_pending_sdk_intent( + self, + *, + request: CtpSimulationWriteRequest, + approval: CtpSimulationWriteApproval, + binding: Any, + identity: CtpSimulationSessionIdentity, + managed_intent_id: str, + order_ref: str, + request_id: int, + action_id: str | None, + ) -> None: + sdk_approval = getattr(binding, "approval", None) + approval_id = getattr(sdk_approval, "approval_id", None) + approval_nonce = getattr(sdk_approval, "nonce", None) + approval_payload_sha256 = getattr(sdk_approval, "payload_sha256", None) + if ( + type(approval_id) is not str + or not approval_id + or type(approval_nonce) not in (str, int) + or type(approval_payload_sha256) is not str + or len(approval_payload_sha256) != 64 + or any(character not in "0123456789abcdef" for character in approval_payload_sha256) + ): + _reject("native_sdk_approval_identity_invalid") + expected = { + "schema_version": "ctp-simnow-managed-write-v1", + "operation": "insert" if request.action == "SUBMIT" else "cancel", + "td_front": self.config.td_front, + "md_front": self.config.md_front, + "environment_profile": self.profile, + "account_fingerprint": "acct_" + + _text(getattr(self._trader, "_account_fingerprint", "")), + "trading_day": identity.trading_day, + "connection_generation": identity.connection_generation, + "instrument_id": request.instrument_id, + "exchange_id": request.exchange_id, + "runtime_order_id": request.client_order_id, + "managed_intent_id": managed_intent_id, + "runtime_action_id": action_id, + "managed_cancel_intent_id": action_id, + "request_id": request_id, + "approval_id": approval_id, + "approval_nonce": str(approval_nonce), + "approval_payload_sha256": approval_payload_sha256, + } + if request.action == "SUBMIT": + expected.update( + { + "order_ref": order_ref, + "direction": "0" if request.side == "BUY" else "1", + "offset_flag": "0", + "hedge_flag": request.hedge_flag, + "volume_total_original": request.quantity, + "limit_price": format(request.limit_price.normalize(), "f"), + "order_price_type": "2", + "time_condition": "3", + "volume_condition": "1", + } + ) + else: + expected.update( + { + "order_action_ref": request_id, + "action_flag": "0", + "target_order_ref": request.target_order_ref, + "target_front_id": request.target_front_id, + "target_session_id": request.target_session_id, + "target_order_sys_id": request.target_order_sys_id, + } + ) + with self._write_lock: + if self._pending_sdk_intent is not None: + _reject("native_sdk_write_intent_already_pending") + self._pending_sdk_intent = { + "expected": expected, + "request": request, + "runtime_approval": approval, + "binding": binding, + } + self._pending_sdk_intent_consumed = False + + def _verify_sdk_write_intent(self, scope: Mapping[str, Any]) -> bool: + """Callback installed into the SDK binding for its final locked gate.""" + + with self._write_lock: + pending = self._pending_sdk_intent + if ( + pending is None + or self._pending_sdk_intent_consumed + or not isinstance(scope, Mapping) + ): + return False + expected = pending["expected"] + if set(scope) != set(expected) or any( + scope.get(key) != value for key, value in expected.items() + ): + return False + request = pending["request"] + approval = pending["runtime_approval"] + binding = pending["binding"] + try: + self._verify_runtime_approval(request, approval) + if self._runtime_credential_binding is not binding: + return False + if self._sdk_approval_rechecker is None: + return False + sdk_valid = self._sdk_approval_rechecker(binding, scope) + except Exception: + return False + if sdk_valid is not True: + return False + self._pending_sdk_intent_consumed = True + return True + + def _clear_pending_sdk_intent(self) -> None: + with self._write_lock: + self._pending_sdk_intent = None + self._pending_sdk_intent_consumed = False + + def get_execution_identity(self) -> CtpSimulationSessionIdentity: + try: + scope = self._trader.get_query_session_scope() + gate = self._trader.get_execution_gate_state() + session = self._trader.get_session_state() + active_front = self._trader._bound_identity_is_current(require_active_front=True) + except Exception as exc: + raise CtpSimulationExecutionError("native_trader_session_unavailable") from exc + if not isinstance(gate, Mapping) or not isinstance(session, Mapping): + _reject("native_trader_session_identity_invalid") + if ( + _text(getattr(self._trader, "_bound_front", "")) != self.config.td_front + or _text(getattr(self._trader, "_bound_md_front", "")) != self.config.md_front + or getattr(self._trader, "ctp_env_profile", None) != self.profile + ): + _reject("native_trader_front_pair_scope_mismatch") + account = _text(getattr(scope, "account_fingerprint", "")) + if account.startswith("acct_"): + account = account[5:] + trading_day = _text(getattr(scope, "trading_day", "")) + generation = getattr(scope, "connection_generation", None) + if ( + account != _text(getattr(self._trader, "_account_fingerprint", "")) + or type(generation) is not int + or generation <= 0 + or type(trading_day) is not str + or len(trading_day) != 8 + or not trading_day.isascii() + or not trading_day.isdigit() + or getattr(scope, "read_only_ready", None) is not True + or session.get("read_only_ready") is not True + or session.get("auto_settlement_confirm") is not False + or active_front is not True + ): + _reject("native_trader_read_only_session_scope_unproven") + + gate_armed = gate.get("armed") + if type(gate_armed) is not bool: + _reject("native_trader_gate_state_invalid") + simnow_managed_mode = False + if self.profile != _SIMNOW_SDK_PROFILE: + _reject("native_trader_session_profile_unsupported") + # SimNow authorizes each typed managed request under the SDK lock; + # its general CTP gate must remain disarmed. A bool in the public + # gate-state mapping is insufficient: the exact installed SDK + # TraderClient, private capability identity, typed SDK binding, + # and per-action verifier are all checked separately. + if gate_armed is True: + _reject("native_simnow_general_execution_arm_forbidden") + gate_generation = gate.get("connection_generation") + if gate_generation not in (None, generation): + _reject("native_trader_session_scope_unproven") + if self._execution_capability is not None: + client_type = _sdk_trader_client_type() + if ( + client_type is None + or type(self._trader) is not client_type + or getattr(self._trader, "_execution_gate_capability", None) + is not self._execution_capability + ): + _reject("native_simnow_managed_capability_unproven") + simnow_managed_mode = True + if gate.get("environment_profile") not in (None, self.profile): + _reject("native_trader_session_scope_unproven") + + return CtpSimulationSessionIdentity( + environment=_SIMNOW_ENVIRONMENT, + sdk_profile=self.profile, + td_front=self.config.td_front, + md_front=self.config.md_front, + account_fingerprint_sha256=self.registration.account_fingerprint_sha256, + trading_day=trading_day, + connection_generation=generation, + production=False, + native_gate_armed=gate_armed, + native_simnow_managed_mode=simnow_managed_mode, + ) + + def _require_installed_runtime_binding(self) -> Any: + binding = self._runtime_credential_binding + _require_sdk_simnow_binding(binding, self.profile, self.config) + if ( + getattr(self._trader, "_runtime_simnow_credential_binding", None) is not binding + or getattr(binding, "write_intent_verifier", None) + is not self._sdk_write_intent_callback + ): + _reject("native_runtime_credential_binding_not_installed") + try: + gate = self._trader.get_execution_gate_state() + except Exception as exc: + raise CtpSimulationExecutionError("native_trader_session_unavailable") from exc + if ( + not isinstance(gate, Mapping) + or gate.get("armed") is not False + or gate.get("runtime_simnow_credential_binding_configured") is not True + or gate.get("runtime_simnow_write_verifier_configured") is not True + or self._runtime_credential_binding_generation + != getattr(self._trader, "_connection_generation", None) + or gate.get("connection_generation") + not in (None, self._runtime_credential_binding_generation) + ): + _reject("native_simnow_managed_gate_state_unproven") + return binding + + def _request_id(self) -> int: + method = getattr(self._trader, "_next_request_id", None) + if not callable(method): + _reject("native_trader_request_id_unavailable") + value = method() + if type(value) is not int or value <= 0: + _reject("native_trader_request_id_invalid") + return value + + @staticmethod + def _require_native_ref(value: str) -> str: + try: + encoded = value.encode("ascii") + except (AttributeError, UnicodeEncodeError): + _reject("native_order_ref_invalid") + if len(encoded) != 12 or not encoded.isdigit() or b"\x00" in encoded: + _reject("native_order_ref_invalid") + return value + + def _resolve_runtime_order_binding( + self, + runtime_order_id: str, + *, + reserve: bool, + expected_identity: CtpSimulationSessionIdentity | None = None, + ) -> Mapping[str, Any]: + resolver = self._runtime_order_binding + if not callable(resolver): + _reject("durable_runtime_order_binding_unavailable") + if type(reserve) is not bool: + _reject("durable_runtime_order_binding_invalid") + identity = self.get_execution_identity() + try: + binding = resolver(runtime_order_id, reserve) + except Exception as exc: + raise CtpSimulationExecutionError("durable_runtime_order_binding_failed") from exc + if identity != self.get_execution_identity() or ( + expected_identity is not None and identity != expected_identity + ): + _reject("durable_runtime_order_binding_session_changed") + if not isinstance(binding, Mapping): + _reject("durable_runtime_order_binding_invalid") + if ( + binding.get("runtime_order_id") != runtime_order_id + or type(binding.get("connection_generation")) is not int + or binding.get("connection_generation") != identity.connection_generation + or type(binding.get("trading_day")) is not str + or binding.get("trading_day") != identity.trading_day + ): + _reject("durable_runtime_order_binding_scope_mismatch") + if type(binding.get("reserved")) is not bool or binding.get("reserved") is not reserve: + _reject("durable_runtime_order_binding_reservation_unconfirmed") + return binding + + def _resolve_runtime_order_ref( + self, + runtime_order_id: str, + *, + reserve: bool, + expected_identity: CtpSimulationSessionIdentity | None = None, + ) -> str: + binding = self._resolve_runtime_order_binding( + runtime_order_id, + reserve=reserve, + expected_identity=expected_identity, + ) + ref = self._require_native_ref(_text(binding.get("ctp_order_ref"))) + client_order_id = _text(binding.get("client_order_id")) + if client_order_id and client_order_id != ref: + _reject("durable_runtime_order_reference_mismatch") + return ref + + @staticmethod + def _managed_intent_id(binding: Mapping[str, Any]) -> str: + value = binding.get("managed_intent_id") + if ( + type(value) is not str + or not value + or value != value.strip() + or len(value) > 256 + or not value.isascii() + or any(not (char.isalnum() or char in "._:-") for char in value) + ): + _reject("durable_runtime_managed_intent_binding_invalid") + return value + + def _dispatch_receipt( + self, + *, + operation: str, + request: CtpSimulationWriteRequest, + managed_intent_id: str, + request_id: int, + identity: CtpSimulationSessionIdentity, + native_result: Any, + evidence: Any, + order_ref: str, + action_id: str | None = None, + snapshot: CtpSimulationOrderSnapshot | None = None, + ) -> CtpSimulationDispatchReceipt: + """Translate the SDK's raw submit code and immutable evidence. + + A zero code plus an exact request record proves only local queueing. A + negative code is a local rejection only when the SDK record proves the + exact request and confirms that no callback arrived. Everything else + stays UNKNOWN so the execution journal freezes the action. + """ + result_code = native_result if type(native_result) is int else None + try: + evidence_code = _value(evidence, "submit_code") if evidence is not None else None + except Exception: + evidence_code = None + evidence_code = evidence_code if type(evidence_code) is int else None + receipt_code = result_code if result_code is not None else evidence_code + outcome = "UNKNOWN" + rejection_verified = False + + evidence_matches = False + callback_received = None + evidence_received = None + status = "" + try: + callback_received = _value(evidence, "callback_received") + evidence_received = _value(evidence, "evidence_received") + status = _text(_value(evidence, "status")).lower() + account = _text(_value(evidence, "account_fingerprint")) + evidence_matches = bool( + evidence is not None + and type(_value(evidence, "request_id")) is int + and _value(evidence, "request_id") == request_id + and evidence_code is not None + and evidence_code == result_code + and account == "acct_" + _text(getattr(self._trader, "_account_fingerprint", "")) + and _text(_value(evidence, "trading_day")) == identity.trading_day + and type(_value(evidence, "connection_generation")) is int + and _value(evidence, "connection_generation") == identity.connection_generation + and _text(_value(evidence, "instrument_id")) == request.instrument_id + and _text(_value(evidence, "exchange_id")) == request.exchange_id + and type(callback_received) is bool + and type(evidence_received) is bool + and evidence_received is callback_received + and status in {"unknown", "accepted", "rejected"} + and identity == self.get_execution_identity() + ) + if operation == "SUBMIT": + evidence_matches = evidence_matches and ( + _text(_value(evidence, "order_ref")) == order_ref + ) + else: + evidence_matches = evidence_matches and snapshot is not None and ( + _text(_value(evidence, "order_action_ref")) == str(request_id) + and _text(_value(evidence, "order_ref")) == snapshot.order_ref + and _text(_value(evidence, "order_sys_id")) == snapshot.order_sys_id + and _int_field(evidence, "front_id", required=False) == snapshot.front_id + and _int_field(evidence, "session_id", required=False) == snapshot.session_id + and _text(_value(evidence, "action_flag")) == "0" + ) + except Exception: + evidence_matches = False + + if evidence_matches and result_code is not None and result_code == evidence_code: + if result_code == 0: + outcome = "QUEUED" + elif ( + result_code < 0 + and callback_received is False + and evidence_received is False + and status == "unknown" + ): + outcome = "REJECTED" + rejection_verified = True + + return CtpSimulationDispatchReceipt( + operation=operation, + outcome=outcome, + request_digest=request.digest, + client_order_id=request.client_order_id, + managed_intent_id=managed_intent_id, + action_id=action_id, + request_id=request_id, + submit_code=receipt_code, + identity=identity, + local_rejection_verified=rejection_verified, + ) + + def _build_order_field( + self, request: CtpSimulationWriteRequest, request_id: int, order_ref: str + ) -> Any: + if request.action != "SUBMIT" or request.offset != "OPEN": + _reject("native_order_request_scope_invalid") + field = self._new_order_field() + values = { + "BrokerID": self.config.broker_id, + "InvestorID": self.config.user_id, + "UserID": self.config.user_id, + "InstrumentID": request.instrument_id, + "ExchangeID": request.exchange_id, + "OrderRef": self._require_native_ref(order_ref), + "OrderPriceType": "2", + "Direction": "0" if request.side == "BUY" else "1", + "CombOffsetFlag": "0", + "CombHedgeFlag": request.hedge_flag, + "LimitPrice": float(request.limit_price), + "VolumeTotalOriginal": request.quantity, + "TimeCondition": "3", + "VolumeCondition": "1", + "MinVolume": 1, + "ContingentCondition": "1", + "ForceCloseReason": "0", + "IsAutoSuspend": 0, + "UserForceClose": 0, + "RequestID": request_id, + } + for name, value in values.items(): + setattr(field, name, value) + return field + + def submit_order_insert( + self, request: CtpSimulationWriteRequest + ) -> CtpSimulationDispatchReceipt: + self._require_write() + if type(request) is not CtpSimulationWriteRequest: + _reject("native_order_request_invalid") + approval, credential_binding = self._require_staged_write(request) + request_id = self._request_id() + order_binding = self._resolve_runtime_order_binding( + request.client_order_id, + reserve=True, + ) + order_ref = self._require_native_ref(_text(order_binding.get("ctp_order_ref"))) + client_order_id = _text(order_binding.get("client_order_id")) + if client_order_id and client_order_id != order_ref: + _reject("durable_runtime_order_reference_mismatch") + managed_intent_id = self._managed_intent_id(order_binding) + field = self._build_order_field(request, request_id, order_ref) + submit = getattr(self._trader, "submit_order_insert", None) + if not callable(submit): + _reject("native_order_insert_unavailable") + self._require_write() + identity = self.get_execution_identity() + self._set_pending_sdk_intent( + request=request, + approval=approval, + binding=credential_binding, + identity=identity, + managed_intent_id=managed_intent_id, + order_ref=order_ref, + request_id=request_id, + action_id=None, + ) + try: + result = submit( + field, + request_id, + execution_capability=self._execution_capability, + runtime_order_id=request.client_order_id, + managed_intent_id=managed_intent_id, + ) + if not self._pending_sdk_intent_consumed: + _reject("native_sdk_write_verifier_not_invoked") + evidence_getter = getattr(self._trader, "get_order_insert_evidence", None) + try: + evidence = ( + evidence_getter(request_id, order_ref=order_ref) + if callable(evidence_getter) + else None + ) + except Exception: + evidence = None + return self._dispatch_receipt( + operation="SUBMIT", + request=request, + managed_intent_id=managed_intent_id, + request_id=request_id, + identity=identity, + native_result=result, + evidence=evidence, + order_ref=order_ref, + ) + finally: + self._clear_pending_sdk_intent() + + def _build_action_field(self, snapshot: CtpSimulationOrderSnapshot, request_id: int) -> Any: + field = self._new_action_field() + for name, value in { + "BrokerID": self.config.broker_id, + "InvestorID": self.config.user_id, + "InstrumentID": snapshot.instrument_id, + "ExchangeID": snapshot.exchange_id, + "ActionFlag": "0", + "OrderSysID": snapshot.order_sys_id, + "OrderRef": self._require_native_ref(snapshot.order_ref), + "FrontID": snapshot.front_id, + "SessionID": snapshot.session_id, + "RequestID": request_id, + "OrderActionRef": request_id, + }.items(): + setattr(field, name, value) + return field + + def submit_order_action( + self, snapshot: CtpSimulationOrderSnapshot, action_id: str + ) -> CtpSimulationDispatchReceipt: + self._require_write() + if type(snapshot) is not CtpSimulationOrderSnapshot: + _reject("native_cancel_order_snapshot_invalid") + if not isinstance(action_id, str) or not action_id or action_id != action_id.strip(): + _reject("native_cancel_action_id_invalid") + if action_id in self._actions: + _reject("native_cancel_action_id_reused") + if self._staged_write is None: + _reject("native_managed_write_not_authorized") + request = self._staged_write[0] + if ( + request.action != "CANCEL" + or request.client_order_id != snapshot.client_order_id + or request.instrument_id != snapshot.instrument_id + or request.exchange_id != snapshot.exchange_id + or request.side != snapshot.side + or request.quantity != snapshot.quantity + or request.limit_price != snapshot.limit_price + or request.target_order_sys_id != snapshot.order_sys_id + or request.target_order_ref != snapshot.order_ref + or request.target_front_id != snapshot.front_id + or request.target_session_id != snapshot.session_id + ): + _reject("native_cancel_request_target_mismatch") + approval, credential_binding = self._require_staged_write(request, action_id=action_id) + identity = self.get_execution_identity() + binding = self._resolve_runtime_order_binding( + snapshot.client_order_id, + reserve=False, + expected_identity=identity, + ) + bound_order_ref = self._require_native_ref(_text(binding.get("ctp_order_ref"))) + client_order_id = _text(binding.get("client_order_id")) + if (client_order_id and client_order_id != bound_order_ref) or self._require_native_ref( + snapshot.order_ref + ) != bound_order_ref: + _reject("durable_runtime_order_reference_mismatch") + managed_intent_id = self._managed_intent_id(binding) + request_id = self._request_id() + field = self._build_action_field(snapshot, request_id) + self._require_write() + # Record before calling the native API: an exception may mean CTP saw + # the request. The outer durable journal then keeps the order UNKNOWN. + self._actions[action_id] = (request_id, snapshot) + submit = getattr(self._trader, "submit_order_action", None) + if not callable(submit): + _reject("native_order_action_unavailable") + self._set_pending_sdk_intent( + request=request, + approval=approval, + binding=credential_binding, + identity=identity, + managed_intent_id=managed_intent_id, + order_ref=bound_order_ref, + request_id=request_id, + action_id=action_id, + ) + try: + result = submit( + field, + request_id, + execution_capability=self._execution_capability, + runtime_order_id=snapshot.client_order_id, + managed_intent_id=managed_intent_id, + runtime_action_id=action_id, + managed_cancel_intent_id=action_id, + ) + if not self._pending_sdk_intent_consumed: + _reject("native_sdk_write_verifier_not_invoked") + evidence_getter = getattr(self._trader, "get_order_action_evidence", None) + try: + evidence = ( + evidence_getter(request_id, order_action_ref=request_id) + if callable(evidence_getter) + else None + ) + except Exception: + evidence = None + return self._dispatch_receipt( + operation="CANCEL", + request=request, + managed_intent_id=managed_intent_id, + request_id=request_id, + identity=identity, + native_result=result, + evidence=evidence, + order_ref=bound_order_ref, + action_id=action_id, + snapshot=snapshot, + ) + finally: + self._clear_pending_sdk_intent() + + def _query(self, name: str, **kwargs: Any) -> tuple[Any, CtpSimulationSessionIdentity]: + identity = self.get_execution_identity() + method = getattr(self._trader, name, None) + if not callable(method): + _reject("native_query_method_unavailable") + try: + result = method(**kwargs) + except Exception as exc: + raise CtpSimulationExecutionError("native_query_failed") from exc + if getattr( + result, "connection_generation", None + ) != identity.connection_generation or _text( + getattr(result, "account_fingerprint", "") + ) != _text(getattr(self._trader, "_account_fingerprint", "")): + _reject("native_query_identity_mismatch") + return result, identity + + def query_orders(self, client_order_id: str) -> CtpSimulationQueryResult: + result, identity = self._query( + "query_orders_result", + instrument_id=self.registration.instrument_id, + exchange_id=self.registration.exchange_id, + ) + native_ref = self._resolve_runtime_order_ref( + client_order_id, reserve=False, expected_identity=identity + ) + records = tuple( + self._order_snapshot(row, client_order_id=client_order_id) + for row in getattr(result, "records", ()) + if _text(_value(row, "OrderRef", "order_ref")) == native_ref + ) + return CtpSimulationQueryResult( + bool(getattr(result, "complete", False)), identity, records, native_evidence=result + ) + + def query_trades(self, client_order_id: str) -> CtpSimulationQueryResult: + result, identity = self._query( + "query_trades_result", + instrument_id=self.registration.instrument_id, + exchange_id=self.registration.exchange_id, + ) + native_ref = self._resolve_runtime_order_ref( + client_order_id, reserve=False, expected_identity=identity + ) + records = tuple( + CtpSimulationTradeSnapshot( + client_order_id=client_order_id, + trade_id=_text(_value(row, "TradeID", "trade_id")), + quantity=_int_field(row, "Volume", "quantity"), + instrument_id=_text(_value(row, "InstrumentID", "instrument_id")), + exchange_id=_text(_value(row, "ExchangeID", "exchange_id")), + side=_SIDE_BY_DIRECTION.get(_text(_value(row, "Direction", "direction")), ""), + ) + for row in getattr(result, "records", ()) + if _text(_value(row, "OrderRef", "order_ref")) == native_ref + ) + return CtpSimulationQueryResult( + bool(getattr(result, "complete", False)), identity, records, native_evidence=result + ) + + def query_positions(self, instrument_id: str, exchange_id: str) -> CtpSimulationQueryResult: + if (instrument_id, exchange_id) != ( + self.registration.instrument_id, + self.registration.exchange_id, + ): + _reject("native_position_query_scope_invalid") + result, identity = self._query("query_positions_result") + totals = {"BUY": 0, "SELL": 0} + for row in getattr(result, "records", ()): + if ( + _text(_value(row, "InstrumentID", "instrument_id")) != instrument_id + or _text(_value(row, "ExchangeID", "exchange_id")) != exchange_id + ): + continue + if _text(_value(row, "HedgeFlag", "hedge_flag")) != self.registration.hedge_flag: + _reject("native_position_hedge_scope_mismatch") + direction = _text(_value(row, "PosiDirection", "position_direction")) + side = {"2": "BUY", "3": "SELL"}.get(direction) + if side is None: + _reject("native_position_direction_unproven") + totals[side] += _int_field(row, "Position", "quantity") + records = tuple( + CtpSimulationPositionSnapshot(instrument_id, exchange_id, side, quantity) + for side, quantity in totals.items() + ) + return CtpSimulationQueryResult( + bool(getattr(result, "complete", False)), identity, records, native_evidence=result + ) + + def query_account_open_orders(self) -> CtpSimulationQueryResult: + result, identity = self._query("query_orders_result") + snapshots = tuple( + self._order_snapshot(row) + for row in getattr(result, "records", ()) + if self._is_open(row) + ) + return CtpSimulationQueryResult( + bool(getattr(result, "complete", False)), identity, snapshots, native_evidence=result + ) + + def query_cancel_requests(self, action_ids: tuple[str, ...]) -> CtpSimulationQueryResult: + """Resolve in-process action callbacks; never guess after restart. + + CTP exposes callback evidence for one request in the live TraderClient, + but no durable native action-query endpoint. If the adapter has no + local correlation for any requested action, return incomplete evidence + so the outer journal keeps the order UNKNOWN. + """ + identity = self.get_execution_identity() + if not action_ids or any(action_id not in self._actions for action_id in action_ids): + return CtpSimulationQueryResult(False, identity, ()) + records = [] + evidence_bundle = [] + for action_id in action_ids: + request_id, snapshot = self._actions[action_id] + try: + evidence = self._trader.get_order_action_evidence( + request_id, order_action_ref=request_id + ) + except Exception: + evidence = None + evidence_bundle.append(evidence) + status = "UNKNOWN" + if self._action_evidence_matches(evidence, request_id, snapshot, identity): + callback_status = _text(_value(evidence, "status")).lower() + if callback_status == "rejected": + status = "REJECTED" + elif callback_status == "accepted": + try: + latest, _ = self._query( + "query_orders_result", + instrument_id=snapshot.instrument_id, + exchange_id=snapshot.exchange_id, + order_sys_id=snapshot.order_sys_id, + ) + evidence_bundle.append(latest) + latest_rows = tuple(getattr(latest, "records", ())) + exact_target = ( + len(latest_rows) == 1 + and _text(_value(latest_rows[0], "OrderRef", "order_ref")) + == snapshot.order_ref + and _text(_value(latest_rows[0], "OrderSysID", "order_sys_id")) + == snapshot.order_sys_id + and _text(_value(latest_rows[0], "InstrumentID", "instrument_id")) + == snapshot.instrument_id + and _text(_value(latest_rows[0], "ExchangeID", "exchange_id")) + == snapshot.exchange_id + ) + if getattr(latest, "complete", False) is True and exact_target: + latest_snapshot = self._order_snapshot(latest_rows[0]) + status = ( + "CANCELED" if latest_snapshot.status == "CANCELED" else "PENDING" + ) + except CtpSimulationExecutionError: + status = "UNKNOWN" + records.append( + CtpSimulationCancelRequestSnapshot( + action_id=action_id, + client_order_id=snapshot.client_order_id, + target_order_sys_id=snapshot.order_sys_id, + target_order_ref=snapshot.order_ref, + target_front_id=snapshot.front_id, + target_session_id=snapshot.session_id, + status=status, + ) + ) + complete = all(record.status in {"CANCELED", "REJECTED"} for record in records) + return CtpSimulationQueryResult( + complete, + identity, + tuple(records), + native_evidence=tuple(evidence_bundle), + ) + + def _action_evidence_matches( + self, + evidence: Any, + request_id: int, + snapshot: CtpSimulationOrderSnapshot, + identity: CtpSimulationSessionIdentity, + ) -> bool: + if evidence is None: + return False + account = _text(_value(evidence, "account_fingerprint")) + if account.startswith("acct_"): + account = account[5:] + return bool( + type(_value(evidence, "request_id")) is int + and _value(evidence, "request_id") == request_id + and _text(_value(evidence, "order_action_ref")) == str(request_id) + and _value(evidence, "callback_received") is True + and _value(evidence, "evidence_received") is True + and account == _text(getattr(self._trader, "_account_fingerprint", "")) + and _value(evidence, "connection_generation") == identity.connection_generation + and _text(_value(evidence, "trading_day")) == identity.trading_day + and _text(_value(evidence, "order_ref")) == snapshot.order_ref + and _text(_value(evidence, "order_sys_id")) == snapshot.order_sys_id + and _int_field(evidence, "front_id", required=False) == snapshot.front_id + and _int_field(evidence, "session_id", required=False) == snapshot.session_id + and _text(_value(evidence, "instrument_id")) == snapshot.instrument_id + and _text(_value(evidence, "exchange_id")) == snapshot.exchange_id + and _text(_value(evidence, "action_flag")) == "0" + ) + + @staticmethod + def _is_open(row: Any) -> bool: + status = _text(_value(row, "OrderStatus", "order_status")).lower() + if status in {"0", "2", "4", "5"}: + return False + if status not in {"1", "3", "a", "b", "c"}: + _reject("native_order_status_unproven") + return True + + @classmethod + def _order_snapshot( + cls, row: Any, *, client_order_id: str | None = None + ) -> CtpSimulationOrderSnapshot: + order_ref = _text(_value(row, "OrderRef", "order_ref")) + submit_status = _text(_value(row, "OrderSubmitStatus", "order_submit_status")) + raw_status = _text(_value(row, "OrderStatus", "order_status")).lower() + traded = _int_field(row, "VolumeTraded", "traded_quantity", required=False) + quantity = _int_field(row, "VolumeTotalOriginal", "quantity") + if submit_status == "4": + status = "REJECTED" + elif raw_status == "0": + status = "FILLED" + elif raw_status in {"1", "3"}: + status = "PARTIAL" if traded else "OPEN" + elif raw_status in {"2", "4", "5"}: + status = "CANCELED" + else: + _reject("native_order_status_unproven") + return CtpSimulationOrderSnapshot( + client_order_id=client_order_id or order_ref, + instrument_id=_text(_value(row, "InstrumentID", "instrument_id")), + exchange_id=_text(_value(row, "ExchangeID", "exchange_id")), + side=_SIDE_BY_DIRECTION.get(_text(_value(row, "Direction", "side")), ""), + quantity=quantity, + limit_price=_decimal_field(row, "LimitPrice", "limit_price"), + traded_quantity=traded, + status=status, + order_ref=order_ref, + order_sys_id=_text(_value(row, "OrderSysID", "order_sys_id")), + front_id=_int_field(row, "FrontID", "front_id"), + session_id=_int_field(row, "SessionID", "session_id"), + ) + + def close(self) -> None: + """Require a complete bounded SDK receipt before reporting close.""" + with self._write_lock: + if self._closed: + if self._close_failed: + _reject("native_session_close_failed") + return + self._closed = True + self._close_failed = not stop_ctp_native_client(self._trader) + if self._close_failed: + _reject("native_session_close_failed") + + +def create_ctp_trader_client_simulation_port( + registration: CtpSimulationExecutionRegistration, + config: CtpSimulationTraderConfig, + *, + trader_client_factory: Callable[..., Any] | None = None, + execution_capability: object | None = None, + runtime_admission_check: Callable[[], bool] | None = None, + runtime_order_binding: Callable[[str, bool], Mapping[str, Any]] | None = None, + runtime_credential_binding: Any | None = None, + runtime_credential_binding_factory: Callable[..., Any] | None = None, + runtime_approval_verifier: Any | None = None, + sdk_approval_rechecker: Callable[[Any, Mapping[str, Any]], bool] | None = None, + order_field_factory: Callable[[], Any] | None = None, + action_field_factory: Callable[[], Any] | None = None, +) -> CtpTraderClientSimulationPort: + """Construct, but do not start, a client against the exact sealed front. + + The injected factory seam is used by offline tests. The production + default imports only ``TraderClient`` after the code-owned profile/front + contract passes; it performs no connection or provider request. + """ + config.validate(registration) + factory = trader_client_factory + if factory is None: + try: + from bt_api_ctp.ctp.client import TraderClient + + factory = TraderClient + except Exception as exc: + raise CtpSimulationExecutionError("native_trader_client_unavailable") from exc + try: + trader = factory( + config.td_front, + config.broker_id, + config.user_id, + config.password, + app_id=config.app_id, + auth_code=config.auth_code, + md_front=config.md_front, + ctp_env_profile=_SIMNOW_SDK_PROFILE, + auto_settlement_confirm=False, + ) + except Exception as exc: + raise CtpSimulationExecutionError("native_trader_client_construction_failed") from exc + return CtpTraderClientSimulationPort( + trader, + registration, + config, + execution_capability=execution_capability, + runtime_admission_check=runtime_admission_check, + runtime_order_binding=runtime_order_binding, + runtime_credential_binding=runtime_credential_binding, + runtime_credential_binding_factory=runtime_credential_binding_factory, + runtime_approval_verifier=runtime_approval_verifier, + sdk_approval_rechecker=sdk_approval_rechecker, + order_field_factory=order_field_factory, + action_field_factory=action_field_factory, + ) + + +__all__ = [ + "CtpSimulationTraderConfig", + "CtpTraderClientSimulationPort", + "create_ctp_trader_client_simulation_port", +] diff --git a/backtrader_runtime/errors.py b/backtrader_runtime/errors.py new file mode 100644 index 00000000..14723eb1 --- /dev/null +++ b/backtrader_runtime/errors.py @@ -0,0 +1,81 @@ +"""Stable, redacted errors for the configuration-first runtime. + +The runtime loader is deliberately small and has no provider, gateway, or +plugin imports. Keeping its error vocabulary here gives callers a stable +machine-readable boundary before any optional trading dependency can be +considered. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + + +CONFIG_REQUIRED = "CONFIG_REQUIRED" +CONFIG_SCHEMA_UNSUPPORTED = "CONFIG_SCHEMA_UNSUPPORTED" +MODE_PRESET_MISMATCH = "MODE_PRESET_MISMATCH" +PRESET_POLICY_VIOLATION = "PRESET_POLICY_VIOLATION" +ENVIRONMENT_MISMATCH = "ENVIRONMENT_MISMATCH" +CONFIG_EXISTS = "CONFIG_EXISTS" +MIGRATION_REVIEW_REQUIRED = "MIGRATION_REVIEW_REQUIRED" + + +ERROR_CODES = frozenset( + ( + CONFIG_REQUIRED, + CONFIG_SCHEMA_UNSUPPORTED, + MODE_PRESET_MISMATCH, + PRESET_POLICY_VIOLATION, + ENVIRONMENT_MISMATCH, + CONFIG_EXISTS, + MIGRATION_REVIEW_REQUIRED, + ) +) + + +class RuntimeConfigError(Exception): + """A deterministic configuration or policy rejection. + + Args: + code: One of :data:`ERROR_CODES`. + message: Safe, operator-facing explanation. Never include a raw + configuration value that might be a credential. + field_path: Optional dotted configuration path associated with the + rejection. + reason: Stable lower-case reason suitable for tests and automation. + """ + + def __init__( + self, + code: str, + message: str, + *, + field_path: Optional[str] = None, + reason: Optional[str] = None, + ) -> None: + if code not in ERROR_CODES: + raise ValueError("unsupported runtime configuration error code: {0}".format(code)) + + self.code = code + self.message = message + self.field_path = field_path + self.reason = reason + super().__init__(self._display_message()) + + def _display_message(self) -> str: + parts = [self.code, self.message] + if self.field_path: + parts.append("field={0}".format(self.field_path)) + if self.reason: + parts.append("reason={0}".format(self.reason)) + return ": ".join((parts[0], " ".join(parts[1:]))) + + def as_dict(self) -> Dict[str, Any]: + """Return a JSON-safe error payload without source configuration data.""" + + payload = {"error_code": self.code, "message": self.message} + if self.field_path is not None: + payload["field_path"] = self.field_path + if self.reason is not None: + payload["reason"] = self.reason + return payload diff --git a/backtrader_runtime/evidence_bundle.py b/backtrader_runtime/evidence_bundle.py new file mode 100644 index 00000000..8cf798fa --- /dev/null +++ b/backtrader_runtime/evidence_bundle.py @@ -0,0 +1,320 @@ +"""Collect a sealed, local-only metadata bundle for Iteration 41 evidence. + +The collector deliberately has a small and code-owned read surface. It reads +only named JSON and JUnit artifacts below the current source tree, never a +runtime ``config.yaml``, environment variable, credential, command, network +endpoint, provider, SDK, or strategy module. A successful collection says +only that the listed local artifacts are structurally present and internally +consistent enough to be reviewed; it is never a deployment or trading +admission. +""" + +from __future__ import annotations + +import datetime as _datetime +import hashlib +import json +import os +import platform +import stat +import xml.etree.ElementTree as _element_tree +from pathlib import Path, PurePosixPath +from typing import Dict, Iterable, Mapping, Optional, Sequence, Tuple + + +EVIDENCE_SCHEMA_VERSION = "iteration41.local-evidence-bundle.v1" +EVIDENCE_KIND = "implementation_acceptance" +BASELINE_ID = "iteration41.local_metadata" +EVIDENCE_BOUNDARY = "LOCAL_METADATA_COLLECTION_ONLY_NOT_LIVE_ADMISSION" + +_ITERATION41_REQUIREMENTS = ( + "docs/_internal/opts/requirements/" + "\u8fed\u4ee341-\u5b9e\u76d8\u6267\u884c\u98ce\u63a7\u76d1\u63a7\u4e0e\u793a\u4f8b\u67b6\u6784\u91cd\u6784" +) + +# These paths are deliberately code-owned. The public CLI accepts no input +# path, glob, command, manifest, or configuration file which could expand the +# collector's read surface. +DEFAULT_ARTIFACT_SPECS: Tuple[Tuple[str, str, str], ...] = ( + ( + "runtime_inventory", + "examples/iteration41-runtime-inventory.json", + "json", + ), + ( + "writer_candidate_inventory", + _ITERATION41_REQUIREMENTS + "/evidence/live-execution-inventory-candidates.json", + "json", + ), + ( + "implementation_document_checks", + _ITERATION41_REQUIREMENTS + "/evidence/implementation-document-checks.json", + "json", + ), + ( + "cpython38_core_runtime_local_windows", + _ITERATION41_REQUIREMENTS + "/evidence/cpython38-core-runtime-local-windows.json", + "json", + ), +) + + +class EvidenceCollectionError(ValueError): + """A safe reason code for a rejected collector input.""" + + def __init__(self, reason_code: str) -> None: + self.reason_code = reason_code + super().__init__(reason_code) + + +def _source_root() -> Path: + """Return this package's repository root without inspecting the CWD.""" + + return Path(__file__).resolve().parents[1] + + +def _safe_artifact_path(source_root: Path, relative_path: str) -> Path: + """Resolve one code-owned, regular, non-link artifact below ``source_root``. + + The implementation refuses traversal and link/reparse components before + reading any bytes. This does not attempt to prove a hostile local Python + process cannot modify files concurrently; it only keeps this local + metadata collector from following an unexpected path supplied by a file. + """ + + if "\\" in relative_path: + raise EvidenceCollectionError("artifact_path_not_code_owned") + relative = PurePosixPath(relative_path) + if ( + relative.is_absolute() + or not relative.parts + or any(part in ("", ".", "..") for part in relative.parts) + ): + raise EvidenceCollectionError("artifact_path_not_code_owned") + + try: + root = source_root.resolve(strict=True) + except OSError as error: + raise EvidenceCollectionError("source_root_unavailable") from error + + cursor = root + for part in relative.parts: + cursor = cursor / part + try: + metadata = os.lstat(str(cursor)) + except FileNotFoundError as error: + raise EvidenceCollectionError("required_artifact_missing") from error + except OSError as error: + raise EvidenceCollectionError("required_artifact_unavailable") from error + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + if stat.S_ISLNK(metadata.st_mode) or (reparse_point and attributes & reparse_point): + raise EvidenceCollectionError("artifact_link_or_reparse_not_allowed") + + if not stat.S_ISREG(metadata.st_mode): + raise EvidenceCollectionError("required_artifact_not_regular") + try: + resolved = cursor.resolve(strict=True) + resolved.relative_to(root) + except (OSError, ValueError) as error: + raise EvidenceCollectionError("artifact_outside_source_root") from error + return resolved + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _json_summary(value: bytes) -> Mapping[str, object]: + try: + decoded = value.decode("utf-8") + parsed = json.loads(decoded) + except (UnicodeDecodeError, ValueError) as error: + raise EvidenceCollectionError("invalid_json_artifact") from error + if not isinstance(parsed, dict): + raise EvidenceCollectionError("json_artifact_must_be_object") + + # Preserve only a small, non-secret structural description. Raw values + # and arbitrary nested data deliberately never leave the collector. + summary: Dict[str, object] = {"top_level_key_count": len(parsed)} + for key in ("schema_version", "status", "evidence_kind", "evidence_scope"): + value_at_key = parsed.get(key) + if isinstance(value_at_key, (str, int, float, bool)) or value_at_key is None: + summary[key] = value_at_key + return summary + + +def _junit_summary(value: bytes) -> Mapping[str, int]: + try: + root = _element_tree.fromstring(value) + except _element_tree.ParseError as error: + raise EvidenceCollectionError("invalid_junit_artifact") from error + + testcases = list(root.iter("testcase")) + failures = list(root.iter("failure")) + errors = list(root.iter("error")) + skipped = list(root.iter("skipped")) + if not testcases: + raise EvidenceCollectionError("junit_has_zero_testcases") + if failures or errors: + raise EvidenceCollectionError("junit_reports_failures") + return { + "testcases": len(testcases), + "failures": len(failures), + "errors": len(errors), + "skipped": len(skipped), + } + + +def _artifact_record( + source_root: Path, name: str, relative_path: str, artifact_type: str +) -> Mapping[str, object]: + """Collect safe metadata for one code-owned evidence artifact.""" + + record: Dict[str, object] = { + "name": name, + "relative_path": relative_path, + "artifact_type": artifact_type, + } + try: + artifact = _safe_artifact_path(source_root, relative_path) + raw = artifact.read_bytes() + if artifact_type == "json": + record["summary"] = _json_summary(raw) + elif artifact_type == "junit": + record["summary"] = _junit_summary(raw) + else: + raise EvidenceCollectionError("unsupported_code_owned_artifact_type") + record["sha256"] = _sha256(raw) + record["status"] = "PASS" + record["reason_code"] = "structural_metadata_collected" + except EvidenceCollectionError as error: + record["status"] = "NOT_RUN" if error.reason_code == "required_artifact_missing" else "FAIL" + record["reason_code"] = error.reason_code + except OSError: + record["status"] = "FAIL" + record["reason_code"] = "required_artifact_read_failed" + return record + + +def _bundle_status(records: Iterable[Mapping[str, object]]) -> Tuple[str, str]: + statuses = {str(record["status"]) for record in records} + if "FAIL" in statuses: + return "FAIL", "artifact_validation_failed" + if "NOT_RUN" in statuses: + return "NOT_RUN", "required_local_artifact_missing" + return "PASS", "local_metadata_collected" + + +def _bundle_digest(records: Sequence[Mapping[str, object]]) -> str: + stable = [] + for record in records: + stable.append( + { + "name": record["name"], + "relative_path": record["relative_path"], + "status": record["status"], + "reason_code": record["reason_code"], + "sha256": record.get("sha256"), + } + ) + encoded = json.dumps(stable, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + return _sha256(encoded.encode("ascii")) + + +def collect_iteration41_evidence( + source_root: Optional[Path] = None, + artifact_specs: Sequence[Tuple[str, str, str]] = DEFAULT_ARTIFACT_SPECS, +) -> Mapping[str, object]: + """Return a redacted structural bundle for the fixed Iteration 41 scope. + + ``source_root`` and ``artifact_specs`` exist for unit tests and reviewed + embedding only. The public CLI intentionally exposes neither control. + No result from this function represents provider, account, deployment, + AI authorization, sandbox, production, or live-trading evidence. + """ + + root = source_root or _source_root() + records = [ + _artifact_record(root, name, relative_path, artifact_type) + for name, relative_path, artifact_type in artifact_specs + ] + status, reason_code = _bundle_status(records) + generated_at = _datetime.datetime.now(_datetime.timezone.utc).isoformat() + digest = _bundle_digest(records) + return { + "schema_version": EVIDENCE_SCHEMA_VERSION, + "config_schema_version": 4, + "evidence_kind": EVIDENCE_KIND, + "baseline_id": BASELINE_ID, + "evidence_id": "iteration41-local-" + digest[:16], + "generated_at": generated_at, + "repository": { + "logical_path": ".", + "commit": None, + "wheel_sha256": None, + "source_dirty_manifest": None, + "identity_status": "NOT_COLLECTED_NO_GIT_OR_WHEEL_PROCESS", + }, + "FR_ids": [], + "NFR_ids": [], + "WP_ids": ["WP41-A", "WP41-H"], + "AC_id": None, + "test_node_id": None, + "fixture_id": None, + "stage": "local_metadata_collection", + "runtime_id": None, + "mode": None, + "preset": None, + "preset_registry_version": None, + "config_seal_digest": None, + "effective_order_route": None, + "effective_account_access": None, + "effective_plugins": [], + "profile": "offline_metadata_only", + "platform": platform.platform(), + "interpreter": { + "implementation": platform.python_implementation(), + "version": platform.python_version(), + }, + "expected": ( + "all code-owned local metadata artifacts parse; any code-owned JUnit artifact " + "listed by the collector reports nonzero passing testcases" + ), + "actual": { + "artifact_count": len(records), + "pass_count": sum(1 for record in records if record["status"] == "PASS"), + "not_run_count": sum(1 for record in records if record["status"] == "NOT_RUN"), + "fail_count": sum(1 for record in records if record["status"] == "FAIL"), + }, + "network_attempt_count": 0, + "fake_dispatch_count": 0, + "external_provider_write_count": 0, + "external_sandbox_write_count": 0, + "external_production_write_count": 0, + "status": status, + "reason_code": reason_code, + "command": ["bt-runtime", "collect-evidence"], + "exit_code": 0 if status == "PASS" else 2, + "artifact_refs": records, + "reviewer_role": None, + "expires_at": None, + "evidence_boundary": EVIDENCE_BOUNDARY, + "limitations": [ + "The collector reads code-owned local metadata only and never executes a test or command.", + "It does not inspect config.yaml, credentials, provider state, account state, network traffic, or runtime dispatch.", + "PASS means only local structural collection succeeded; it never grants deployment, execution, control, sandbox, production, or live admission.", + "Git commit, wheel, and dirty-worktree provenance remain NOT_COLLECTED until a separate reviewed release collector records them.", + ], + } + + +__all__ = [ + "BASELINE_ID", + "DEFAULT_ARTIFACT_SPECS", + "EVIDENCE_BOUNDARY", + "EVIDENCE_KIND", + "EVIDENCE_SCHEMA_VERSION", + "EvidenceCollectionError", + "collect_iteration41_evidence", +] diff --git a/backtrader_runtime/framework_projection.py b/backtrader_runtime/framework_projection.py new file mode 100644 index 00000000..a511e415 --- /dev/null +++ b/backtrader_runtime/framework_projection.py @@ -0,0 +1,1145 @@ +"""Durable, session-scoped receipts for managed Backtrader fill projection. + +The execution SDK is authoritative for provider identity and normalized fill +facts. Backtrader's order, position and observer state is deliberately +in-process, so it cannot share the SDK's SQLite transaction. This small +journal records which *framework runtime session* has taken one evidenced +checkpoint. A later process session may replay the same checkpoint into its +fresh framework state; a second callback in the same session is rejected. + +This is intentionally a local crash-recovery mechanism. It never performs +provider I/O, does not infer cash or fees, and cannot turn separate execution, +risk, monitor and provider stores into a distributed transaction. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import time +import uuid +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from threading import RLock +from typing import Any, Optional + + +class FrameworkProjectionRecoveryError(RuntimeError): + """A framework projection checkpoint cannot be claimed or completed safely.""" + + +@dataclass(frozen=True) +class FrameworkProjectionClaim: + """One private in-process claim to apply a durable execution checkpoint.""" + + receipt_id: str + scope_key: str + intent_id: str + evidence_sha256: str + session_id: str + claim_token: str + intent_fingerprint: str + canonical_fingerprint: str + state: str + provider_order_id: str + filled_quantity: Decimal + average_price: Decimal + cumulative_commission: Decimal + + +@dataclass(frozen=True) +class FrameworkSourceEventClaim: + """Private receipt for one immutable execution-outbox event.""" + + receipt_id: str + scope_key: str + journal_incarnation_id: str + session_id: str + sequence: int + event_id: str + event_sha256: str + intent_id: str + canonical_fingerprint: str + claim_token: str + event_type: str + state: str + provider_order_id: Optional[str] + filled_quantity: Decimal + average_price: Optional[Decimal] + cumulative_commission: Optional[Decimal] + + +class FrameworkProjectionJournal: + """SQLite receipt store that provides exactly once *per framework session*. + + A process crash destroys Backtrader's in-memory broker state. The next + reviewed runtime therefore gets a distinct session id and is allowed to + apply the same checkpoint once into its fresh state. Within one session, + the completed receipt blocks a duplicate strategy callback from booking the + same position/commission/observer event twice. + """ + + _SCHEMA_VERSION = 1 + _CLAIMED = "CLAIMED" + _APPLIED = "APPLIED" + + def __init__(self, state_directory: str | Path) -> None: + root = Path(state_directory).expanduser().resolve(strict=False) + root.mkdir(parents=True, exist_ok=True) + self.path = root / "framework_projection.sqlite3" + try: + self._connection = sqlite3.connect( + str(self.path), isolation_level=None, check_same_thread=False + ) + self._connection.row_factory = sqlite3.Row + self._connection.execute("PRAGMA busy_timeout = 5000") + self._connection.execute("PRAGMA journal_mode = WAL") + self._connection.execute("PRAGMA synchronous = FULL") + self._lock = RLock() + self._create_schema() + except sqlite3.Error as error: + raise FrameworkProjectionRecoveryError( + "unable to initialize framework projection journal" + ) from error + + def close(self) -> None: + with self._lock: + self._connection.close() + + def claim( + self, + *, + record: Any, + canonical_fingerprint: str, + session_id: str, + lease_fencing_token: int, + ) -> FrameworkProjectionClaim: + """Claim one fill checkpoint for one fresh framework runtime session. + + ``record`` must be the SDK's durable execution record, rather than a + provider response. Therefore a crash after SDK confirmation but + before this bridge runs can still be recovered when the same sealed + intent is materialized after restart. + """ + + scope_key, intent_id, intent_fingerprint = self._record_identity(record) + canonical_fingerprint = self._digest_text( + canonical_fingerprint, "canonical framework projection fingerprint" + ) + session_id = self._identifier(session_id, "framework projection session") + if type(lease_fencing_token) is not int or lease_fencing_token <= 0: + raise FrameworkProjectionRecoveryError("invalid framework projection writer fence") + state, provider_order_id, filled, average, commission = self._fill_evidence(record) + evidence_sha256 = self._evidence_digest( + scope_key=scope_key, + intent_id=intent_id, + intent_fingerprint=intent_fingerprint, + canonical_fingerprint=canonical_fingerprint, + state=state, + provider_order_id=provider_order_id, + filled_quantity=filled, + average_price=average, + cumulative_commission=commission, + ) + now_ns = time.time_ns() + with self._transaction() as cursor: + prior_rows = cursor.execute( + """ + SELECT intent_fingerprint, canonical_fingerprint + FROM framework_projection_checkpoints + WHERE scope_key = ? AND intent_id = ? + """, + (scope_key, intent_id), + ).fetchall() + if any( + str(prior["intent_fingerprint"]) != intent_fingerprint + or str(prior["canonical_fingerprint"]) != canonical_fingerprint + for prior in prior_rows + ): + # A new local order must materialize the same sealed intent + # and framework mapping. Letting a changed Backtrader order + # create a second receipt for one durable intent would turn a + # recovery journal into a duplicate-accounting bypass. + raise FrameworkProjectionRecoveryError( + "framework projection intent was materialized with different order facts" + ) + row = cursor.execute( + """ + SELECT * FROM framework_projection_checkpoints + WHERE scope_key = ? AND intent_id = ? AND evidence_sha256 = ? + """, + (scope_key, intent_id, evidence_sha256), + ).fetchone() + if row is None: + receipt_id = uuid.uuid4().hex + claim_token = uuid.uuid4().hex + cursor.execute( + """ + INSERT INTO framework_projection_checkpoints( + scope_key, intent_id, evidence_sha256, receipt_id, + intent_fingerprint, canonical_fingerprint, state, + provider_order_id, filled_quantity, average_price, + cumulative_commission, status, claim_session_id, + claim_token, lease_fencing_token, projection_count, + created_at_ns, updated_at_ns + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scope_key, + intent_id, + evidence_sha256, + receipt_id, + intent_fingerprint, + canonical_fingerprint, + state, + provider_order_id, + format(filled, "f"), + format(average, "f"), + format(commission, "f"), + self._CLAIMED, + session_id, + claim_token, + lease_fencing_token, + 0, + now_ns, + now_ns, + ), + ) + return self._claim_from_values( + receipt_id, + scope_key, + intent_id, + evidence_sha256, + session_id, + claim_token, + intent_fingerprint, + canonical_fingerprint, + state, + provider_order_id, + filled, + average, + commission, + ) + + self._assert_row_evidence( + row, + intent_fingerprint=intent_fingerprint, + canonical_fingerprint=canonical_fingerprint, + state=state, + provider_order_id=provider_order_id, + filled_quantity=filled, + average_price=average, + cumulative_commission=commission, + ) + if str(row["claim_session_id"]) == session_id: + raise FrameworkProjectionRecoveryError( + "framework projection checkpoint is already claimed by this session" + ) + # A new session is a restart boundary: the prior Backtrader state + # is gone, so the same durable checkpoint must be reconstructed + # once again. The caller already owns the current SDK writer + # lease; its fencing token is kept as audit evidence only. + claim_token = uuid.uuid4().hex + cursor.execute( + """ + UPDATE framework_projection_checkpoints + SET status = ?, claim_session_id = ?, claim_token = ?, + lease_fencing_token = ?, updated_at_ns = ? + WHERE scope_key = ? AND intent_id = ? AND evidence_sha256 = ? + """, + ( + self._CLAIMED, + session_id, + claim_token, + lease_fencing_token, + now_ns, + scope_key, + intent_id, + evidence_sha256, + ), + ) + return self._claim_from_values( + str(row["receipt_id"]), + scope_key, + intent_id, + evidence_sha256, + session_id, + claim_token, + intent_fingerprint, + canonical_fingerprint, + state, + provider_order_id, + filled, + average, + commission, + ) + + def validate(self, claim: FrameworkProjectionClaim) -> bool: + """Return whether the response still owns an active projection claim.""" + + with self._lock: + try: + row = self._connection.execute( + """ + SELECT receipt_id, status, claim_session_id, claim_token + FROM framework_projection_checkpoints + WHERE scope_key = ? AND intent_id = ? AND evidence_sha256 = ? + """, + (claim.scope_key, claim.intent_id, claim.evidence_sha256), + ).fetchone() + except sqlite3.Error as error: + raise FrameworkProjectionRecoveryError( + "unable to validate framework projection receipt" + ) from error + return bool( + row is not None + and str(row["receipt_id"]) == claim.receipt_id + and str(row["status"]) == self._CLAIMED + and str(row["claim_session_id"]) == claim.session_id + and str(row["claim_token"]) == claim.claim_token + ) + + def complete(self, claim: FrameworkProjectionClaim) -> None: + """Commit that this session has applied exactly this checkpoint once.""" + + now_ns = time.time_ns() + with self._transaction() as cursor: + result = cursor.execute( + """ + UPDATE framework_projection_checkpoints + SET status = ?, projection_count = projection_count + 1, updated_at_ns = ? + WHERE scope_key = ? AND intent_id = ? AND evidence_sha256 = ? + AND receipt_id = ? AND status = ? AND claim_session_id = ? + AND claim_token = ? + """, + ( + self._APPLIED, + now_ns, + claim.scope_key, + claim.intent_id, + claim.evidence_sha256, + claim.receipt_id, + self._CLAIMED, + claim.session_id, + claim.claim_token, + ), + ) + if result.rowcount != 1: + raise FrameworkProjectionRecoveryError( + "framework projection receipt was not active for completion" + ) + + def claim_source_event( + self, + *, + event: Any, + session_id: str, + expected_scope_key: str, + canonical_fingerprint: str, + ) -> Optional[FrameworkSourceEventClaim]: + """Claim an immutable source event in sequence for one Broker session. + + A fresh framework process gets a new ``session_id`` and can replay the + complete source journal into its empty Broker. Within that session, + duplicate events are no-ops and older or conflicting sequence facts + fail closed. + """ + + try: + sequence = getattr(event, "sequence", None) + if type(sequence) is not int or sequence <= 0: + raise FrameworkProjectionRecoveryError("invalid source event sequence") + event_id = self._identifier(getattr(event, "event_id", None), "source event id") + scope_key = self._identifier(getattr(event, "scope_key", None), "source event scope") + intent_id = self._identifier(getattr(event, "intent_id", None), "source event intent") + incarnation = self._identifier( + getattr(event, "journal_incarnation_id", None), "source journal incarnation" + ) + raw_state = getattr( + getattr(event, "state", None), "value", getattr(event, "state", None) + ) + event_type = self._identifier(getattr(event, "event_type", None), "source event type") + payload = getattr(event, "payload", None) + created_at_ns = getattr(event, "created_at_ns", None) + except AttributeError as error: + raise FrameworkProjectionRecoveryError("invalid immutable source event") from error + if scope_key != expected_scope_key: + raise FrameworkProjectionRecoveryError("source event scope does not match runtime") + if event_type not in { + "provider_observation", + "reconciled_observation", + "provider_commission_evidence", + "cancelled_by_cancel_intent", + }: + raise FrameworkProjectionRecoveryError("unsupported source event type") + if raw_state not in {"ACKED", "PARTIALLY_FILLED", "FILLED", "CANCELLED", "REJECTED"}: + raise FrameworkProjectionRecoveryError("unsupported source event state") + if type(created_at_ns) is not int or created_at_ns <= 0: + raise FrameworkProjectionRecoveryError("invalid source event timestamp") + if not isinstance(payload, Mapping): + raise FrameworkProjectionRecoveryError("invalid source event payload") + canonical_fingerprint = self._digest_text( + canonical_fingerprint, "canonical framework projection fingerprint" + ) + session_id = self._identifier(session_id, "framework projection session") + filled = self._nonnegative_decimal(payload.get("filled_quantity"), "source event quantity") + average_raw = payload.get("average_price") + average = ( + None + if average_raw is None + else self._positive_decimal(average_raw, "source event average price") + ) + commission_raw = payload.get("cumulative_commission") + commission = ( + None + if commission_raw is None + else self._finite_decimal(commission_raw, "source event cumulative commission") + ) + provider_order_id = payload.get("provider_order_id") + if provider_order_id is not None: + provider_order_id = self._identifier(provider_order_id, "source event provider order") + if filled > 0 and (average is None or commission is None or provider_order_id is None): + raise FrameworkProjectionRecoveryError( + "source fill event lacks price, cumulative fee, or provider identity" + ) + if filled == 0 and raw_state in {"PARTIALLY_FILLED", "FILLED"}: + raise FrameworkProjectionRecoveryError("source fill event has no filled quantity") + + event_facts = { + "sequence": sequence, + "event_id": event_id, + "scope_key": scope_key, + "intent_id": intent_id, + "journal_incarnation_id": incarnation, + "event_type": event_type, + "state": str(raw_state), + "created_at_ns": created_at_ns, + "payload": dict(payload), + } + try: + encoded = json.dumps( + event_facts, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("ascii") + except (TypeError, ValueError) as error: + raise FrameworkProjectionRecoveryError("source event is not canonical JSON") from error + event_sha256 = hashlib.sha256(encoded).hexdigest() + now_ns = time.time_ns() + with self._transaction() as cursor: + stream = cursor.execute( + """ + SELECT high_water_sequence, blocked FROM framework_projection_source_streams + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + """, + (scope_key, incarnation, session_id), + ).fetchone() + if stream is None: + cursor.execute( + """ + INSERT INTO framework_projection_source_streams( + scope_key, journal_incarnation_id, session_id, + high_water_sequence, blocked, block_reason, updated_at_ns + ) VALUES (?, ?, ?, 0, 0, NULL, ?) + """, + (scope_key, incarnation, session_id, now_ns), + ) + high_water = 0 + else: + if int(stream["blocked"]): + raise FrameworkProjectionRecoveryError( + "framework projection source session is fenced" + ) + high_water = int(stream["high_water_sequence"]) + prior = cursor.execute( + """ + SELECT event_sha256, canonical_fingerprint, status + FROM framework_projection_source_events + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + AND event_id = ? + """, + (scope_key, incarnation, session_id, event_id), + ).fetchone() + if prior is not None: + if str(prior["event_sha256"]) != event_sha256: + raise FrameworkProjectionRecoveryError( + "source event identity was reused with different facts" + ) + if str(prior["canonical_fingerprint"]) != canonical_fingerprint: + raise FrameworkProjectionRecoveryError( + "source event was rebound to a different framework order" + ) + if str(prior["status"]) == self._APPLIED: + return None + raise FrameworkProjectionRecoveryError("source event claim is still active") + if sequence <= high_water: + raise FrameworkProjectionRecoveryError("source event sequence moved backwards") + conflicting = cursor.execute( + """ + SELECT event_id, event_sha256 FROM framework_projection_source_events + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + AND sequence = ? + """, + (scope_key, incarnation, session_id, sequence), + ).fetchone() + if conflicting is not None: + raise FrameworkProjectionRecoveryError( + "source sequence was reused with different event identity" + ) + receipt_id = uuid.uuid4().hex + claim_token = uuid.uuid4().hex + cursor.execute( + """ + INSERT INTO framework_projection_source_events( + scope_key, journal_incarnation_id, session_id, sequence, + event_id, event_sha256, intent_id, canonical_fingerprint, + status, receipt_id, claim_token, created_at_ns, updated_at_ns + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scope_key, + incarnation, + session_id, + sequence, + event_id, + event_sha256, + intent_id, + canonical_fingerprint, + self._CLAIMED, + receipt_id, + claim_token, + now_ns, + now_ns, + ), + ) + return FrameworkSourceEventClaim( + receipt_id=receipt_id, + scope_key=scope_key, + journal_incarnation_id=incarnation, + session_id=session_id, + sequence=sequence, + event_id=event_id, + event_sha256=event_sha256, + intent_id=intent_id, + canonical_fingerprint=canonical_fingerprint, + claim_token=claim_token, + event_type=event_type, + state=str(raw_state), + provider_order_id=provider_order_id, + filled_quantity=filled, + average_price=average, + cumulative_commission=commission, + ) + + def source_high_water( + self, *, scope_key: str, journal_incarnation_id: str, session_id: str + ) -> int: + """Read the durable scoped source cursor for this Broker session.""" + + scope_key = self._identifier(scope_key, "framework projection scope") + incarnation = self._identifier(journal_incarnation_id, "source journal incarnation") + session_id = self._identifier(session_id, "framework projection session") + with self._lock: + try: + row = self._connection.execute( + """ + SELECT high_water_sequence, blocked + FROM framework_projection_source_streams + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + """, + (scope_key, incarnation, session_id), + ).fetchone() + except sqlite3.Error as error: + raise FrameworkProjectionRecoveryError( + "unable to read source event cursor" + ) from error + if row is None: + return 0 + if int(row["blocked"]): + raise FrameworkProjectionRecoveryError("framework projection source session is fenced") + return int(row["high_water_sequence"]) + + def advance_source_event( + self, + *, + event: Any, + session_id: str, + expected_scope_key: str, + ) -> bool: + """Durably consume a known non-projection outbox row in stream order. + + Callers must use this only for explicitly allowlisted lifecycle events + that carry no Broker execution facts. Unknown event kinds must fence, + not advance the cursor. + """ + + try: + sequence = getattr(event, "sequence", None) + if type(sequence) is not int or sequence <= 0: + raise FrameworkProjectionRecoveryError("invalid source event sequence") + event_id = self._identifier(getattr(event, "event_id", None), "source event id") + scope_key = self._identifier(getattr(event, "scope_key", None), "source event scope") + intent_id = self._identifier(getattr(event, "intent_id", None), "source event intent") + incarnation = self._identifier( + getattr(event, "journal_incarnation_id", None), "source journal incarnation" + ) + event_type = self._identifier(getattr(event, "event_type", None), "source event type") + state = getattr(getattr(event, "state", None), "value", getattr(event, "state", None)) + created_at_ns = getattr(event, "created_at_ns", None) + payload = getattr(event, "payload", None) + except AttributeError as error: + raise FrameworkProjectionRecoveryError("invalid immutable source event") from error + if scope_key != expected_scope_key: + raise FrameworkProjectionRecoveryError("source event scope does not match runtime") + if ( + type(state) is not str + or not state + or type(created_at_ns) is not int + or created_at_ns <= 0 + ): + raise FrameworkProjectionRecoveryError("invalid non-projection source event facts") + if not isinstance(payload, Mapping): + raise FrameworkProjectionRecoveryError("invalid non-projection source event payload") + session_id = self._identifier(session_id, "framework projection session") + facts = { + "sequence": sequence, + "event_id": event_id, + "scope_key": scope_key, + "intent_id": intent_id, + "journal_incarnation_id": incarnation, + "event_type": event_type, + "state": state, + "created_at_ns": created_at_ns, + "payload": dict(payload), + } + try: + encoded = json.dumps( + facts, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("ascii") + except (TypeError, ValueError) as error: + raise FrameworkProjectionRecoveryError("source event is not canonical JSON") from error + event_sha256 = hashlib.sha256(encoded).hexdigest() + noop_fingerprint = hashlib.sha256( + ("non-projection:" + event_type).encode("utf-8") + ).hexdigest() + now_ns = time.time_ns() + with self._transaction() as cursor: + stream = cursor.execute( + """ + SELECT high_water_sequence, blocked + FROM framework_projection_source_streams + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + """, + (scope_key, incarnation, session_id), + ).fetchone() + if stream is None: + cursor.execute( + """ + INSERT INTO framework_projection_source_streams( + scope_key, journal_incarnation_id, session_id, + high_water_sequence, blocked, block_reason, updated_at_ns + ) VALUES (?, ?, ?, 0, 0, NULL, ?) + """, + (scope_key, incarnation, session_id, now_ns), + ) + high_water = 0 + else: + if int(stream["blocked"]): + raise FrameworkProjectionRecoveryError( + "framework projection source session is fenced" + ) + high_water = int(stream["high_water_sequence"]) + prior = cursor.execute( + """ + SELECT event_sha256, status FROM framework_projection_source_events + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + AND event_id = ? + """, + (scope_key, incarnation, session_id, event_id), + ).fetchone() + if prior is not None: + if str(prior["event_sha256"]) != event_sha256: + raise FrameworkProjectionRecoveryError( + "source event identity was reused with different facts" + ) + if str(prior["status"]) == self._APPLIED: + return False + raise FrameworkProjectionRecoveryError("source event claim is still active") + if sequence <= high_water: + raise FrameworkProjectionRecoveryError("source event sequence moved backwards") + conflicting = cursor.execute( + """ + SELECT event_id FROM framework_projection_source_events + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + AND sequence = ? + """, + (scope_key, incarnation, session_id, sequence), + ).fetchone() + if conflicting is not None: + raise FrameworkProjectionRecoveryError( + "source sequence was reused with different event identity" + ) + cursor.execute( + """ + INSERT INTO framework_projection_source_events( + scope_key, journal_incarnation_id, session_id, sequence, + event_id, event_sha256, intent_id, canonical_fingerprint, + status, receipt_id, claim_token, created_at_ns, updated_at_ns + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scope_key, + incarnation, + session_id, + sequence, + event_id, + event_sha256, + intent_id, + noop_fingerprint, + self._APPLIED, + uuid.uuid4().hex, + uuid.uuid4().hex, + now_ns, + now_ns, + ), + ) + updated = cursor.execute( + """ + UPDATE framework_projection_source_streams + SET high_water_sequence = ?, updated_at_ns = ? + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + AND blocked = 0 AND high_water_sequence < ? + """, + (sequence, now_ns, scope_key, incarnation, session_id, sequence), + ) + if updated.rowcount != 1: + raise FrameworkProjectionRecoveryError( + "source stream high-water moved unexpectedly" + ) + return True + + def complete_source_event(self, claim: FrameworkSourceEventClaim) -> None: + """Persist the source high-water only after Broker accounting succeeds.""" + + now_ns = time.time_ns() + with self._transaction() as cursor: + result = cursor.execute( + """ + UPDATE framework_projection_source_events SET status = ?, updated_at_ns = ? + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + AND sequence = ? AND event_id = ? AND event_sha256 = ? + AND receipt_id = ? AND claim_token = ? AND status = ? + """, + ( + self._APPLIED, + now_ns, + claim.scope_key, + claim.journal_incarnation_id, + claim.session_id, + claim.sequence, + claim.event_id, + claim.event_sha256, + claim.receipt_id, + claim.claim_token, + self._CLAIMED, + ), + ) + if result.rowcount != 1: + raise FrameworkProjectionRecoveryError( + "source event receipt was not active for completion" + ) + stream = cursor.execute( + """ + UPDATE framework_projection_source_streams + SET high_water_sequence = ?, updated_at_ns = ? + WHERE scope_key = ? AND journal_incarnation_id = ? AND session_id = ? + AND blocked = 0 AND high_water_sequence < ? + """, + ( + claim.sequence, + now_ns, + claim.scope_key, + claim.journal_incarnation_id, + claim.session_id, + claim.sequence, + ), + ) + if stream.rowcount != 1: + raise FrameworkProjectionRecoveryError( + "source stream high-water moved unexpectedly" + ) + + def validate_source_event(self, claim: FrameworkSourceEventClaim) -> bool: + """Return whether a private source-event claim is still active.""" + + with self._lock: + try: + row = self._connection.execute( + """ + SELECT e.event_sha256, e.status, e.receipt_id, e.claim_token, + s.blocked + FROM framework_projection_source_events AS e + JOIN framework_projection_source_streams AS s + ON s.scope_key = e.scope_key + AND s.journal_incarnation_id = e.journal_incarnation_id + AND s.session_id = e.session_id + WHERE e.scope_key = ? AND e.journal_incarnation_id = ? + AND e.session_id = ? AND e.sequence = ? AND e.event_id = ? + """, + ( + claim.scope_key, + claim.journal_incarnation_id, + claim.session_id, + claim.sequence, + claim.event_id, + ), + ).fetchone() + except sqlite3.Error as error: + raise FrameworkProjectionRecoveryError( + "unable to validate source event receipt" + ) from error + return bool( + row is not None + and not int(row["blocked"]) + and str(row["event_sha256"]) == claim.event_sha256 + and str(row["status"]) == self._CLAIMED + and str(row["receipt_id"]) == claim.receipt_id + and str(row["claim_token"]) == claim.claim_token + ) + + def fence_source_session( + self, *, scope_key: str, journal_incarnation_id: str, session_id: str, reason: str + ) -> None: + """Permanently fence the current framework session after uncertain apply.""" + + now_ns = time.time_ns() + scope_key = self._identifier(scope_key, "framework projection scope") + incarnation = self._identifier(journal_incarnation_id, "source journal incarnation") + session_id = self._identifier(session_id, "framework projection session") + reason = self._identifier(reason, "framework projection failure reason") + with self._transaction() as cursor: + cursor.execute( + """ + INSERT INTO framework_projection_source_streams( + scope_key, journal_incarnation_id, session_id, + high_water_sequence, blocked, block_reason, updated_at_ns + ) VALUES (?, ?, ?, 0, 1, ?, ?) + ON CONFLICT(scope_key, journal_incarnation_id, session_id) DO UPDATE SET + blocked = 1, block_reason = excluded.block_reason, + updated_at_ns = excluded.updated_at_ns + """, + (scope_key, incarnation, session_id, reason, now_ns), + ) + + def pending_intent_ids(self, scope_key: str) -> tuple[str, ...]: + """List durable fill checkpoints not completed by the current process. + + This is an operator/recovery discovery aid. It performs no provider + activity and does not claim that an arbitrary strategy can be rebuilt + without its reviewed order factory. + """ + + scope_key = self._identifier(scope_key, "framework projection scope") + with self._lock: + try: + rows = self._connection.execute( + """ + SELECT DISTINCT intent_id FROM framework_projection_checkpoints + WHERE scope_key = ? ORDER BY intent_id ASC + """, + (scope_key,), + ).fetchall() + except sqlite3.Error as error: + raise FrameworkProjectionRecoveryError( + "unable to read framework projection recovery work" + ) from error + return tuple(str(row["intent_id"]) for row in rows) + + def _create_schema(self) -> None: + with self._transaction() as cursor: + cursor.executescript( + """ + CREATE TABLE IF NOT EXISTS framework_projection_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS framework_projection_checkpoints ( + scope_key TEXT NOT NULL, + intent_id TEXT NOT NULL, + evidence_sha256 TEXT NOT NULL, + receipt_id TEXT NOT NULL UNIQUE, + intent_fingerprint TEXT NOT NULL, + canonical_fingerprint TEXT NOT NULL, + state TEXT NOT NULL, + provider_order_id TEXT NOT NULL, + filled_quantity TEXT NOT NULL, + average_price TEXT NOT NULL, + cumulative_commission TEXT NOT NULL, + status TEXT NOT NULL, + claim_session_id TEXT NOT NULL, + claim_token TEXT NOT NULL, + lease_fencing_token INTEGER NOT NULL, + projection_count INTEGER NOT NULL DEFAULT 0, + created_at_ns INTEGER NOT NULL, + updated_at_ns INTEGER NOT NULL, + PRIMARY KEY(scope_key, intent_id, evidence_sha256) + ); + CREATE INDEX IF NOT EXISTS framework_projection_scope_intent + ON framework_projection_checkpoints(scope_key, intent_id, updated_at_ns); + CREATE TABLE IF NOT EXISTS framework_projection_source_streams ( + scope_key TEXT NOT NULL, + journal_incarnation_id TEXT NOT NULL, + session_id TEXT NOT NULL, + high_water_sequence INTEGER NOT NULL, + blocked INTEGER NOT NULL DEFAULT 0, + block_reason TEXT, + updated_at_ns INTEGER NOT NULL, + PRIMARY KEY(scope_key, journal_incarnation_id, session_id) + ); + CREATE TABLE IF NOT EXISTS framework_projection_source_events ( + scope_key TEXT NOT NULL, + journal_incarnation_id TEXT NOT NULL, + session_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + event_id TEXT NOT NULL, + event_sha256 TEXT NOT NULL, + intent_id TEXT NOT NULL, + canonical_fingerprint TEXT NOT NULL, + status TEXT NOT NULL, + receipt_id TEXT NOT NULL UNIQUE, + claim_token TEXT NOT NULL, + created_at_ns INTEGER NOT NULL, + updated_at_ns INTEGER NOT NULL, + PRIMARY KEY(scope_key, journal_incarnation_id, session_id, event_id), + UNIQUE(scope_key, journal_incarnation_id, session_id, sequence) + ); + CREATE INDEX IF NOT EXISTS framework_projection_source_event_order + ON framework_projection_source_events( + scope_key, journal_incarnation_id, session_id, sequence + ); + """ + ) + row = cursor.execute( + "SELECT value FROM framework_projection_meta WHERE key = ?", ("schema_version",) + ).fetchone() + if row is None: + cursor.execute( + "INSERT INTO framework_projection_meta(key, value) VALUES (?, ?)", + ("schema_version", str(self._SCHEMA_VERSION)), + ) + elif str(row["value"]) != str(self._SCHEMA_VERSION): + raise FrameworkProjectionRecoveryError( + "unsupported framework projection journal schema" + ) + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Cursor]: + with self._lock: + cursor = self._connection.cursor() + try: + cursor.execute("BEGIN IMMEDIATE") + yield cursor + except sqlite3.Error as error: + self._connection.rollback() + raise FrameworkProjectionRecoveryError( + "framework projection journal transaction failed" + ) from error + except BaseException: + self._connection.rollback() + raise + else: + try: + self._connection.commit() + except sqlite3.Error as error: + self._connection.rollback() + raise FrameworkProjectionRecoveryError( + "framework projection journal commit failed" + ) from error + finally: + cursor.close() + + @staticmethod + def _identifier(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not value or value != value.strip() or len(value) > 256: + raise FrameworkProjectionRecoveryError("invalid " + field_name) + return value + + @classmethod + def _digest_text(cls, value: Any, field_name: str) -> str: + value = cls._identifier(value, field_name) + if len(value) != 64 or any(char not in "0123456789abcdef" for char in value): + raise FrameworkProjectionRecoveryError("invalid " + field_name) + return value + + @classmethod + def _record_identity(cls, record: Any) -> tuple[str, str, str]: + return ( + cls._identifier(getattr(record, "scope_key", None), "framework projection scope"), + cls._identifier(getattr(record, "intent_id", None), "framework projection intent"), + cls._digest_text( + getattr(record, "payload_sha256", None), "framework projection intent fingerprint" + ), + ) + + @classmethod + def _fill_evidence(cls, record: Any) -> tuple[str, str, Decimal, Decimal, Decimal]: + raw_state = getattr(getattr(record, "state", None), "value", getattr(record, "state", None)) + if raw_state not in {"PARTIALLY_FILLED", "FILLED"}: + raise FrameworkProjectionRecoveryError( + "framework projection recovery requires a durable fill checkpoint" + ) + provider_order_id = cls._identifier( + getattr(record, "provider_order_id", None), "framework projection provider order" + ) + filled = cls._positive_decimal( + getattr(record, "filled_quantity", None), "framework projection filled quantity" + ) + average = cls._positive_decimal( + getattr(record, "average_price", None), "framework projection average price" + ) + commission = cls._finite_decimal( + getattr(record, "cumulative_commission", None), + "framework projection cumulative commission", + ) + return str(raw_state), provider_order_id, filled, average, commission + + @staticmethod + def _positive_decimal(value: Any, field_name: str) -> Decimal: + if isinstance(value, bool): + raise FrameworkProjectionRecoveryError("invalid " + field_name) + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise FrameworkProjectionRecoveryError("invalid " + field_name) from error + if not result.is_finite() or result <= 0: + raise FrameworkProjectionRecoveryError("invalid " + field_name) + return result + + @staticmethod + def _nonnegative_decimal(value: Any, field_name: str) -> Decimal: + if isinstance(value, bool): + raise FrameworkProjectionRecoveryError("invalid " + field_name) + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise FrameworkProjectionRecoveryError("invalid " + field_name) from error + if not result.is_finite() or result < 0: + raise FrameworkProjectionRecoveryError("invalid " + field_name) + return result + + @staticmethod + def _finite_decimal(value: Any, field_name: str) -> Decimal: + if isinstance(value, bool): + raise FrameworkProjectionRecoveryError("invalid " + field_name) + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise FrameworkProjectionRecoveryError("invalid " + field_name) from error + if not result.is_finite(): + raise FrameworkProjectionRecoveryError("invalid " + field_name) + return result + + @staticmethod + def _evidence_digest(**values: Any) -> str: + serializable = { + name: format(value, "f") if isinstance(value, Decimal) else value + for name, value in values.items() + } + payload = json.dumps(serializable, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + @classmethod + def _assert_row_evidence(cls, row: sqlite3.Row, **expected: Any) -> None: + comparisons = { + "intent_fingerprint": expected["intent_fingerprint"], + "canonical_fingerprint": expected["canonical_fingerprint"], + "state": expected["state"], + "provider_order_id": expected["provider_order_id"], + "filled_quantity": format(expected["filled_quantity"], "f"), + "average_price": format(expected["average_price"], "f"), + "cumulative_commission": format(expected["cumulative_commission"], "f"), + } + if any(str(row[name]) != value for name, value in comparisons.items()): + raise FrameworkProjectionRecoveryError( + "framework projection checkpoint evidence changed" + ) + + @staticmethod + def _claim_from_values( + receipt_id: str, + scope_key: str, + intent_id: str, + evidence_sha256: str, + session_id: str, + claim_token: str, + intent_fingerprint: str, + canonical_fingerprint: str, + state: str, + provider_order_id: str, + filled_quantity: Decimal, + average_price: Decimal, + cumulative_commission: Decimal, + ) -> FrameworkProjectionClaim: + return FrameworkProjectionClaim( + receipt_id=receipt_id, + scope_key=scope_key, + intent_id=intent_id, + evidence_sha256=evidence_sha256, + session_id=session_id, + claim_token=claim_token, + intent_fingerprint=intent_fingerprint, + canonical_fingerprint=canonical_fingerprint, + state=state, + provider_order_id=provider_order_id, + filled_quantity=filled_quantity, + average_price=average_price, + cumulative_commission=cumulative_commission, + ) + + +def canonical_framework_projection_fingerprint(canonical: Any) -> str: + """Hash the immutable order facts that must survive a restart. + + Framework order references deliberately stay out of this digest: a new + process may materialize the same reviewed intent with a new local ref. + """ + + provider_info = getattr(canonical, "provider_info", None) + if not isinstance(provider_info, Mapping): + raise FrameworkProjectionRecoveryError("managed canonical order lacks provider facts") + side = getattr(getattr(canonical, "side", None), "value", getattr(canonical, "side", None)) + effect = getattr( + getattr(canonical, "position_effect", None), + "value", + getattr(canonical, "position_effect", None), + ) + payload = { + "instrument": getattr(canonical, "instrument", None), + "side": side, + "quantity": format(Decimal(str(getattr(canonical, "quantity", None))), "f"), + "price": format(Decimal(str(getattr(canonical, "price", None))), "f"), + "position_effect": effect, + "reduce_only": getattr(canonical, "reduce_only", None), + "metadata_digest": getattr(canonical, "metadata_digest", None), + "quantity_unit": getattr(canonical, "quantity_unit", None), + "provider_info": dict(provider_info), + } + try: + canonical_json = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, default=str + ) + except (TypeError, ValueError) as error: + raise FrameworkProjectionRecoveryError( + "managed canonical order cannot be fingerprinted" + ) from error + return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest() + + +__all__ = [ + "FrameworkProjectionClaim", + "FrameworkProjectionJournal", + "FrameworkProjectionRecoveryError", + "canonical_framework_projection_fingerprint", +] diff --git a/backtrader_runtime/inventory.py b/backtrader_runtime/inventory.py new file mode 100644 index 00000000..7007d53b --- /dev/null +++ b/backtrader_runtime/inventory.py @@ -0,0 +1,439 @@ +"""Reviewed Iteration 41 runtime registrations. + +This is deliberately a short, code-owned inventory. It does not scan the +current directory, environment, templates, or user configuration. Adding a +runtime here is a reviewable change that binds one canonical directory, +strategy identity, preset allow-list, and parameter allow-list. +""" + +from __future__ import annotations + +from pathlib import Path + +from .ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding +from .policy import MANAGED_WRITE_CAPABILITIES +from .registry import ( + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + RuntimeSet, + UnavailableModeProfile, +) + + +SOURCE_ROOT = Path(__file__).resolve().parent.parent +# The two packaged L2 fixtures are deliberately distinct from the source +# example registrations below. They include only secret-free, replay-only +# fake-provider artifacts needed by an isolated consumer test; they are never +# an installation route for the wider examples tree. +PACKAGE_L2_FIXTURE_ROOT = Path(__file__).resolve().parent / "_iteration41_l2_fixture" +PACKAGE_BACKTEST_FIXTURE_ROOT = Path(__file__).resolve().parent / "_iteration41_backtest_fixture" + +# These are import-source allow-lists, not capability grants. They bind the +# top-level SDK packages that a reviewed runner may load after its config and +# policy seals have passed. ``config.yaml`` cannot add a module here. +_REPLAY_CAPABILITY_MODULES = ( + "bt_api_py", + "bt_api_base", + "bt_api_binance", + "bt_api_okx", + "bt_api_ctp", +) +_MANAGED_REPLAY_CAPABILITY_MODULES = ( + "bt_api_py", + "bt_api_base", + "bt_api_execution", + "bt_api_risk", + "bt_api_monitor", +) +ITERATION41_012_1_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "012_1_midfreq_cross_exchange" / "runtime" +) +ITERATION41_012_1_STRATEGY_ID = "example.012_1.midfreq_cross_exchange" +ITERATION41_012_2_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "012_2_event_driven_cross_exchange" / "runtime" +) +ITERATION41_012_2_STRATEGY_ID = "example.012_2.event_driven_cross_exchange" +ITERATION41_013_1_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "013_1_midfreq_cross_arbitrage" / "runtime" +) +ITERATION41_013_1_STRATEGY_ID = "example.013_1.midfreq_cross_arbitrage" +ITERATION41_013_2_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "013_2_highfreq_calendar_arbitrage" / "runtime" +) +ITERATION41_013_2_STRATEGY_ID = "example.013_2.highfreq_calendar_arbitrage" +ITERATION41_013_3_RUNTIME_DIR = SOURCE_ROOT / "examples" / "013_3_sa_midfreq_simnow" / "runtime" +ITERATION41_013_3_STRATEGY_ID = "example.013_3.sa_midfreq_simnow" +ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "013_3_sa_midfreq_simnow" / "runtime-ctp-private" +) +ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID = "example.013_3.sa_midfreq_simnow.ctp_private" +ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "013_3_sa_midfreq_simnow" / "runtime-managed-replay" +) +ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_ID = "example.013_3.sa_midfreq_simnow.managed_replay_l2" +ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "ctp_options_simnow_managed_replay_runtime" +) +ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_ID = ( + "example.ctp_options_simnow.mechanical_managed_replay_l2" +) +ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_STRATEGY_ID = ( + "example.ctp_options_simnow.mechanical_managed_replay_l2" +) +ITERATION41_PACKAGE_013_3_MANAGED_REPLAY_RUNTIME_DIR = ( + PACKAGE_L2_FIXTURE_ROOT / "runtimes" / "managed_013_3" +) +ITERATION41_PACKAGE_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_DIR = ( + PACKAGE_L2_FIXTURE_ROOT / "runtimes" / "mechanical_p1b" +) +ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_DIR = ( + PACKAGE_BACKTEST_FIXTURE_ROOT / "runtimes" / "local_backtest" +) +ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_ID = "backtrader.iteration41.local_backtest_fixture" +ITERATION41_014_1_RUNTIME_DIR = SOURCE_ROOT / "examples" / "014_1_ctp_options_lowfreq" / "runtime" +ITERATION41_014_1_STRATEGY_ID = "example.014_1.ctp_options_lowfreq" +ITERATION41_014_2_RUNTIME_DIR = SOURCE_ROOT / "examples" / "014_2_ctp_options_midfreq" / "runtime" +ITERATION41_014_2_STRATEGY_ID = "example.014_2.ctp_options_midfreq" +ITERATION41_015_RUNTIME_DIR = SOURCE_ROOT / "examples" / "015_ctp_options_highfreq" / "runtime" +ITERATION41_015_STRATEGY_ID = "example.015.ctp_options_highfreq" +ITERATION41_SAMPLE_RUNTIME_DIR = SOURCE_ROOT / "examples" / "sample" / "runtime" +ITERATION41_SAMPLE_STRATEGY_ID = "example.sample.ctp_legacy" +ITERATION41_007_CTP_RUNTIME_DIR = SOURCE_ROOT / "examples" / "007_ctp" / "runtime" +ITERATION41_007_CTP_STRATEGY_ID = "example.007_ctp.legacy_direct" +ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR = ( + SOURCE_ROOT + / "examples" + / "007_ctp" + / "live_certification" + / "simnow_penetration" +) +ITERATION41_007_CTP_PRIVATE_RUNTIME_ID = "example.007_ctp.simnow_penetration.ctp_private" +ITERATION41_007_CTP_PRIVATE_STRATEGY_ID = "example.007_ctp.simnow_penetration" +ITERATION41_010_LIVE_EXAMPLES_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "010_live_examples" / "runtime" +) +ITERATION41_010_LIVE_EXAMPLES_STRATEGY_ID = "example.010_live_examples.simnow_legacy" +ITERATION41_010_OKX_SHADOW_RUNTIME_DIR = ( + SOURCE_ROOT / "examples" / "010_live_examples" / "runtime-okx-shadow" +) +ITERATION41_010_OKX_SHADOW_STRATEGY_ID = "example.010_live_examples.okx_public_shadow" + + +ITERATION41_012_1_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_012_1_RUNTIME_DIR, + strategy_id=ITERATION41_012_1_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.012_1_midfreq_cross_exchange.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_012_2_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_012_2_RUNTIME_DIR, + strategy_id=ITERATION41_012_2_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.012_2_event_driven_cross_exchange.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_013_1_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_013_1_RUNTIME_DIR, + strategy_id=ITERATION41_013_1_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.013_1_midfreq_cross_arbitrage.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_013_2_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_013_2_RUNTIME_DIR, + strategy_id=ITERATION41_013_2_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.013_2_highfreq_calendar_arbitrage.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_013_3_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_013_3_RUNTIME_DIR, + strategy_id=ITERATION41_013_3_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.013_3_sa_midfreq_simnow.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_013_3_CTP_PRIVATE_READONLY_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + strategy_id=ITERATION41_013_3_STRATEGY_ID, + runtime_id=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + allowed_presets=(), + sandbox_write_policy="deny", + unavailable_mode_profiles=( + UnavailableModeProfile( + mode="live", + preset="managed_live_direct", + reason="managed_live_direct_profile_unavailable", + ), + ), + profiles=( + RuntimeProfile( + mode="simulation", + preset="sandbox", + allowed_parameter_keys=(), + allowed_secrets_refs=("config_yaml",), + available_capabilities=(), + approval_receipt_digest=None, + runner_module=None, + runner_entrypoint="run_runtime", + capability_modules=(), + offline_managed_execution=False, + sandbox_write_policy="deny", + ), + ), +) +ITERATION41_013_3_CTP_PRIVATE_READONLY_BINDING = CtpSimNowConfigReadOnlyBinding( + runtime_id=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, +) +ITERATION41_007_CTP_PRIVATE_READONLY_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR, + strategy_id=ITERATION41_007_CTP_PRIVATE_STRATEGY_ID, + runtime_id=ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + allowed_presets=(), + allowed_parameter_keys=(), + allowed_secrets_refs=("none",), + available_capabilities=(), + capability_modules=(), + offline_managed_execution=False, + runner_module=None, + runner_entrypoint="run_runtime", + sandbox_write_policy="deny", + unavailable_mode_profiles=( + UnavailableModeProfile( + mode="live", + preset="managed_live_direct", + reason="managed_live_direct_profile_unavailable", + ), + ), + profiles=( + RuntimeProfile( + mode="simulation", + preset="sandbox", + allowed_parameter_keys=(), + allowed_secrets_refs=("config_yaml",), + available_capabilities=(), + approval_receipt_digest=None, + runner_module=None, + runner_entrypoint="run_runtime", + capability_modules=(), + offline_managed_execution=False, + sandbox_write_policy="deny", + ), + ), +) +ITERATION41_007_CTP_PRIVATE_READONLY_BINDING = CtpSimNowConfigReadOnlyBinding( + runtime_id=ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, +) +ITERATION41_013_3_MANAGED_REPLAY_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_DIR, + strategy_id=ITERATION41_013_3_STRATEGY_ID, + runtime_id=ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_ID, + allowed_presets=("replay",), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + offline_managed_execution=True, + runner_module="examples.013_3_sa_midfreq_simnow.run_managed_replay_runtime", + capability_modules=_MANAGED_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_DIR, + strategy_id=ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_STRATEGY_ID, + runtime_id=ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_ID, + allowed_presets=("replay",), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + offline_managed_execution=True, + runner_module="examples.ctp_options_simnow_managed_replay_runtime", + capability_modules=_MANAGED_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_PACKAGE_013_3_MANAGED_REPLAY_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_PACKAGE_013_3_MANAGED_REPLAY_RUNTIME_DIR, + strategy_id=ITERATION41_013_3_STRATEGY_ID, + runtime_id=ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_ID, + allowed_presets=("replay",), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + offline_managed_execution=True, + runner_module="backtrader_runtime._iteration41_l2_fixture.managed_013_3", + capability_modules=_MANAGED_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_PACKAGE_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_PACKAGE_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_DIR, + strategy_id=ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_STRATEGY_ID, + runtime_id=ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_ID, + allowed_presets=("replay",), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + offline_managed_execution=True, + runner_module="backtrader_runtime._iteration41_l2_fixture.mechanical_p1b", + capability_modules=_MANAGED_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_PACKAGE_LOCAL_BACKTEST_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_DIR, + strategy_id=ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_ID, + allowed_presets=("local_backtest",), + runner_module="backtrader_runtime._iteration41_backtest_fixture.run", +) +ITERATION41_014_1_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_014_1_RUNTIME_DIR, + strategy_id=ITERATION41_014_1_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.014_1_ctp_options_lowfreq.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_014_2_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_014_2_RUNTIME_DIR, + strategy_id=ITERATION41_014_2_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.014_2_ctp_options_midfreq.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_015_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_015_RUNTIME_DIR, + strategy_id=ITERATION41_015_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.015_ctp_options_highfreq.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_SAMPLE_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_SAMPLE_RUNTIME_DIR, + strategy_id=ITERATION41_SAMPLE_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.sample", +) +ITERATION41_007_CTP_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_007_CTP_RUNTIME_DIR, + strategy_id=ITERATION41_007_CTP_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.007_ctp.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_010_LIVE_EXAMPLES_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_010_LIVE_EXAMPLES_RUNTIME_DIR, + strategy_id=ITERATION41_010_LIVE_EXAMPLES_STRATEGY_ID, + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + runner_module="examples.010_live_examples.run_runtime", + capability_modules=_REPLAY_CAPABILITY_MODULES, +) +ITERATION41_010_OKX_SHADOW_REGISTRATION = RegisteredRuntime( + runtime_dir=ITERATION41_010_OKX_SHADOW_RUNTIME_DIR, + strategy_id=ITERATION41_010_OKX_SHADOW_STRATEGY_ID, + allowed_presets=("shadow",), + allowed_parameter_keys=("symbols", "duration_seconds", "orderbook_limit"), + runner_module="examples.010_live_examples.run_okx_shadow_runtime", + bootstrap_parameters=( + ("symbols", ("BTC/USDT:USDT", "ETH/USDT:USDT")), + ("duration_seconds", 10), + ("orderbook_limit", 5), + ), +) + + +ITERATION41_REPLAY_RUNTIME_SET = RuntimeSet( + name="iteration41-replay", + runtime_ids=( + ITERATION41_012_1_STRATEGY_ID, + ITERATION41_012_2_STRATEGY_ID, + ITERATION41_013_1_STRATEGY_ID, + ITERATION41_013_2_STRATEGY_ID, + ITERATION41_013_3_STRATEGY_ID, + ITERATION41_014_1_STRATEGY_ID, + ITERATION41_014_2_STRATEGY_ID, + ITERATION41_015_STRATEGY_ID, + ITERATION41_SAMPLE_STRATEGY_ID, + ITERATION41_007_CTP_STRATEGY_ID, + ITERATION41_010_LIVE_EXAMPLES_STRATEGY_ID, + ), +) +ITERATION41_MANAGED_REPLAY_RUNTIME_SET = RuntimeSet( + name="iteration41-managed-replay-l2", + runtime_ids=( + ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_ID, + ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_ID, + ), +) +ITERATION41_PACKAGE_L2_FIXTURE_RUNTIME_SET = RuntimeSet( + name="iteration41-l2-wheel-fixture", + runtime_ids=( + ITERATION41_013_3_MANAGED_REPLAY_RUNTIME_ID, + ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_RUNTIME_ID, + ), +) +ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_SET = RuntimeSet( + name="iteration41-local-backtest", + runtime_ids=(ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_ID,), +) + + +def iteration41_runtime_registry() -> RuntimeRegistry: + """Return the only reviewed runtime registry shipped in this phase.""" + + return RuntimeRegistry( + ( + ITERATION41_012_1_REGISTRATION, + ITERATION41_012_2_REGISTRATION, + ITERATION41_013_1_REGISTRATION, + ITERATION41_013_2_REGISTRATION, + ITERATION41_013_3_REGISTRATION, + ITERATION41_013_3_CTP_PRIVATE_READONLY_REGISTRATION, + ITERATION41_007_CTP_PRIVATE_READONLY_REGISTRATION, + ITERATION41_013_3_MANAGED_REPLAY_REGISTRATION, + ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION, + ITERATION41_014_1_REGISTRATION, + ITERATION41_014_2_REGISTRATION, + ITERATION41_015_REGISTRATION, + ITERATION41_SAMPLE_REGISTRATION, + ITERATION41_007_CTP_REGISTRATION, + ITERATION41_010_LIVE_EXAMPLES_REGISTRATION, + ITERATION41_010_OKX_SHADOW_REGISTRATION, + ITERATION41_PACKAGE_LOCAL_BACKTEST_REGISTRATION, + ), + runtime_sets=( + ITERATION41_REPLAY_RUNTIME_SET, + ITERATION41_MANAGED_REPLAY_RUNTIME_SET, + ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_SET, + ), + ctp_simnow_readonly_bindings=( + ITERATION41_013_3_CTP_PRIVATE_READONLY_BINDING, + ITERATION41_007_CTP_PRIVATE_READONLY_BINDING, + ), + registry_id="backtrader.iteration41", + ) + + +def iteration41_l2_fixture_registry() -> RuntimeRegistry: + """Return only the two package-owned offline L2 fixture registrations. + + This registry is intentionally not a filtered view of the normal source + inventory. It makes the wheel-consumer surface explicit and cannot imply + that the remaining eleven source examples are packaged or runnable after + installation. + """ + + return RuntimeRegistry( + ( + ITERATION41_PACKAGE_013_3_MANAGED_REPLAY_REGISTRATION, + ITERATION41_PACKAGE_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION, + ), + runtime_sets=(ITERATION41_PACKAGE_L2_FIXTURE_RUNTIME_SET,), + registry_id="backtrader.iteration41.l2-wheel-fixture", + ) + + +def iteration41_backtest_fixture_registry() -> RuntimeRegistry: + """Return the one package-owned, zero-I/O local-backtest fixture.""" + + return RuntimeRegistry( + (ITERATION41_PACKAGE_LOCAL_BACKTEST_REGISTRATION,), + runtime_sets=(ITERATION41_PACKAGE_LOCAL_BACKTEST_RUNTIME_SET,), + registry_id="backtrader.iteration41.local-backtest-fixture", + ) diff --git a/backtrader_runtime/legacy.py b/backtrader_runtime/legacy.py new file mode 100644 index 00000000..0c5b35e8 --- /dev/null +++ b/backtrader_runtime/legacy.py @@ -0,0 +1,76 @@ +"""Small configuration-first fences for retired direct trading entrypoints. + +This module intentionally depends only on the Iteration 41 configuration +layer. A historical script can import it before Backtrader, a provider, or a +native CTP extension, so rejected invocations cannot reach an old direct +execution path while the migration is incomplete. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional, Sequence, TextIO, Union + +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError + + +def _write_error(error: RuntimeConfigError, output: TextIO) -> None: + """Emit the same small redacted JSON shape used by the runtime CLI.""" + + print(json.dumps(error.as_dict(), ensure_ascii=False, sort_keys=True), file=output) + + +def legacy_direct_execution_error(component: str) -> RuntimeConfigError: + """Return the deterministic error used for a retained direct writer. + + ``component`` is deliberately not included in the public error payload: + this avoids leaking a legacy parameter file, credential, or endpoint. + """ + + del component + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the historical direct execution entrypoint is not supported by the Iteration 41 " + "runtime; use the registered configuration-first migration route", + field_path="legacy_entrypoint", + reason="legacy_direct_execution_not_supported", + ) + + +def run_legacy_config_first_cli( + runtime_dir: Union[str, Path], + argv: Optional[Sequence[str]] = None, + *, + stderr: Optional[TextIO] = None, +) -> int: + """Run a retired script only through its fixed registered runtime directory. + + Historical switches once selected providers, YAML files, dry-run behavior, + or process-child modes. They are rejected before importing the legacy + script body. A no-argument invocation is retained as a convenience alias + for ``bt-runtime run --strategy-dir ``; that still enforces a + mandatory local ``config.yaml`` and exact code-owned registration. + """ + + arguments = tuple(sys.argv[1:] if argv is None else argv) + if arguments: + error = RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "legacy CLI arguments cannot select or override an Iteration 41 runtime; " + "supplied values are redacted (***)", + field_path="argv", + reason="legacy_cli_arguments_not_supported", + ) + _write_error(error, stderr or sys.stderr) + return 2 + + # Import the CLI only after rejecting all legacy flags. The CLI itself is + # config/schema code and imports no framework/provider capability. + from .cli import main as runtime_main + + return runtime_main(("run", "--strategy-dir", str(Path(runtime_dir)))) + + +__all__ = ["legacy_direct_execution_error", "run_legacy_config_first_cli"] diff --git a/backtrader_runtime/managed_execution.py b/backtrader_runtime/managed_execution.py new file mode 100644 index 00000000..0215f2ac --- /dev/null +++ b/backtrader_runtime/managed_execution.py @@ -0,0 +1,3301 @@ +"""Configuration-bound Backtrader projection for a composed managed runtime. + +This module does not import an SDK, execution package, provider, or broker at +module import time. The caller must first validate ``runtime/config.yaml`` +and compose a sealed managed capability runtime. Only then may it bind the +bridge to a :class:`backtrader.stores.BtApiStore` before startup. +""" + +from __future__ import annotations + +import datetime as _dt +import hashlib +import importlib +import math +import re +import struct +from collections import deque +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from enum import Enum +from fractions import Fraction +from types import MappingProxyType, SimpleNamespace +from typing import Any, Optional + +from .framework_projection import ( + FrameworkProjectionClaim, + FrameworkProjectionJournal, + FrameworkProjectionRecoveryError, + FrameworkSourceEventClaim, + canonical_framework_projection_fingerprint, +) +from .registry import EffectiveRuntimeConfig + + +class ManagedExecutionBindingError(RuntimeError): + """The Backtrader projection cannot safely map or dispatch an order.""" + + +class CtpQueueReceiptState(str, Enum): + """Local classification of an asynchronous CTP Store queue receipt. + + These values deliberately are not provider execution states. In particular, + ``UNKNOWN`` means only that the SDK queue accepted the command; it does not + mean the CTP front or exchange accepted an order or cancellation. + """ + + UNKNOWN = "UNKNOWN" + REJECTED = "REJECTED" + + +@dataclass(frozen=True) +class CtpQueueReceiptClassification: + """Validated local queue result, with no provider-observation fields.""" + + operation: str + state: CtpQueueReceiptState + receipt_id: str + error_code: Optional[str] = None + queued: bool = False + + +_CTP_QUEUE_RECEIPT_ID_RE = re.compile(r"^[0-9a-f]{32}$") +_CTP_QUEUE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") +_CTP_QUEUE_ERROR_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_CTP_QUEUE_RECEIPT_FIELDS = frozenset( + { + "kind", + "command", + "receipt_id", + "bt_order_ref", + "client_order_id", + "status", + "queued", + "priority", + "queue_depth", + "error_code", + "error_msg", + } +) + + +def _ctp_queue_expected_identity(value: Any, name: str) -> Any: + """Validate one Store correlation identity before comparing its echo.""" + + if name == "bt_order_ref": + # Recovered SDK bindings have no Backtrader reference. In that case + # the Store receipt must still echo the durable native CTP OrderRef + # below; None is an explicit missing framework identity, not a + # wildcard. + if value is None: + return None + if type(value) is int and value > 0: + return value + if ( + type(value) is str + and value + and value == value.strip() + and value.isascii() + and _CTP_QUEUE_TOKEN_RE.fullmatch(value) + ): + return value + raise ManagedExecutionBindingError("managed CTP expected bt_order_ref is invalid") + if name == "client_order_id": + # For CTP this is the durable native OrderRef, reserved by the SDK + # with its fixed-width numeric representation. + if type(value) is str and value.isascii() and value.isdigit() and len(value) == 12: + return value + raise ManagedExecutionBindingError("managed CTP expected client_order_id is invalid") + + +def classify_ctp_queue_receipt( + response: Any, + *, + operation: str, + expected_bt_order_ref: Any, + expected_client_order_id: str, +) -> CtpQueueReceiptClassification: + """Classify a Store queue receipt without inventing provider ACK evidence. + + The expected identities come from the already validated typed managed CTP + dispatch and Store binding. A queued receipt becomes durable ``UNKNOWN`` + at the caller; a rejected receipt becomes local ``REJECTED`` only after + those identities and the Store-generated receipt envelope match exactly. + This helper never constructs a ``ProviderObservation`` or + ``CancelObservation``. + """ + + if type(operation) is not str or operation not in {"submit", "cancel"}: + raise ManagedExecutionBindingError("managed CTP queue operation is invalid") + expected_ref = _ctp_queue_expected_identity(expected_bt_order_ref, "bt_order_ref") + expected_client_id = _ctp_queue_expected_identity(expected_client_order_id, "client_order_id") + if not isinstance(response, Mapping): + raise ManagedExecutionBindingError("managed CTP queue receipt is not a mapping") + if not set(response).issubset(_CTP_QUEUE_RECEIPT_FIELDS): + raise ManagedExecutionBindingError("managed CTP queue receipt contains unexpected fields") + queued = response.get("queued") + expected_fields = { + "kind", + "command", + "receipt_id", + "bt_order_ref", + "client_order_id", + "status", + "queued", + "priority", + "queue_depth", + } + if queued is False: + expected_fields.update(("error_code", "error_msg")) + if type(queued) is not bool or set(response) != expected_fields: + raise ManagedExecutionBindingError("managed CTP queue receipt fields are incomplete") + + if ( + response.get("kind") != "command_receipt" + or response.get("command") != operation + or type(response.get("receipt_id")) is not str + or not _CTP_QUEUE_RECEIPT_ID_RE.fullmatch(response["receipt_id"]) + or response.get("bt_order_ref") != expected_ref + or type(response.get("bt_order_ref")) is not type(expected_ref) + or response.get("client_order_id") != expected_client_id + or type(response.get("client_order_id")) is not str + ): + raise ManagedExecutionBindingError("managed CTP queue receipt identity is invalid") + + priority = response.get("priority") + allowed_priorities = {"cancel"} if operation == "cancel" else {"open", "close"} + queue_depth = response.get("queue_depth") + if ( + type(priority) is not str + or priority not in allowed_priorities + or type(queue_depth) is not int + or queue_depth < 0 + ): + raise ManagedExecutionBindingError("managed CTP queue receipt evidence is invalid") + + receipt_id = response["receipt_id"] + if queued is True: + if response.get("status") != "submitted": + raise ManagedExecutionBindingError("managed CTP queued receipt status is invalid") + return CtpQueueReceiptClassification( + operation=operation, + state=CtpQueueReceiptState.UNKNOWN, + receipt_id=receipt_id, + queued=True, + ) + + error_code = response.get("error_code") + if ( + response.get("status") != "rejected" + or type(error_code) is not str + or not _CTP_QUEUE_ERROR_RE.fullmatch(error_code) + or type(response.get("error_msg")) is not str + or not response["error_msg"].strip() + ): + raise ManagedExecutionBindingError("managed CTP rejected receipt status is invalid") + return CtpQueueReceiptClassification( + operation=operation, + state=CtpQueueReceiptState.REJECTED, + receipt_id=receipt_id, + error_code=error_code, + queued=False, + ) + + +class CtpManagedExecutionAdapterPlaceholder: + """Typed fail-closed placeholder for the uncomposed CTP write route. + + The Store contract is ready to carry managed CTP identities, but the + repository has no write-authorizing CTP admission yet. Its current CTP + admission/configuration surfaces are private-read only, and the SDK Store + dispatcher returns an asynchronous queue receipt rather than the provider + observation required by the durable execution facade. This placeholder + makes a CTP binding explicit while refusing before the SDK callback can + enqueue anything. + """ + + ctp_managed_execution_version = 1 + + def __init__(self, runtime: Any) -> None: + self.runtime = runtime + + @staticmethod + def _reject() -> None: + error = ManagedExecutionBindingError( + "MANAGED_CTP_WRITE_COMPOSITION_UNAVAILABLE: a write-authorizing CTP admission " + "and synchronous BtApi execution-observation handoff are required" + ) + # The placeholder refuses before invoking the Store callback. This + # local classification prevents Broker SDK fallback rules from turning + # the refusal into an Accepted or execution-unknown order. + error.code = "managed_ctp_write_composition_unavailable" + error.managed_local_reject = True + error.definite_reject = True + raise error + + def submit_order(self, order: Any, sdk_dispatch: Callable[[Any], Any]) -> Any: + """Reject without invoking the SDK dispatch callback.""" + + self._reject() + + def cancel_order( + self, + order_or_ref: Any, + dataname: Optional[str], + sdk_dispatch: Callable[[Any], Any], + ) -> Any: + """Reject without invoking the SDK dispatch callback.""" + + self._reject() + + +def _info_value(order: Any, name: str) -> Any: + info = getattr(order, "info", None) + getter = getattr(info, "get", None) + return getter(name) if callable(getter) else None + + +def _positive_decimal(value: Any, field_name: str) -> Decimal: + if isinstance(value, bool): + raise ManagedExecutionBindingError("invalid " + field_name) + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise ManagedExecutionBindingError("invalid " + field_name) from error + if not decimal_value.is_finite() or decimal_value <= 0: + raise ManagedExecutionBindingError("invalid " + field_name) + return decimal_value + + +def _finite_decimal(value: Any, field_name: str) -> Decimal: + """Parse an exact monetary fact without assuming its sign. + + A venue fee can be negative for a maker rebate, so fill accounting may not + apply the strictly-positive quantity/price rule to commission evidence. + """ + + if isinstance(value, bool): + raise ManagedExecutionBindingError("invalid " + field_name) + try: + decimal_value = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise ManagedExecutionBindingError("invalid " + field_name) from error + if not decimal_value.is_finite(): + raise ManagedExecutionBindingError("invalid " + field_name) + return decimal_value + + +def _required_info_text(order: Any, name: str) -> str: + value = _info_value(order, name) + if not isinstance(value, str) or not value or value != value.strip(): + raise ManagedExecutionBindingError("managed order is missing " + name) + return value + + +def _managed_runtime_order_id(scope: Any, intent_id: str) -> str: + """Derive a bounded provider reservation identity from durable SDK identity. + + ``ExecutionScope.key`` is a stable digest over provider, environment, + account reference, strategy, and trading day. The SDK execution journal + keys records by that scope plus ``intent_id``. Hashing the same pair with + a Backtrader-owned domain/version prefix gives CTP's separate durable + OrderRef reservation the same restart identity without using ``Order.ref``. + """ + + scope_key = getattr(scope, "key", None) + if ( + not isinstance(scope_key, str) + or len(scope_key) != 70 + or not scope_key.startswith("scope:") + or any(character not in "0123456789abcdef" for character in scope_key[6:]) + ): + raise ManagedExecutionBindingError("managed runtime scope key is not canonical") + if not isinstance(intent_id, str) or not intent_id or intent_id != intent_id.strip(): + raise ManagedExecutionBindingError("managed intent identity is invalid") + digest = hashlib.sha256( + b"backtrader.managed.runtime-order-id.v1\0" + + scope_key.encode("utf-8") + + b"\0" + + intent_id.encode("utf-8") + ).hexdigest() + return "bt-managed-v1:" + digest + + +def _order_price(order: Any) -> Any: + direct = getattr(order, "price", None) + if direct not in (None, 0, 0.0): + return direct + created = getattr(order, "created", None) + return getattr(created, "price", None) + + +def _order_quantity(order: Any) -> Any: + direct = getattr(order, "size", None) + if direct not in (None, 0, 0.0): + try: + return abs(direct) + except TypeError: + return direct + created = getattr(order, "created", None) + size = getattr(created, "size", None) + if size is None: + return None + try: + return abs(size) + except TypeError: + return size + + +@dataclass(frozen=True) +class _CanonicalManagedOrder: + """The exact framework facts that the legacy Store will turn into a write. + + ``order.info`` is strategy-controlled metadata. It may name an intent, + but it must never be allowed to describe a safer order than the Backtrader + object which the legacy Store will eventually serialize. This small value + object is constructed from the same order fields used by + ``BtApiStore._order_to_payload`` and is checked both before durable risk + admission and immediately before the private legacy dispatcher is called. + """ + + instrument: str + side: Any + is_buy: bool + quantity: Decimal + price: Decimal + position_effect: Any + reduce_only: bool + metadata_digest: str + quantity_unit: Optional[str] + exectype: Any + order_ref: Any + valid: Any + tradeid: Any + provider_info: Mapping[str, Any] + + +@dataclass(frozen=True) +class _ProviderProjectionPreApplyFacts: + """Broker state and expected incremental facts captured before mutation.""" + + quantity: Decimal + prior_source_quantity: Decimal + average_price: Decimal + commission: Decimal + execution_bit_count: int + incremental_quantity: Optional[float] + incremental_price: Optional[float] + incremental_commission: Optional[float] + + +def _payload_data_name(order: Any) -> str: + """Return the data-name selection used by ``BtApiStore._order_to_payload``.""" + + data = getattr(order, "data", None) + value = ( + getattr(data, "_name", None) + or getattr(data, "_dataname", None) + or getattr(getattr(data, "p", None), "dataname", None) + or getattr(data, "_dataname", None) + or repr(data) + ) + if not isinstance(value, str) or not value or value != value.strip(): + raise ManagedExecutionBindingError("managed order data symbol is not provable") + return value + + +def _payload_quantity(order: Any) -> Any: + """Return the exact quantity source the legacy Store serializes.""" + + value = getattr(order, "size", None) + try: + return abs(value) + except TypeError: + return value + + +def _payload_limit_price(order: Any) -> Any: + """Mirror the legacy Store's limit-price selection without importing it.""" + + price = getattr(order, "price", None) + created = getattr(order, "created", None) + created_price = getattr(created, "price", None) + if price is None: + price = created_price + if price is not None: + try: + if float(price) <= 0: + price = created_price if created_price is not None else None + except (TypeError, ValueError): + # Decimal validation below produces the public, deterministic + # managed-route error. Do not guess a replacement price here. + pass + return price + + +def _actual_limit_side(order: Any, execution: Any) -> Any: + """Derive one unambiguous side from the actual Backtrader order.""" + + isbuy = getattr(order, "isbuy", None) + issell = getattr(order, "issell", None) + if not callable(isbuy) or not callable(issell): + raise ManagedExecutionBindingError("managed order side is not provable") + buy = isbuy() + sell = issell() + if buy is True and sell is False: + return execution.Side.BUY + if sell is True and buy is False: + return execution.Side.SELL + raise ManagedExecutionBindingError("managed order side is not provable") + + +def _position_effect_offset(effect: Any) -> str: + """Map an SDK position effect to the exact legacy Store payload offset.""" + + value = getattr(effect, "value", effect) + mapping = { + "OPEN": "open", + "CLOSE": "close", + "CLOSE_TODAY": "close_today", + "CLOSE_YESTERDAY": "close_yesterday", + } + try: + return mapping[value] + except (KeyError, TypeError) as error: + raise ManagedExecutionBindingError("invalid managed_position_effect") from error + + +def _runtime_is_live(runtime: Any) -> bool: + """Return whether this bridge is guarding a live managed contract.""" + + contract = getattr(runtime, "contract", None) + return ( + bool(getattr(contract, "is_managed_live", False)) + or getattr(contract, "mode", None) == "live" + ) + + +def _sealed_quantity_unit(runtime: Any, instrument: str, metadata_digest: str) -> Optional[str]: + """Read quantity semantics only from the sealed runtime snapshot. + + ``order.info`` is strategy-controlled and must never select whether a + numeric quantity means base units, contracts, or another venue unit. The + direct managed SDK composition installs one immutable snapshot on every + live runtime; replay runtimes may intentionally have no snapshot. + """ + + snapshot = getattr(runtime, "instrument_metadata_snapshot", None) + live = _runtime_is_live(runtime) + if snapshot is None: + if live: + raise ManagedExecutionBindingError( + "managed live runtime lacks a sealed instrument metadata snapshot" + ) + return None + + record_for = getattr(snapshot, "instrument_metadata", None) + digest_for = getattr(snapshot, "instrument_digest", None) + if not callable(record_for) or not callable(digest_for): + raise ManagedExecutionBindingError( + "managed runtime metadata snapshot cannot prove instrument quantity semantics" + ) + try: + record = record_for(instrument) + expected_digest = digest_for(instrument) + except Exception as error: + raise ManagedExecutionBindingError( + "managed instrument is absent from the sealed metadata snapshot" + ) from error + if not isinstance(expected_digest, str) or metadata_digest != expected_digest: + raise ManagedExecutionBindingError( + "managed instrument metadata digest does not match the sealed snapshot" + ) + unit = getattr(record, "quantity_unit", None) + if unit is None and not live: + return None + if not isinstance(unit, str) or not unit or unit != unit.strip(): + raise ManagedExecutionBindingError( + "managed live metadata lacks a canonical instrument quantity_unit" + ) + return unit + + +def _frozen_order_scalar(value: Any, field_name: str, *, allow_none: bool = False) -> Any: + """Reject mutable framework values before they enter a provider projection.""" + + if value is None and allow_none: + return None + if type(value) in (str, int, float, Decimal): + return value + if isinstance(value, (_dt.date, _dt.datetime)): + return value + raise ManagedExecutionBindingError("managed order has unsupported " + field_name) + + +def _optional_provider_text(order: Any, name: str) -> Optional[str]: + """Copy one scalar Store payload field without retaining mutable order.info.""" + + value = _info_value(order, name) + if value is None: + return None + if not isinstance(value, str) or not value or value != value.strip(): + raise ManagedExecutionBindingError("invalid managed provider field " + name) + return value + + +def _optional_provider_scalar(order: Any, name: str) -> Any: + value = _info_value(order, name) + if value is None: + return None + return _frozen_order_scalar(value, "provider field " + name) + + +def _canonical_provider_info( + order: Any, + *, + offset: str, + reduce_only: bool, + quantity_unit: Optional[str], + runtime_order_id: Optional[str], +) -> Mapping[str, Any]: + """Freeze every Store-recognized execution-shaping ``order.info`` field. + + The legacy Store maps only this finite set of fields into its provider + payload. A managed bridge deliberately copies those values before risk + admission and later dispatches a proxy with this mapping, rather than the + mutable framework order. Fields with no sealed meaning at this boundary + are rejected instead of being allowed to override durable idempotency or + unit semantics. + """ + + if _info_value(order, "quantity_unit") is not None: + raise ManagedExecutionBindingError( + "managed order quantity_unit must come from sealed instrument metadata" + ) + if _info_value(order, "client_order_id") is not None: + raise ManagedExecutionBindingError( + "managed order client_order_id is owned by the durable execution runtime" + ) + if _info_value(order, "runtime_order_id") is not None: + raise ManagedExecutionBindingError( + "managed order runtime_order_id is derived from its durable scope and intent" + ) + if _info_value(order, "exchange_id") is not None: + raise ManagedExecutionBindingError( + "managed order exchange_id requires a sealed venue-routing contract" + ) + + result: dict[str, Any] = { + "offset": offset, + "reduce_only": reduce_only, + } + if quantity_unit is not None: + result["quantity_unit"] = quantity_unit + + position_mode = _optional_provider_text(order, "position_mode") + if position_mode is not None: + if position_mode != "net": + raise ManagedExecutionBindingError( + "managed order position_mode requires a typed position-leg contract" + ) + result["position_mode"] = position_mode + + time_in_force = _optional_provider_text(order, "time_in_force") + if time_in_force is not None: + if time_in_force.upper() != "GTC": + raise ManagedExecutionBindingError( + "managed order time_in_force requires a typed execution policy" + ) + result["time_in_force"] = time_in_force + + # These identity fields are already validated by the CTP/Broker layer when + # that layer owns them. Preserve only immutable scalar copies so no + # strategy callback can substitute one after risk admission. + for name in ( + "position_side", + "position_id", + "execution_cycle_id", + "execution_role", + "strategy_identity_sha256", + ): + value = _optional_provider_text(order, name) + if value is not None: + result[name] = value + # The SDK durable CTP reservation is keyed by authenticated venue/account/ + # strategy identity plus this stable scope-and-intent digest. + if runtime_order_id is not None: + result["runtime_order_id"] = runtime_order_id + for name in ("front_id", "session_id", "order_ref"): + value = _optional_provider_scalar(order, name) + if value is not None: + result[name] = value + return MappingProxyType(result) + + +class _CanonicalManagedOrderProxy: + """Minimal immutable Backtrader-order shape accepted by the legacy Store. + + The Store needs only the attributes below to form its provider payload. + It never receives the mutable source order once durable admission starts, + closing the check-to-dispatch window for ``order.info`` and order fields. + """ + + __slots__ = ( + "created", + "data", + "exectype", + "info", + "price", + "pricelimit", + "ref", + "size", + "tradeid", + "valid", + "_is_buy", + ) + + def __init__(self, canonical: _CanonicalManagedOrder) -> None: + self.ref = canonical.order_ref + self.exectype = canonical.exectype + self.data = SimpleNamespace(_name=canonical.instrument) + self.price = canonical.price + self.pricelimit = None + self.created = SimpleNamespace(price=canonical.price) + self._is_buy = canonical.is_buy + self.size = canonical.quantity if self._is_buy else -canonical.quantity + self.valid = canonical.valid + self.tradeid = canonical.tradeid + self.info = canonical.provider_info + + def isbuy(self) -> bool: + return self._is_buy + + def issell(self) -> bool: + return not self._is_buy + + @staticmethod + def getordername() -> str: + return "Limit" + + +def _canonical_managed_order_from_order(order: Any, runtime: Any) -> _CanonicalManagedOrder: + """Validate metadata against the actual order which the Store will write.""" + + execution = getattr(runtime, "execution", None) + if execution is None: + raise ManagedExecutionBindingError("managed runtime lacks execution contracts") + if _required_info_text(order, "managed_order_type") != "LIMIT": + raise ManagedExecutionBindingError( + "managed route currently supports only explicit LIMIT orders" + ) + # Keep normal Backtrader imports out of module import time. This function + # only runs after a reviewed managed runtime has already been composed. + try: + from backtrader.order import OrderBase + except Exception as error: + raise ManagedExecutionBindingError( + "Backtrader limit-order contract is unavailable" + ) from error + if getattr(order, "exectype", None) != OrderBase.Limit: + raise ManagedExecutionBindingError("managed route requires Backtrader Order.Limit") + + instrument = _required_info_text(order, "managed_instrument") + intent_id = _required_info_text(order, "managed_intent_id") + scope = getattr(runtime, "scope", None) + provider = getattr(scope, "provider", None) + runtime_order_id = ( + _managed_runtime_order_id(scope, intent_id) + if isinstance(provider, str) and provider.strip().upper() == "CTP" + else None + ) + actual_instrument = _payload_data_name(order) + if actual_instrument != instrument: + raise ManagedExecutionBindingError( + "managed instrument does not match the Backtrader order data symbol" + ) + metadata_digest = _required_info_text(order, "managed_instrument_metadata_digest") + quantity_unit = _sealed_quantity_unit(runtime, actual_instrument, metadata_digest) + + effect_name = _required_info_text(order, "managed_position_effect") + try: + effect = execution.PositionEffect(effect_name) + except (TypeError, ValueError) as error: + raise ManagedExecutionBindingError("invalid managed_position_effect") from error + expected_offset = _position_effect_offset(effect) + offset = _info_value(order, "offset") + if offset != expected_offset: + raise ManagedExecutionBindingError( + "managed position effect does not match the Backtrader order offset" + ) + expected_reduce_only = expected_offset != "open" + reduce_only = _info_value(order, "reduce_only") + if type(reduce_only) is not bool or reduce_only is not expected_reduce_only: + raise ManagedExecutionBindingError( + "managed position effect does not match the Backtrader order reduce_only flag" + ) + if getattr(order, "pricelimit", None) is not None: + raise ManagedExecutionBindingError( + "managed route requires one canonical limit price without pricelimit" + ) + side = _actual_limit_side(order, execution) + is_buy = side == execution.Side.BUY + if not is_buy and side != execution.Side.SELL: + raise ManagedExecutionBindingError("managed order side is not provable") + valid = _frozen_order_scalar(getattr(order, "valid", None), "valid", allow_none=True) + tradeid = _frozen_order_scalar(getattr(order, "tradeid", 0), "tradeid") + order_ref = _frozen_order_scalar(getattr(order, "ref", None), "reference") + + return _CanonicalManagedOrder( + instrument=actual_instrument, + side=side, + is_buy=is_buy, + quantity=_positive_decimal(_payload_quantity(order), "managed quantity"), + price=_positive_decimal(_payload_limit_price(order), "managed limit price"), + position_effect=effect, + reduce_only=reduce_only, + metadata_digest=metadata_digest, + quantity_unit=quantity_unit, + exectype=getattr(order, "exectype", None), + order_ref=order_ref, + valid=valid, + tradeid=tradeid, + provider_info=_canonical_provider_info( + order, + offset=expected_offset, + reduce_only=reduce_only, + quantity_unit=quantity_unit, + runtime_order_id=runtime_order_id, + ), + ) + + +def _assert_intent_matches_canonical_order( + order: Any, intent: Any, runtime: Any +) -> _CanonicalManagedOrder: + """Reject any drift between durable admission and the outgoing payload.""" + + if getattr(intent, "scope", None) != getattr(runtime, "scope", None): + raise ManagedExecutionBindingError( + "managed admitted intent scope does not match runtime scope" + ) + canonical = _canonical_managed_order_from_order(order, runtime) + fields = ( + ("instrument", canonical.instrument), + ("side", canonical.side), + ("quantity", canonical.quantity), + ("price", canonical.price), + ("position_effect", canonical.position_effect), + ("reduce_only", canonical.reduce_only), + ) + for name, expected in fields: + if getattr(intent, name, None) != expected: + raise ManagedExecutionBindingError( + "managed admitted intent does not match Backtrader order " + name + ) + if getattr(intent, "intent_id", None) != _required_info_text(order, "managed_intent_id"): + raise ManagedExecutionBindingError( + "managed admitted intent does not match Backtrader order intent_id" + ) + if getattr(intent, "signal_id", None) != _required_info_text(order, "managed_signal_id"): + raise ManagedExecutionBindingError( + "managed admitted intent does not match Backtrader order signal_id" + ) + if getattr(intent, "metadata_version", None) != _required_info_text( + order, "managed_metadata_version" + ): + raise ManagedExecutionBindingError( + "managed admitted intent does not match Backtrader order metadata_version" + ) + tags = getattr(intent, "tags", None) + if ( + not isinstance(tags, Mapping) + or tags.get("instrument_metadata_digest") != canonical.metadata_digest + ): + raise ManagedExecutionBindingError( + "managed admitted intent does not match Backtrader instrument metadata digest" + ) + if canonical.quantity_unit is not None and tags.get("quantity_unit") != canonical.quantity_unit: + raise ManagedExecutionBindingError( + "managed admitted intent does not match sealed instrument quantity_unit" + ) + return canonical + + +def strict_limit_intent_from_order(order: Any, runtime: Any) -> Any: + """Map one explicitly annotated Backtrader limit order to an execution intent. + + The framework alone cannot prove a stable signal identity, execution + metadata version, native quantity semantics, or a CTP close effect. The + adapter therefore requires those reviewed values in ``order.info`` instead + of guessing from an order reference or silently treating every sell as a + close. ``managed_order_type=LIMIT`` also stops stop/market/bracket/OCO + orders from degrading into ordinary provider writes. + """ + + execution = getattr(runtime, "execution", None) + scope = getattr(runtime, "scope", None) + if execution is None or scope is None: + raise ManagedExecutionBindingError("managed runtime lacks execution contracts") + canonical = _canonical_managed_order_from_order(order, runtime) + intent = execution.OrderIntent.limit( + intent_id=_required_info_text(order, "managed_intent_id"), + scope=scope, + signal_id=_required_info_text(order, "managed_signal_id"), + instrument=canonical.instrument, + side=canonical.side, + quantity=canonical.quantity, + price=canonical.price, + position_effect=canonical.position_effect, + reduce_only=canonical.reduce_only, + metadata_version=_required_info_text(order, "managed_metadata_version"), + # The bridge does not construct or infer instrument facts. A reviewed + # composition root must bind the exact metadata digest to the + # framework order, so an instrument-aware admission gate can reject a + # stale or substituted quantity/fee/tick profile before dispatch. + tags={ + # A Backtrader reference is process-local and changes when a + # crashed runtime materializes the same durable intent again. + # Keeping it in the intent fingerprint would make the SDK reject + # the only safe recovery path as a conflicting duplicate. + "instrument_metadata_digest": canonical.metadata_digest, + **( + {"quantity_unit": canonical.quantity_unit} + if canonical.quantity_unit is not None + else {} + ), + }, + ) + _assert_intent_matches_canonical_order(order, intent, runtime) + return intent + + +def observation_from_store_response(runtime: Any, intent: Any, response: Any) -> Any: + """Turn only explicit provider acceptance/rejection evidence into an observation. + + Anything else is deliberately raised to the durable execution facade, + which records the dispatch result as ``UNKNOWN`` and never resends it. + """ + + execution = getattr(runtime, "execution", None) + if execution is None or not isinstance(response, Mapping): + raise ManagedExecutionBindingError("provider response is not managed execution evidence") + status = str(response.get("status") or response.get("order_status") or "").strip().lower() + if response.get("execution_unknown") is True: + raise ManagedExecutionBindingError("provider response is execution-unknown") + if status in {"rejected", "reject", "failed", "error"}: + return execution.ProviderObservation.rejected(intent.intent_id, "provider_rejected") + provider_order_id = next( + ( + response.get(key) + for key in ("id", "order_id", "orderId", "ordId", "external_order_id") + if response.get(key) not in (None, "") + ), + None, + ) + if isinstance(provider_order_id, bool) or not isinstance(provider_order_id, (str, int)): + raise ManagedExecutionBindingError("provider acceptance lacks order identity") + provider_order_id = str(provider_order_id).strip() + if not provider_order_id: + raise ManagedExecutionBindingError("provider acceptance lacks order identity") + if status in {"accepted", "submitted", "open", "success", "ok"}: + return execution.ProviderObservation.accepted(intent.intent_id, provider_order_id) + if status in {"partial", "partially_filled", "filled"}: + # A legacy provider payload is not enough evidence to fabricate a fill. + # The provider adapter must normalize both quantities explicitly before + # this bridge can write a durable partial/full execution state. Missing + # or inconsistent evidence raises here; the execution facade then marks + # the one attempted dispatch UNKNOWN instead of pretending it is merely + # ACKED and losing fill progress. + filled_quantity = _positive_decimal( + response.get("filled_quantity"), "managed filled_quantity" + ) + average_price = _positive_decimal(response.get("average_price"), "managed average_price") + # The durable execution record currently carries fill quantity and + # average price, but no atomic Backtrader accounting receipt. The + # direct, first-dispatch response is therefore the only automatic + # projection path and must include its exact cumulative fee fact. Do + # not let a managed fill silently fall back to a local commission-rate + # estimate; ambiguous evidence is converted by the facade to UNKNOWN. + cumulative_commission = _finite_decimal( + response.get("cumulative_commission"), "managed cumulative_commission" + ) + expected_quantity = _positive_decimal( + getattr(intent, "quantity", None), "managed intent quantity" + ) + if filled_quantity > expected_quantity: + raise ManagedExecutionBindingError("managed filled_quantity exceeds intent quantity") + if status in {"partial", "partially_filled"}: + if filled_quantity >= expected_quantity: + raise ManagedExecutionBindingError( + "managed partial fill must be below intent quantity" + ) + state = execution.ExecutionState.PARTIALLY_FILLED + else: + if filled_quantity != expected_quantity: + raise ManagedExecutionBindingError( + "managed filled quantity must equal intent quantity" + ) + state = execution.ExecutionState.FILLED + try: + return execution.ProviderObservation( + intent.intent_id, + state, + provider_order_id=provider_order_id, + filled_quantity=filled_quantity, + average_price=average_price, + cumulative_commission=cumulative_commission, + ) + except TypeError: + # Older isolated fixture doubles intentionally expose the prior + # execution contract. They cannot take part in durable framework + # projection recovery, but retaining their shape keeps the normal + # no-I/O bridge tests focused on their stated boundary. + return execution.ProviderObservation( + intent.intent_id, + state, + provider_order_id=provider_order_id, + filled_quantity=filled_quantity, + average_price=average_price, + ) + raise ManagedExecutionBindingError("provider response is not confirmed execution evidence") + + +def strict_cancel_intent_from_order(order: Any, runtime: Any) -> Any: + """Map one annotated Backtrader order to a journal-bound cancellation. + + The Store never uses a bare framework reference as a cancellation + authority. The original managed intent id comes from reviewed order + metadata; the provider order identity is then loaded from the same scoped + durable execution record that confirmed the original submission. A stable + cancellation id is derived from that immutable target unless a reviewed + explicit ``managed_cancel_intent_id`` is present. + """ + + execution = getattr(runtime, "execution", None) + scope = getattr(runtime, "scope", None) + execution_store = getattr(runtime, "execution_store", None) + if execution is None or scope is None or execution_store is None: + raise ManagedExecutionBindingError("managed runtime lacks cancellation contracts") + target_intent_id = _required_info_text(order, "managed_intent_id") + target = execution_store.get(target_intent_id, scope=scope) + if target is None: + raise ManagedExecutionBindingError("managed cancellation target intent is unknown") + provider_order_id = getattr(target, "provider_order_id", None) + if not isinstance(provider_order_id, str) or not provider_order_id.strip(): + raise ManagedExecutionBindingError( + "managed cancellation target lacks confirmed provider identity" + ) + declared_provider_order_id = _info_value(order, "managed_provider_order_id") + if declared_provider_order_id not in (None, ""): + if ( + not isinstance(declared_provider_order_id, str) + or declared_provider_order_id.strip() != provider_order_id + ): + raise ManagedExecutionBindingError( + "managed cancellation provider identity does not match durable target" + ) + cancel_id = _info_value(order, "managed_cancel_intent_id") + if cancel_id in (None, ""): + cancel_id = "cancel." + target_intent_id + if not isinstance(cancel_id, str) or cancel_id != cancel_id.strip(): + raise ManagedExecutionBindingError("invalid managed_cancel_intent_id") + try: + return execution.CancelIntent( + cancel_id=cancel_id, + scope=scope, + target_intent_id=target_intent_id, + provider_order_id=provider_order_id, + metadata_version=_required_info_text(order, "managed_metadata_version"), + # As with submissions, a local framework ref is not stable across + # process recovery and must not become cancellation identity. + tags={}, + ) + except Exception as error: + raise ManagedExecutionBindingError("invalid managed cancellation intent") from error + + +def cancel_observation_from_store_response(runtime: Any, intent: Any, response: Any) -> Any: + """Convert only explicit, target-identified provider cancellation evidence. + + The legacy Store's cancellation response may be permissive for historic + callers. A managed route is deliberately stricter: no response may become + an acknowledgement unless it repeats the target provider order identity + exactly. Ambiguity reaches the durable facade as ``UNKNOWN``. + """ + + execution = getattr(runtime, "execution", None) + if execution is None or not isinstance(response, Mapping): + raise ManagedExecutionBindingError("provider response is not cancellation evidence") + if response.get("execution_unknown") is True: + raise ManagedExecutionBindingError("provider response is cancellation-unknown") + provider_order_id = next( + ( + response.get(key) + for key in ( + "provider_order_id", + "id", + "order_id", + "orderId", + "ordId", + "external_order_id", + ) + if response.get(key) not in (None, "") + ), + None, + ) + if isinstance(provider_order_id, bool) or not isinstance(provider_order_id, (str, int)): + raise ManagedExecutionBindingError("provider cancellation lacks target order identity") + provider_order_id = str(provider_order_id).strip() + if not provider_order_id or provider_order_id != getattr(intent, "provider_order_id", None): + raise ManagedExecutionBindingError("provider cancellation target identity mismatch") + status = str(response.get("status") or response.get("order_status") or "").strip().lower() + if status in {"cancelled", "canceled"}: + return execution.CancelObservation.cancelled( + intent.cancel_id, intent.target_intent_id, provider_order_id + ) + if status in { + "accepted", + "submitted", + "open", + "success", + "ok", + "pending", + "pending_cancel", + "cancel_requested", + "cancel_submitted", + }: + return execution.CancelObservation.accepted( + intent.cancel_id, intent.target_intent_id, provider_order_id + ) + if status in {"rejected", "reject", "failed", "error", "denied"}: + return execution.CancelObservation.rejected( + intent.cancel_id, + intent.target_intent_id, + provider_order_id, + "provider_cancel_rejected", + ) + raise ManagedExecutionBindingError("provider response is not confirmed cancellation evidence") + + +def project_record_to_store_response(record: Any, provider_response: Any) -> Mapping[str, Any]: + """Project durable execution state to the existing Broker response shape.""" + + state = str(getattr(getattr(record, "state", None), "value", "")) + if state in {"ACKED", "PARTIALLY_FILLED", "FILLED"}: + if isinstance(provider_response, Mapping): + return dict(provider_response) + # A repeated intent is resolved from the durable journal and deliberately + # does not call the Store's provider port again. Preserve that safe + # idempotency at the framework boundary by reconstructing only the + # confirmed provider identity recorded by the execution facade. + provider_order_id = getattr(record, "provider_order_id", None) + if isinstance(provider_order_id, bool) or not isinstance(provider_order_id, (str, int)): + return { + "status": "rejected", + "error_code": "managed_execution_missing_provider_identity", + "error_msg": "Managed execution record lacks a confirmed provider order identity.", + "managed_execution_state": state, + } + provider_order_id = str(provider_order_id).strip() + if not provider_order_id: + return { + "status": "rejected", + "error_code": "managed_execution_missing_provider_identity", + "error_msg": "Managed execution record lacks a confirmed provider order identity.", + "managed_execution_state": state, + } + status = { + "ACKED": "accepted", + "PARTIALLY_FILLED": "partial", + "FILLED": "filled", + }[state] + response = { + "status": status, + "id": provider_order_id, + "managed_execution_replayed": True, + "managed_execution_state": state, + } + if state in {"PARTIALLY_FILLED", "FILLED"}: + response["filled_quantity"] = str(getattr(record, "filled_quantity", "")) + average_price = getattr(record, "average_price", None) + if average_price is not None: + response["average_price"] = str(average_price) + cumulative_commission = getattr(record, "cumulative_commission", None) + if cumulative_commission is not None: + response["cumulative_commission"] = str(cumulative_commission) + return response + if state == "UNKNOWN": + return { + "status": "submitted", + "execution_unknown": True, + "error_code": str(getattr(record, "unknown_reason", "") or "managed_execution_unknown"), + # A direct ambiguous reply keeps the one framework order alive for + # reconciliation. A later durable replay belongs to a fresh + # framework order and must be blocked by BtApiBroker just like a + # replayed confirmed fill: there is no persisted projection receipt + # proving which local order owns that uncertain provider attempt. + "managed_execution_replayed": provider_response is None, + "managed_execution_state": state, + } + return { + "status": "rejected", + "error_code": "managed_execution_" + (state.lower() or "unconfirmed"), + "error_msg": "Managed execution did not confirm a provider submission.", + "managed_execution_state": state or "UNCONFIRMED", + } + + +def project_cancel_record_to_store_response( + record: Any, provider_response: Any +) -> Mapping[str, Any]: + """Project a durable cancellation result to the legacy Store response shape. + + A provider ACK says only that a cancellation command was received. It is + not a Backtrader terminal state: the original mapping must remain available + for a late fill or a separately reconciled terminal update. Mark every + non-terminal managed result explicitly so ``BtApiBroker`` cannot take its + historic optimistic-local-cancel branch. + """ + + state = str(getattr(getattr(record, "state", None), "value", "")) + provider_order_id = getattr(record, "provider_order_id", None) + if state in {"ACKED", "CANCELLED"}: + if isinstance(provider_response, Mapping): + response = dict(provider_response) + if state == "ACKED": + # Do not pass a permissive legacy ``accepted`` response to + # the Broker as if it were enough to cancel locally. + response["status"] = "cancel_requested" + response["managed_execution_cancel_pending"] = True + response["managed_execution_cancel_state"] = state + response["managed_execution_cancel_reconciliation_required"] = True + else: + # The durable cancellation state is the terminal authority. + # Do not let a legacy response spelling (or a stale provider + # echo) route this record through the Broker's optimistic + # cancellation branch as an arbitrary non-terminal response. + response["status"] = "cancelled" + return response + if not isinstance(provider_order_id, str) or not provider_order_id.strip(): + return { + "status": "rejected", + "error_code": "managed_cancel_missing_provider_identity", + "error_msg": "Managed cancellation record lacks a confirmed provider order identity.", + "managed_execution_cancel_state": state, + "managed_execution_cancel_pending": True, + "managed_execution_cancel_reconciliation_required": True, + } + response = { + "status": "cancel_requested" if state == "ACKED" else "cancelled", + "id": provider_order_id, + "managed_execution_cancel_replayed": True, + "managed_execution_cancel_state": state, + } + if state == "ACKED": + response["managed_execution_cancel_pending"] = True + response["managed_execution_cancel_reconciliation_required"] = True + return response + if state == "UNKNOWN": + return { + "status": "submitted", + "execution_unknown": True, + "error_code": str(getattr(record, "unknown_reason", "") or "managed_cancel_unknown"), + "managed_execution_cancel_state": state, + "managed_execution_cancel_pending": True, + "managed_execution_cancel_reconciliation_required": True, + "managed_execution_cancel_freeze_required": True, + } + return { + "status": "rejected", + "error_code": "managed_cancel_" + (state.lower() or "unconfirmed"), + "error_msg": "Managed execution did not confirm provider cancellation.", + "managed_execution_cancel_state": state or "UNCONFIRMED", + "managed_execution_cancel_pending": True, + "managed_execution_cancel_reconciliation_required": True, + } + + +@dataclass +class ManagedExecutionBridge: + """Adapt framework orders to a previously composed durable execution runtime.""" + + runtime: Any + intent_from_order: Callable[[Any, Any], Any] = strict_limit_intent_from_order + observation_from_response: Callable[[Any, Any, Any], Any] = observation_from_store_response + response_from_record: Callable[[Any, Any], Mapping[str, Any]] = project_record_to_store_response + cancel_intent_from_order: Callable[[Any, Any], Any] = strict_cancel_intent_from_order + cancel_observation_from_response: Callable[[Any, Any, Any], Any] = ( + cancel_observation_from_store_response + ) + cancel_response_from_record: Callable[[Any, Any], Mapping[str, Any]] = ( + project_cancel_record_to_store_response + ) + provider_observation_from_update: Optional[Callable[[Any, Any, Mapping[str, Any]], Any]] = None + _cancellation_facade: Any = field(default=None, init=False, repr=False) + _framework_projection_journal: Optional[FrameworkProjectionJournal] = field( + default=None, init=False, repr=False + ) + _framework_projection_claims: dict[str, FrameworkProjectionClaim] = field( + default_factory=dict, init=False, repr=False + ) + _source_event_claims: dict[str, FrameworkSourceEventClaim] = field( + default_factory=dict, init=False, repr=False + ) + _source_event_pre_apply_facts: dict[str, _ProviderProjectionPreApplyFacts] = field( + default_factory=dict, init=False, repr=False + ) + _source_event_last_facts: dict[str, tuple[Decimal, Optional[Decimal], Optional[Decimal]]] = ( + field(default_factory=dict, init=False, repr=False) + ) + _source_replay_orders: dict[str, dict[str, Any]] = field( + default_factory=dict, init=False, repr=False + ) + _source_replay_events: dict[str, Any] = field(default_factory=dict, init=False, repr=False) + _deferred_fee_events: dict[str, Any] = field(default_factory=dict, init=False, repr=False) + _source_projection_blocked: bool = field(default=False, init=False, repr=False) + _interrupted_cancellation_recovery_pending: bool = field(default=False, init=False, repr=False) + + def __post_init__(self) -> None: + if ( + not callable(getattr(self.runtime, "submit", None)) + or getattr(self.runtime, "scope", None) is None + ): + raise ManagedExecutionBindingError("a composed managed execution runtime is required") + if not all( + callable(callback) + for callback in ( + self.intent_from_order, + self.observation_from_response, + self.response_from_record, + self.cancel_intent_from_order, + self.cancel_observation_from_response, + self.cancel_response_from_record, + ) + ): + raise ManagedExecutionBindingError("managed bridge callbacks must be callable") + try: + self._framework_projection_journal = self._create_framework_projection_journal() + self._register_framework_projection_closeable() + self._recover_interrupted_cancellation_dispatches(defer_if_writer_unavailable=True) + self._recover_unknown_cancellation_freezes() + except Exception: + self.close() + raise + + def get_strategy_allocation(self, strategy_id: str) -> Any: + """Read this strategy's local risk allocation without provider I/O. + + The optional SDK reader returns its immutable reservation-ledger + snapshot. Its notional amounts are neither account cash nor margin, + and a sizing observation does not reserve or authorize an order. + """ + + scope = self.runtime.scope + if ( + type(strategy_id) is not str + or not strategy_id + or strategy_id != getattr(scope, "strategy_id", None) + ): + raise ManagedExecutionBindingError("allocation strategy does not match runtime scope") + risk_scope = getattr(self.runtime, "risk_scope", None) + reader = getattr(getattr(self.runtime, "risk_gate", None), "get_strategy_allocation", None) + if risk_scope is None or not callable(reader): + raise ManagedExecutionBindingError("managed allocation reader is unavailable") + snapshot = reader(risk_scope, strategy_id) + if ( + getattr(snapshot, "scope", None) != risk_scope + or getattr(snapshot, "strategy_id", None) != strategy_id + ): + raise ManagedExecutionBindingError("allocation snapshot does not match runtime scope") + return snapshot + + def submit_order(self, order: Any, legacy_dispatch: Callable[[Any], Any]) -> Mapping[str, Any]: + """Persist/admit an intent, then call the Store port only through the runtime facade.""" + + self._ensure_interrupted_cancellation_recovery() + if not callable(legacy_dispatch): + raise ManagedExecutionBindingError("Store legacy dispatch port is unavailable") + intent = self.intent_from_order(order, self.runtime) + if getattr(intent, "scope", None) != self.runtime.scope: + raise ManagedExecutionBindingError("managed intent scope does not match runtime scope") + # The risk facade must receive an intent derived from the actual order + # payload, not a friendlier set of ``order.info`` declarations. This + # is intentionally repeated in ``dispatch`` below because admission + # hooks run between these two points and must not be able to mutate the + # framework order or substitute the admitted intent before the private + # Store port performs its provider write. + canonical = _assert_intent_matches_canonical_order(order, intent, self.runtime) + if self.provider_observation_from_update is not None: + self._source_replay_orders[str(intent.intent_id)] = { + "bt_order_ref": getattr(order, "ref", None), + "data_name": canonical.instrument, + "side": "buy" if canonical.is_buy else "sell", + "external_order_id": None, + "venue_order_id": None, + "client_order_id": None, + "runtime_order_id": None, + "order_ref": None, + } + provider_response: list[Any] = [] + + def dispatch(admitted_intent: Any) -> Any: + current = _assert_intent_matches_canonical_order(order, admitted_intent, self.runtime) + if current != canonical: + raise ManagedExecutionBindingError( + "managed provider projection changed after risk admission" + ) + # Pass only the sealed snapshot to the legacy Store. The source + # Backtrader object remains mutable to the strategy and observers, + # so rechecking it immediately above alone would leave a race + # before ``_order_to_payload`` reads its fields. + response = legacy_dispatch(_CanonicalManagedOrderProxy(canonical)) + provider_response.append(response) + return self.observation_from_response(self.runtime, admitted_intent, response) + + record = self.runtime.submit(intent, dispatch) + response = provider_response[0] if provider_response else None + projected = self.response_from_record(record, response) + if self.managed_provider_projection_enabled and str( + getattr(getattr(record, "state", None), "value", "") + ) in {"ACKED", "PARTIALLY_FILLED", "FILLED"}: + # Never turn an ExecutionRecord into historical fill facts. The + # Broker stays uncertain until the exact immutable outbox rows are + # pulled and applied through the receipt path below. + return { + "status": "accepted", + "id": str(getattr(record, "provider_order_id", "") or ""), + "execution_unknown": True, + "managed_execution_state": str( + getattr(getattr(record, "state", None), "value", "") + ), + "managed_provider_outbox_replay_pending": True, + } + claim = self._claim_framework_projection(intent, canonical, record) + if claim is None: + return projected + result = dict(projected) + # The receipt id is a non-secret lookup token only. The active claim + # token stays in this bridge so a strategy/provider response cannot + # forge permission to make the Broker book a durable replay. + result["managed_framework_projection_receipt_id"] = claim.receipt_id + result["managed_framework_projection_state"] = claim.state + return result + + def validate_framework_projection(self, response: Any, order: Any) -> bool: + """Prove that this exact Broker order owns a live fill receipt. + + ``BtApiBroker`` calls this before it lets a durable replay reach its + ordinary position/commission path. A response flag by itself is never + sufficient because legacy/provider payloads are not an authority for a + framework recovery receipt. + """ + + if self._source_projection_blocked: + return False + claim = self._framework_projection_claim(response) + journal = self._framework_projection_journal + if claim is None or journal is None or not journal.validate(claim): + return False + try: + intent = self.intent_from_order(order, self.runtime) + canonical = _assert_intent_matches_canonical_order(order, intent, self.runtime) + return ( + getattr(intent, "intent_id", None) == claim.intent_id + and getattr(intent, "fingerprint", None) == claim.intent_fingerprint + and canonical_framework_projection_fingerprint(canonical) + == claim.canonical_fingerprint + ) + except (FrameworkProjectionRecoveryError, ManagedExecutionBindingError): + return False + + def complete_framework_projection(self, response: Any, order: Any) -> None: + """Commit the local receipt only after Broker fill accounting succeeds.""" + + claim = self._framework_projection_claim(response) + journal = self._framework_projection_journal + if ( + claim is None + or journal is None + or not self.validate_framework_projection(response, order) + ): + raise ManagedExecutionBindingError("managed framework projection receipt is not active") + self._assert_framework_projection_execution(order, claim) + try: + journal.complete(claim) + except FrameworkProjectionRecoveryError as error: + raise ManagedExecutionBindingError( + "managed framework projection receipt could not be completed" + ) from error + + def fail_framework_projection(self, response: Any, reason: str) -> None: + """Fence this process after a submit-path Broker apply is uncertain.""" + + self._source_projection_blocked = True + self._fence_source_projection_identity(reason) + + @property + def managed_provider_projection_enabled(self) -> bool: + """Whether this bridge was explicitly injected for durable callback projection.""" + + return bool( + self.provider_observation_from_update is not None + and self._framework_projection_journal is not None + and callable( + getattr( + getattr(self.runtime, "facade", None), "record_provider_observation_event", None + ) + ) + and callable( + getattr(getattr(self.runtime, "execution_store", None), "read_outbox", None) + ) + ) + + def poll_managed_provider_outbox_update(self) -> Optional[Mapping[str, Any]]: + """Read the next exact scoped source event after the local durable cursor. + + The cursor is session-local: a new Broker session starts at sequence + zero and reconstructs its empty in-memory state from immutable outbox + rows. Known intent lifecycle rows are recorded as consumed no-ops; + unknown event types and missing order mappings stop the stream. + """ + + if not self.managed_provider_projection_enabled or self._source_projection_blocked: + return None + journal = self._framework_projection_journal + assert journal is not None + try: + identity = self.runtime.execution_store.journal_source_identity() + generation = self._execution_journal_generation(identity) + scope_key = getattr(self.runtime.scope, "key", None) + session_id = self.runtime.framework_projection_session_id + known_lifecycle_events = { + "intent_recorded", + "intent_admitted", + "dispatch_claimed", + "intent_rejected", + "intent_blocked", + "dispatch_blocked", + } + projectable_events = { + "provider_observation", + "reconciled_observation", + "provider_commission_evidence", + "cancelled_by_cancel_intent", + } + # Bound work per Broker poll. A later call resumes at the durable + # cursor if the page contains only lifecycle facts. + for _ in range(100): + after_sequence = journal.source_high_water( + scope_key=scope_key, + journal_incarnation_id=generation, + session_id=session_id, + ) + page = self.runtime.execution_store.read_outbox( + after_sequence=after_sequence, + limit=100, + scope=self.runtime.scope, + ) + if not isinstance(page, (tuple, list)): + raise ManagedExecutionBindingError("source outbox page is invalid") + if not page: + return None + event = page[0] + if ( + getattr(event, "scope_key", None) != scope_key + or getattr(event, "journal_incarnation_id", None) != generation + ): + raise ManagedExecutionBindingError( + "source outbox event does not match the execution journal identity" + ) + event_type = getattr(event, "event_type", None) + if event_type in known_lifecycle_events: + self._validate_no_fill_lifecycle_event(event) + journal.advance_source_event( + event=event, + session_id=session_id, + expected_scope_key=scope_key, + ) + continue + if event_type not in projectable_events: + raise ManagedExecutionBindingError( + "source outbox contains an unsupported event type" + ) + self._validate_projectable_source_event(event) + intent_id = getattr(event, "intent_id", None) + deferred = self._deferred_fee_events.get(intent_id) + if deferred is event or ( + deferred is not None + and getattr(deferred, "event_id", None) == getattr(event, "event_id", None) + ): + later_page = self.runtime.execution_store.read_outbox( + after_sequence=event.sequence, + limit=100, + scope=self.runtime.scope, + ) + later_event = None + for candidate in later_page: + candidate_type = getattr(candidate, "event_type", None) + if candidate_type in known_lifecycle_events: + self._validate_no_fill_lifecycle_event(candidate) + continue + if candidate_type in projectable_events: + self._validate_projectable_source_event(candidate) + if self._event_supersedes_fee_pending(event, candidate): + later_event = candidate + break + if self._event_can_wait_for_fee_evidence(event, candidate): + continue + break + if later_event is None or not self._event_supersedes_fee_pending( + event, later_event + ): + return None + journal.advance_source_event( + event=event, + session_id=session_id, + expected_scope_key=scope_key, + ) + self._deferred_fee_events.pop(intent_id, None) + continue + mapping = self._source_replay_orders.get(intent_id) + if mapping is None: + # Do not step past another order's earlier event. Its + # strategy order must first be materialized in this fresh + # Broker session. + return None + event_id = getattr(event, "event_id", None) + if not isinstance(event_id, str) or not event_id: + raise ManagedExecutionBindingError("source outbox event id is invalid") + self._source_replay_events[event_id] = event + original = dict(mapping) + original.update( + kind="order", + status="accepted", + managed_provider_outbox_event_id=event_id, + ) + return self._provider_event_broker_update(event, original) + return None + except Exception as error: + self._fence_source_projection_identity("source_outbox_recovery_failure") + if isinstance(error, ManagedExecutionBindingError): + raise + raise ManagedExecutionBindingError( + "durable provider source outbox is unavailable" + ) from error + + def prepare_managed_provider_projection( + self, update: Mapping[str, Any], order: Any + ) -> Optional[Mapping[str, Any]]: + """Persist, claim and normalize one callback before Broker mutation. + + The injected normalizer is the only raw-provider boundary. The facade + must return the exact immutable event appended by its SQLite transaction; + this method never joins a newer execution record to an older event. + """ + + if not self.managed_provider_projection_enabled: + raise ManagedExecutionBindingError( + "durable managed provider projection was not explicitly injected" + ) + if self._source_projection_blocked: + raise ManagedExecutionBindingError("managed provider projection session is fenced") + journal = self._framework_projection_journal + assert journal is not None + try: + intent = self.intent_from_order(order, self.runtime) + canonical = _assert_intent_matches_canonical_order(order, intent, self.runtime) + replay_event_id = update.get("managed_provider_outbox_event_id") + if isinstance(replay_event_id, str): + event = self._source_replay_events.get(replay_event_id) + if event is None: + raise ManagedExecutionBindingError( + "durable provider outbox event is no longer available" + ) + if getattr(event, "intent_id", None) != getattr(intent, "intent_id", None): + raise ManagedExecutionBindingError( + "durable provider event intent does not match the framework order" + ) + return self._claim_provider_event(event, update, order, canonical) + + callback = self.provider_observation_from_update + assert callback is not None + observation = callback(self.runtime, intent, update) + observation_type = getattr( + getattr(self.runtime, "execution", None), "ProviderObservation", None + ) + if observation_type is None or not isinstance(observation, observation_type): + raise ManagedExecutionBindingError( + "provider update normalizer returned no typed observation" + ) + if getattr(observation, "intent_id", None) != getattr(intent, "intent_id", None): + raise ManagedExecutionBindingError( + "provider observation intent does not match the framework order" + ) + event = self.runtime.facade.record_provider_observation_event(observation) + # Even when this append is new, only the scoped outbox reader may + # advance it. That orders it behind any earlier durable events. + if event is None: + return None + self._validate_event_against_observation(event, observation, intent) + event_id = getattr(event, "event_id", None) + if not isinstance(event_id, str) or not event_id: + raise ManagedExecutionBindingError("immutable provider event id is invalid") + self._source_replay_events[event_id] = event + return None + except ManagedExecutionBindingError as error: + if not bool(getattr(error, "provider_projection_retryable", False)): + self._fence_source_projection_identity("source_observation_preparation_failure") + raise + except FrameworkProjectionRecoveryError as error: + self._fence_source_projection_identity("source_event_journal_failure") + raise ManagedExecutionBindingError( + "managed provider source event could not be claimed" + ) from error + except Exception as error: + self._fence_source_projection_identity("source_observation_preparation_failure") + raise ManagedExecutionBindingError( + "managed provider event could not be durably prepared" + ) from error + + def validate_managed_provider_projection(self, update: Any, order: Any) -> bool: + """Prove a prepared provider event still owns its private local receipt.""" + + if not isinstance(update, Mapping) or self._source_projection_blocked: + return False + receipt_id = update.get("_managed_provider_projection_receipt_id") + if not isinstance(receipt_id, str): + return False + claim = self._source_event_claims.get(receipt_id) + journal = self._framework_projection_journal + if claim is None or journal is None or not journal.validate_source_event(claim): + return False + pre_apply = self._source_event_pre_apply_facts.get(claim.receipt_id) + if pre_apply is None: + return False + status_by_state = { + "ACKED": "accepted", + "PARTIALLY_FILLED": "partial", + "FILLED": "completed", + "CANCELLED": "canceled", + "REJECTED": "rejected", + } + try: + if ( + update.get("managed_source_event_id") != claim.event_id + or update.get("managed_source_event_sequence") != claim.sequence + or update.get("managed_source_event_type") != claim.event_type + or update.get("status") != status_by_state[claim.state] + or self._source_decimal(update.get("filled")) != claim.filled_quantity + or self._source_optional_decimal(update.get("managed_source_average_price")) + != claim.average_price + or self._source_optional_decimal(update.get("managed_source_cumulative_commission")) + != claim.cumulative_commission + or "avg_price" in update + or "cumulative_commission" in update + or update.get("price") + != ( + pre_apply.incremental_price + if pre_apply.incremental_price is not None + else ( + self._source_binary64(claim.average_price, "source cumulative average") + if claim.average_price is not None + else 0.0 + ) + ) + or update.get("commission") != (pre_apply.incremental_commission or 0.0) + or update.get("commission_normalized") is not True + or update.get("order_id") != claim.provider_order_id + ): + return False + except (KeyError, ManagedExecutionBindingError): + return False + try: + intent = self.intent_from_order(order, self.runtime) + canonical = _assert_intent_matches_canonical_order(order, intent, self.runtime) + return getattr( + intent, "intent_id", None + ) == claim.intent_id and canonical_framework_projection_fingerprint( + canonical + ) == self._source_event_order_fingerprint(claim) + except (FrameworkProjectionRecoveryError, ManagedExecutionBindingError): + return False + + def complete_managed_provider_projection(self, update: Any, order: Any) -> None: + """Complete the local source high-water after successful Broker accounting.""" + + claim = self._source_event_claim(update) + journal = self._framework_projection_journal + if ( + claim is None + or journal is None + or not self.validate_managed_provider_projection(update, order) + ): + raise ManagedExecutionBindingError("managed provider event receipt is not active") + pre_apply = self._source_event_pre_apply_facts.get(claim.receipt_id) + if pre_apply is None: + raise ManagedExecutionBindingError("managed provider pre-apply facts are unavailable") + self._assert_provider_event_execution(order, claim, pre_apply) + try: + journal.complete_source_event(claim) + except FrameworkProjectionRecoveryError as error: + self._fence_source_projection(claim, "post_apply_receipt_failure") + raise ManagedExecutionBindingError( + "managed provider event receipt could not be completed" + ) from error + self._source_event_claims.pop(claim.receipt_id, None) + self._source_event_pre_apply_facts.pop(claim.receipt_id, None) + self._source_event_last_facts[claim.intent_id] = ( + claim.filled_quantity, + claim.average_price, + claim.cumulative_commission, + ) + self._source_replay_events.pop(claim.event_id, None) + + def fail_managed_provider_projection(self, update: Any, reason: str) -> None: + """Fence this session if Broker apply was interrupted or became uncertain.""" + + claim = self._source_event_claim(update) + if claim is not None: + self._fence_source_projection(claim, reason) + + def _fence_source_projection(self, claim: FrameworkSourceEventClaim, reason: str) -> None: + self._source_projection_blocked = True + journal = self._framework_projection_journal + if journal is not None: + try: + journal.fence_source_session( + scope_key=claim.scope_key, + journal_incarnation_id=claim.journal_incarnation_id, + session_id=claim.session_id, + reason=reason, + ) + except FrameworkProjectionRecoveryError: + # The in-memory latch still fences this process if the journal + # itself is what failed. + pass + + def _fence_source_projection_identity(self, reason: str) -> None: + self._source_projection_blocked = True + journal = self._framework_projection_journal + if journal is None: + return + identity_reader = getattr(self.runtime.execution_store, "journal_source_identity", None) + if not callable(identity_reader): + return + try: + identity = identity_reader() + incarnation = ( + identity.get("generation") + if isinstance(identity, Mapping) + else getattr(identity, "generation", None) + ) + journal.fence_source_session( + scope_key=self.runtime.scope.key, + journal_incarnation_id=incarnation, + session_id=self.runtime.framework_projection_session_id, + reason=reason, + ) + except Exception: + pass + + def _source_event_claim(self, update: Any) -> Optional[FrameworkSourceEventClaim]: + if not isinstance(update, Mapping): + return None + receipt_id = update.get("_managed_provider_projection_receipt_id") + if not isinstance(receipt_id, str): + return None + return self._source_event_claims.get(receipt_id) + + def _claim_provider_event( + self, event: Any, update: Mapping[str, Any], order: Any, canonical: Any + ) -> Mapping[str, Any]: + journal = self._framework_projection_journal + assert journal is not None + identity = self.runtime.execution_store.journal_source_identity() + generation = self._execution_journal_generation(identity) + scope_key = getattr(self.runtime.scope, "key", None) + if ( + getattr(event, "scope_key", None) != scope_key + or getattr(event, "journal_incarnation_id", None) != generation + or getattr(event, "intent_id", None) + != getattr(self.intent_from_order(order, self.runtime), "intent_id", None) + ): + raise ManagedExecutionBindingError( + "immutable source event identity changed before claim" + ) + payload = getattr(event, "payload", None) + if not isinstance(payload, Mapping): + raise ManagedExecutionBindingError("immutable source event payload is invalid") + filled = self._source_decimal(payload.get("filled_quantity")) + average_raw = payload.get("average_price") + commission_raw = payload.get("cumulative_commission") + executed = getattr(order, "executed", None) + already_filled = abs(self._source_decimal(getattr(executed, "size", 0) or 0)) + already_average = self._source_decimal(getattr(executed, "price", 0) or 0) + already_commission = self._source_decimal(getattr(executed, "comm", 0) or 0) + intent_id = str(getattr(event, "intent_id")) + previous_facts = self._source_event_last_facts.get(intent_id) + if previous_facts is None: + previous_quantity, previous_average, previous_commission = ( + Decimal("0"), + None, + Decimal("0"), + ) + else: + previous_quantity, previous_average, previous_commission = previous_facts + if filled < previous_quantity: + self._source_projection_blocked = True + self._fence_source_projection_identity("source_quantity_moved_backwards") + raise ManagedExecutionBindingError( + "managed provider cumulative quantity moved backwards" + ) + if previous_facts is None: + broker_matches_previous = already_filled == 0 + else: + broker_matches_previous = self._within_binary64_ulps( + already_filled, previous_quantity, budget=1 + ) + if previous_average is not None: + broker_matches_previous = broker_matches_previous and self._within_binary64_ulps( + already_average, previous_average, budget=2 + ) + if previous_commission is not None: + broker_matches_previous = broker_matches_previous and self._within_binary64_ulps( + already_commission, previous_commission, budget=1 + ) + if not broker_matches_previous: + self._reject_unrepresentable_source_event( + "source_broker_checkpoint_diverged", + "Broker facts diverged from the previous immutable source event", + ) + source_quantity_grew = filled > previous_quantity + if source_quantity_grew and average_raw is None: + self._source_projection_blocked = True + self._fence_source_projection_identity("source_fill_missing_average") + raise ManagedExecutionBindingError( + "managed provider fill lacks its event-time average price" + ) + if source_quantity_grew and commission_raw is None: + self._deferred_fee_events[str(getattr(event, "intent_id"))] = event + error = ManagedExecutionBindingError( + "managed provider fill awaits event-time cumulative fee evidence" + ) + error.provider_projection_retryable = True + raise error + if previous_facts is None and filled == 0: + event_average = self._source_optional_decimal(average_raw) + event_commission = self._source_optional_decimal(commission_raw) + changed_economics = event_average is not None or event_commission not in ( + None, + Decimal("0"), + ) + if changed_economics: + self._reject_unrepresentable_source_event( + "same_quantity_economic_adjustment", + "same-quantity provider event requires an unsupported fee or price adjustment", + ) + elif previous_facts is not None and filled == previous_quantity: + event_average = self._source_optional_decimal(average_raw) + event_commission = self._source_optional_decimal(commission_raw) + changed_economics = ( + event_average != previous_average or event_commission != previous_commission + ) + if changed_economics: + self._reject_unrepresentable_source_event( + "same_quantity_economic_adjustment", + "same-quantity provider event requires an unsupported fee or price adjustment", + ) + + pre_apply = self._source_projection_pre_apply_facts( + order=order, + filled=filled, + average_raw=average_raw, + commission_raw=commission_raw, + already_filled=already_filled, + already_average=already_average, + already_commission=already_commission, + previous_quantity=previous_quantity, + previous_average=previous_average, + previous_commission=previous_commission, + ) + try: + claim = journal.claim_source_event( + event=event, + session_id=self.runtime.framework_projection_session_id, + expected_scope_key=scope_key, + canonical_fingerprint=canonical_framework_projection_fingerprint(canonical), + ) + except FrameworkProjectionRecoveryError as error: + self._fence_source_projection_identity("source_event_journal_failure") + raise ManagedExecutionBindingError( + "managed provider source event could not be claimed" + ) from error + if claim is None: + return None + self._source_event_pre_apply_facts[claim.receipt_id] = pre_apply + projected = self._provider_event_broker_update(event, update) + source_average = projected.pop("avg_price", None) + source_commission = projected.pop("cumulative_commission", None) + projected["managed_source_average_price"] = source_average + projected["managed_source_cumulative_commission"] = source_commission + projected["price"] = ( + pre_apply.incremental_price + if pre_apply.incremental_price is not None + else ( + self._source_binary64( + self._source_decimal(source_average), "source cumulative average" + ) + if source_average is not None + else 0.0 + ) + ) + projected["commission"] = pre_apply.incremental_commission or 0.0 + projected["commission_normalized"] = True + projected["_managed_provider_projection_receipt_id"] = claim.receipt_id + self._source_event_claims[claim.receipt_id] = claim + return projected + + def _source_projection_pre_apply_facts( + self, + *, + order: Any, + filled: Decimal, + average_raw: Any, + commission_raw: Any, + already_filled: Decimal, + already_average: Decimal, + already_commission: Decimal, + previous_quantity: Decimal, + previous_average: Optional[Decimal], + previous_commission: Optional[Decimal], + ) -> _ProviderProjectionPreApplyFacts: + """Reject nonrepresentable economic deltas before touching Broker state.""" + + executed = getattr(order, "executed", None) + bits = getattr(executed, "exbits", None) + if not isinstance(bits, (list, tuple, deque)): + raise ManagedExecutionBindingError("Broker execution checkpoint is unavailable") + try: + bit_count = len(bits) + except Exception as error: + raise ManagedExecutionBindingError( + "Broker execution checkpoint is unavailable" + ) from error + + incremental_quantity: Optional[float] = None + incremental_price: Optional[float] = None + incremental_commission: Optional[float] = None + if filled > previous_quantity: + requested_quantity = abs(self._source_decimal(getattr(order, "size", None))) + if filled > requested_quantity: + self._reject_unrepresentable_source_event( + "source_quantity_exceeds_order", + "managed provider cumulative quantity exceeds the framework order", + ) + filled_float = self._source_binary64(filled, "source cumulative quantity") + current_quantity_float = abs( + self._source_binary64(already_filled, "Broker cumulative quantity") + ) + incremental_quantity = filled_float - current_quantity_float + # BtApiBroker ignores cumulative quantity deltas at or below this + # explicit threshold. Refuse them before it can silently no-op. + if not math.isfinite(incremental_quantity) or incremental_quantity <= 1e-12: + self._reject_unrepresentable_source_event( + "source_quantity_delta_unrepresentable", + "managed provider quantity increment is below Broker resolution", + ) + source_quantity_delta = Fraction(filled) - Fraction(previous_quantity) + if not self._within_binary64_fraction_ulps( + source_quantity_delta, incremental_quantity, budget=1 + ): + self._reject_unrepresentable_source_event( + "source_quantity_precision_lost", + "managed provider quantity delta is not preserved by Broker float arithmetic", + ) + + average = self._source_decimal(average_raw) + if previous_quantity == 0: + source_incremental_price = Fraction(average) + else: + if previous_average is None: + self._reject_unrepresentable_source_event( + "source_previous_average_missing", + "previous source fill lacks its immutable cumulative average", + ) + source_incremental_price = ( + Fraction(filled) * Fraction(average) + - Fraction(previous_quantity) * Fraction(previous_average) + ) / source_quantity_delta + if source_incremental_price <= 0: + self._reject_unrepresentable_source_event( + "source_incremental_price_unrepresentable", + "managed provider incremental price is not positive", + ) + incremental_price = self._source_fraction_binary64( + source_incremental_price, "source incremental price" + ) + + if commission_raw is None: + self._reject_unrepresentable_source_event( + "source_cumulative_fee_missing", + "managed provider fill lacks cumulative fee evidence", + ) + commission = self._source_decimal(commission_raw) + prior_fee = previous_commission or Decimal("0") + source_incremental_commission = Fraction(commission) - Fraction(prior_fee) + incremental_commission = self._source_fraction_binary64( + source_incremental_commission, "source incremental commission" + ) + elif previous_quantity != filled: + self._reject_unrepresentable_source_event( + "source_quantity_checkpoint_mismatch", + "managed provider checkpoint quantity differs from the prior source event", + ) + + return _ProviderProjectionPreApplyFacts( + quantity=already_filled, + prior_source_quantity=previous_quantity, + average_price=already_average, + commission=already_commission, + execution_bit_count=bit_count, + incremental_quantity=incremental_quantity, + incremental_price=incremental_price, + incremental_commission=incremental_commission, + ) + + def _reject_unrepresentable_source_event(self, reason: str, message: str) -> None: + self._source_projection_blocked = True + self._fence_source_projection_identity(reason) + raise ManagedExecutionBindingError(message) + + @staticmethod + def _source_binary64(value: Decimal, name: str) -> float: + try: + converted = float(value) + except (OverflowError, TypeError, ValueError) as error: + raise ManagedExecutionBindingError(f"{name} is outside Broker float range") from error + if not math.isfinite(converted): + raise ManagedExecutionBindingError(f"{name} is outside Broker float range") + return converted + + @staticmethod + def _source_fraction_binary64(value: Fraction, name: str) -> float: + try: + converted = float(value) + except (OverflowError, TypeError, ValueError) as error: + raise ManagedExecutionBindingError(f"{name} is outside Broker float range") from error + if not math.isfinite(converted) or (value and converted == 0.0): + raise ManagedExecutionBindingError(f"{name} is outside Broker float resolution") + return converted + + @classmethod + def _within_binary64_ulps(cls, actual: Decimal, expected: Decimal, *, budget: int) -> bool: + """Allow at most a fixed count of adjacent binary64 representations.""" + + try: + actual_float = cls._source_binary64(actual, "Broker execution fact") + expected_float = cls._source_binary64(expected, "source execution fact") + return cls._binary64_ulps_between(actual_float, expected_float, budget=budget) + except (ManagedExecutionBindingError, OverflowError, ValueError, struct.error): + return False + + @classmethod + def _within_binary64_fraction_ulps( + cls, exact_value: Fraction, projected_float: float, *, budget: int + ) -> bool: + """Compare a source-exact rational delta with a proposed Broker float.""" + + try: + exact_float = float(exact_value) + return cls._binary64_ulps_between(exact_float, projected_float, budget=budget) + except (OverflowError, ValueError, struct.error): + return False + + @staticmethod + def _binary64_ulps_between(actual: float, expected: float, *, budget: int) -> bool: + if not math.isfinite(actual) or not math.isfinite(expected): + return False + sign_bit = 1 << 63 + uint64_mask = (1 << 64) - 1 + + def ordered_bits(value: float) -> int: + bits = struct.unpack(">Q", struct.pack(">d", value))[0] + if bits & sign_bit: + return (~bits) & uint64_mask + return bits | sign_bit + + return abs(ordered_bits(actual) - ordered_bits(expected)) <= budget + + def _validate_event_against_observation( + self, event: Any, observation: Any, intent: Any + ) -> None: + self._validate_projectable_source_event(event) + identity = self.runtime.execution_store.journal_source_identity() + generation = self._execution_journal_generation(identity) + payload = getattr(event, "payload", None) + if ( + getattr(event, "journal_incarnation_id", None) != generation + or getattr(event, "scope_key", None) != getattr(self.runtime.scope, "key", None) + or getattr(event, "intent_id", None) != getattr(intent, "intent_id", None) + or not isinstance(payload, Mapping) + ): + raise ManagedExecutionBindingError( + "immutable provider event identity does not match the execution journal" + ) + if ( + payload.get("provider_order_id") != getattr(observation, "provider_order_id", None) + or self._source_decimal(payload.get("filled_quantity")) + != self._source_decimal(getattr(observation, "filled_quantity", None)) + or self._source_optional_decimal(payload.get("average_price")) + != self._source_optional_decimal(getattr(observation, "average_price", None)) + or self._source_optional_decimal(payload.get("cumulative_commission")) + != self._source_optional_decimal(getattr(observation, "cumulative_commission", None)) + or getattr(getattr(event, "state", None), "value", None) + != getattr(getattr(observation, "state", None), "value", None) + ): + raise ManagedExecutionBindingError( + "immutable provider event differs from the normalized observation" + ) + + @staticmethod + def _execution_journal_generation(identity: Any) -> str: + """Validate the only journal lineage this projector currently accepts.""" + + generation_kind = ( + identity.get("generation_kind") + if isinstance(identity, Mapping) + else getattr(identity, "generation_kind", None) + ) + generation = ( + identity.get("generation") + if isinstance(identity, Mapping) + else getattr(identity, "generation", None) + ) + epoch = ( + identity.get("epoch") + if isinstance(identity, Mapping) + else getattr(identity, "epoch", None) + ) + if ( + generation_kind != "EXECUTION_JOURNAL" + or not isinstance(generation, str) + or len(generation) != 32 + or any(character not in "0123456789abcdef" for character in generation) + or type(epoch) is not int + or epoch != 1 + ): + raise ManagedExecutionBindingError("source journal identity is invalid") + return generation + + @staticmethod + def _validate_no_fill_lifecycle_event(event: Any) -> None: + """Accept only exact execution-store lifecycle events with no fill facts.""" + + event_type = getattr(event, "event_type", None) + state = getattr(getattr(event, "state", None), "value", getattr(event, "state", None)) + payload = getattr(event, "payload", None) + expected = { + "intent_recorded": ("PENDING_ADMISSION", {"payload_sha256"}), + "intent_admitted": ("PENDING_DISPATCH", {"permit_reference"}), + "dispatch_claimed": ("DISPATCHING", {"dispatch_attempt"}), + # The execution store only emits these states before any provider + # dispatch. Exact reason-only payloads make their no-fill meaning + # explicit; added economic fields must go through projection. + "intent_rejected": ("REJECTED", {"reason_code"}), + "intent_blocked": ("BLOCKED", {"reason_code"}), + # A dispatch guard can block only before the facade invokes its + # dispatcher port. The distinct event is terminal no-fill evidence. + "dispatch_blocked": ("BLOCKED", {"reason_code"}), + } + rule = expected.get(event_type) + if rule is None or state != rule[0] or not isinstance(payload, Mapping): + raise ManagedExecutionBindingError("source lifecycle event is not a known no-fill fact") + if set(payload) != rule[1]: + raise ManagedExecutionBindingError( + "source lifecycle event has unexpected payload facts" + ) + value = next(iter(payload.values())) + if event_type == "intent_recorded": + valid = ( + type(value) is str + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + ) + elif event_type == "intent_admitted": + valid = value is None or (type(value) is str and bool(value.strip())) + elif event_type == "dispatch_claimed": + valid = type(value) is int and value > 0 + else: + valid = ( + type(value) is str + and bool(value) + and value.isascii() + and value.replace("_", "").isalnum() + ) + if not valid: + raise ManagedExecutionBindingError("source lifecycle event payload is invalid") + + def _validate_projectable_source_event(self, event: Any) -> None: + """Validate the exact immutable event schema before using economic facts.""" + + event_type = getattr(event, "event_type", None) + state = getattr(getattr(event, "state", None), "value", getattr(event, "state", None)) + payload = getattr(event, "payload", None) + if event_type == "cancelled_by_cancel_intent": + expected_keys = { + "cancel_id", + "provider_order_id", + "source", + "filled_quantity", + "average_price", + "cumulative_commission", + } + valid_states = {"CANCELLED"} + elif event_type == "provider_commission_evidence": + expected_keys = { + "provider_order_id", + "filled_quantity", + "average_price", + "cumulative_commission", + "source", + } + valid_states = {"ACKED", "PARTIALLY_FILLED", "FILLED", "CANCELLED", "REJECTED"} + elif event_type in {"provider_observation", "reconciled_observation"}: + expected_keys = { + "provider_order_id", + "filled_quantity", + "average_price", + "cumulative_commission", + "reason_code", + "source", + } + valid_states = {"ACKED", "PARTIALLY_FILLED", "FILLED", "CANCELLED", "REJECTED"} + else: + raise ManagedExecutionBindingError("source event type is not projectable") + if ( + state not in valid_states + or not isinstance(payload, Mapping) + or set(payload) != expected_keys + ): + raise ManagedExecutionBindingError("projectable source event schema is invalid") + source = payload.get("source") + if source not in {"provider", "reconcile"}: + raise ManagedExecutionBindingError("projectable source event provenance is invalid") + provider_order_id = payload.get("provider_order_id") + if provider_order_id is not None and ( + type(provider_order_id) is not str or not provider_order_id.strip() + ): + raise ManagedExecutionBindingError("projectable source provider order id is invalid") + if event_type == "cancelled_by_cancel_intent": + cancel_id = payload.get("cancel_id") + if type(cancel_id) is not str or not cancel_id.strip() or not provider_order_id: + raise ManagedExecutionBindingError("projectable cancellation identity is invalid") + + def decimal_text(name: str, *, optional: bool) -> Optional[Decimal]: + raw = payload.get(name) + if raw is None and optional: + return None + if type(raw) is not str or not raw or len(raw) > 128: + raise ManagedExecutionBindingError("projectable source decimal is invalid") + value = self._source_decimal(raw) + if format(value, "f") != raw: + raise ManagedExecutionBindingError("projectable source decimal is noncanonical") + return value + + quantity = decimal_text("filled_quantity", optional=False) + average = decimal_text("average_price", optional=True) + commission = decimal_text("cumulative_commission", optional=True) + if quantity is None or quantity < 0: + raise ManagedExecutionBindingError("projectable source quantity is invalid") + if average is not None and average <= 0: + raise ManagedExecutionBindingError("projectable source average price is invalid") + if event_type == "provider_commission_evidence" and commission is None: + raise ManagedExecutionBindingError("commission evidence lacks cumulative fee") + if quantity > 0 and provider_order_id is None: + raise ManagedExecutionBindingError("projectable fill lacks provider order identity") + if "reason_code" in expected_keys: + reason_code = payload.get("reason_code") + if reason_code is not None and ( + type(reason_code) is not str + or not reason_code.isascii() + or not reason_code.replace("_", "").isalnum() + ): + raise ManagedExecutionBindingError("projectable source reason code is invalid") + + @staticmethod + def _event_supersedes_fee_pending(previous: Any, later: Any) -> bool: + if ( + getattr(later, "intent_id", None) != getattr(previous, "intent_id", None) + or type(getattr(later, "sequence", None)) is not int + or type(getattr(previous, "sequence", None)) is not int + or later.sequence <= previous.sequence + or getattr(later, "scope_key", None) != getattr(previous, "scope_key", None) + or getattr(later, "journal_incarnation_id", None) + != getattr(previous, "journal_incarnation_id", None) + or getattr(later, "event_type", None) + not in { + "provider_commission_evidence", + "provider_observation", + "reconciled_observation", + } + ): + return False + old_payload = getattr(previous, "payload", None) + new_payload = getattr(later, "payload", None) + if not isinstance(old_payload, Mapping) or not isinstance(new_payload, Mapping): + return False + if ( + old_payload.get("provider_order_id") != new_payload.get("provider_order_id") + or new_payload.get("average_price") is None + or new_payload.get("cumulative_commission") is None + ): + return False + try: + return Decimal(str(new_payload.get("filled_quantity"))) >= Decimal( + str(old_payload.get("filled_quantity")) + ) + except (InvalidOperation, TypeError, ValueError): + return False + + @staticmethod + def _event_can_wait_for_fee_evidence(previous: Any, candidate: Any) -> bool: + """Whether a later same-intent snapshot can be skipped after fee catch-up.""" + + if ( + getattr(candidate, "intent_id", None) != getattr(previous, "intent_id", None) + or type(getattr(candidate, "sequence", None)) is not int + or type(getattr(previous, "sequence", None)) is not int + or candidate.sequence <= previous.sequence + or getattr(candidate, "scope_key", None) != getattr(previous, "scope_key", None) + or getattr(candidate, "journal_incarnation_id", None) + != getattr(previous, "journal_incarnation_id", None) + or getattr(candidate, "event_type", None) + not in { + "provider_commission_evidence", + "provider_observation", + "reconciled_observation", + "cancelled_by_cancel_intent", + } + ): + return False + state = getattr( + getattr(candidate, "state", None), "value", getattr(candidate, "state", None) + ) + if state not in {"ACKED", "PARTIALLY_FILLED", "FILLED", "CANCELLED"}: + return False + old_payload = getattr(previous, "payload", None) + new_payload = getattr(candidate, "payload", None) + if not isinstance(old_payload, Mapping) or not isinstance(new_payload, Mapping): + return False + if ( + old_payload.get("provider_order_id") != new_payload.get("provider_order_id") + or new_payload.get("average_price") is None + ): + return False + try: + return Decimal(str(new_payload.get("filled_quantity"))) >= Decimal( + str(old_payload.get("filled_quantity")) + ) + except (InvalidOperation, TypeError, ValueError): + return False + + @staticmethod + def _source_decimal(value: Any) -> Decimal: + if isinstance(value, bool): + raise ManagedExecutionBindingError("managed provider event decimal is invalid") + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as error: + raise ManagedExecutionBindingError( + "managed provider event decimal is invalid" + ) from error + if not result.is_finite(): + raise ManagedExecutionBindingError("managed provider event decimal is non-finite") + return result + + @classmethod + def _source_optional_decimal(cls, value: Any) -> Optional[Decimal]: + return None if value is None else cls._source_decimal(value) + + @classmethod + def _assert_provider_event_execution( + cls, + order: Any, + claim: FrameworkSourceEventClaim, + pre_apply: _ProviderProjectionPreApplyFacts, + ) -> None: + """Check rounded totals, actual incremental execution and terminal state.""" + + executed = getattr(order, "executed", None) + actual_quantity = abs(cls._source_decimal(getattr(executed, "size", None))) + actual_average = cls._source_decimal(getattr(executed, "price", None)) + actual_commission = cls._source_decimal(getattr(executed, "comm", None)) + expected_average = claim.average_price or Decimal("0") + expected_commission = claim.cumulative_commission or Decimal("0") + if ( + not cls._within_binary64_ulps(actual_quantity, claim.filled_quantity, budget=1) + or not cls._within_binary64_ulps(actual_average, expected_average, budget=2) + or not cls._within_binary64_ulps(actual_commission, expected_commission, budget=1) + ): + raise ManagedExecutionBindingError( + "Broker execution did not reach immutable provider event cumulative facts" + ) + + bits = getattr(executed, "exbits", None) + if not isinstance(bits, (list, tuple, deque)): + raise ManagedExecutionBindingError("Broker execution bits are unavailable") + quantity_advanced = claim.filled_quantity > pre_apply.prior_source_quantity + expected_bit_count = pre_apply.execution_bit_count + (1 if quantity_advanced else 0) + if len(bits) != expected_bit_count: + raise ManagedExecutionBindingError( + "Broker execution did not append the claimed incremental fill" + ) + if quantity_advanced: + bit = bits[-1] + bit_quantity = abs(cls._source_decimal(getattr(bit, "size", None))) + bit_price = cls._source_decimal(getattr(bit, "price", None)) + bit_commission = cls._source_decimal(getattr(bit, "comm", None)) + assert pre_apply.incremental_quantity is not None + assert pre_apply.incremental_price is not None + assert pre_apply.incremental_commission is not None + expected_bit_quantity = Decimal(str(pre_apply.incremental_quantity)) + expected_bit_price = Decimal(str(pre_apply.incremental_price)) + expected_bit_commission = Decimal(str(pre_apply.incremental_commission)) + if ( + bit_quantity != expected_bit_quantity + or bit_price != expected_bit_price + or (pre_apply.incremental_commission != 0.0 and bit_commission == 0) + or not cls._within_binary64_ulps(bit_commission, expected_bit_commission, budget=1) + ): + raise ManagedExecutionBindingError( + "Broker incremental execution differs from the prepared source delta" + ) + + get_status_name = getattr(order, "getstatusname", None) + if not callable(get_status_name): + raise ManagedExecutionBindingError("Broker order status is unavailable") + status = str(get_status_name()).lower() + valid_statuses = { + "ACKED": {"accepted"}, + "PARTIALLY_FILLED": {"partial", "completed"}, + "FILLED": {"completed"}, + "CANCELLED": {"canceled", "completed"}, + "REJECTED": {"rejected"}, + }.get(claim.state) + if valid_statuses is None or status not in valid_statuses: + raise ManagedExecutionBindingError( + "Broker order status does not match the immutable provider event" + ) + + @staticmethod + def _provider_event_broker_update(event: Any, original: Mapping[str, Any]) -> dict[str, Any]: + raw_state = getattr(getattr(event, "state", None), "value", getattr(event, "state", None)) + status_by_state = { + "ACKED": "accepted", + "PARTIALLY_FILLED": "partial", + "FILLED": "completed", + "CANCELLED": "canceled", + "REJECTED": "rejected", + } + if raw_state not in status_by_state: + raise ManagedExecutionBindingError("immutable provider event has unsupported state") + payload = getattr(event, "payload", None) + if not isinstance(payload, Mapping): + raise ManagedExecutionBindingError("immutable provider event payload is invalid") + projected = { + key: original[key] + for key in ( + "bt_order_ref", + "data_name", + "side", + "external_order_id", + "venue_order_id", + "client_order_id", + "runtime_order_id", + "order_ref", + "managed_provider_outbox_event_id", + ) + if key in original + } + projected.update( + kind="order", + status=status_by_state[raw_state], + filled=payload.get("filled_quantity"), + avg_price=payload.get("average_price"), + cumulative_commission=payload.get("cumulative_commission"), + order_id=payload.get("provider_order_id"), + managed_source_event_id=getattr(event, "event_id", None), + managed_source_event_sequence=getattr(event, "sequence", None), + managed_source_event_type=getattr(event, "event_type", None), + ) + return projected + + def _source_event_order_fingerprint(self, claim: FrameworkSourceEventClaim) -> str: + return claim.canonical_fingerprint + + def pending_framework_projection_intent_ids(self) -> tuple[str, ...]: + """Return discovered projection checkpoints without provider activity.""" + + journal = self._framework_projection_journal + scope_key = getattr(getattr(self.runtime, "scope", None), "key", None) + if journal is None or not isinstance(scope_key, str) or not scope_key: + return () + return journal.pending_intent_ids(scope_key) + + def close(self) -> None: + """Release only the bridge-owned framework projection journal handle.""" + + journal = self._framework_projection_journal + self._framework_projection_journal = None + self._framework_projection_claims.clear() + self._source_event_claims.clear() + self._source_event_pre_apply_facts.clear() + self._source_event_last_facts.clear() + if journal is not None: + journal.close() + + def _register_framework_projection_closeable(self) -> None: + """Let a composed SDK runtime release the bridge receipt before its DBs. + + The normal SDK runtime owns the durable execution store lifecycle. It + exposes this narrow optional registration port so Backtrader can close + its own independent SQLite receipt first without creating a reverse + import from the SDK into the framework package. Historical test + doubles intentionally have no such lifecycle hook. + """ + + if self._framework_projection_journal is None: + return + register = getattr(self.runtime, "register_framework_projection_closeable", None) + if register is None: + return + if not callable(register): + raise ManagedExecutionBindingError( + "managed runtime framework projection lifecycle hook is invalid" + ) + try: + register(self) + except Exception as error: + raise ManagedExecutionBindingError( + "managed runtime could not register framework projection receipt" + ) from error + + def _create_framework_projection_journal(self) -> Optional[FrameworkProjectionJournal]: + """Open recovery durability only for a fully composed SDK runtime. + + Isolated bridge doubles intentionally predate the projection port and + retain their established replay rejection behavior. A real composed + runtime has all four fields below; failure to open its journal is an + admission failure, never a silent best-effort downgrade. + """ + + if ( + getattr(getattr(self.runtime, "contract", None), "preset", None) + == "managed_live_gateway" + ): + # The gateway client intentionally has no direct framework fill + # authority. Its server-side projection requires a separately + # reviewed transport/recovery contract and must not open or scan a + # direct local receipt journal merely because a fake exposes a + # similarly named state directory. + return None + state_directory = getattr(self.runtime, "state_directory", None) + session_id = getattr(self.runtime, "framework_projection_session_id", None) + facade = getattr(self.runtime, "facade", None) + execution_store = getattr(self.runtime, "execution_store", None) + present = (state_directory is not None, facade is not None, execution_store is not None) + if not any(present): + return None + if not all(present) or not isinstance(session_id, str) or not session_id: + raise ManagedExecutionBindingError( + "managed composed runtime lacks framework projection recovery contract" + ) + if not callable(getattr(facade, "acquire_writer_lease", None)) or not callable( + getattr(execution_store, "assert_writer_lease", None) + ): + raise ManagedExecutionBindingError( + "managed composed runtime lacks framework projection writer fence" + ) + try: + return FrameworkProjectionJournal(state_directory) + except FrameworkProjectionRecoveryError as error: + raise ManagedExecutionBindingError( + "managed framework projection journal is unavailable" + ) from error + + def _claim_framework_projection( + self, intent: Any, canonical: _CanonicalManagedOrder, record: Any + ) -> Optional[FrameworkProjectionClaim]: + journal = self._framework_projection_journal + if journal is None: + return None + state = str(getattr(getattr(record, "state", None), "value", "")) + if state not in {"PARTIALLY_FILLED", "FILLED"}: + return None + session_id = getattr(self.runtime, "framework_projection_session_id", None) + facade = getattr(self.runtime, "facade", None) + execution_store = getattr(self.runtime, "execution_store", None) + try: + writer_lease = facade.acquire_writer_lease() + execution_store.assert_writer_lease(self.runtime.scope, writer_lease) + if getattr(intent, "fingerprint", None) != getattr(record, "payload_sha256", None): + raise ManagedExecutionBindingError( + "managed framework projection record does not match admitted intent" + ) + claim = journal.claim( + record=record, + canonical_fingerprint=canonical_framework_projection_fingerprint(canonical), + session_id=session_id, + lease_fencing_token=getattr(writer_lease, "fencing_token", None), + ) + except ManagedExecutionBindingError: + raise + except Exception as error: + # A fill without a durable framework recovery claim must not reach + # Broker accounting. It remains in the SDK journal for a later + # reviewed recovery session instead of becoming an untracked local + # position/commission mutation. + raise ManagedExecutionBindingError( + "managed framework projection recovery claim is unavailable" + ) from error + self._framework_projection_claims[claim.receipt_id] = claim + return claim + + def _framework_projection_claim(self, response: Any) -> Optional[FrameworkProjectionClaim]: + if not isinstance(response, Mapping): + return None + receipt_id = response.get("managed_framework_projection_receipt_id") + if not isinstance(receipt_id, str) or not receipt_id: + return None + return self._framework_projection_claims.get(receipt_id) + + @staticmethod + def _assert_framework_projection_execution(order: Any, claim: FrameworkProjectionClaim) -> None: + """Check the ordinary Broker path booked exactly the durable checkpoint.""" + + executed = getattr(order, "executed", None) + try: + executed_size = abs(_finite_decimal(getattr(executed, "size", None), "executed size")) + executed_price = _positive_decimal(getattr(executed, "price", None), "executed price") + executed_commission = _finite_decimal( + getattr(executed, "comm", None), "executed commission" + ) + except ManagedExecutionBindingError as error: + raise ManagedExecutionBindingError( + "managed framework projection did not produce complete Broker execution facts" + ) from error + if ( + executed_size != claim.filled_quantity + or executed_price != claim.average_price + or executed_commission != claim.cumulative_commission + ): + raise ManagedExecutionBindingError( + "managed framework projection differs from durable fill evidence" + ) + + def cancel_order( + self, + order: Any, + dataname: Optional[str], + legacy_dispatch: Callable[[Any, Optional[str]], Any], + ) -> Mapping[str, Any]: + """Durably cancel one previously confirmed managed provider order. + + ``order`` must carry the original managed intent metadata. A bare + reference cannot enter this route because it cannot prove the target's + strategy scope or durable provider identity. The Store receives no + exception-to-legacy fallback path from this method. + """ + + # Gateway submission deliberately discards the Store's legacy callback. + # Cancellation has no corresponding sealed gateway command yet, so + # passing this callback into the cancellation facade would silently + # restore a direct provider route. Keep that route unavailable until + # an explicit typed gateway cancellation port is reviewed and bound. + if ( + getattr(getattr(self.runtime, "contract", None), "preset", None) + == "managed_live_gateway" + ): + raise ManagedExecutionBindingError( + "MANAGED_GATEWAY_CANCEL_NOT_IMPLEMENTED: " + "a sealed gateway cancellation dispatcher is required" + ) + self._ensure_interrupted_cancellation_recovery() + if not callable(legacy_dispatch): + raise ManagedExecutionBindingError("Store legacy cancellation port is unavailable") + intent = self.cancel_intent_from_order(order, self.runtime) + if getattr(intent, "scope", None) != self.runtime.scope: + raise ManagedExecutionBindingError( + "managed cancellation scope does not match runtime scope" + ) + provider_response: list[Any] = [] + + def dispatch(admitted_intent: Any) -> Any: + response = legacy_dispatch(admitted_intent.provider_order_id, dataname) + provider_response.append(response) + return self.cancel_observation_from_response(self.runtime, admitted_intent, response) + + record = self._get_cancellation_facade().cancel(intent, dispatch) + if str(getattr(getattr(record, "state", None), "value", "")) == "UNKNOWN": + self._freeze_unknown_cancellation(record) + response = provider_response[0] if provider_response else None + return self.cancel_response_from_record(record, response) + + def reconcile_cancel(self, observation: Any) -> Any: + """Apply typed cancellation evidence without issuing a provider request.""" + + self._ensure_interrupted_cancellation_recovery() + record = self._get_cancellation_facade().reconcile(observation) + if str(getattr(getattr(record, "state", None), "value", "")) == "UNKNOWN": + self._freeze_unknown_cancellation(record) + return record + + def resolve_confirmed_cancel_freeze(self, cancel_id: str) -> None: + """Fail closed until a separately reviewed cancellation control port exists. + + A terminal cancellation observation cannot by itself establish account + identity, monitor delivery, reconciliation completeness, and operator + audit authorization. Keeping this compatibility-shaped method as a + deterministic rejection prevents a local caller from clearing an + unknown-cancel safety latch through a convenience API. + """ + + raise ManagedExecutionBindingError( + "CONTROLLED_CANCEL_FREEZE_RELEASE_REQUIRED: " + "cancellation outcome freezes require an audited control port" + ) + + def create_cancellation_reconciliation_control( + self, + *, + authorize: Callable[[Any], Any], + clock: Optional[Callable[[], float]] = None, + ) -> Any: + """Create the explicit audited control port for one unknown cancellation. + + This opt-in composition hook intentionally has no convenience release + method. The returned SDK control port requires terminal typed + cancellation evidence, a durable monitor-outbox fact, an + identity-bound authorization decision, and a local audit command before + it can clear one ``cancel-outcome-unknown`` freeze. Gateway cancellation + remains unsupported and cannot obtain this direct Store control path. + """ + + if not callable(authorize): + raise ManagedExecutionBindingError("managed cancellation authorizer must be callable") + self._ensure_interrupted_cancellation_recovery() + if not self._supports_cancellation_reconciliation_control(): + raise ManagedExecutionBindingError( + "MANAGED_CANCELLATION_CONTROL_ROUTE_UNSUPPORTED: " + "only managed_live_direct or the exact offline fake replay cancellation route " + "is implemented" + ) + state_directory = getattr(self.runtime, "state_directory", None) + if state_directory is None: + raise ManagedExecutionBindingError( + "managed runtime lacks a cancellation control state directory" + ) + try: + runtime_plugins = importlib.import_module("bt_api_py.runtime_plugins") + factory = getattr(runtime_plugins, "ManagedCancellationReconciliationControlPort") + except Exception as error: + raise ManagedExecutionBindingError( + "managed cancellation reconciliation control is unavailable" + ) from error + if not callable(factory): + raise ManagedExecutionBindingError("managed cancellation control factory is invalid") + try: + return factory( + self.runtime, + self._get_cancellation_facade(), + state_directory=state_directory, + authorize=authorize, + clock=clock, + ) + except Exception as error: + raise ManagedExecutionBindingError( + "managed cancellation reconciliation control could not be composed" + ) from error + + def _supports_cancellation_reconciliation_control(self) -> bool: + """Recognize direct control and one exact registered offline fake identity. + + The offline replay exception is tied to the same scope tuple accepted + by the SDK fake journal authority. A production-shaped scope cannot + borrow that local fake recovery contract. + """ + + contract = getattr(self.runtime, "contract", None) + if getattr(contract, "preset", None) == "managed_live_direct": + return True + scope = getattr(self.runtime, "scope", None) + risk_scope = getattr(self.runtime, "risk_scope", None) + fake_authority = getattr(self.runtime, "fake_dispatch_authority", None) + return ( + getattr(contract, "strategy_id", None) == "example.013_3.sa_midfreq_simnow" + and getattr(contract, "mode", None) == "simulation" + and getattr(contract, "preset", None) == "replay" + and getattr(contract, "environment", None) == "offline" + and getattr(contract, "order_route", None) == "managed_execution" + and getattr(scope, "provider", None) + == "iteration41_managed_replay_fake_provider" + and getattr(scope, "environment", None) == "offline" + and getattr(scope, "account_ref", None) + == "iteration41_managed_replay_fake_account" + and getattr(scope, "strategy_id", None) == getattr(contract, "strategy_id", None) + and getattr(risk_scope, "provider", None) == "fake" + and getattr(risk_scope, "environment", None) == "offline" + and fake_authority is not None + ) + + def _get_cancellation_facade(self) -> Any: + """Build one SDK cancellation facade only after the managed route is bound.""" + + if self._cancellation_facade is not None: + return self._cancellation_facade + execution = getattr(self.runtime, "execution", None) + execution_store = getattr(self.runtime, "execution_store", None) + order_facade = getattr(self.runtime, "facade", None) + acquire_writer_lease = getattr(order_facade, "acquire_writer_lease", None) + factory = getattr(execution, "ManagedCancellationFacade", None) + if execution_store is None or not callable(acquire_writer_lease) or not callable(factory): + raise ManagedExecutionBindingError("managed runtime lacks cancellation durability") + risk_gate = getattr(self.runtime, "risk_gate", None) + risk_scope = getattr(self.runtime, "risk_scope", None) + if risk_gate is None or risk_scope is None: + raise ManagedExecutionBindingError("managed runtime lacks cancellation risk admission") + try: + risk = importlib.import_module("bt_api_risk") + except Exception as error: + raise ManagedExecutionBindingError( + "managed cancellation risk package is unavailable" + ) from error + cancellation_risk_intent_id = self._cancellation_admission_risk_intent_id + + class _CancelRiskAdmission: + def __init__(self, gate: Any, scope: Any) -> None: + self._gate = gate + self._scope = scope + + def _risk_intent(self, intent: Any) -> Any: + return risk.RiskIntent( + # Risk reservations are account-wide, while execution + # cancellation ids are unique only inside an execution + # scope. Keep the external cancellation id intact, but + # qualify this local reservation identity so sibling + # strategies cannot collide in one account journal. + intent_id=cancellation_risk_intent_id(intent), + scope=self._scope, + action=risk.IntentAction.CANCEL, + notional=Decimal("0"), + payload_fingerprint=intent.fingerprint, + ) + + def reserve(self, intent: Any) -> Any: + return self._gate.reserve(self._risk_intent(intent)) + + def validate(self, permit_reference: str, intent: Any) -> Any: + return self._gate.validate_permit(permit_reference, self._risk_intent(intent)) + + def claim_before_dispatch(self, permit_reference: str, intent: Any) -> Any: + """Return proof that this exact cancel risk claim is durable.""" + risk_intent = self._risk_intent(intent) + permit = self._gate.claim_for_dispatch(permit_reference, risk_intent) + binding = self._gate.dispatch_claim_binding(permit_reference) + binding_scope = getattr(binding, "scope", None) + permit_scope = getattr(permit, "scope", None) + if ( + type(permit) is not risk.RiskPermit + or permit.permit_id != permit_reference + or permit.intent_id != risk_intent.intent_id + or getattr(permit_scope, "key", None) != self._scope.key + or permit.action is not risk.IntentAction.CANCEL + or permit.notional != Decimal("0") + or type(binding) is not risk.DispatchClaimBinding + or binding.permit_id != permit_reference + or getattr(binding_scope, "key", None) != self._scope.key + or binding.intent_id != risk_intent.intent_id + or binding.intent_hash != risk_intent.fingerprint + or binding.cause_id != "dispatch-inflight:" + risk_intent.intent_id + ): + raise ManagedExecutionBindingError( + "risk gate did not confirm the exact cancellation dispatch claim" + ) + receipt_type = getattr(execution, "CancelDispatchClaimReceiptV1", None) + if not isinstance(receipt_type, type): + raise ManagedExecutionBindingError( + "execution package lacks the cancellation claim receipt contract" + ) + return receipt_type( + permit_reference=permit_reference, + scope_key=intent.scope.key, + cancel_id=intent.cancel_id, + intent_fingerprint=intent.fingerprint, + ) + + def settle(self, permit_reference: str) -> Any: + return self._gate.settle(permit_reference) + + def release(self, permit_reference: str, reason: str) -> None: + self._gate.release(permit_reference, reason) + + self._cancellation_facade = factory( + execution_store, + self.runtime.scope, + acquire_writer_lease=acquire_writer_lease, + admission_gate=_CancelRiskAdmission(risk_gate, risk_scope), + ) + return self._cancellation_facade + + def _supports_cancellation_recovery_contract(self) -> bool: + """Limit cancellation recovery to direct and exact offline-fake runtimes.""" + + contract = getattr(self.runtime, "contract", None) + if getattr(contract, "preset", None) == "managed_live_direct": + return True + risk_scope = getattr(self.runtime, "risk_scope", None) + return ( + getattr(contract, "mode", None) == "simulation" + and getattr(contract, "preset", None) == "replay" + and getattr(contract, "environment", None) == "offline" + and getattr(contract, "order_route", None) == "managed_execution" + and getattr(risk_scope, "provider", None) == "fake" + and getattr(risk_scope, "environment", None) == "offline" + ) + + def _recover_unknown_cancellation_freezes(self) -> None: + """Rebuild account freezes from all unresolved durable cancellations. + + An UNKNOWN cancellation is committed in the execution SQLite journal + before this bridge can persist its independent risk SQLite latch. That + is not a cross-store transaction. Under the account writer lease, scan + every strategy scope for the account and reassert a freeze for each + UNKNOWN or interrupted DISPATCHING cancellation before accepting a + mutation. The scan is local only and never retries a provider request. + """ + + if not self._supports_cancellation_recovery_contract(): + return + # Minimal test doubles can exercise order projection without the SDK + # durability surface. Real composed runtimes provide a state directory. + if getattr(self.runtime, "state_directory", None) is None: + return + if self._interrupted_cancellation_recovery_pending: + # Retry both current-scope and account-wide recovery together once + # the existing writer lease becomes available. + return + execution_store = getattr(self.runtime, "execution_store", None) + scope = getattr(self.runtime, "scope", None) + facade = getattr(self.runtime, "facade", None) + risk_gate = getattr(self.runtime, "risk_gate", None) + risk_scope = getattr(self.runtime, "risk_scope", None) + list_unresolved = getattr( + execution_store, "list_unresolved_cancellations_for_account", None + ) + acquire_lease = getattr(facade, "acquire_writer_lease", None) + assert_lease = getattr(execution_store, "assert_writer_lease", None) + recover_interrupted = getattr(execution_store, "recover_interrupted_cancellations", None) + get_cancel = getattr(execution_store, "get_cancel", None) + freeze = getattr(risk_gate, "freeze", None) + if ( + scope is None + or risk_scope is None + or not callable(list_unresolved) + or not callable(acquire_lease) + or not callable(assert_lease) + or not callable(recover_interrupted) + or not callable(get_cancel) + or not callable(freeze) + ): + raise ManagedExecutionBindingError( + "managed runtime lacks account cancellation recovery controls" + ) + try: + writer_lease = acquire_lease() + assert_lease(scope, writer_lease) + scoped_records = list_unresolved(scope, writer_lease=writer_lease) + except Exception as error: + raise ManagedExecutionBindingError( + "managed account cancellation recovery journal could not be read under its lease" + ) from error + + if not isinstance(scoped_records, tuple): + raise ManagedExecutionBindingError( + "managed account cancellation recovery returned invalid evidence" + ) + for item in scoped_records: + if not isinstance(item, tuple) or len(item) != 2: + raise ManagedExecutionBindingError( + "managed account cancellation recovery returned an invalid scope record" + ) + source_scope, record = item + source_scope_key = getattr(source_scope, "key", None) + source_account_key = getattr(source_scope, "account_key", None) + cancel_id = getattr(record, "cancel_id", None) + state = getattr(getattr(record, "state", None), "value", None) + if ( + not isinstance(source_scope_key, str) + or not source_scope_key + or source_account_key != getattr(scope, "account_key", None) + or getattr(record, "scope_key", None) != source_scope_key + or state not in {"DISPATCHING", "UNKNOWN"} + or not isinstance(cancel_id, str) + or not cancel_id + ): + raise ManagedExecutionBindingError( + "managed account cancellation recovery returned mismatched evidence" + ) + + if state == "DISPATCHING": + try: + recover_interrupted(source_scope, writer_lease=writer_lease) + record = get_cancel(cancel_id, scope=source_scope) + except Exception as error: + raise ManagedExecutionBindingError( + "managed account cancellation dispatch recovery failed" + ) from error + if ( + getattr(record, "scope_key", None) != source_scope_key + or getattr(getattr(record, "state", None), "value", None) != "UNKNOWN" + or getattr(record, "cancel_id", None) != cancel_id + ): + raise ManagedExecutionBindingError( + "managed account cancellation dispatch recovery was not confirmed" + ) + + cause_id = "cancel-outcome-unknown:" + source_scope_key + ":" + cancel_id + try: + freeze(risk_scope, cause_id, cause_id) + if cause_id not in risk_gate.active_freeze_reasons(risk_scope): + raise ManagedExecutionBindingError( + "managed account cancellation freeze is not active" + ) + except ManagedExecutionBindingError: + raise + except Exception as error: + raise ManagedExecutionBindingError( + "managed account cancellation UNKNOWN freeze could not be persisted" + ) from error + + def _recover_interrupted_cancellation_dispatches( + self, *, defer_if_writer_unavailable: bool = False + ) -> None: + """Fence old DISPATCHING rows before a supported adapter accepts cancels. + + Recovery runs only for a fully composed direct runtime or the exact + offline fake replay contract. The SDK facade acquires the account + writer lease and changes current-scope interrupted provider dispatches + to UNKNOWN without retrying provider I/O. Runtime fakes without durable + state keep their established projection-only path. + """ + + if not self._supports_cancellation_recovery_contract(): + return + if getattr(self.runtime, "state_directory", None) is None: + return + + scope = getattr(self.runtime, "scope", None) + if scope is None: + raise ManagedExecutionBindingError( + "managed runtime lacks interrupted cancellation scope" + ) + facade = self._get_cancellation_facade() + if getattr(facade, "scope", None) != scope: + raise ManagedExecutionBindingError( + "managed cancellation recovery facade has a foreign execution scope" + ) + recover = getattr(facade, "recover_interrupted_dispatches", None) + if not callable(recover): + raise ManagedExecutionBindingError( + "managed runtime lacks interrupted cancellation recovery" + ) + try: + records = recover() + except Exception as error: + if ( + defer_if_writer_unavailable + and getattr(error, "code", None) == "writer_lease_unavailable" + ): + # A different account writer may still own the lease. Keep + # this bridge constructible for read-only recovery inspection, + # but block every managed mutation until a later retry fences + # interrupted dispatches under the current writer lease. + self._interrupted_cancellation_recovery_pending = True + return + raise ManagedExecutionBindingError( + "managed interrupted cancellation recovery failed" + ) from error + if not isinstance(records, tuple): + raise ManagedExecutionBindingError( + "managed interrupted cancellation recovery returned an invalid result" + ) + for record in records: + state = getattr(getattr(record, "state", None), "value", None) + cancel_id = getattr(record, "cancel_id", None) + if ( + getattr(record, "scope_key", None) != getattr(scope, "key", None) + or state != "UNKNOWN" + or not isinstance(cancel_id, str) + or not cancel_id + ): + raise ManagedExecutionBindingError( + "managed interrupted cancellation recovery returned foreign evidence" + ) + self._interrupted_cancellation_recovery_pending = False + + def _ensure_interrupted_cancellation_recovery(self) -> None: + """Retry deferred startup recovery before any managed state mutation.""" + + if not self._interrupted_cancellation_recovery_pending: + return + self._recover_interrupted_cancellation_dispatches() + self._recover_unknown_cancellation_freezes() + + def _freeze_unknown_cancellation(self, record: Any) -> None: + """Persist an account freeze when a cancellation outcome is uncertain.""" + + cancel_id = getattr(record, "cancel_id", None) + if not isinstance(cancel_id, str) or not cancel_id: + raise ManagedExecutionBindingError("unknown cancellation record lacks cancel identity") + risk_gate = getattr(self.runtime, "risk_gate", None) + risk_scope = getattr(self.runtime, "risk_scope", None) + freeze = getattr(risk_gate, "freeze", None) + if risk_scope is None or not callable(freeze): + raise ManagedExecutionBindingError("managed runtime lacks cancellation freeze controls") + cause_id = self._cancel_unknown_freeze_cause(cancel_id) + try: + freeze(risk_scope, cause_id, cause_id) + except Exception as error: + raise ManagedExecutionBindingError( + "managed cancellation unknown freeze could not be persisted" + ) from error + + def _cancellation_admission_risk_intent_id(self, intent: Any) -> str: + """Derive the local account-reservation id without changing cancel identity.""" + + scope_key = getattr(getattr(intent, "scope", None), "key", None) + cancel_id = getattr(intent, "cancel_id", None) + if not isinstance(scope_key, str) or not scope_key: + raise ManagedExecutionBindingError( + "managed cancellation admission lacks execution scope" + ) + if not isinstance(cancel_id, str) or not cancel_id: + raise ManagedExecutionBindingError( + "managed cancellation admission lacks cancellation identity" + ) + return "cancel-admission:" + scope_key + ":" + cancel_id + + def _cancel_unknown_freeze_cause(self, cancel_id: str) -> str: + scope_key = getattr(getattr(self.runtime, "scope", None), "key", None) + if not isinstance(scope_key, str) or not scope_key: + raise ManagedExecutionBindingError("managed cancellation freeze lacks execution scope") + if not isinstance(cancel_id, str) or not cancel_id: + raise ManagedExecutionBindingError( + "managed cancellation freeze lacks cancellation identity" + ) + return "cancel-outcome-unknown:" + scope_key + ":" + cancel_id + + +def bind_managed_execution( + store: Any, effective: EffectiveRuntimeConfig, runtime: Any +) -> ManagedExecutionBridge: + """Attach a bridge only for a trusted resolved managed runtime contract.""" + + if getattr(effective, "profile", None) is not None or getattr( + getattr(effective, "registration", None), "profiles", () + ): + raise ManagedExecutionBindingError( + "profile_dispatch_unavailable: profile-scoped managed execution dispatch is not enabled" + ) + if effective.order_route != "managed_execution" or not effective.required_capabilities: + raise ManagedExecutionBindingError( + "effective configuration is not a managed execution route" + ) + # A scope comparison alone is insufficient: a process could compose a + # different managed preset, then attach its direct Store port to this + # configuration. The SDK composition root retains the exact sealed + # capability contract. Compare its complete security-relevant shape here + # without importing the optional SDK package into normal Backtrader loads. + # + contract = getattr(runtime, "contract", None) + if contract is None: + raise ManagedExecutionBindingError("managed runtime has no sealed capability contract") + expected_contract = { + "strategy_id": effective.strategy_id, + "mode": effective.mode, + "preset": effective.preset, + "environment": getattr(getattr(effective, "policy", None), "environment", None), + "order_route": effective.order_route, + "effective_digest": effective.effective_digest, + } + for name, expected_value in expected_contract.items(): + if expected_value is None or getattr(contract, name, None) != expected_value: + raise ManagedExecutionBindingError( + "managed runtime contract does not match effective configuration: " + name + ) + if getattr(store, "_sdk_mode", False): + store_execution_config = getattr(store, "_sdk_execution_config", None) + store_strategy_id = ( + store_execution_config.get("strategy_id") + if isinstance(store_execution_config, Mapping) + else None + ) + if store_strategy_id != effective.strategy_id: + raise ManagedExecutionBindingError( + "managed runtime strategy scope does not match SDK Store execution config" + ) + expected_capabilities = tuple(effective.required_capabilities) + runtime_capabilities = getattr(contract, "required_capabilities", None) + if not isinstance(runtime_capabilities, tuple) or runtime_capabilities != expected_capabilities: + raise ManagedExecutionBindingError( + "managed runtime contract does not match effective configuration: required_capabilities" + ) + scope = getattr(runtime, "scope", None) + if scope is None: + raise ManagedExecutionBindingError("managed runtime has no execution scope") + if getattr(scope, "strategy_id", None) != effective.strategy_id: + raise ManagedExecutionBindingError( + "managed runtime strategy scope does not match configuration" + ) + expected_environment = getattr(getattr(effective, "policy", None), "environment", None) + if expected_environment is None or getattr(scope, "environment", None) != expected_environment: + raise ManagedExecutionBindingError( + "managed runtime environment scope does not match configuration" + ) + if getattr(effective, "preset", None) == "managed_live_gateway": + # The gateway client implements the same small ``submit`` port as the + # direct managed runtime, but it must prove that the Store callback is + # intentionally discarded and that dispatch is delegated to the typed + # gateway transport. Merely supplying a gateway-shaped contract would + # otherwise re-open the legacy direct provider path below. + if getattr(runtime, "gateway_dispatch", None) != "zmq_gateway_v1": + raise ManagedExecutionBindingError( + "managed gateway runtime lacks the sealed ZMQ dispatch adapter" + ) + + is_ctp_store = getattr(store, "_is_ctp_session_provider", None) + try: + ctp_store = callable(is_ctp_store) and is_ctp_store() is True + except Exception as error: + raise ManagedExecutionBindingError( + "managed Store CTP route could not be validated" + ) from error + if ctp_store: + if str(getattr(scope, "provider", "")).strip().upper() != "CTP": + raise ManagedExecutionBindingError( + "managed runtime provider scope does not match the CTP Store" + ) + # Do not hand a CTP Store the generic synchronous legacy callback. + # The SDK path is asynchronous and this repository has no + # write-authorizing CTP admission with a compatible observation + # handoff yet. The typed adapter refuses before calling its SDK port; + # replacing it requires a reviewed implementation of both contracts. + bridge = CtpManagedExecutionAdapterPlaceholder(runtime=runtime) + else: + bridge = ManagedExecutionBridge(runtime=runtime) + attach = getattr(store, "attach_managed_execution_adapter", None) + if not callable(attach): + raise ManagedExecutionBindingError("Store cannot attach a managed execution adapter") + attach(bridge) + return bridge diff --git a/backtrader_runtime/policy.py b/backtrader_runtime/policy.py new file mode 100644 index 00000000..29cfc8cc --- /dev/null +++ b/backtrader_runtime/policy.py @@ -0,0 +1,170 @@ +"""Reviewed mode/preset policy definitions for the Iteration 41 runtime. + +This module contains data only. In particular it must never import an SDK, +provider, gateway, execution package, risk package, or monitoring package. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Optional, Tuple + + +CAPABILITY_EXECUTION = "execution" +CAPABILITY_RISK = "risk" +CAPABILITY_MONITOR = "monitor" +CAPABILITY_GATEWAY = "gateway" +CAPABILITY_TRANSPORT_ZMQ = "transport_zmq" + +MANAGED_WRITE_CAPABILITIES = ( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, +) + + +@dataclass(frozen=True) +class PresetPolicy: + """A sealed policy selected by a schema-v4 mode/preset pair.""" + + name: str + mode: str + environment: str + order_route: Optional[str] + account_access: Optional[str] + allows_network: bool + allows_external_writes: bool + allows_production_writes: bool + allows_hypothetical_fills: bool + required_capabilities: Tuple[str, ...] + accepts_provider_secrets: bool + requires_approval: bool + requires_live_confirmation: bool + + +# The public schema has exactly these seven preset names. A runtime registry +# must still explicitly bind a preset to each registered strategy directory; +# possessing a valid name is not authorization to use it. +_PRESET_POLICIES = { + "local_backtest": PresetPolicy( + name="local_backtest", + mode="backtest", + environment="local", + order_route=None, + account_access=None, + allows_network=False, + allows_external_writes=False, + allows_production_writes=False, + allows_hypothetical_fills=False, + required_capabilities=(), + accepts_provider_secrets=False, + requires_approval=False, + requires_live_confirmation=False, + ), + "replay": PresetPolicy( + name="replay", + mode="simulation", + environment="offline", + order_route=None, + account_access=None, + allows_network=False, + allows_external_writes=False, + allows_production_writes=False, + allows_hypothetical_fills=False, + required_capabilities=(), + accepts_provider_secrets=False, + requires_approval=False, + requires_live_confirmation=False, + ), + "shadow": PresetPolicy( + name="shadow", + mode="simulation", + environment="public_read", + order_route="read_only", + account_access="public_read", + allows_network=True, + allows_external_writes=False, + allows_production_writes=False, + allows_hypothetical_fills=False, + required_capabilities=(), + accepts_provider_secrets=False, + requires_approval=False, + requires_live_confirmation=False, + ), + "paper": PresetPolicy( + name="paper", + mode="simulation", + environment="public_read", + order_route="local_simulation", + account_access="public_read", + allows_network=True, + allows_external_writes=False, + allows_production_writes=False, + allows_hypothetical_fills=True, + required_capabilities=(), + accepts_provider_secrets=False, + requires_approval=False, + requires_live_confirmation=False, + ), + "sandbox": PresetPolicy( + name="sandbox", + mode="simulation", + environment="sandbox", + order_route=None, + account_access="sandbox_private_read", + allows_network=True, + allows_external_writes=False, + allows_production_writes=False, + allows_hypothetical_fills=False, + required_capabilities=(), + accepts_provider_secrets=True, + requires_approval=False, + requires_live_confirmation=False, + ), + "managed_live_direct": PresetPolicy( + name="managed_live_direct", + mode="live", + environment="production", + order_route="managed_execution", + account_access="direct_provider", + allows_network=True, + allows_external_writes=True, + allows_production_writes=True, + allows_hypothetical_fills=False, + required_capabilities=MANAGED_WRITE_CAPABILITIES, + accepts_provider_secrets=True, + requires_approval=True, + requires_live_confirmation=True, + ), + "managed_live_gateway": PresetPolicy( + name="managed_live_gateway", + mode="live", + environment="production", + order_route="managed_execution", + account_access="gateway", + allows_network=True, + allows_external_writes=True, + allows_production_writes=True, + allows_hypothetical_fills=False, + required_capabilities=MANAGED_WRITE_CAPABILITIES + + (CAPABILITY_GATEWAY, CAPABILITY_TRANSPORT_ZMQ), + accepts_provider_secrets=True, + requires_approval=True, + requires_live_confirmation=True, + ), +} + +PRESET_REGISTRY = MappingProxyType(_PRESET_POLICIES) + + +def get_preset_policy(name: str) -> Optional[PresetPolicy]: + """Return the sealed policy for *name*, or ``None`` when it is unknown.""" + + return PRESET_REGISTRY.get(name) + + +def preset_names() -> Tuple[str, ...]: + """Return the stable, sorted public preset names.""" + + return tuple(sorted(PRESET_REGISTRY)) diff --git a/backtrader_runtime/provider_deployment.py b/backtrader_runtime/provider_deployment.py new file mode 100644 index 00000000..95ec880a --- /dev/null +++ b/backtrader_runtime/provider_deployment.py @@ -0,0 +1,816 @@ +"""Offline receipt binding for future provider deployment admission. + +This module deliberately owns only a narrow, pure validation boundary. It +does not resolve ``secrets_ref``, import a provider or SDK, start a preflight, +or dispatch a runner. A matching receipt is still not deployment, execution, +or provider authorization. + +There is intentionally no built-in trust implementation. A deployment owner +must inject a verifier with its actual offline trust material. The default +verifier rejects every receipt, so this module cannot turn a record into an +admission grant on its own. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import math +import re +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Optional, Protocol, Sequence, Tuple, Union + + +PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION = "bt-provider-deployment-receipt/v2" +ACTIVE_RECEIPT_STATUS = "active" +REVOKED_RECEIPT_STATUS = "revoked" +RECEIPT_BINDING_VALIDATED = "RECEIPT_BINDING_VALIDATED" +MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES = 64 * 1024 +MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULES = 16 +MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULE_NAME_LENGTH = 128 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") +_STATUS_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_OS_SECRET_REF_RE = re.compile(r"^os_secret_store:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_CAPABILITY_MODULE_RE = re.compile(r"^bt_api(?:_[A-Za-z0-9]+)+$") +_RECEIPT_FIELDS = frozenset( + ( + "account_fingerprint_sha256", + "approval_receipt_digest", + "artifact_sha256", + "capability_receipt_digest", + "created_at", + "effective_config_digest", + "environment", + "expires_at", + "provider", + "receipt_id", + "registration_id", + "required_capability_modules", + "revoked_at", + "runtime_id", + "schema_version", + "secrets_ref", + "status", + "strategy_id", + ) +) + + +SerializedProviderDeploymentReceipt = Union[str, bytes, bytearray] + + +class ProviderDeploymentReceiptError(ValueError): + """A deterministic, redacted rejection of an untrusted deployment receipt.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise ProviderDeploymentReceiptError(reason, message) + + +def _identifier(value: Any, field_name: str) -> str: + if ( + type(value) is not str + or len(value) > 128 + or value != value.strip() + or not _IDENTIFIER_RE.fullmatch(value) + ): + _reject("invalid_identifier", "invalid {0}".format(field_name)) + return value + + +def _provider(value: Any, field_name: str) -> str: + if ( + type(value) is not str + or len(value) > 64 + or value != value.strip() + or not _PROVIDER_RE.fullmatch(value) + ): + _reject("invalid_provider", "invalid {0}".format(field_name)) + return value + + +def _status(value: Any) -> str: + if ( + type(value) is not str + or len(value) > 64 + or value != value.strip() + or not _STATUS_RE.fullmatch(value) + ): + _reject("invalid_status", "invalid receipt status") + return value + + +def _sha256(value: Any, field_name: str) -> str: + if type(value) is not str or len(value) != 64 or not _SHA256_RE.fullmatch(value): + _reject("invalid_digest", "invalid {0}".format(field_name)) + return value + + +def _opaque_secret_ref(value: Any, field_name: str) -> str: + if type(value) is not str or len(value) > 144 or not _OS_SECRET_REF_RE.fullmatch(value): + _reject("invalid_secrets_ref", "invalid {0}".format(field_name)) + return value + + +def _timestamp(value: Any, field_name: str) -> float: + if type(value) not in (int, float): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + try: + normalized = float(value) + except (OverflowError, TypeError, ValueError): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + if not math.isfinite(normalized): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + return 0.0 if normalized == 0.0 else normalized + + +def _capability_modules(value: Any, field_name: str) -> Tuple[str, ...]: + if type(value) not in (list, tuple) or not value: + _reject("invalid_capability_modules", "invalid {0}".format(field_name)) + if len(value) > MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULES: + _reject("receipt_too_large", "receipt contains too many required capability modules") + modules = tuple(value) + if any( + type(module) is not str + or len(module) > MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULE_NAME_LENGTH + or not _CAPABILITY_MODULE_RE.fullmatch(module) + for module in modules + ): + if any( + type(module) is str + and len(module) > MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULE_NAME_LENGTH + for module in modules + ): + _reject("receipt_too_large", "required capability module name is too large") + _reject("invalid_capability_modules", "invalid required capability module") + if len(set(modules)) != len(modules): + _reject("invalid_capability_modules", "duplicate required capability module") + return tuple(sorted(modules)) + + +def _registration_value(value: Any, validator, field_name: str) -> Any: + """Convert a receipt-style validation failure into a code-registration error.""" + + try: + return validator(value, field_name) + except ProviderDeploymentReceiptError as error: + raise ValueError(str(error)) from None + + +@dataclass(frozen=True) +class ProviderDeploymentRegistration: + """A code-owned, exact deployment scope for one provider environment. + + This is intentionally not parsed from ``config.yaml``. Its opaque secret + references are names only; this object never locates or resolves them. + """ + + registration_id: str + runtime_id: str + strategy_id: str + provider: str + environment: str + allowed_secrets_refs: Tuple[str, ...] = field(repr=False) + account_fingerprint_sha256: str + approval_receipt_digest: str + artifact_sha256: str + effective_config_digest: str + capability_receipt_digest: str + required_capability_modules: Tuple[str, ...] + + def __post_init__(self) -> None: + object.__setattr__( + self, + "registration_id", + _registration_value(self.registration_id, _identifier, "registration_id"), + ) + object.__setattr__( + self, "runtime_id", _registration_value(self.runtime_id, _identifier, "runtime_id") + ) + object.__setattr__( + self, "strategy_id", _registration_value(self.strategy_id, _identifier, "strategy_id") + ) + object.__setattr__( + self, "provider", _registration_value(self.provider, _provider, "provider") + ) + object.__setattr__( + self, "environment", _registration_value(self.environment, _provider, "environment") + ) + + if type(self.allowed_secrets_refs) not in (list, tuple): + raise ValueError("allowed_secrets_refs must be a non-empty sequence") + refs = tuple(self.allowed_secrets_refs) + if not refs: + raise ValueError("registered deployment must allow an opaque secret reference") + try: + normalized_refs = tuple( + _opaque_secret_ref(reference, "allowed_secrets_refs") for reference in refs + ) + except ProviderDeploymentReceiptError as error: + raise ValueError(str(error)) from None + if len(set(normalized_refs)) != len(normalized_refs): + raise ValueError("registered deployment has duplicate allowed secret references") + object.__setattr__(self, "allowed_secrets_refs", normalized_refs) + + object.__setattr__( + self, + "account_fingerprint_sha256", + _registration_value( + self.account_fingerprint_sha256, _sha256, "account_fingerprint_sha256" + ), + ) + object.__setattr__( + self, + "approval_receipt_digest", + _registration_value(self.approval_receipt_digest, _sha256, "approval_receipt_digest"), + ) + object.__setattr__( + self, + "artifact_sha256", + _registration_value(self.artifact_sha256, _sha256, "artifact_sha256"), + ) + object.__setattr__( + self, + "effective_config_digest", + _registration_value(self.effective_config_digest, _sha256, "effective_config_digest"), + ) + object.__setattr__( + self, + "capability_receipt_digest", + _registration_value( + self.capability_receipt_digest, _sha256, "capability_receipt_digest" + ), + ) + try: + modules = _capability_modules( + self.required_capability_modules, "required_capability_modules" + ) + except ProviderDeploymentReceiptError as error: + raise ValueError(str(error)) from None + object.__setattr__(self, "required_capability_modules", modules) + + def as_public_dict(self) -> dict[str, Any]: + """Return a diagnostic view that never reveals opaque reference names.""" + + return { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "approval_receipt_digest": self.approval_receipt_digest, + "allowed_secrets_refs": tuple("configured" for _ in self.allowed_secrets_refs), + "artifact_sha256": self.artifact_sha256, + "capability_receipt_digest": self.capability_receipt_digest, + "effective_config_digest": self.effective_config_digest, + "environment": self.environment, + "provider": self.provider, + "registration_id": self.registration_id, + "required_capability_modules": self.required_capability_modules, + "runtime_id": self.runtime_id, + "strategy_id": self.strategy_id, + } + + +@dataclass(frozen=True) +class ProviderDeploymentReceipt: + """The parsed, still-untrusted wire record for one deployment scope.""" + + receipt_id: str + registration_id: str + runtime_id: str + strategy_id: str + provider: str + environment: str + secrets_ref: str = field(repr=False) + account_fingerprint_sha256: str + approval_receipt_digest: str + artifact_sha256: str + effective_config_digest: str + capability_receipt_digest: str + required_capability_modules: Tuple[str, ...] + status: str + created_at: float + expires_at: float + revoked_at: Optional[float] + schema_version: str = PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION + + def __post_init__(self) -> None: + object.__setattr__(self, "receipt_id", _identifier(self.receipt_id, "receipt_id")) + object.__setattr__( + self, "registration_id", _identifier(self.registration_id, "registration_id") + ) + object.__setattr__(self, "runtime_id", _identifier(self.runtime_id, "runtime_id")) + object.__setattr__(self, "strategy_id", _identifier(self.strategy_id, "strategy_id")) + object.__setattr__(self, "provider", _provider(self.provider, "provider")) + object.__setattr__(self, "environment", _provider(self.environment, "environment")) + object.__setattr__(self, "secrets_ref", _opaque_secret_ref(self.secrets_ref, "secrets_ref")) + object.__setattr__( + self, + "account_fingerprint_sha256", + _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256"), + ) + object.__setattr__( + self, + "approval_receipt_digest", + _sha256(self.approval_receipt_digest, "approval_receipt_digest"), + ) + object.__setattr__( + self, "artifact_sha256", _sha256(self.artifact_sha256, "artifact_sha256") + ) + object.__setattr__( + self, + "effective_config_digest", + _sha256(self.effective_config_digest, "effective_config_digest"), + ) + object.__setattr__( + self, + "capability_receipt_digest", + _sha256(self.capability_receipt_digest, "capability_receipt_digest"), + ) + object.__setattr__( + self, + "required_capability_modules", + _capability_modules(self.required_capability_modules, "required_capability_modules"), + ) + object.__setattr__(self, "status", _status(self.status)) + created_at = _timestamp(self.created_at, "created_at") + expires_at = _timestamp(self.expires_at, "expires_at") + if expires_at <= created_at: + _reject("invalid_timestamp", "receipt expiry must follow creation") + object.__setattr__(self, "created_at", created_at) + object.__setattr__(self, "expires_at", expires_at) + if self.revoked_at is not None: + object.__setattr__(self, "revoked_at", _timestamp(self.revoked_at, "revoked_at")) + if ( + type(self.schema_version) is not str + or self.schema_version != PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION + ): + _reject("unsupported_schema", "provider deployment receipt schema is not supported") + + def as_wire(self) -> dict[str, Any]: + """Return the canonical field set used for digest and trust verification.""" + + return { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "approval_receipt_digest": self.approval_receipt_digest, + "artifact_sha256": self.artifact_sha256, + "capability_receipt_digest": self.capability_receipt_digest, + "created_at": self.created_at, + "effective_config_digest": self.effective_config_digest, + "environment": self.environment, + "expires_at": self.expires_at, + "provider": self.provider, + "receipt_id": self.receipt_id, + "registration_id": self.registration_id, + "required_capability_modules": list(self.required_capability_modules), + "revoked_at": self.revoked_at, + "runtime_id": self.runtime_id, + "schema_version": self.schema_version, + "secrets_ref": self.secrets_ref, + "status": self.status, + "strategy_id": self.strategy_id, + } + + +class ProviderDeploymentReceiptVerifier(Protocol): + """A deployment-owner supplied, pure verifier for a parsed receipt. + + Implementations receive a canonical byte payload and may use preloaded, + offline trust material. They must not resolve secrets, import a provider + SDK, contact a provider, or start a provider preflight. + """ + + def verify(self, receipt: ProviderDeploymentReceipt, canonical_payload: bytes) -> bool: + """Return whether this exact canonical receipt is trusted.""" + + +class RejectingProviderDeploymentReceiptVerifier: + """The default fail-closed verifier when no real trust implementation exists.""" + + def verify(self, receipt: ProviderDeploymentReceipt, canonical_payload: bytes) -> bool: + del receipt, canonical_payload + return False + + +@dataclass(frozen=True) +class ProviderDeploymentReceiptValidation: + """A verified binding observation that deliberately has no admission authority.""" + + receipt_id: str + receipt_sha256: str + registration_id: str + runtime_id: str + strategy_id: str + provider: str + environment: str + account_fingerprint_sha256: str + approval_receipt_digest: str + artifact_sha256: str + effective_config_digest: str + capability_receipt_digest: str + required_capability_modules: Tuple[str, ...] + checked_at: float + valid_until: float + status: str = RECEIPT_BINDING_VALIDATED + receipt_binding_valid: bool = True + deployment_authorized: bool = False + execution_authorized: bool = False + secrets_resolved: bool = False + provider_preflight_started: bool = False + + def __post_init__(self) -> None: + checked_at = _timestamp(self.checked_at, "checked_at") + valid_until = _timestamp(self.valid_until, "valid_until") + if ( + self.status != RECEIPT_BINDING_VALIDATED + or self.receipt_binding_valid is not True + or self.deployment_authorized is not False + or self.execution_authorized is not False + or self.secrets_resolved is not False + or self.provider_preflight_started is not False + ): + raise ValueError("provider deployment validation cannot grant admission or execution") + if valid_until <= checked_at: + raise ValueError( + "provider deployment validation must retain a future validity deadline" + ) + object.__setattr__( + self, + "approval_receipt_digest", + _sha256(self.approval_receipt_digest, "approval_receipt_digest"), + ) + object.__setattr__(self, "checked_at", checked_at) + object.__setattr__(self, "valid_until", valid_until) + + def __bool__(self) -> bool: + """Reject truthiness so a binding observation cannot become an approval gate. + + A successful receipt-binding check is deliberately non-authoritative. + Future session factories must inspect their own explicit, provider-owned + admission result rather than accidentally treating this object as a + permission token in ``if validation:`` code. + """ + + raise TypeError( + "ProviderDeploymentReceiptValidation is not an admission decision; " + "do not use it as a boolean" + ) + + def as_public_dict(self) -> dict[str, Any]: + """Return a redacted observation safe for offline diagnostics.""" + + return { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "approval_receipt_digest": self.approval_receipt_digest, + "artifact_sha256": self.artifact_sha256, + "capability_receipt_digest": self.capability_receipt_digest, + "checked_at": self.checked_at, + "deployment_authorized": self.deployment_authorized, + "effective_config_digest": self.effective_config_digest, + "environment": self.environment, + "execution_authorized": self.execution_authorized, + "provider": self.provider, + "provider_preflight_started": self.provider_preflight_started, + "receipt_binding_valid": self.receipt_binding_valid, + "receipt_id": self.receipt_id, + "receipt_sha256": self.receipt_sha256, + "registration_id": self.registration_id, + "required_capability_modules": self.required_capability_modules, + "runtime_id": self.runtime_id, + "secrets_resolved": self.secrets_resolved, + "status": self.status, + "strategy_id": self.strategy_id, + "valid_until": self.valid_until, + } + + +def _reject_json_constant(_: str) -> None: + _reject("invalid_json", "receipt contains a non-finite JSON value") + + +def _reject_duplicate_fields(pairs: Sequence[Tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _reject("invalid_json", "receipt JSON contains duplicate object keys") + result[key] = value + return result + + +def _coerce_receipt_wire( + value: Union[Mapping[str, Any], SerializedProviderDeploymentReceipt] +) -> Mapping[str, Any]: + if type(value) is bytearray: + if len(value) > MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES: + _reject("receipt_too_large", "receipt exceeds the maximum supported size") + value = bytes(value) + if type(value) is bytes: + if len(value) > MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES: + _reject("receipt_too_large", "receipt exceeds the maximum supported size") + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + _reject("invalid_json", "receipt must be UTF-8 JSON") + if type(value) is str: + if len(value) > MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES: + _reject("receipt_too_large", "receipt exceeds the maximum supported size") + try: + encoded_length = len(value.encode("utf-8")) + except UnicodeEncodeError: + _reject("invalid_json", "receipt must be UTF-8 JSON") + if encoded_length > MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES: + _reject("receipt_too_large", "receipt exceeds the maximum supported size") + try: + parsed = json.loads( + value, + object_pairs_hook=_reject_duplicate_fields, + parse_constant=_reject_json_constant, + ) + except ProviderDeploymentReceiptError: + raise + except (json.JSONDecodeError, RecursionError, ValueError): + _reject("invalid_json", "receipt is not valid JSON") + if type(parsed) is not dict: + _reject("invalid_wire", "receipt wire must be an object") + return parsed + # Do not invoke arbitrary Mapping implementations here. This is an + # offline parsing boundary, so accepting a lazy/stateful Mapping would let + # its __iter__ or __getitem__ implementation perform side effects while a + # receipt is merely being validated. A plain dict is the only supported + # in-memory wire form; serialized JSON remains the interchange form. + if type(value) is not dict: + _reject("invalid_wire", "receipt wire must be a plain JSON object") + return dict(value) + + +def parse_provider_deployment_receipt( + value: Union[Mapping[str, Any], SerializedProviderDeploymentReceipt] +) -> ProviderDeploymentReceipt: + """Strictly parse a receipt without contacting a trust or provider system.""" + + wire = _coerce_receipt_wire(value) + keys = tuple(wire) + if any(type(key) is not str for key in keys) or set(keys) != _RECEIPT_FIELDS: + _reject("invalid_wire", "receipt wire fields do not match the deployment contract") + receipt = ProviderDeploymentReceipt( + receipt_id=wire["receipt_id"], + registration_id=wire["registration_id"], + runtime_id=wire["runtime_id"], + strategy_id=wire["strategy_id"], + provider=wire["provider"], + environment=wire["environment"], + secrets_ref=wire["secrets_ref"], + account_fingerprint_sha256=wire["account_fingerprint_sha256"], + approval_receipt_digest=wire["approval_receipt_digest"], + artifact_sha256=wire["artifact_sha256"], + effective_config_digest=wire["effective_config_digest"], + capability_receipt_digest=wire["capability_receipt_digest"], + required_capability_modules=wire["required_capability_modules"], + status=wire["status"], + created_at=wire["created_at"], + expires_at=wire["expires_at"], + revoked_at=wire["revoked_at"], + schema_version=wire["schema_version"], + ) + if len(canonical_provider_deployment_receipt(receipt)) > MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES: + _reject("receipt_too_large", "receipt exceeds the maximum supported size") + return receipt + + +def canonical_provider_deployment_receipt(receipt: ProviderDeploymentReceipt) -> bytes: + """Return the deterministic payload supplied to the injected trust verifier.""" + + if type(receipt) is not ProviderDeploymentReceipt: + raise TypeError("receipt must be a ProviderDeploymentReceipt") + return json.dumps( + ProviderDeploymentReceipt.as_wire(receipt), + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def provider_deployment_receipt_sha256(receipt: ProviderDeploymentReceipt) -> str: + """Return the SHA-256 of the complete canonical receipt wire.""" + + return hashlib.sha256(canonical_provider_deployment_receipt(receipt)).hexdigest() + + +def _require_bound(receipt_value: str, registered_value: str, reason: str, message: str) -> None: + if receipt_value != registered_value: + _reject(reason, message) + + +def _require_bound_digest( + receipt_value: str, registered_value: str, reason: str, message: str +) -> None: + if not hmac.compare_digest(receipt_value, registered_value): + _reject(reason, message) + + +def _validate_receipt_binding( + receipt: ProviderDeploymentReceipt, registration: ProviderDeploymentRegistration +) -> None: + _require_bound( + receipt.registration_id, + registration.registration_id, + "registration_mismatch", + "receipt registration does not match the reviewed deployment scope", + ) + _require_bound( + receipt.runtime_id, + registration.runtime_id, + "runtime_mismatch", + "receipt runtime does not match the reviewed deployment scope", + ) + _require_bound( + receipt.strategy_id, + registration.strategy_id, + "strategy_mismatch", + "receipt strategy does not match the reviewed deployment scope", + ) + _require_bound( + receipt.provider, + registration.provider, + "provider_mismatch", + "receipt provider does not match the reviewed deployment scope", + ) + _require_bound( + receipt.environment, + registration.environment, + "environment_mismatch", + "receipt environment does not match the reviewed deployment scope", + ) + if receipt.secrets_ref not in registration.allowed_secrets_refs: + _reject( + "secrets_ref_not_registered", "receipt secret reference is not in the reviewed scope" + ) + _require_bound_digest( + receipt.account_fingerprint_sha256, + registration.account_fingerprint_sha256, + "account_fingerprint_mismatch", + "receipt account fingerprint does not match the reviewed deployment scope", + ) + _require_bound_digest( + receipt.approval_receipt_digest, + registration.approval_receipt_digest, + "approval_receipt_digest_mismatch", + "receipt approval binding does not match the reviewed deployment scope", + ) + _require_bound_digest( + receipt.artifact_sha256, + registration.artifact_sha256, + "artifact_mismatch", + "receipt artifact does not match the reviewed deployment scope", + ) + _require_bound_digest( + receipt.effective_config_digest, + registration.effective_config_digest, + "effective_config_digest_mismatch", + "receipt effective configuration does not match the reviewed deployment scope", + ) + _require_bound_digest( + receipt.capability_receipt_digest, + registration.capability_receipt_digest, + "capability_receipt_digest_mismatch", + "receipt capability receipt does not match the reviewed deployment scope", + ) + if receipt.required_capability_modules != registration.required_capability_modules: + _reject( + "required_capability_modules_mismatch", + "receipt capability modules do not match the reviewed deployment scope", + ) + + +def _validate_receipt_lifecycle(receipt: ProviderDeploymentReceipt, checked_at: float) -> None: + if receipt.status == REVOKED_RECEIPT_STATUS or receipt.revoked_at is not None: + _reject("receipt_revoked", "provider deployment receipt is revoked") + if receipt.status != ACTIVE_RECEIPT_STATUS: + _reject("unsupported_receipt_status", "provider deployment receipt status is not supported") + if checked_at < receipt.created_at: + _reject("receipt_not_yet_valid", "provider deployment receipt is not yet valid") + if checked_at >= receipt.expires_at: + _reject("receipt_expired", "provider deployment receipt has expired") + + +def validate_provider_deployment_receipt( + value: Union[Mapping[str, Any], SerializedProviderDeploymentReceipt], + *, + registration: ProviderDeploymentRegistration, + verifier: Optional[ProviderDeploymentReceiptVerifier] = None, +) -> ProviderDeploymentReceiptValidation: + """Validate one receipt's offline binding before any secret or provider work. + + The injected verifier is called only after exact wire parsing, scope, + revocation, status, and expiry checks pass. A successful return remains a + non-authoritative observation: it does not resolve a secret, initiate a + provider session, or permit a runner to dispatch. + """ + + if type(registration) is not ProviderDeploymentRegistration: + raise TypeError("registration must be a ProviderDeploymentRegistration") + # Frozen dataclasses can still be changed with object.__setattr__. Take a + # validated copy at this boundary so malformed code-owned scope cannot be + # smuggled past construction-time checks. + registration = ProviderDeploymentRegistration( + registration_id=registration.registration_id, + runtime_id=registration.runtime_id, + strategy_id=registration.strategy_id, + provider=registration.provider, + environment=registration.environment, + allowed_secrets_refs=registration.allowed_secrets_refs, + account_fingerprint_sha256=registration.account_fingerprint_sha256, + approval_receipt_digest=registration.approval_receipt_digest, + artifact_sha256=registration.artifact_sha256, + effective_config_digest=registration.effective_config_digest, + capability_receipt_digest=registration.capability_receipt_digest, + required_capability_modules=registration.required_capability_modules, + ) + receipt = parse_provider_deployment_receipt(value) + _validate_receipt_binding(receipt, registration) + checked_at = _timestamp(time.time(), "now") + _validate_receipt_lifecycle(receipt, checked_at) + + # The verifier receives an isolated copy. Frozen dataclasses can still + # be modified through object.__setattr__, so retain a canonical snapshot + # for the returned observation and reject a verifier that changes its + # input. The canonical bytes are also the exact payload the verifier + # attests to. + canonical_payload = canonical_provider_deployment_receipt(receipt) + snapshot_receipt = parse_provider_deployment_receipt(canonical_payload) + verifier_receipt = parse_provider_deployment_receipt(canonical_payload) + + selected_verifier: ProviderDeploymentReceiptVerifier + if verifier is None: + selected_verifier = RejectingProviderDeploymentReceiptVerifier() + else: + selected_verifier = verifier + try: + trusted = selected_verifier.verify(verifier_receipt, canonical_payload) + except Exception: + _reject("receipt_verifier_failed", "provider deployment receipt verifier failed") + if trusted is not True: + _reject("receipt_untrusted", "provider deployment receipt is not trusted") + try: + post_verifier_payload = canonical_provider_deployment_receipt(verifier_receipt) + except Exception: + _reject( + "receipt_mutated_by_verifier", "provider deployment receipt verifier changed its input" + ) + if not hmac.compare_digest(canonical_payload, post_verifier_payload): + _reject( + "receipt_mutated_by_verifier", "provider deployment receipt verifier changed its input" + ) + + # A real verifier can take time. Recheck expiry after it returns and + # expose the deadline so a future provider session factory must obtain a + # fresh validation rather than treating this observation as durable. + post_verify_checked_at = _timestamp(time.time(), "post_verify_now") + _validate_receipt_lifecycle(snapshot_receipt, post_verify_checked_at) + + return ProviderDeploymentReceiptValidation( + receipt_id=snapshot_receipt.receipt_id, + receipt_sha256=hashlib.sha256(canonical_payload).hexdigest(), + registration_id=registration.registration_id, + runtime_id=snapshot_receipt.runtime_id, + strategy_id=snapshot_receipt.strategy_id, + provider=snapshot_receipt.provider, + environment=snapshot_receipt.environment, + account_fingerprint_sha256=snapshot_receipt.account_fingerprint_sha256, + approval_receipt_digest=snapshot_receipt.approval_receipt_digest, + artifact_sha256=snapshot_receipt.artifact_sha256, + effective_config_digest=snapshot_receipt.effective_config_digest, + capability_receipt_digest=snapshot_receipt.capability_receipt_digest, + required_capability_modules=snapshot_receipt.required_capability_modules, + checked_at=post_verify_checked_at, + valid_until=snapshot_receipt.expires_at, + ) + + +__all__ = [ + "ACTIVE_RECEIPT_STATUS", + "MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULES", + "MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULE_NAME_LENGTH", + "MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES", + "PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION", + "RECEIPT_BINDING_VALIDATED", + "REVOKED_RECEIPT_STATUS", + "ProviderDeploymentReceipt", + "ProviderDeploymentReceiptError", + "ProviderDeploymentReceiptValidation", + "ProviderDeploymentReceiptVerifier", + "ProviderDeploymentRegistration", + "RejectingProviderDeploymentReceiptVerifier", + "canonical_provider_deployment_receipt", + "parse_provider_deployment_receipt", + "provider_deployment_receipt_sha256", + "validate_provider_deployment_receipt", +] diff --git a/backtrader_runtime/provider_preflight.py b/backtrader_runtime/provider_preflight.py new file mode 100644 index 00000000..cb505a6e --- /dev/null +++ b/backtrader_runtime/provider_preflight.py @@ -0,0 +1,411 @@ +"""Sealed, offline binding for a future provider-session preflight. + +This module closes the gap between a configuration-first runtime and the +standalone provider-deployment receipt contract. It deliberately does not +resolve a secret, import a provider SDK, open a socket, create a provider +client, or authorize a write. A provider-specific composition root may use a +successful binding only as one input to its own later read-only session +preflight. + +Keeping this boundary separate matters: a receipt binding proves that a +reviewed deployment record still matches a sealed runtime configuration. It +does not prove that the named account is reachable, that a provider accepts a +credential, or that a session may submit an order. +""" + +from __future__ import annotations + +import hmac +import re +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Tuple, Union + +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from .policy import MANAGED_WRITE_CAPABILITIES, get_preset_policy +from .provider_deployment import ( + ProviderDeploymentReceiptError, + ProviderDeploymentReceiptValidation, + ProviderDeploymentReceiptVerifier, + ProviderDeploymentRegistration, + validate_provider_deployment_receipt, +) +from .registry import ( + EffectiveRuntimeConfig, + RuntimeRegistry, + require_effective_runtime_config_seal, +) + + +_SANDBOX_ACCOUNT_ACCESS = "sandbox_direct_provider" +_MANAGED_PRESETS = frozenset(("sandbox", "managed_live_direct", "managed_live_gateway")) +_CTP_SIMNOW_ENVIRONMENT_RE = re.compile(r"^simnow(?:_set[1-9][0-9]*)?$") +_SANDBOX_ENVIRONMENTS_BY_PROVIDER = { + "okx": frozenset(("demo", "testnet")), + "binance": frozenset(("demo", "testnet")), +} + + +def _binding_error(reason: str, message: str) -> RuntimeConfigError: + """Return a redacted policy error without exposing a secret reference.""" + + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + message, + field_path="runtime.provider_preflight", + reason=reason, + ) + + +def _exact_modules(value: Tuple[str, ...]) -> Tuple[str, ...]: + """Normalize a code-owned module tuple without accepting arbitrary iterables.""" + + if type(value) is not tuple or any(type(item) is not str for item in value): + raise ValueError("capability_modules must be an exact tuple of strings") + return tuple(sorted(value)) + + +def _validate_provider_environment(provider: str, environment: str, preset: str) -> None: + """Require a code-owned provider environment for the selected route.""" + + if preset == "sandbox": + if provider == "ctp": + valid = _CTP_SIMNOW_ENVIRONMENT_RE.fullmatch(environment) is not None + else: + valid = environment in _SANDBOX_ENVIRONMENTS_BY_PROVIDER.get(provider, ()) + if not valid: + raise ValueError( + "provider sandbox preflight environment is not a reviewed non-production target" + ) + return + + if environment != "production": + raise ValueError("provider live preflight environment must be exact production") + + +def _snapshot_registration( + registration: "ProviderSessionPreflightRegistration", +) -> "ProviderSessionPreflightRegistration": + """Re-run code-owned checks after construction to catch frozen-object mutation.""" + + deployment = registration.deployment + if type(deployment) is not ProviderDeploymentRegistration: + raise TypeError("deployment must be a ProviderDeploymentRegistration") + deployment_snapshot = ProviderDeploymentRegistration( + registration_id=deployment.registration_id, + runtime_id=deployment.runtime_id, + strategy_id=deployment.strategy_id, + provider=deployment.provider, + environment=deployment.environment, + allowed_secrets_refs=deployment.allowed_secrets_refs, + account_fingerprint_sha256=deployment.account_fingerprint_sha256, + approval_receipt_digest=deployment.approval_receipt_digest, + artifact_sha256=deployment.artifact_sha256, + effective_config_digest=deployment.effective_config_digest, + capability_receipt_digest=deployment.capability_receipt_digest, + required_capability_modules=deployment.required_capability_modules, + ) + return ProviderSessionPreflightRegistration( + deployment=deployment_snapshot, + mode=registration.mode, + preset=registration.preset, + account_access=registration.account_access, + ) + + +@dataclass(frozen=True) +class ProviderSessionPreflightRegistration: + """Code-owned policy linking one deployment record to one runtime route. + + ``deployment`` deliberately carries the provider-specific environment, + account-fingerprint digest, artifact digest, capability receipt and opaque + secret references. This wrapper adds the exact Iteration 41 + mode/preset/route shape. Neither object is parsed from ``config.yaml``. + """ + + deployment: ProviderDeploymentRegistration + mode: str + preset: str + account_access: str + + def __post_init__(self) -> None: + if type(self.deployment) is not ProviderDeploymentRegistration: + raise TypeError("deployment must be a ProviderDeploymentRegistration") + if any(type(value) is not str for value in (self.mode, self.preset, self.account_access)): + raise ValueError("provider session preflight route fields must be strings") + if self.preset not in _MANAGED_PRESETS: + raise ValueError( + "provider session preflight supports only managed sandbox or live presets" + ) + policy = get_preset_policy(self.preset) + if policy is None or policy.mode != self.mode: + raise ValueError( + "provider session preflight mode and preset must match reviewed policy" + ) + if self.preset == "sandbox": + expected_account_access = _SANDBOX_ACCOUNT_ACCESS + else: + expected_account_access = policy.account_access + if self.account_access != expected_account_access: + raise ValueError("provider session preflight account access must match reviewed policy") + _validate_provider_environment( + self.deployment.provider, self.deployment.environment, self.preset + ) + if not self.deployment.required_capability_modules: + raise ValueError("provider session preflight requires capability modules") + + def as_public_dict(self) -> dict[str, Any]: + """Return a redacted static diagnostic view.""" + + return { + "account_access": self.account_access, + "deployment": self.deployment.as_public_dict(), + "mode": self.mode, + "preset": self.preset, + } + + +@dataclass(frozen=True) +class ProviderSessionPreflightBinding: + """A verified binding observation with intentionally no execution authority. + + The result is not a session object or a permit. Its explicit false + authority fields and disabled truthiness prevent accidental use as an + approval token by an integration runner. + """ + + receipt_validation: ProviderDeploymentReceiptValidation + mode: str + preset: str + account_access: str + preflight_binding_valid: bool = True + provider_preflight_started: bool = False + secrets_resolved: bool = False + session_connected: bool = False + execution_authorized: bool = False + external_writes_authorized: bool = False + + def __post_init__(self) -> None: + if type(self.receipt_validation) is not ProviderDeploymentReceiptValidation: + raise TypeError("receipt_validation must be a ProviderDeploymentReceiptValidation") + if ( + self.preflight_binding_valid is not True + or self.provider_preflight_started is not False + or self.secrets_resolved is not False + or self.session_connected is not False + or self.execution_authorized is not False + or self.external_writes_authorized is not False + ): + raise ValueError( + "provider preflight binding cannot grant session or execution authority" + ) + + def __bool__(self) -> bool: + raise TypeError( + "ProviderSessionPreflightBinding is not a session or execution authorization; " + "do not use it as a boolean" + ) + + @property + def valid_until(self) -> float: + """Return the receipt deadline a later session preflight must recheck.""" + + return self.receipt_validation.valid_until + + def as_public_dict(self) -> dict[str, Any]: + """Return an offline-safe status projection with no opaque secret reference.""" + + return { + "account_access": self.account_access, + "deployment": self.receipt_validation.as_public_dict(), + "execution_authorized": self.execution_authorized, + "external_writes_authorized": self.external_writes_authorized, + "mode": self.mode, + "preflight_binding_valid": self.preflight_binding_valid, + "provider_preflight_started": self.provider_preflight_started, + "preset": self.preset, + "secrets_resolved": self.secrets_resolved, + "session_connected": self.session_connected, + "valid_until": self.valid_until, + } + + +def _require_matching_runtime_contract( + effective: EffectiveRuntimeConfig, + registration: ProviderSessionPreflightRegistration, +) -> None: + """Require all code-owned route facts before validating a receipt wire.""" + + deployment = registration.deployment + if effective.mode != registration.mode or effective.preset != registration.preset: + raise _binding_error( + "provider_runtime_mode_preset_mismatch", + "provider preflight registration does not match the sealed runtime mode and preset", + ) + if effective.order_route != "managed_execution": + raise _binding_error( + "provider_runtime_route_not_managed", + "provider preflight requires a sealed managed execution route", + ) + if effective.account_access != registration.account_access: + raise _binding_error( + "provider_runtime_account_access_mismatch", + "provider preflight registration does not match the sealed account access route", + ) + if not effective.allows_external_writes or not effective.requires_approval: + raise _binding_error( + "provider_runtime_write_admission_missing", + "provider preflight requires an approved write-capable managed runtime", + ) + if effective.registration.approval_receipt_digest is None: + raise _binding_error( + "provider_runtime_approval_missing", + "provider preflight requires a reviewed runtime approval binding", + ) + if not hmac.compare_digest( + effective.registration.approval_receipt_digest, + deployment.approval_receipt_digest, + ): + raise _binding_error( + "provider_approval_receipt_digest_mismatch", + "provider deployment does not match the sealed runtime approval binding", + ) + if effective.registration.runtime_id != deployment.runtime_id: + raise _binding_error( + "provider_runtime_id_mismatch", + "provider deployment does not match the sealed runtime identity", + ) + if effective.strategy_id != deployment.strategy_id: + raise _binding_error( + "provider_strategy_id_mismatch", + "provider deployment does not match the sealed strategy identity", + ) + if not hmac.compare_digest(effective.effective_digest, deployment.effective_config_digest): + raise _binding_error( + "provider_effective_config_mismatch", + "provider deployment does not match the sealed effective configuration", + ) + if effective.config.secrets_ref not in deployment.allowed_secrets_refs: + raise _binding_error( + "provider_secrets_ref_mismatch", + "provider deployment does not allow this sealed opaque secret reference", + ) + if ( + _exact_modules(effective.registration.capability_modules) + != deployment.required_capability_modules + ): + raise _binding_error( + "provider_capability_modules_mismatch", + "provider deployment modules do not match the sealed runtime registration", + ) + + policy = get_preset_policy(effective.preset) + if policy is None: + raise _binding_error( + "provider_runtime_policy_missing", + "provider preflight runtime policy is unavailable", + ) + if effective.preset == "sandbox": + if ( + effective.mode != "simulation" + or effective.policy.environment != "sandbox" + or effective.allows_production_writes + or effective.required_capabilities != MANAGED_WRITE_CAPABILITIES + ): + raise _binding_error( + "provider_sandbox_contract_mismatch", + "provider sandbox preflight requires the sealed non-production managed contract", + ) + try: + _validate_provider_environment( + deployment.provider, deployment.environment, registration.preset + ) + except (TypeError, ValueError): + raise _binding_error( + "provider_environment_mismatch", + "provider sandbox registration does not target a reviewed non-production environment", + ) from None + return + + if ( + effective.mode != "live" + or effective.policy.environment != "production" + or not effective.allows_production_writes + or effective.required_capabilities != policy.required_capabilities + ): + raise _binding_error( + "provider_live_contract_mismatch", + "provider live preflight requires the sealed reviewed live managed contract", + ) + try: + _validate_provider_environment( + deployment.provider, deployment.environment, registration.preset + ) + except (TypeError, ValueError): + raise _binding_error( + "provider_environment_mismatch", + "provider live registration must target the reviewed production environment", + ) from None + + +def validate_provider_session_preflight_binding( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + registration: ProviderSessionPreflightRegistration, + receipt: Union[Mapping[str, Any], bytes, bytearray, str], + *, + verifier: Optional[ProviderDeploymentReceiptVerifier] = None, +) -> ProviderSessionPreflightBinding: + """Bind a trusted deployment receipt to one sealed managed runtime. + + This executes only local validation. It rechecks effective-config + provenance and directory identity before inspecting any public effective + field, then validates the full receipt lifecycle with the injected offline + verifier. A successful result still leaves secret resolution, provider + session construction, account queries, reconciliation, and all external + writes unavailable. + """ + + if type(effective) is not EffectiveRuntimeConfig: + raise TypeError("effective must be an EffectiveRuntimeConfig") + if type(registry) is not RuntimeRegistry: + raise TypeError("registry must be a RuntimeRegistry") + if type(registration) is not ProviderSessionPreflightRegistration: + raise TypeError("registration must be a ProviderSessionPreflightRegistration") + require_effective_runtime_config_seal(effective, registry) + try: + registration = _snapshot_registration(registration) + except (TypeError, ValueError): + raise _binding_error( + "provider_preflight_registration_invalid", + "provider preflight registration no longer matches its code-owned contract", + ) from None + _require_matching_runtime_contract(effective, registration) + validation = validate_provider_deployment_receipt( + receipt, + registration=registration.deployment, + verifier=verifier, + ) + if ( + validation.deployment_authorized is not False + or validation.execution_authorized is not False + or validation.secrets_resolved is not False + or validation.provider_preflight_started is not False + ): + raise _binding_error( + "provider_receipt_authority_invalid", + "provider deployment receipt validation cannot grant session or execution authority", + ) + return ProviderSessionPreflightBinding( + receipt_validation=validation, + mode=effective.mode, + preset=effective.preset, + account_access=effective.account_access, + ) + + +__all__ = [ + "ProviderSessionPreflightBinding", + "ProviderSessionPreflightRegistration", + "ProviderDeploymentReceiptError", + "validate_provider_session_preflight_binding", +] diff --git a/backtrader_runtime/registry.py b/backtrader_runtime/registry.py new file mode 100644 index 00000000..ef3fa2df --- /dev/null +++ b/backtrader_runtime/registry.py @@ -0,0 +1,1985 @@ +"""Trusted runtime-directory bindings and effective configuration resolution. + +The registry is intentionally data-oriented. It validates an immutable +configuration and returns capability descriptors; optional execution, risk, +monitoring, gateway, and provider modules remain unimported. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import weakref +from contextlib import contextmanager +from dataclasses import dataclass, field, fields +from pathlib import Path +from types import MappingProxyType +from typing import Any, Dict, Generator, Iterable, Mapping, Optional, Tuple, Union + +from .config import ( + RuntimeConfig, + _parse_verified_runtime_config_text, + _read_config_text, + load_runtime_config, + require_loaded_runtime_config_seal, + write_bootstrap_config, +) +from .errors import ( + CONFIG_EXISTS, + ENVIRONMENT_MISMATCH, + MODE_PRESET_MISMATCH, + PRESET_POLICY_VIOLATION, + RuntimeConfigError, +) +from .policy import MANAGED_WRITE_CAPABILITIES, PresetPolicy, get_preset_policy + + +_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") +_RUNNER_MODULE_RE = re.compile(r"^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*$") +_RUNNER_ENTRYPOINT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_CAPABILITY_MODULE_RE = re.compile(r"^bt_api(?:_[A-Za-z0-9]+)+$") +_PROFILE_REASON_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") + + +def _ctp_binding_snapshot(binding: Any) -> Tuple[Any, ...]: + """Capture every reviewed binding field, including non-public values.""" + + return (type(binding),) + tuple( + (item.name, getattr(binding, item.name)) for item in fields(binding) + ) + + +def _canonical_dir(path: Union[str, os.PathLike]) -> Path: + try: + return Path(path).expanduser().resolve(strict=False) + except (OSError, RuntimeError, TypeError, ValueError): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "strategy runtime directory is not usable", + field_path="strategy_dir", + reason="invalid_runtime_directory", + ) from None + + +def _directory_key(path: Union[str, os.PathLike]) -> str: + return os.path.normcase(str(_canonical_dir(path))) + + +def _lexical_directory_key(path: Union[str, os.PathLike]) -> str: + """Normalise a path spelling without following a later replacement link.""" + + try: + expanded = Path(path).expanduser() + return os.path.normcase(os.path.normpath(os.path.abspath(str(expanded)))) + except (OSError, RuntimeError, TypeError, ValueError): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "strategy runtime directory is not usable", + field_path="strategy_dir", + reason="invalid_runtime_directory", + ) from None + + +def _is_link_or_reparse(stat_result: os.stat_result) -> bool: + """Return whether a directory entry is a link on this platform. + + ``stat.S_ISLNK`` covers POSIX links. Windows junctions can instead be + directory reparse points, so reject those too when Python exposes the + file-attribute bit. A reviewed runtime directory must be a concrete + directory owned by the registration, not a redirectable path. + """ + + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + attributes = getattr(stat_result, "st_file_attributes", 0) + return stat.S_ISLNK(stat_result.st_mode) or bool(attributes & reparse_point) + + +@dataclass(frozen=True) +class RuntimeDirectoryIdentity: + """The lstat identity captured for a reviewed runtime directory.""" + + device: int + inode: int + mode: int + + @classmethod + def capture(cls, path: Path) -> Optional["RuntimeDirectoryIdentity"]: + """Capture a concrete directory identity without following its leaf. + + A missing directory remains representable so the bootstrap policy can + still reject a write-capable preset before reporting availability. It + can never subsequently become an executable trusted directory because + no reviewed identity was captured for it. + """ + + try: + result = os.lstat(str(path)) + except FileNotFoundError: + return None + except OSError: + raise ValueError("registered runtime directory cannot be inspected") from None + if _is_link_or_reparse(result) or not stat.S_ISDIR(result.st_mode): + raise ValueError("registered runtime directory must be a non-link directory") + return cls(device=result.st_dev, inode=result.st_ino, mode=result.st_mode) + + def matches(self, result: os.stat_result) -> bool: + return ( + not _is_link_or_reparse(result) + and stat.S_ISDIR(result.st_mode) + and (result.st_dev, result.st_ino, result.st_mode) + == (self.device, self.inode, self.mode) + ) + + +@dataclass(frozen=True) +class UnavailableModeProfile: + """A code-owned mode/preset pair that is recognized but cannot run.""" + + mode: str + preset: str + reason: str + + def __post_init__(self) -> None: + policy = get_preset_policy(self.preset) if isinstance(self.preset, str) else None + if not isinstance(self.mode, str) or policy is None or policy.mode != self.mode: + raise ValueError("unavailable mode profile must name a known mode/preset pair") + if not isinstance(self.reason, str) or not _PROFILE_REASON_RE.fullmatch(self.reason): + raise ValueError("unavailable mode profile reason must be a stable identifier") + + +@dataclass(frozen=True) +class RuntimeProfile: + """One exact mode/preset contract carried by a registered runtime. + + Profile fields are deliberately complete and are never inherited from the + enclosing runtime's legacy fields. The enclosing registration remains the + owner of directory and strategy identity; this value owns only the policy + and dispatch facts for one exact mode/preset pair. + """ + + mode: str + preset: str + allowed_parameter_keys: Tuple[str, ...] + allowed_secrets_refs: Tuple[str, ...] + available_capabilities: Tuple[str, ...] + approval_receipt_digest: Optional[str] + runner_module: Optional[str] + runner_entrypoint: str + capability_modules: Tuple[str, ...] + offline_managed_execution: bool + sandbox_write_policy: str + + def __post_init__(self) -> None: + policy = get_preset_policy(self.preset) if isinstance(self.preset, str) else None + if not isinstance(self.mode, str) or policy is None or policy.mode != self.mode: + raise ValueError("runtime profile must name a known mode/preset pair") + for name in ( + "allowed_parameter_keys", + "allowed_secrets_refs", + "available_capabilities", + "capability_modules", + ): + object.__setattr__(self, name, tuple(getattr(self, name))) + if any(not isinstance(key, str) or not key for key in self.allowed_parameter_keys): + raise ValueError("profile parameter keys must be non-empty strings") + if len(set(self.allowed_parameter_keys)) != len(self.allowed_parameter_keys): + raise ValueError("profile parameter keys must not contain duplicates") + if not self.allowed_secrets_refs or any( + not isinstance(value, str) or not value for value in self.allowed_secrets_refs + ): + raise ValueError("profile secret references must be non-empty strings") + if len(set(self.allowed_secrets_refs)) != len(self.allowed_secrets_refs): + raise ValueError("profile secret references must not contain duplicates") + if any(not isinstance(value, str) or not value for value in self.available_capabilities): + raise ValueError("profile capabilities must be non-empty strings") + if len(set(self.available_capabilities)) != len(self.available_capabilities): + raise ValueError("profile capabilities must not contain duplicates") + if type(self.offline_managed_execution) is not bool: + raise ValueError("profile offline_managed_execution must be a bool") + if self.sandbox_write_policy not in ("deny", "receipt_required"): + raise ValueError("profile sandbox_write_policy must be deny or receipt_required") + if self.sandbox_write_policy == "receipt_required" and self.preset != "sandbox": + raise ValueError("receipt-required profile policy needs the sandbox preset") + if self.approval_receipt_digest is not None and not _DIGEST_RE.fullmatch( + self.approval_receipt_digest + ): + raise ValueError("profile approval receipt must be a lower-case SHA-256 digest") + if self.runner_module is not None and ( + not isinstance(self.runner_module, str) + or not _RUNNER_MODULE_RE.fullmatch(self.runner_module) + ): + raise ValueError("profile runner_module must be a dotted code-owned module name") + if not isinstance(self.runner_entrypoint, str) or not _RUNNER_ENTRYPOINT_RE.fullmatch( + self.runner_entrypoint + ): + raise ValueError("profile runner_entrypoint must be a Python identifier") + if self.runner_module is None and self.runner_entrypoint != "run_runtime": + raise ValueError("profile runner_entrypoint requires runner_module") + if len(set(self.capability_modules)) != len(self.capability_modules): + raise ValueError("profile capability_modules must not contain duplicates") + if any( + not isinstance(module, str) or not _CAPABILITY_MODULE_RE.fullmatch(module) + for module in self.capability_modules + ): + raise ValueError("profile capability_modules must be top-level bt_api package names") + _validate_profile_offline_managed_shape(self) + + @property + def digest(self) -> str: + canonical = { + "allowed_parameter_keys": self.allowed_parameter_keys, + "allowed_secrets_refs": self.allowed_secrets_refs, + "approval_receipt_digest": self.approval_receipt_digest, + "available_capabilities": self.available_capabilities, + "capability_modules": self.capability_modules, + "mode": self.mode, + "offline_managed_execution": self.offline_managed_execution, + "preset": self.preset, + "runner_entrypoint": self.runner_entrypoint, + "runner_module": self.runner_module, + "sandbox_write_policy": self.sandbox_write_policy, + } + encoded = json.dumps(canonical, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class RegisteredRuntime: + """A code-owned, exact binding for one runtime directory. + + ``allowed_presets`` and ``allowed_parameter_keys`` are an allow-list, not + suggestions. They must be supplied by reviewed code or a signed operator + registry, never from ``config.yaml`` or a command-line flag. + """ + + runtime_dir: Path + strategy_id: str + allowed_presets: Tuple[str, ...] + allowed_parameter_keys: Tuple[str, ...] = () + allowed_secrets_refs: Tuple[str, ...] = ("none",) + available_capabilities: Tuple[str, ...] = () + offline_managed_execution: bool = False + sandbox_write_policy: str = "deny" + approval_receipt_digest: Optional[str] = None + runtime_id: Optional[str] = None + runner_module: Optional[str] = None + runner_entrypoint: str = "run_runtime" + capability_modules: Tuple[str, ...] = () + unavailable_mode_profiles: Tuple[UnavailableModeProfile, ...] = () + bootstrap_parameters: Tuple[Tuple[str, Any], ...] = () + # Appended after the original public fields so positional callers that + # supplied bootstrap_parameters retain their constructor semantics. + profiles: Tuple[RuntimeProfile, ...] = () + directory_identity: Optional[RuntimeDirectoryIdentity] = field( + init=False, repr=False, compare=False + ) + runtime_dir_lookup_key: str = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + supplied_dir = Path(self.runtime_dir).expanduser() + # Do not permit a code registration to silently canonicalise a leaf + # symlink or junction. The canonical path below is retained for + # deterministic lookup, while the identity seals the actual directory + # selected at registry construction time. + captured_identity = RuntimeDirectoryIdentity.capture(supplied_dir) + resolved_dir = _canonical_dir(supplied_dir) + object.__setattr__(self, "runtime_dir", resolved_dir) + object.__setattr__(self, "directory_identity", captured_identity) + object.__setattr__(self, "runtime_dir_lookup_key", _lexical_directory_key(supplied_dir)) + object.__setattr__(self, "allowed_presets", tuple(self.allowed_presets)) + object.__setattr__(self, "allowed_parameter_keys", tuple(self.allowed_parameter_keys)) + normalized_bootstrap_parameters = [] + bootstrap_parameter_keys = set() + for item in self.bootstrap_parameters: + if not isinstance(item, (tuple, list)) or len(item) != 2: + raise ValueError("bootstrap_parameters entries must be key/value pairs") + key, value = item + if ( + not isinstance(key, str) + or key not in self.allowed_parameter_keys + or key in bootstrap_parameter_keys + ): + raise ValueError("bootstrap_parameters must use unique allowed parameter keys") + bootstrap_parameter_keys.add(key) + if isinstance(value, (tuple, list)): + normalized_value = tuple(value) + if any(type(part) not in (str, int, bool) for part in normalized_value): + raise ValueError("bootstrap parameter sequences must contain scalar values") + elif type(value) in (str, int, bool): + normalized_value = value + else: + raise ValueError("bootstrap parameter values must be simple scalar sequences") + normalized_bootstrap_parameters.append((key, normalized_value)) + object.__setattr__(self, "bootstrap_parameters", tuple(normalized_bootstrap_parameters)) + object.__setattr__(self, "allowed_secrets_refs", tuple(self.allowed_secrets_refs)) + object.__setattr__(self, "available_capabilities", tuple(self.available_capabilities)) + object.__setattr__(self, "capability_modules", tuple(self.capability_modules)) + object.__setattr__(self, "unavailable_mode_profiles", tuple(self.unavailable_mode_profiles)) + object.__setattr__(self, "profiles", tuple(self.profiles)) + + if not self.strategy_id: + raise ValueError("registered runtime strategy_id must not be empty") + if not self.allowed_presets and not self.profiles: + raise ValueError("registered runtime must allow at least one preset or profile") + if len(set(self.allowed_presets)) != len(self.allowed_presets): + raise ValueError("registered runtime has duplicate allowed presets") + if len(set(self.allowed_parameter_keys)) != len(self.allowed_parameter_keys): + raise ValueError("registered runtime has duplicate allowed parameter keys") + if len(set(self.allowed_secrets_refs)) != len(self.allowed_secrets_refs): + raise ValueError("registered runtime has duplicate allowed secret references") + if any(get_preset_policy(name) is None for name in self.allowed_presets): + raise ValueError("registered runtime references an unknown preset") + _validate_unavailable_mode_profiles_shape(self) + _validate_runtime_profiles_shape(self) + if self.sandbox_write_policy not in ("deny", "receipt_required"): + raise ValueError("sandbox_write_policy must be deny or receipt_required") + if ( + self.sandbox_write_policy == "receipt_required" + and "sandbox" not in self.allowed_presets + ): + raise ValueError("receipt-required sandbox policy needs the sandbox preset") + if self.approval_receipt_digest is not None and not _DIGEST_RE.fullmatch( + self.approval_receipt_digest + ): + raise ValueError("approval_receipt_digest must be a lower-case SHA-256 digest") + if self.runtime_id is None: + object.__setattr__(self, "runtime_id", self.strategy_id) + if self.runner_module is not None and ( + not isinstance(self.runner_module, str) + or not _RUNNER_MODULE_RE.fullmatch(self.runner_module) + ): + raise ValueError("runner_module must be a dotted code-owned module name") + if not isinstance(self.runner_entrypoint, str) or not _RUNNER_ENTRYPOINT_RE.fullmatch( + self.runner_entrypoint + ): + raise ValueError("runner_entrypoint must be a Python identifier") + if self.runner_module is None and self.runner_entrypoint != "run_runtime": + raise ValueError("runner_entrypoint requires runner_module") + if len(set(self.capability_modules)) != len(self.capability_modules): + raise ValueError("capability_modules must not contain duplicates") + if any( + not isinstance(module, str) or not _CAPABILITY_MODULE_RE.fullmatch(module) + for module in self.capability_modules + ): + raise ValueError("capability_modules must contain top-level bt_api package names") + _validate_offline_managed_registration_shape(self) + if self.profiles and ( + self.allowed_presets + or self.allowed_parameter_keys + or self.allowed_secrets_refs != ("none",) + or self.available_capabilities + or self.offline_managed_execution + or self.sandbox_write_policy != "deny" + or self.approval_receipt_digest is not None + or self.runner_module is not None + or self.runner_entrypoint != "run_runtime" + or self.capability_modules + ): + raise ValueError("profile-scoped runtime legacy policy fields must remain inert") + + @property + def digest(self) -> str: + """Return a safe digest of the reviewed registration data.""" + + canonical = { + "allowed_parameter_keys": self.allowed_parameter_keys, + "bootstrap_parameters": self.bootstrap_parameters, + "allowed_presets": self.allowed_presets, + "allowed_secrets_refs": self.allowed_secrets_refs, + "available_capabilities": self.available_capabilities, + "capability_modules": self.capability_modules, + "offline_managed_execution": self.offline_managed_execution, + "approval_receipt_digest": self.approval_receipt_digest, + "runtime_id": self.runtime_id, + "runner_entrypoint": self.runner_entrypoint, + "runner_module": self.runner_module, + "sandbox_write_policy": self.sandbox_write_policy, + "strategy_id": self.strategy_id, + "unavailable_mode_profiles": tuple( + (profile.mode, profile.preset, profile.reason) + for profile in self.unavailable_mode_profiles + ), + } + if self.profiles: + canonical["profiles"] = tuple(profile.digest for profile in self.profiles) + encoded = json.dumps(canonical, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + def profile_for(self, mode: str, preset: str) -> Optional[RuntimeProfile]: + """Return the exact registered profile, if this runtime is profile-scoped.""" + + return next( + ( + profile + for profile in self.profiles + if (profile.mode, profile.preset) == (mode, preset) + ), + None, + ) + + +def _validate_unavailable_mode_profiles_shape(registration: RegisteredRuntime) -> None: + """Keep unavailable profiles exact, disjoint from active presets, and inert.""" + + profiles = registration.unavailable_mode_profiles + if any(type(profile) is not UnavailableModeProfile for profile in profiles): + raise ValueError("unavailable_mode_profiles must contain exact profile declarations") + pairs = tuple((profile.mode, profile.preset) for profile in profiles) + if len(set(pairs)) != len(pairs): + raise ValueError("registered runtime has duplicate unavailable mode profiles") + if any(profile.preset in registration.allowed_presets for profile in profiles): + raise ValueError("an unavailable mode profile cannot also be an allowed preset") + # Revalidate each declaration at registry/resolution boundaries because + # frozen instances can still be altered with object.__setattr__. + for profile in profiles: + UnavailableModeProfile.__post_init__(profile) + + +def _validate_profile_offline_managed_shape(profile: RuntimeProfile) -> None: + if not profile.offline_managed_execution: + return + if (profile.mode, profile.preset) != ("simulation", "replay"): + raise ValueError("profile offline managed execution is restricted to simulation/replay") + if profile.allowed_secrets_refs != ("none",): + raise ValueError("profile offline managed execution must not accept provider secrets") + if profile.available_capabilities != MANAGED_WRITE_CAPABILITIES: + raise ValueError("profile offline managed execution requires execution, risk, and monitor") + if profile.sandbox_write_policy != "deny": + raise ValueError("profile offline managed execution must deny sandbox provider writes") + if profile.approval_receipt_digest is not None: + raise ValueError("profile offline managed execution must not carry an approval receipt") + + +def _validate_runtime_profiles_shape(registration: RegisteredRuntime) -> None: + profiles = registration.profiles + if any(type(profile) is not RuntimeProfile for profile in profiles): + raise ValueError("profiles must contain exact RuntimeProfile declarations") + pairs = tuple((profile.mode, profile.preset) for profile in profiles) + if len(set(pairs)) != len(pairs): + raise ValueError("registered runtime has duplicate profiles") + unavailable = { + (profile.mode, profile.preset) for profile in registration.unavailable_mode_profiles + } + if unavailable.intersection(pairs): + raise ValueError("an unavailable mode profile cannot also be an available profile") + for profile in profiles: + RuntimeProfile.__post_init__(profile) + + +def _profile_policy_values( + registration: RegisteredRuntime, profile: Optional[RuntimeProfile] +) -> Any: + """Return the selected profile or legacy registration policy facts.""" + + return registration if profile is None else profile + + +def _validate_offline_managed_registration_shape(registration: RegisteredRuntime) -> None: + """Recheck the narrow offline managed shape at every registry boundary.""" + + if type(registration.offline_managed_execution) is not bool: + raise ValueError("offline_managed_execution must be a bool") + if not registration.offline_managed_execution: + return + if registration.allowed_presets != ("replay",): + raise ValueError("offline managed execution is restricted to the replay preset") + if registration.allowed_secrets_refs != ("none",): + raise ValueError("offline managed execution must not accept provider secrets") + if registration.available_capabilities != MANAGED_WRITE_CAPABILITIES: + raise ValueError("offline managed execution requires exactly execution, risk, and monitor") + if registration.sandbox_write_policy != "deny": + raise ValueError("offline managed execution must deny sandbox provider writes") + if registration.approval_receipt_digest is not None: + raise ValueError("offline managed execution must not carry an approval receipt") + + +@dataclass(frozen=True) +class RuntimeSet: + """A reviewed, code-owned set of registered runtime identities. + + Batch bootstrap deliberately accepts a set *name*, never a user supplied + list of filesystem paths. The registry validates every identity when it + is constructed, so an operator cannot use a batch command to discover or + create configuration in an arbitrary directory. + """ + + name: str + runtime_ids: Tuple[str, ...] + + def __post_init__(self) -> None: + object.__setattr__(self, "runtime_ids", tuple(self.runtime_ids)) + if not isinstance(self.name, str) or not self.name: + raise ValueError("runtime set name must not be empty") + if not self.runtime_ids: + raise ValueError("runtime set must contain at least one runtime identity") + if any( + not isinstance(runtime_id, str) or not runtime_id for runtime_id in self.runtime_ids + ): + raise ValueError("runtime set identities must be non-empty strings") + if len(set(self.runtime_ids)) != len(self.runtime_ids): + raise ValueError("runtime set has duplicate runtime identities") + + +@dataclass(frozen=True) +class BootstrapRuntimeSetItem: + """A redacted, deterministic result for one batch-bootstrap target.""" + + runtime_id: str + strategy_id: str + runtime_dir: str + mode: str + preset: str + status: str + reason: Optional[str] = None + config_digest: Optional[str] = None + + def as_public_dict(self) -> Dict[str, Optional[str]]: + result: Dict[str, Optional[str]] = { + "runtime_id": self.runtime_id, + "strategy_id": self.strategy_id, + "runtime_dir": self.runtime_dir, + "mode": self.mode, + "preset": self.preset, + "status": self.status, + } + if self.reason is not None: + result["reason"] = self.reason + if self.config_digest is not None: + result["config_digest"] = self.config_digest + return result + + +@dataclass(frozen=True) +class BootstrapRuntimeSetResult: + """Batch-bootstrap outcome that never includes secrets or config values.""" + + runtime_set: str + status: str + items: Tuple[BootstrapRuntimeSetItem, ...] + + @property + def succeeded(self) -> bool: + return self.status == "bootstrapped" + + def as_public_dict(self) -> Dict[str, object]: + return { + "status": self.status, + "runtime_set": self.runtime_set, + "items": [item.as_public_dict() for item in self.items], + } + + +class RuntimeRegistry: + """An immutable collection of registered runtime directories.""" + + def __init__( + self, + registrations: Iterable[RegisteredRuntime] = (), + *, + runtime_sets: Iterable[RuntimeSet] = (), + ctp_simnow_readonly_bindings: Iterable[Any] = (), + registry_id: str = "backtrader.runtime", + trusted: bool = True, + ) -> None: + entries = tuple(registrations) + by_directory: Dict[str, RegisteredRuntime] = {} + by_lexical_directory: Dict[str, RegisteredRuntime] = {} + by_runtime_id: Dict[str, RegisteredRuntime] = {} + for registration in entries: + if not isinstance(registration, RegisteredRuntime): + raise TypeError("runtime registry entries must be RegisteredRuntime instances") + _validate_offline_managed_registration_shape(registration) + _validate_unavailable_mode_profiles_shape(registration) + _validate_runtime_profiles_shape(registration) + key = _directory_key(registration.runtime_dir) + if key in by_directory: + raise ValueError("runtime registry has duplicate runtime directories") + lexical_key = registration.runtime_dir_lookup_key + if lexical_key in by_lexical_directory: + raise ValueError("runtime registry has duplicate runtime directory spellings") + if registration.runtime_id in by_runtime_id: + raise ValueError("runtime registry has duplicate runtime identities") + by_directory[key] = registration + by_lexical_directory[lexical_key] = registration + by_runtime_id[registration.runtime_id] = registration + + named_sets = tuple(runtime_sets) + by_set_name: Dict[str, Tuple[RegisteredRuntime, ...]] = {} + for runtime_set in named_sets: + if not isinstance(runtime_set, RuntimeSet): + raise TypeError("runtime registry sets must be RuntimeSet instances") + if runtime_set.name in by_set_name: + raise ValueError("runtime registry has duplicate runtime set names") + try: + members = tuple(by_runtime_id[runtime_id] for runtime_id in runtime_set.runtime_ids) + except KeyError: + raise ValueError( + "runtime set references an unregistered runtime identity" + ) from None + by_set_name[runtime_set.name] = members + + readonly_bindings = tuple(ctp_simnow_readonly_bindings) + by_ctp_simnow_runtime_id: Dict[str, Any] = {} + ctp_simnow_binding_snapshots: Dict[str, Tuple[Any, ...]] = {} + if readonly_bindings: + # Keep the ordinary config/registry import path free of CTP + # contracts. The optional operator surface is loaded only for a + # registry that explicitly declares a reviewed private-read route. + from .ctp_simnow_operator import ( + CtpSimNowConfigReadOnlyBinding, + CtpSimNowReadOnlyBinding, + ) + + for binding in readonly_bindings: + if type(binding) not in ( + CtpSimNowReadOnlyBinding, + CtpSimNowConfigReadOnlyBinding, + ): + raise TypeError( + "CTP SimNow operator bindings must be exact code-owned binding instances" + ) + # A frozen dataclass can still be changed through + # object.__setattr__. Validate the current values and seal + # every field before exposing this registry to a dispatcher. + type(binding).__post_init__(binding) + if binding.runtime_id in by_ctp_simnow_runtime_id: + raise ValueError("runtime has duplicate CTP SimNow read-only bindings") + registration = by_runtime_id.get(binding.runtime_id) + if registration is None: + raise ValueError("CTP SimNow binding references an unregistered runtime") + if registration.profiles: + profile = registration.profile_for("simulation", "sandbox") + unavailable = tuple( + (item.mode, item.preset, item.reason) + for item in registration.unavailable_mode_profiles + ) + profile_shape_ok = ( + type(binding) is CtpSimNowConfigReadOnlyBinding + and len(registration.profiles) == 1 + and type(profile) is RuntimeProfile + and registration.profiles[0] is profile + and profile.mode == "simulation" + and profile.preset == "sandbox" + and registration.allowed_presets == () + and registration.allowed_parameter_keys == () + and registration.allowed_secrets_refs == ("none",) + and registration.available_capabilities == () + and registration.offline_managed_execution is False + and registration.sandbox_write_policy == "deny" + and registration.approval_receipt_digest is None + and registration.runner_module is None + and registration.runner_entrypoint == "run_runtime" + and registration.capability_modules == () + and registration.bootstrap_parameters == () + and profile.allowed_parameter_keys == () + and profile.allowed_secrets_refs == ("config_yaml",) + and profile.available_capabilities == () + and profile.approval_receipt_digest is None + and profile.runner_module is None + and profile.runner_entrypoint == "run_runtime" + and profile.capability_modules == () + and profile.offline_managed_execution is False + and profile.sandbox_write_policy == "deny" + and binding.secrets_ref == "config_yaml" + and unavailable + == ( + ( + "live", + "managed_live_direct", + "managed_live_direct_profile_unavailable", + ), + ) + ) + else: + profile_shape_ok = ( + registration.allowed_presets == ("sandbox",) + and registration.allowed_parameter_keys == () + and registration.allowed_secrets_refs == (binding.secrets_ref,) + and registration.available_capabilities == () + and registration.offline_managed_execution is False + and registration.sandbox_write_policy == "deny" + and registration.approval_receipt_digest is None + and registration.runner_module is None + and registration.capability_modules == () + ) + if not profile_shape_ok: + raise ValueError( + "CTP SimNow read-only binding requires an exact sandbox-only runtime with zero-write policy" + ) + # Reuse the admission contract's field validation while + # binding it to the exact RegisteredRuntime object. This is + # local validation only; it does not load credentials or an SDK. + if type(binding) is CtpSimNowReadOnlyBinding: + binding._admission(registration) + by_ctp_simnow_runtime_id[binding.runtime_id] = binding + ctp_simnow_binding_snapshots[binding.runtime_id] = _ctp_binding_snapshot(binding) + if not registry_id: + raise ValueError("registry_id must not be empty") + + self._registrations = entries + self._by_directory = by_directory + self._by_lexical_directory = by_lexical_directory + self._by_runtime_id = by_runtime_id + self._runtime_sets = named_sets + self._by_set_name = by_set_name + self._registration_digests = MappingProxyType( + {registration.runtime_id: registration.digest for registration in entries} + ) + self._ctp_simnow_readonly_bindings = readonly_bindings + self._by_ctp_simnow_runtime_id = by_ctp_simnow_runtime_id + self._ctp_simnow_binding_snapshots = MappingProxyType(ctp_simnow_binding_snapshots) + self.registry_id = registry_id + self.trusted = bool(trusted) + + @property + def registrations(self) -> Tuple[RegisteredRuntime, ...]: + """Return the immutable registry entries in declaration order.""" + + return self._registrations + + @property + def runtime_sets(self) -> Tuple[RuntimeSet, ...]: + """Return reviewed batch-bootstrap set declarations in declaration order.""" + + return self._runtime_sets + + @property + def ctp_simnow_readonly_bindings(self) -> Tuple[Any, ...]: + """Return the registry's exact code-owned CTP private-read bindings.""" + + return self._ctp_simnow_readonly_bindings + + def require_ctp_simnow_readonly_binding(self, runtime_id: str) -> Any: + """Return one exact read-only binding or reject before provider imports.""" + + binding = self._by_ctp_simnow_runtime_id.get(runtime_id) + if binding is None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "no reviewed CTP SimNow read-only route is registered for this runtime", + field_path="runtime.preset", + reason="ctp_simnow_preflight_route_unregistered", + ) + if not self.trusted: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime registry is not trusted for provider preflight", + field_path="strategy_dir", + reason="registry_not_trusted", + ) + try: + binding_unchanged = _ctp_binding_snapshot( + binding + ) == self._ctp_simnow_binding_snapshots.get(runtime_id) + except Exception: + binding_unchanged = False + if not binding_unchanged: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "registered CTP SimNow read-only binding changed after registration", + field_path="runtime.preset", + reason="ctp_simnow_preflight_binding_invalid", + ) + return binding + + def require_runtime_dir(self, runtime_dir: Union[str, os.PathLike]) -> RegisteredRuntime: + """Return an exact directory registration or reject the startup.""" + + registration = self._by_directory.get(_directory_key(runtime_dir)) + if registration is None: + # If a registered path was replaced by a symlink/junction between + # registration and lookup, resolving the caller spelling would + # point at the attacker target. Retain the original lexical + # binding long enough to emit the directory-identity rejection + # rather than treating that target as an unrelated runtime. + registration = self._by_lexical_directory.get(_lexical_directory_key(runtime_dir)) + if registration is None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "strategy runtime directory is not registered", + field_path="strategy_dir", + reason="runtime_not_registered", + ) + if not self.trusted: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime registry is not trusted for execution", + field_path="strategy_dir", + reason="registry_not_trusted", + ) + self._verify_registration_snapshot(registration) + return registration + + @staticmethod + def _registration_identity_error() -> RuntimeConfigError: + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime policy changed after registry construction", + field_path="runtime.registration", + reason="runtime_registration_identity_changed", + ) + + def _verify_registration_snapshot(self, registration: RegisteredRuntime) -> None: + # Preserve legacy registration error ordering. The additive profile + # contract needs a registry-time snapshot because profile selectors + # are new mutable authority inputs; older registrations retain their + # established downstream mismatch diagnostics. + if ( + registration.profiles + and self._registration_digests.get(registration.runtime_id) != registration.digest + ): + raise self._registration_identity_error() + + @staticmethod + def _directory_identity_error() -> RuntimeConfigError: + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime directory identity changed after registration", + field_path="strategy_dir", + reason="config_directory_identity_changed", + ) + + @staticmethod + def _open_windows_directory_lock(path: Path) -> int: + """Open a Windows directory handle which denies concurrent deletion. + + ``os.open`` cannot open a directory on Windows and the stdlib offers no + ``dir_fd`` equivalent there. A normal ``CreateFileW`` directory handle + is still useful: requesting ``GENERIC_READ`` while deliberately + omitting ``FILE_SHARE_DELETE`` prevents an unprivileged peer from + renaming or replacing that directory (or one of its parents) until the + handle closes. The caller converts the handle to a CRT descriptor so + that it can compare its identity with the reviewed registration. + + This is intentionally a narrowly-scoped private primitive. It does + not turn Windows into a descriptor-relative filesystem API; path based + file operations below remain safe only while this lease is held. + """ + + try: + import ctypes + import msvcrt + from ctypes import wintypes + except (ImportError, AttributeError): + raise OSError("Windows directory locking support is unavailable") from None + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + create_file.restype = wintypes.HANDLE + close_handle = kernel32.CloseHandle + close_handle.argtypes = (wintypes.HANDLE,) + close_handle.restype = wintypes.BOOL + + generic_read = 0x80000000 + file_share_read = 0x00000001 + file_share_write = 0x00000002 + open_existing = 3 + file_flag_backup_semantics = 0x02000000 + file_flag_open_reparse_point = 0x00200000 + invalid_handle = ctypes.c_void_p(-1).value + handle = create_file( + str(path), + generic_read, + file_share_read | file_share_write, + None, + open_existing, + file_flag_backup_semantics | file_flag_open_reparse_point, + None, + ) + if handle == invalid_handle or handle == -1: + raise OSError(ctypes.get_last_error(), "CreateFileW could not lock runtime directory") + try: + # Ownership transfers to the CRT fd. ``O_NOINHERIT`` ensures a + # spawned provider or test process cannot accidentally retain the + # lease after this runtime call has finished. + return msvcrt.open_osfhandle(handle, os.O_RDONLY | getattr(os, "O_NOINHERIT", 0)) + except OSError: + close_handle(handle) + raise + + def verify_runtime_dir_identity(self, registration: RegisteredRuntime) -> None: + """Fail closed when a reviewed runtime directory was replaced. + + The registration records an ``lstat`` identity, including the exact + mode, when the code-owned registry is built. Rechecking both the + directory type and identity catches a directory replacement, a leaf + symlink/junction, and a parent-path redirect before runtime config or + runner code is used. + """ + + trusted = self._by_directory.get(_directory_key(registration.runtime_dir)) + if trusted is not registration: + raise self._directory_identity_error() + self._verify_registration_snapshot(registration) + expected = registration.directory_identity + if expected is None: + raise self._directory_identity_error() + try: + current = os.lstat(str(registration.runtime_dir)) + except OSError: + raise self._directory_identity_error() from None + if not expected.matches(current): + raise self._directory_identity_error() + + @contextmanager + def verified_runtime_directory( + self, registration: RegisteredRuntime + ) -> Generator[Optional[int], None, None]: + """Yield a verified runtime-directory fd where the OS supports it. + + POSIX runs use ``O_DIRECTORY`` and ``O_NOFOLLOW`` plus descriptor + relative config opens. Windows has no portable descriptor-relative + API, so it holds a ``CreateFileW`` directory lease which excludes + ``FILE_SHARE_DELETE`` for the whole operation. That blocks normal + cross-process directory or parent replacement while bootstrap writes + and while a runner receives its runtime pathname. + """ + + self.verify_runtime_dir_identity(registration) + descriptor: Optional[int] = None + windows_lock_fd: Optional[int] = None + if os.name == "posix" and hasattr(os, "O_DIRECTORY"): + flags = os.O_RDONLY | os.O_DIRECTORY + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(str(registration.runtime_dir), flags) + opened = os.fstat(descriptor) + except OSError: + if descriptor is not None: + try: + os.close(descriptor) + except OSError: + pass + raise self._directory_identity_error() from None + expected = registration.directory_identity + if expected is None or not expected.matches(opened): + try: + os.close(descriptor) + except OSError: + pass + raise self._directory_identity_error() + elif os.name == "nt": + try: + windows_lock_fd = self._open_windows_directory_lock(registration.runtime_dir) + opened = os.fstat(windows_lock_fd) + except OSError: + if windows_lock_fd is not None: + try: + os.close(windows_lock_fd) + except OSError: + pass + raise self._directory_identity_error() from None + expected = registration.directory_identity + if expected is None or not expected.matches(opened): + try: + os.close(windows_lock_fd) + except OSError: + pass + raise self._directory_identity_error() + # The handle identity proves the path lookup selected the reviewed + # object. Recheck the name while the lease is now active so a + # path change immediately before handle acquisition is also a + # fail-closed startup error. + try: + self.verify_runtime_dir_identity(registration) + except RuntimeConfigError: + try: + os.close(windows_lock_fd) + except OSError: + pass + raise + try: + yield descriptor + finally: + # Validate while the Windows lease is still held. Without this + # ordering, closing it first would reopen a narrow window in which + # a runner's pathname could be replaced before the final check. + identity_error: Optional[RuntimeConfigError] = None + try: + self.verify_runtime_dir_identity(registration) + except RuntimeConfigError as error: + identity_error = error + if descriptor is not None: + try: + os.close(descriptor) + except OSError: + pass + if windows_lock_fd is not None: + try: + os.close(windows_lock_fd) + except OSError: + pass + if identity_error is not None: + raise identity_error + self.verify_runtime_dir_identity(registration) + + def require_runtime_set(self, name: str) -> Tuple[RegisteredRuntime, ...]: + """Resolve a reviewed runtime-set name without accepting arbitrary paths.""" + + members = self._by_set_name.get(name) + if members is None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime set is not registered", + field_path="runtime_set", + reason="runtime_set_not_registered", + ) + if not self.trusted: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime registry is not trusted for execution", + field_path="runtime_set", + reason="registry_not_trusted", + ) + return members + + +def default_runtime_registry() -> RuntimeRegistry: + """Return the package default registry. + + The source distribution ships only reviewed offline replay runtimes. + Deployments add their own reviewed registrations through code or a signed + operator integration; the loader never discovers arbitrary directories from + CWD, environment variables, or user-editable YAML. + """ + + # Imported lazily to avoid the registry/inventory construction cycle and to + # keep this module free of example, strategy, and provider imports. + from .inventory import iteration41_runtime_registry + + return iteration41_runtime_registry() + + +@dataclass(frozen=True) +class EffectiveRuntimeConfig: + """Read-only mode/preset policy resolved against a trusted registration.""" + + config: RuntimeConfig + registration: RegisteredRuntime + policy: PresetPolicy + order_route: Optional[str] + account_access: Optional[str] + required_capabilities: Tuple[str, ...] + allows_network: bool + allows_external_writes: bool + allows_production_writes: bool + allows_hypothetical_fills: bool + requires_approval: bool + requires_live_confirmation: bool + effective_digest: str + profile: Optional[RuntimeProfile] = None + + @property + def strategy_id(self) -> str: + return self.config.strategy_id + + @property + def mode(self) -> str: + return self.config.mode + + @property + def preset(self) -> str: + return self.config.preset + + @property + def parameters(self) -> Mapping[str, Any]: + return self.config.parameters + + @property + def config_digest(self) -> str: + return self.config.config_digest + + @property + def profile_dispatch_available(self) -> bool: + """Return whether this profile is eligible for the offline runner bridge. + + Profile dispatch currently admits only a code-owned, secret-free, + network-free replay or local backtest runner. In particular, the + simulation/sandbox CTP profile remains closed even when its write + policy is ``deny`` because that preset permits network and secret + references. + """ + + profile = self.profile + if profile is None: + # Preserve the established single-profile registration path. + return True + if type(profile) is not RuntimeProfile: + return False + if profile is not self.registration.profile_for(self.mode, self.preset): + return False + if (profile.mode, profile.preset) not in ( + ("simulation", "replay"), + ("backtest", "local_backtest"), + ): + return False + return ( + profile.runner_module is not None + and profile.allowed_secrets_refs == ("none",) + and self.config.secrets_ref == "none" + and profile.available_capabilities == () + and profile.capability_modules == () + and profile.approval_receipt_digest is None + and profile.offline_managed_execution is False + and profile.sandbox_write_policy == "deny" + and self.order_route is None + and self.account_access is None + and self.required_capabilities == () + and self.allows_network is False + and self.allows_external_writes is False + and self.allows_production_writes is False + and self.allows_hypothetical_fills is False + and self.requires_approval is False + and self.requires_live_confirmation is False + ) + + def as_public_dict(self) -> Dict[str, Any]: + """Return a redacted, JSON-safe effective configuration summary.""" + + dispatch_available = self.profile_dispatch_available + return { + "strategy_id": self.strategy_id, + "mode": self.mode, + "preset": self.preset, + "environment": self.policy.environment, + # Profile policy bits are visible only when the selected profile + # is eligible for the narrow offline runner dispatch contract. + "order_route": self.order_route if dispatch_available else None, + "account_access": self.account_access if dispatch_available else None, + "required_capabilities": self.required_capabilities, + "allows_network": self.allows_network if dispatch_available else False, + "allows_external_writes": ( + self.allows_external_writes if dispatch_available else False + ), + "allows_production_writes": ( + self.allows_production_writes if dispatch_available else False + ), + "allows_hypothetical_fills": ( + self.allows_hypothetical_fills if dispatch_available else False + ), + "requires_approval": self.requires_approval, + "requires_live_confirmation": self.requires_live_confirmation, + "config_digest": self.config_digest, + "registration_digest": self.registration.digest, + "effective_digest": self.effective_digest, + "profile_dispatch_available": self.profile_dispatch_available, + "profile_dispatch_unavailable_reason": ( + None if self.profile_dispatch_available else "profile_dispatch_unavailable" + ), + "profile": None + if self.profile is None + else {"mode": self.profile.mode, "preset": self.profile.preset}, + } + + +@dataclass(frozen=True) +class _EffectiveRuntimeConfigSeal: + """Private identity/snapshot for effective configs produced by this module.""" + + registry: RuntimeRegistry + config: RuntimeConfig + registration: RegisteredRuntime + registration_directory_identity: Optional[RuntimeDirectoryIdentity] + registration_digest: str + policy: PresetPolicy + order_route: Optional[str] + account_access: Optional[str] + required_capabilities: Tuple[str, ...] + allows_network: bool + allows_external_writes: bool + allows_production_writes: bool + allows_hypothetical_fills: bool + requires_approval: bool + requires_live_confirmation: bool + effective_digest: str + profile: Optional[RuntimeProfile] + profile_digest: Optional[str] + + +_EFFECTIVE_RUNTIME_CONFIG_SEALS: Dict[int, Tuple[Any, _EffectiveRuntimeConfigSeal]] = {} + + +def _seal_effective_runtime_config( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> EffectiveRuntimeConfig: + """Record resolver provenance outside the public EffectiveRuntimeConfig fields.""" + + identifier = id(effective) + + def discard(reference: Any) -> None: + current = _EFFECTIVE_RUNTIME_CONFIG_SEALS.get(identifier) + if current is not None and current[0] is reference: + _EFFECTIVE_RUNTIME_CONFIG_SEALS.pop(identifier, None) + + reference = weakref.ref(effective, discard) + _EFFECTIVE_RUNTIME_CONFIG_SEALS[identifier] = ( + reference, + _EffectiveRuntimeConfigSeal( + registry=registry, + config=effective.config, + registration=effective.registration, + registration_directory_identity=effective.registration.directory_identity, + registration_digest=effective.registration.digest, + policy=effective.policy, + order_route=effective.order_route, + account_access=effective.account_access, + required_capabilities=effective.required_capabilities, + allows_network=effective.allows_network, + allows_external_writes=effective.allows_external_writes, + allows_production_writes=effective.allows_production_writes, + allows_hypothetical_fills=effective.allows_hypothetical_fills, + requires_approval=effective.requires_approval, + requires_live_confirmation=effective.requires_live_confirmation, + effective_digest=effective.effective_digest, + profile=effective.profile, + profile_digest=None if effective.profile is None else effective.profile.digest, + ), + ) + return effective + + +def require_effective_runtime_config_seal( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> None: + """Reject forged values, cross-registry reuse, and changed directories.""" + + entry = _EFFECTIVE_RUNTIME_CONFIG_SEALS.get(id(effective)) + if entry is None or entry[0]() is not effective: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the effective runtime configuration was not produced by policy resolution", + field_path="runtime.runner", + reason="effective_config_mismatch", + ) + seal = entry[1] + if ( + seal.registry is not registry + or seal.registration_directory_identity != seal.registration.directory_identity + or (effective.profile is not None and seal.registration_digest != seal.registration.digest) + or effective.config is not seal.config + or effective.registration is not seal.registration + or effective.policy is not seal.policy + or effective.order_route != seal.order_route + or effective.account_access != seal.account_access + or effective.required_capabilities is not seal.required_capabilities + or effective.allows_network != seal.allows_network + or effective.allows_external_writes != seal.allows_external_writes + or effective.allows_production_writes != seal.allows_production_writes + or effective.allows_hypothetical_fills != seal.allows_hypothetical_fills + or effective.requires_approval != seal.requires_approval + or effective.requires_live_confirmation != seal.requires_live_confirmation + or effective.effective_digest != seal.effective_digest + or effective.profile is not seal.profile + or (None if effective.profile is None else effective.profile.digest) != seal.profile_digest + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the effective runtime configuration no longer matches policy resolution", + field_path="runtime.runner", + reason="effective_config_mismatch", + ) + # The effective value was issued for this exact registry and its original + # directory identity. Verify that private binding before any caller-owned + # EffectiveRuntimeConfig field is read during runner re-resolution. + registry.verify_runtime_dir_identity(seal.registration) + require_loaded_runtime_config_seal(effective.config, registry) + + +def _validate_parameters( + config: RuntimeConfig, + registration: RegisteredRuntime, + profile: Optional[RuntimeProfile] = None, +) -> None: + policy_values = _profile_policy_values(registration, profile) + allowed = frozenset(policy_values.allowed_parameter_keys) + for key in config.parameters: + if key not in allowed: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "strategy parameter is not registered for this runtime", + field_path="parameters.{0}".format(key), + reason="parameter_not_registered", + ) + + +def _validate_secret_ref( + config: RuntimeConfig, + policy: PresetPolicy, + registration: RegisteredRuntime, + profile: Optional[RuntimeProfile] = None, +) -> None: + policy_values = _profile_policy_values(registration, profile) + if config.secrets_ref != "none" and not policy.accepts_provider_secrets: + raise RuntimeConfigError( + ENVIRONMENT_MISMATCH, + "this mode/preset does not accept a provider secret reference", + field_path="secrets_ref", + reason="secrets_not_allowed_for_preset", + ) + if config.secrets_ref not in policy_values.allowed_secrets_refs: + raise RuntimeConfigError( + ENVIRONMENT_MISMATCH, + "secrets_ref is not bound to the registered runtime environment", + field_path="secrets_ref", + reason="secrets_ref_not_registered", + ) + + +def _require_approval( + registration: RegisteredRuntime, + field_path: str, + profile: Optional[RuntimeProfile] = None, +) -> None: + policy_values = _profile_policy_values(registration, profile) + if policy_values.approval_receipt_digest is None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered write-capable profile has no trusted approval receipt", + field_path=field_path, + reason="approval_receipt_missing", + ) + + +def _require_capabilities( + registration: RegisteredRuntime, + required_capabilities: Tuple[str, ...], + field_path: str, + profile: Optional[RuntimeProfile] = None, +) -> None: + policy_values = _profile_policy_values(registration, profile) + available = frozenset(policy_values.available_capabilities) + missing = tuple(name for name in required_capabilities if name not in available) + if missing: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered profile lacks a required managed capability", + field_path=field_path, + reason="required_capability_not_declared", + ) + + +def _effective_digest( + config: RuntimeConfig, + registration: RegisteredRuntime, + policy: PresetPolicy, + order_route: Optional[str], + account_access: Optional[str], + required_capabilities: Tuple[str, ...], + allows_external_writes: bool, + profile: Optional[RuntimeProfile] = None, +) -> str: + canonical = { + "account_access": account_access, + "allows_external_writes": allows_external_writes, + "config_digest": config.config_digest, + "order_route": order_route, + "policy": policy.name, + "registration_digest": registration.digest, + "required_capabilities": required_capabilities, + } + if profile is not None: + canonical["profile_digest"] = profile.digest + encoded = json.dumps(canonical, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def resolve_runtime_config( + config: RuntimeConfig, registry: RuntimeRegistry +) -> EffectiveRuntimeConfig: + """Bind a parsed config to a reviewed registry entry without I/O. + + This is still a static phase. It does not read secrets, inspect installed + optional distributions, initialize a strategy, or make a network call. + """ + + if not isinstance(config, RuntimeConfig): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime configuration must be a loader-produced RuntimeConfig", + field_path="runtime.config", + reason="config_provenance_invalid", + ) + require_loaded_runtime_config_seal(config, registry) + registration = registry.require_runtime_dir(config.strategy_dir) + registry.verify_runtime_dir_identity(registration) + if config.ctp_production is not None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "CTP production config has parser support only and no reviewed admission route", + field_path="ctp_production", + reason="ctp_production_admission_unavailable", + ) + if registration.strategy_id != config.strategy_id: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "strategy.id does not match the registered runtime identity", + field_path="strategy.id", + reason="strategy_id_not_registered", + ) + _validate_unavailable_mode_profiles_shape(registration) + for profile in registration.unavailable_mode_profiles: + if (config.mode, config.preset) == (profile.mode, profile.preset): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime profile is declared but unavailable", + field_path="runtime.preset", + reason=profile.reason, + ) + _validate_runtime_profiles_shape(registration) + profile = registration.profile_for(config.mode, config.preset) + if registration.profiles and profile is None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime.mode and runtime.preset are not bound to a profile for this runtime", + field_path="runtime.preset", + reason="profile_not_registered", + ) + if not registration.profiles and config.preset not in registration.allowed_presets: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime.preset is not bound to this registered runtime", + field_path="runtime.preset", + reason="preset_not_registered", + ) + + policy = get_preset_policy(config.preset) + if policy is None or policy.mode != config.mode: + # This normally cannot happen because load_runtime_config checks it, + # but retain the gate for programmatic RuntimeConfig construction. + raise RuntimeConfigError( + MODE_PRESET_MISMATCH, + "runtime.mode and runtime.preset are not a permitted pair", + field_path="runtime.preset", + reason="mode_preset_mismatch", + ) + + policy_values = _profile_policy_values(registration, profile) + _validate_parameters(config, registration, profile) + _validate_secret_ref(config, policy, registration, profile) + + order_route = policy.order_route + account_access = policy.account_access + required_capabilities = policy.required_capabilities + allows_external_writes = policy.allows_external_writes + allows_production_writes = policy.allows_production_writes + requires_approval = policy.requires_approval + + if policy_values.offline_managed_execution: + # This is a deliberately narrow L2 fixture seam. It is enabled only + # by reviewed registration data; config.yaml cannot request it. Keep + # the replay policy's offline/no-write booleans intact while exposing + # the three managed SDK capabilities to the adapter bridge. + if (config.mode, config.preset) != ("simulation", "replay"): + raise RuntimeConfigError( + MODE_PRESET_MISMATCH, + "offline managed execution is restricted to simulation/replay", + field_path="runtime.preset", + reason="offline_managed_execution_requires_replay", + ) + if config.secrets_ref != "none": + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "offline managed execution must not accept provider secrets", + field_path="secrets_ref", + reason="offline_managed_execution_forbids_secrets", + ) + required_capabilities = MANAGED_WRITE_CAPABILITIES + _require_capabilities(registration, required_capabilities, "runtime.preset", profile) + order_route = "managed_execution" + account_access = "fake_provider" + elif config.preset == "sandbox" and policy_values.sandbox_write_policy == "receipt_required": + _require_approval(registration, "runtime.preset", profile) + required_capabilities = MANAGED_WRITE_CAPABILITIES + _require_capabilities(registration, required_capabilities, "runtime.preset", profile) + order_route = "managed_execution" + account_access = "sandbox_direct_provider" + allows_external_writes = True + allows_production_writes = False + requires_approval = True + elif required_capabilities: + _require_approval(registration, "runtime.preset", profile) + _require_capabilities(registration, required_capabilities, "runtime.preset", profile) + + effective_digest = _effective_digest( + config, + registration, + policy, + order_route, + account_access, + required_capabilities, + allows_external_writes, + profile, + ) + return _seal_effective_runtime_config( + EffectiveRuntimeConfig( + config=config, + registration=registration, + policy=policy, + order_route=order_route, + account_access=account_access, + required_capabilities=required_capabilities, + allows_network=policy.allows_network, + allows_external_writes=allows_external_writes, + allows_production_writes=allows_production_writes, + allows_hypothetical_fills=policy.allows_hypothetical_fills, + requires_approval=requires_approval, + requires_live_confirmation=policy.requires_live_confirmation, + effective_digest=effective_digest, + profile=profile, + ), + registry, + ) + + +def validate_runtime_config( + strategy_dir: Union[str, os.PathLike], registry: RuntimeRegistry +) -> EffectiveRuntimeConfig: + """Run registration, strict schema, and sealed-policy validation only.""" + + config = load_runtime_config(strategy_dir, registry=registry) + return resolve_runtime_config(config, registry) + + +def _bootstrap_content( + registration: RegisteredRuntime, policy: PresetPolicy, *, secrets_ref: str = "none" +) -> str: + parameter_lines = [] + for key, value in registration.bootstrap_parameters: + if isinstance(value, tuple): + parameter_lines.append(" {0}:".format(key)) + parameter_lines.extend( + " - {0}".format(_bootstrap_yaml_scalar(item)) for item in value + ) + else: + parameter_lines.append(" {0}: {1}".format(key, _bootstrap_yaml_scalar(value))) + parameters = ( + "parameters: {}" if not parameter_lines else "parameters:\n" + "\n".join(parameter_lines) + ) + return ( + "config_schema_version: 4\n\n" + "strategy:\n" + " id: {0}\n\n" + "runtime:\n" + " mode: {1}\n" + " preset: {2}\n\n" + "{3}\n" + "secrets_ref: {4}\n" + ).format(registration.strategy_id, policy.mode, policy.name, parameters, secrets_ref) + + +def _bootstrap_yaml_scalar(value: Any) -> str: + """Serialize one constrained, code-owned bootstrap scalar as YAML text.""" + + if type(value) is bool: + return "true" if value else "false" + if type(value) is int: + return str(value) + return json.dumps(value, ensure_ascii=True) + + +_SAFE_BOOTSTRAP_PRESETS = ("local_backtest", "replay", "shadow", "paper", "sandbox") + + +def select_bootstrap_preset(registration: RegisteredRuntime) -> str: + """Select the safest reviewed non-write preset bound to one registration.""" + + for preset in _SAFE_BOOTSTRAP_PRESETS: + if preset not in registration.allowed_presets: + continue + if preset == "sandbox" and registration.sandbox_write_policy == "receipt_required": + continue + return preset + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "this runtime has no bootstrap-safe preset", + field_path="runtime.preset", + reason="bootstrap_safe_preset_unavailable", + ) + + +def _prepare_bootstrap( + registration: RegisteredRuntime, preset: str, *, secrets_ref: str = "none" +) -> Tuple[PresetPolicy, str]: + """Validate a no-write bootstrap request and produce its canonical text.""" + + policy = get_preset_policy(preset) + if policy is None: + raise RuntimeConfigError( + MODE_PRESET_MISMATCH, + "runtime.preset is not a registered preset name", + field_path="runtime.preset", + reason="unsupported_preset", + ) + if preset not in registration.allowed_presets: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runtime.preset is not bound to this registered runtime", + field_path="runtime.preset", + reason="preset_not_registered", + ) + if secrets_ref not in registration.allowed_secrets_refs: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "bootstrap has no code-owned secret reference for this runtime", + field_path="secrets_ref", + reason="bootstrap_secret_reference_unavailable", + ) + if policy.allows_production_writes or ( + preset == "sandbox" and registration.sandbox_write_policy == "receipt_required" + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "bootstrap cannot generate a write-capable runtime contract", + field_path="runtime.preset", + reason="bootstrap_write_capable_preset_not_allowed", + ) + if registration.directory_identity is None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "registered runtime directory is unavailable", + field_path="strategy_dir", + reason="runtime_directory_unavailable", + ) + return policy, _bootstrap_content(registration, policy, secrets_ref=secrets_ref) + + +def _bootstrap_secrets_ref( + registration: RegisteredRuntime, registry: RuntimeRegistry, policy: PresetPolicy +) -> str: + """Select only the secret ref of a code-owned CTP read-only binding.""" + + if policy.name == "sandbox": + binding = registry._by_ctp_simnow_runtime_id.get(registration.runtime_id) + if binding is not None: + # Neither the legacy validation-only binding nor the reviewed + # config-driven binding can provide account, credential, contract, + # or exact front values. Generic bootstrap must not write an + # unusable private config or imply that it grants provider access. + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "CTP SimNow private config.yaml must be prepared by the operator", + field_path="ctp_simnow", + reason="ctp_simnow_private_config_required", + ) + return "none" + + +def _runtime_config_entry_exists( + registration: RegisteredRuntime, directory_fd: Optional[int] +) -> bool: + """Check only whether config.yaml has a directory entry under the seal.""" + + try: + if directory_fd is None: + os.lstat(str(registration.runtime_dir / "config.yaml")) + else: + os.lstat("config.yaml", dir_fd=directory_fd) + except FileNotFoundError: + return False + except OSError: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config.yaml cannot be inspected in the registered runtime directory", + field_path="config.yaml", + reason="config_directory_identity_changed", + ) from None + return True + + +def _matching_bootstrap_config( + registration: RegisteredRuntime, + registry: RuntimeRegistry, + content: str, + *, + directory_fd: Optional[int] = None, +) -> Optional[RuntimeConfig]: + """Return a validated config only when it is byte-for-byte bootstrap content. + + Batch retries intentionally do not rewrite or normalize an operator-edited + file. A canonical file created by a prior batch is safe to skip; every + other existing path is reported as a conflict during preflight. + """ + + try: + source_path, existing = _read_config_text( + registration.runtime_dir, directory_fd=directory_fd + ) + except RuntimeConfigError: + return None + if existing != content: + return None + try: + # Match and schema-validate the same descriptor-sealed text. A second + # path read here could turn a changed file into an `already_matching` + # result after the comparison had already succeeded. + return _parse_verified_runtime_config_text( + existing, registration.runtime_dir, source_path, registry=registry + ) + except RuntimeConfigError: + return None + + +def _require_matching_bootstrap_config( + registration: RegisteredRuntime, + registry: RuntimeRegistry, + content: str, + *, + directory_fd: Optional[int] = None, +) -> RuntimeConfig: + """Return one post-write config only when its sealed bytes are canonical. + + The writer and parser share the caller's already verified directory + descriptor on POSIX. In particular, do not call ``load_runtime_config`` + here: it would reopen the path after creation and could report a digest + for a different, concurrently replaced file. + """ + + config = _matching_bootstrap_config(registration, registry, content, directory_fd=directory_fd) + if config is not None: + return config + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config.yaml changed after bootstrap creation", + field_path="config.yaml", + reason="config_identity_changed", + ) + + +def bootstrap_runtime_set(runtime_set: str, registry: RuntimeRegistry) -> BootstrapRuntimeSetResult: + """Create canonical safe configs for one reviewed set of runtime identities. + + All targets are inspected before any file is written. Existing canonical + configs are idempotent ``already_matching`` results. Any preflight + conflict leaves every missing target untouched. After a successful + preflight each target is created with the existing O_EXCL writer, so a + concurrent creator can only affect that one target and never cause an + overwrite or rollback of unrelated configurations. + """ + + registrations = registry.require_runtime_set(runtime_set) + prepared = [] + preflight_items = [] + has_conflict = False + for registration in registrations: + try: + preset = select_bootstrap_preset(registration) + preview_policy = get_preset_policy(preset) + secrets_ref = ( + "none" + if preview_policy is None + else _bootstrap_secrets_ref(registration, registry, preview_policy) + ) + policy, content = _prepare_bootstrap(registration, preset, secrets_ref=secrets_ref) + with registry.verified_runtime_directory(registration) as directory_fd: + has_existing_config = _runtime_config_entry_exists(registration, directory_fd) + existing = ( + _matching_bootstrap_config( + registration, + registry, + content, + directory_fd=directory_fd, + ) + if has_existing_config + else None + ) + except RuntimeConfigError as error: + preflight_items.append( + BootstrapRuntimeSetItem( + runtime_id=registration.runtime_id or registration.strategy_id, + strategy_id=registration.strategy_id, + runtime_dir=str(registration.runtime_dir), + mode="", + preset="", + status="conflict", + reason=error.reason or "bootstrap_preflight_failed", + ) + ) + has_conflict = True + continue + + if has_existing_config: + if existing is None: + preflight_items.append( + BootstrapRuntimeSetItem( + runtime_id=registration.runtime_id or registration.strategy_id, + strategy_id=registration.strategy_id, + runtime_dir=str(registration.runtime_dir), + mode=policy.mode, + preset=policy.name, + status="conflict", + reason="existing_config_differs", + ) + ) + has_conflict = True + else: + preflight_items.append( + BootstrapRuntimeSetItem( + runtime_id=registration.runtime_id or registration.strategy_id, + strategy_id=registration.strategy_id, + runtime_dir=str(registration.runtime_dir), + mode=policy.mode, + preset=policy.name, + status="already_matching", + config_digest=existing.config_digest, + ) + ) + continue + prepared.append((registration, policy, content)) + preflight_items.append( + BootstrapRuntimeSetItem( + runtime_id=registration.runtime_id or registration.strategy_id, + strategy_id=registration.strategy_id, + runtime_dir=str(registration.runtime_dir), + mode=policy.mode, + preset=policy.name, + status="pending", + ) + ) + + if has_conflict: + items = [] + for item in preflight_items: + if item.status == "pending": + items.append( + BootstrapRuntimeSetItem( + runtime_id=item.runtime_id, + strategy_id=item.strategy_id, + runtime_dir=item.runtime_dir, + mode=item.mode, + preset=item.preset, + status="not_written", + reason="batch_preflight_failed", + ) + ) + else: + items.append(item) + return BootstrapRuntimeSetResult( + runtime_set=runtime_set, + status="preflight_failed", + items=tuple(items), + ) + + created_by_runtime_id: Dict[str, BootstrapRuntimeSetItem] = {} + creation_failed = False + for registration, policy, content in prepared: + runtime_id = registration.runtime_id or registration.strategy_id + try: + with registry.verified_runtime_directory(registration) as directory_fd: + write_bootstrap_config( + registration.runtime_dir / "config.yaml", + content, + directory_fd=directory_fd, + ) + config = _require_matching_bootstrap_config( + registration, + registry, + content, + directory_fd=directory_fd, + ) + created_by_runtime_id[runtime_id] = BootstrapRuntimeSetItem( + runtime_id=runtime_id, + strategy_id=registration.strategy_id, + runtime_dir=str(registration.runtime_dir), + mode=policy.mode, + preset=policy.name, + status="created", + config_digest=config.config_digest, + ) + except RuntimeConfigError as error: + # A successful create followed by a non-matching descriptor must + # never be relabelled ``already_matching`` by reopening a later + # pathname. Only an O_EXCL/concurrent-create failure may inspect + # one current descriptor to recognise an independently-created + # canonical file. + existing = None + if error.reason != "config_identity_changed": + try: + with registry.verified_runtime_directory(registration) as directory_fd: + existing = _matching_bootstrap_config( + registration, + registry, + content, + directory_fd=directory_fd, + ) + except RuntimeConfigError: + existing = None + if existing is not None: + created_by_runtime_id[runtime_id] = BootstrapRuntimeSetItem( + runtime_id=runtime_id, + strategy_id=registration.strategy_id, + runtime_dir=str(registration.runtime_dir), + mode=policy.mode, + preset=policy.name, + status="already_matching", + config_digest=existing.config_digest, + ) + else: + created_by_runtime_id[runtime_id] = BootstrapRuntimeSetItem( + runtime_id=runtime_id, + strategy_id=registration.strategy_id, + runtime_dir=str(registration.runtime_dir), + mode=policy.mode, + preset=policy.name, + status="creation_failed", + reason=error.reason or "config_create_failed", + ) + creation_failed = True + + items = [] + for item in preflight_items: + if item.status == "pending": + items.append(created_by_runtime_id[item.runtime_id]) + else: + items.append(item) + return BootstrapRuntimeSetResult( + runtime_set=runtime_set, + status="partial" if creation_failed else "bootstrapped", + items=tuple(items), + ) + + +def bootstrap_runtime_config( + strategy_dir: Union[str, os.PathLike], + registry: RuntimeRegistry, + preset: str = "local_backtest", +) -> RuntimeConfig: + """Create a reviewed safe default config with atomic no-overwrite semantics.""" + + registration = registry.require_runtime_dir(strategy_dir) + preview_policy = get_preset_policy(preset) + if preview_policy is None: + policy, content = _prepare_bootstrap(registration, preset) + else: + secrets_ref = _bootstrap_secrets_ref(registration, registry, preview_policy) + policy, content = _prepare_bootstrap(registration, preset, secrets_ref=secrets_ref) + + with registry.verified_runtime_directory(registration) as directory_fd: + if _runtime_config_entry_exists(registration, directory_fd): + raise RuntimeConfigError( + CONFIG_EXISTS, + "config.yaml already exists and will not be overwritten", + field_path="config.yaml", + reason="config_exists", + ) + write_bootstrap_config( + registration.runtime_dir / "config.yaml", content, directory_fd=directory_fd + ) + config = _require_matching_bootstrap_config( + registration, + registry, + content, + directory_fd=directory_fd, + ) + return config diff --git a/backtrader_runtime/review_evidence.py b/backtrader_runtime/review_evidence.py new file mode 100644 index 00000000..2c29f7dd --- /dev/null +++ b/backtrader_runtime/review_evidence.py @@ -0,0 +1,507 @@ +"""Trusted Iteration 41 bindings for offline AI review evidence. + +The portable ``bt-api-deployment-evidence/v1`` wire is deliberately shared by +three independently packaged AI products. None of those products may import +the Backtrader runtime registry, because doing so would turn a review helper +into an execution dependency. This module is the narrow composition point +where Backtrader derives the consumer's expected bindings from a *currently +registered* schema-v4 ``config.yaml`` and the actual strategy artifact bytes. + +It has no provider, account, execution, gateway, or AI-product import. A +successful validation is still only ``REVIEW_REQUIRED``. It is not an +admission receipt and cannot authorize deployment, execution, or control. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import math +import re +import stat +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Optional, Union + +from .config import load_runtime_config +from .registry import RuntimeRegistry, resolve_runtime_config + + +ITERATION41_EVIDENCE_SCHEMA_VERSION = "bt-api-deployment-evidence/v1" +REVIEW_REQUIRED = "review_required" + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_WIRE_FIELDS = frozenset( + { + "artifact_sha256", + "config_effective_digest", + "created_at", + "evidence_id", + "expires_at", + "metadata", + "producer", + "review_status", + "schema_version", + "strategy_id", + "tenant_id", + } +) +_PRODUCER_FIELDS = frozenset({"commit", "product", "version", "wheel_sha256"}) +_CHUNK_SIZE = 1024 * 1024 + +# Evidence metadata is descriptive. It must never carry a credential, +# approval, account/control assertion, or a command-shaped field that could be +# mistaken for an admission capability. The producer already enforces this +# family of names; repeat it at this independent consumer boundary. +_FORBIDDEN_METADATA_KEY_PARTS = ( + "accesskey", + "accountaccess", + "admission", + "apikey", + "approval", + "approved", + "authorization", + "authorize", + "control", + "credential", + "deploy", + "drain", + "executionauthorize", + "freeze", + "passphrase", + "password", + "permission", + "permit", + "privatekey", + "promotion", + "reviewed", + "reviewstatus", + "resume", + "riskpermit", + "secret", + "submitorder", + "token", +) + +SerializedEvidence = Union[str, bytes, bytearray] + + +class Iteration41ReviewEvidenceError(ValueError): + """A safe, deterministic rejection at the review-evidence boundary.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise Iteration41ReviewEvidenceError(reason, message) + + +def _identifier(value: Any, field_name: str) -> str: + if not isinstance(value, str) or value != value.strip() or not _IDENTIFIER.fullmatch(value): + _reject("invalid_identifier", "invalid {0}".format(field_name)) + return value + + +def _sha256(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not _SHA256.fullmatch(value): + _reject("invalid_digest", "invalid {0}".format(field_name)) + return value + + +def _timestamp(value: Any, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + try: + normalized = float(value) + except (OverflowError, TypeError, ValueError): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + if not math.isfinite(normalized): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + return 0.0 if normalized == 0.0 else normalized + + +def _normalized_key(key: str) -> str: + return "".join(character for character in key.lower() if character.isalnum()) + + +def _is_forbidden_metadata_key(key: str) -> bool: + normalized = _normalized_key(key) + return any(part in normalized for part in _FORBIDDEN_METADATA_KEY_PARTS) + + +def _copy_json( + value: Any, + *, + path: tuple[str, ...] = (), + allow_root_review_status: bool = False, + ancestors: frozenset[int] = frozenset(), +) -> Any: + """Return a finite JSON copy while rejecting authority-shaped fields.""" + + if value is None or isinstance(value, bool): + return value + if isinstance(value, int): + return value + if isinstance(value, float): + if not math.isfinite(value): + _reject("invalid_json", "evidence contains a non-finite JSON value") + return 0.0 if value == 0.0 else value + if isinstance(value, str): + return value + if isinstance(value, Mapping): + if id(value) in ancestors: + _reject("invalid_json", "evidence JSON must not contain cycles") + next_ancestors = ancestors | {id(value)} + copied: dict[str, Any] = {} + for key, nested in value.items(): + if not isinstance(key, str): + _reject("invalid_json", "evidence JSON object keys must be strings") + if key in copied: + _reject("invalid_json", "evidence JSON contains duplicate object keys") + root_review_status = ( + allow_root_review_status and not path and _normalized_key(key) == "reviewstatus" + ) + if _is_forbidden_metadata_key(key) and not root_review_status: + _reject( + "authority_shaped_metadata", + "evidence metadata contains a secret or authority-shaped field", + ) + copied[key] = _copy_json( + nested, + path=path + (key,), + allow_root_review_status=allow_root_review_status, + ancestors=next_ancestors, + ) + return copied + if isinstance(value, (list, tuple)): + if id(value) in ancestors: + _reject("invalid_json", "evidence JSON must not contain cycles") + next_ancestors = ancestors | {id(value)} + return [ + _copy_json( + nested, + path=path, + allow_root_review_status=allow_root_review_status, + ancestors=next_ancestors, + ) + for nested in value + ] + _reject("invalid_json", "evidence contains an unsupported JSON value") + + +def canonical_json(value: Any) -> str: + """Serialize v1 evidence using the independently shared canonical form.""" + + allow_root_review_status = isinstance(value, Mapping) and set(value) == _WIRE_FIELDS + copied = _copy_json(value, allow_root_review_status=allow_root_review_status) + return json.dumps( + copied, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ) + + +def evidence_sha256(value: Any) -> str: + """Return the SHA-256 of the full canonical v1 wire record.""" + + return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +def _reject_json_constant(_: str) -> None: + _reject("invalid_json", "evidence contains a non-finite JSON value") + + +def _reject_duplicate_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _reject("invalid_json", "evidence JSON contains duplicate object keys") + result[key] = value + return result + + +def _coerce_wire(value: Mapping[str, Any] | SerializedEvidence) -> dict[str, Any]: + if isinstance(value, bytearray): + value = bytes(value) + if isinstance(value, bytes): + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + _reject("invalid_json", "evidence must be UTF-8 JSON") + if isinstance(value, str): + try: + parsed = json.loads( + value, + object_pairs_hook=_reject_duplicate_fields, + parse_constant=_reject_json_constant, + ) + except Iteration41ReviewEvidenceError: + raise + except json.JSONDecodeError: + _reject("invalid_json", "evidence is not valid JSON") + if not isinstance(parsed, dict): + _reject("invalid_wire", "evidence wire must be an object") + return parsed + if not isinstance(value, Mapping): + _reject("invalid_wire", "evidence wire must be an object") + copied = _copy_json(value, allow_root_review_status=set(value) == _WIRE_FIELDS) + if not isinstance(copied, dict): # Defensive: Mapping always copies to dict. + _reject("invalid_wire", "evidence wire must be an object") + return copied + + +def _artifact_sha256(path: Path) -> str: + try: + file_status = path.lstat() + except OSError: + _reject("artifact_unreadable", "strategy artifact cannot be read") + if stat.S_ISLNK(file_status.st_mode) or not stat.S_ISREG(file_status.st_mode): + _reject("artifact_not_regular", "strategy artifact must be a regular non-symlink file") + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(_CHUNK_SIZE), b""): + digest.update(chunk) + except OSError: + _reject("artifact_unreadable", "strategy artifact cannot be read") + return digest.hexdigest() + + +@dataclass(frozen=True) +class Iteration41ReviewEvidenceBindings: + """Independent expected values for an AI evidence consumer. + + The values are derived from a registered runtime's currently parsed v4 + config and actual artifact bytes. They are input to a read-only consumer, + never a credential, admission receipt, or provider capability. + """ + + tenant_id: str + strategy_id: str + artifact_sha256: str + config_effective_digest: str + registration_digest: str + + def __post_init__(self) -> None: + object.__setattr__(self, "tenant_id", _identifier(self.tenant_id, "tenant_id")) + object.__setattr__(self, "strategy_id", _identifier(self.strategy_id, "strategy_id")) + object.__setattr__( + self, "artifact_sha256", _sha256(self.artifact_sha256, "artifact_sha256") + ) + object.__setattr__( + self, + "config_effective_digest", + _sha256(self.config_effective_digest, "config_effective_digest"), + ) + object.__setattr__( + self, "registration_digest", _sha256(self.registration_digest, "registration_digest") + ) + + def as_consumer_bindings(self) -> Mapping[str, str]: + """Return detached bindings shared by the independent read-only consumers.""" + + return MappingProxyType( + { + "expected_tenant_id": self.tenant_id, + "expected_strategy_id": self.strategy_id, + "expected_artifact_sha256": self.artifact_sha256, + "expected_config_effective_digest": self.config_effective_digest, + } + ) + + +@dataclass(frozen=True) +class Iteration41ReviewEvidenceObservation: + """A successful review-only observation with no authority fields set.""" + + evidence_id: str + evidence_sha256: str + tenant_id: str + strategy_id: str + artifact_sha256: str + config_effective_digest: str + checked_at: float + status: str = "REVIEW_REQUIRED" + review_status: str = REVIEW_REQUIRED + read_only: bool = True + deployment_authorized: bool = False + execution_authorized: bool = False + control_authorized: bool = False + + def as_public_dict(self) -> dict[str, Any]: + """Return the deliberately non-authoritative observation payload.""" + + return { + "artifact_sha256": self.artifact_sha256, + "checked_at": self.checked_at, + "config_effective_digest": self.config_effective_digest, + "control_authorized": self.control_authorized, + "deployment_authorized": self.deployment_authorized, + "evidence_id": self.evidence_id, + "evidence_sha256": self.evidence_sha256, + "execution_authorized": self.execution_authorized, + "read_only": self.read_only, + "review_status": self.review_status, + "status": self.status, + "strategy_id": self.strategy_id, + "tenant_id": self.tenant_id, + } + + +def resolve_iteration41_review_evidence_bindings( + *, + strategy_dir: Union[str, Path], + tenant_id: str, + artifact_path: Union[str, Path], + registry: RuntimeRegistry, +) -> Iteration41ReviewEvidenceBindings: + """Derive independent evidence bindings from a registered v4 runtime. + + ``load_runtime_config(..., registry=...)`` rejects an unregistered path + *before* opening its config. Resolution then includes the reviewed + registration digest in the effective digest, so a changed config or + registration cannot reuse old evidence under this Iteration 41 path. + """ + + expected_tenant = _identifier(tenant_id, "tenant_id") + config = load_runtime_config(strategy_dir, registry=registry) + effective = resolve_runtime_config(config, registry) + return Iteration41ReviewEvidenceBindings( + tenant_id=expected_tenant, + strategy_id=effective.strategy_id, + artifact_sha256=_artifact_sha256(Path(artifact_path)), + config_effective_digest=effective.effective_digest, + registration_digest=effective.registration.digest, + ) + + +def validate_iteration41_review_evidence( + evidence: Union[Mapping[str, Any], SerializedEvidence], + *, + expected_evidence_sha256: str, + bindings: Iteration41ReviewEvidenceBindings, + now: Optional[float] = None, +) -> Iteration41ReviewEvidenceObservation: + """Validate one v1 record against an independent registered-runtime scope. + + This is intentionally an Iteration 41 profile, not a replacement for the + three products' broader portable v1 reader APIs. Legacy evidence may be + read by its original product, but it cannot pass this config-v4 binding + unless it is a fresh, unexpired ``review_required`` record for the exact + registered runtime and artifact. + """ + + expected_digest = _sha256(expected_evidence_sha256, "expected_evidence_sha256") + wire = _coerce_wire(evidence) + if set(wire) != _WIRE_FIELDS: + _reject("invalid_wire", "evidence wire fields do not match the v1 contract") + producer = wire.get("producer") + if not isinstance(producer, Mapping) or set(producer) != _PRODUCER_FIELDS: + _reject("invalid_wire", "evidence producer fields do not match the v1 contract") + if wire["schema_version"] != ITERATION41_EVIDENCE_SCHEMA_VERSION: + _reject("unsupported_schema", "evidence schema version is not supported") + + evidence_id = _identifier(wire["evidence_id"], "evidence_id") + tenant_id = _identifier(wire["tenant_id"], "tenant_id") + strategy_id = _identifier(wire["strategy_id"], "strategy_id") + for field_name in ("product", "version", "commit"): + _identifier(producer[field_name], "producer.{0}".format(field_name)) + artifact_sha256 = _sha256(wire["artifact_sha256"], "artifact_sha256") + config_effective_digest = _sha256(wire["config_effective_digest"], "config_effective_digest") + _sha256(producer["wheel_sha256"], "producer.wheel_sha256") + if wire["review_status"] != REVIEW_REQUIRED: + _reject( + "review_status_not_review_required", + "Iteration 41 evidence must remain review_required", + ) + if not isinstance(wire["metadata"], Mapping): + _reject("invalid_wire", "evidence metadata must be an object") + created_at = _timestamp(wire["created_at"], "created_at") + expires_at = _timestamp(wire["expires_at"], "expires_at") + if expires_at <= created_at: + _reject("invalid_timestamp", "evidence expiry must follow creation") + + digest = evidence_sha256(wire) + if not hmac.compare_digest(digest, expected_digest): + _reject("evidence_digest_mismatch", "evidence does not match its trusted digest") + if tenant_id != bindings.tenant_id: + _reject("tenant_mismatch", "evidence tenant does not match the registered review scope") + if strategy_id != bindings.strategy_id: + _reject("strategy_mismatch", "evidence strategy does not match the registered runtime") + if not hmac.compare_digest(artifact_sha256, bindings.artifact_sha256): + _reject("artifact_mismatch", "evidence artifact does not match the reviewed artifact bytes") + if not hmac.compare_digest(config_effective_digest, bindings.config_effective_digest): + _reject( + "config_effective_digest_mismatch", + "evidence config does not match the current registered configuration", + ) + + checked_at = _timestamp(time.time() if now is None else now, "now") + if checked_at < created_at: + _reject("evidence_not_yet_valid", "evidence is not yet valid") + if checked_at >= expires_at: + _reject("evidence_expired", "evidence has expired") + + return Iteration41ReviewEvidenceObservation( + evidence_id=evidence_id, + evidence_sha256=digest, + tenant_id=tenant_id, + strategy_id=strategy_id, + artifact_sha256=artifact_sha256, + config_effective_digest=config_effective_digest, + checked_at=checked_at, + ) + + +def validate_registered_iteration41_review_evidence( + evidence: Union[Mapping[str, Any], SerializedEvidence], + *, + expected_evidence_sha256: str, + strategy_dir: Union[str, Path], + tenant_id: str, + artifact_path: Union[str, Path], + registry: RuntimeRegistry, + now: Optional[float] = None, +) -> Iteration41ReviewEvidenceObservation: + """Resolve current config-v4 bindings and validate one review-only record. + + This convenience API is the admission-facing call site: it recomputes the + expected scope immediately before validation, preventing a caller from + reusing bindings captured from an earlier config or artifact revision. + """ + + bindings = resolve_iteration41_review_evidence_bindings( + strategy_dir=strategy_dir, + tenant_id=tenant_id, + artifact_path=artifact_path, + registry=registry, + ) + return validate_iteration41_review_evidence( + evidence, + expected_evidence_sha256=expected_evidence_sha256, + bindings=bindings, + now=now, + ) + + +__all__ = [ + "ITERATION41_EVIDENCE_SCHEMA_VERSION", + "REVIEW_REQUIRED", + "Iteration41ReviewEvidenceBindings", + "Iteration41ReviewEvidenceError", + "Iteration41ReviewEvidenceObservation", + "canonical_json", + "evidence_sha256", + "resolve_iteration41_review_evidence_bindings", + "validate_iteration41_review_evidence", + "validate_registered_iteration41_review_evidence", +] diff --git a/backtrader_runtime/runner.py b/backtrader_runtime/runner.py new file mode 100644 index 00000000..7c84a3b5 --- /dev/null +++ b/backtrader_runtime/runner.py @@ -0,0 +1,1337 @@ +"""Code-owned dispatch for already validated Iteration 41 runtime runners. + +The runtime registry carries a reviewed module and entrypoint name. This +module reads neither a user-supplied import string nor an entry-point plugin; +it imports the runner only after schema, directory, preset, and capability +policy resolution has succeeded. +""" + +from __future__ import annotations + +import importlib +import importlib.machinery +import importlib.util +import inspect +import os +import stat +import sys +import types +import weakref +from collections.abc import Mapping +from contextlib import contextmanager +from dataclasses import dataclass, replace +from itertools import count +from pathlib import Path +from typing import Any, Generator, Optional, Tuple, Union, cast + +from .capability_imports import ( + first_unavailable_trusted_capability_module, + trusted_capability_import_context, +) +from .errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from .registry import ( + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + require_effective_runtime_config_seal, + resolve_runtime_config, + validate_runtime_config, +) + + +_MAX_RUNNER_SOURCE_BYTES = 4 * 1024 * 1024 +_PRIVATE_NAMESPACE_SEQUENCE = count() + + +def resolve_runner_effective_config( + runtime_dir: Optional[Union[str, Path]], + registry: RuntimeRegistry, + *, + effective: Optional[EffectiveRuntimeConfig] = None, +) -> EffectiveRuntimeConfig: + """Return the sealed config a registered runner is allowed to consume. + + Direct runner calls must carry a sealed effective configuration. The + config-first dispatcher validates ``config.yaml`` and then passes sealed + policy values into the code-owned runner. POSIX dispatch projects those + values into a path-free runner view; Windows retains the original object + only while its delete-denying directory lease is held. A runner must + never reopen the pathname, otherwise replacing a directory or config + between validation and strategy startup could alter its route. + + The supplied effective value is re-resolved from its in-memory immutable + config against the same registry. This performs no config-path read and + detects an unrelated registry, runtime directory, or forged stale + descriptor without re-reading ``config.yaml``. An unsealed direct call is + rejected before it can inspect a runtime pathname; operators use + ``bt-runtime run`` and programmatic callers use sealed dispatch. + """ + + # POSIX dispatch supplies a path-free view plus an opaque registry token. + # Recognise that pair before the public EffectiveRuntimeConfig type check; + # its identity grant is the authority, and no config pathname is reopened. + if isinstance(effective, _RunnerEffectiveConfig) or isinstance(registry, _RunnerRegistry): + return _require_runner_dispatch_view(runtime_dir, registry, effective) + + if effective is None: + raise _runner_error( + "runner_dispatch_required", + "an unsealed direct runner call is not allowed; use bt-runtime run --strategy-dir " + " or validate then dispatch the sealed configuration", + ) + + if not isinstance(effective, EffectiveRuntimeConfig): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the runner did not receive a sealed effective runtime configuration", + field_path="runtime.runner", + reason="effective_config_invalid", + ) + + require_effective_runtime_config_seal(effective, registry) + resolved = resolve_runtime_config(effective.config, registry) + if runtime_dir is not None: + requested_registration = registry.require_runtime_dir(runtime_dir) + if requested_registration != resolved.registration: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the supplied runtime directory does not match the sealed runtime configuration", + field_path="strategy_dir", + reason="effective_config_runtime_mismatch", + ) + + if ( + effective.registration != resolved.registration + or effective.config.strategy_dir != resolved.config.strategy_dir + or effective.config.config_digest != resolved.config.config_digest + or effective.effective_digest != resolved.effective_digest + ): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the sealed effective runtime configuration no longer matches the registry policy", + field_path="runtime.runner", + reason="effective_config_mismatch", + ) + return resolved + + +def _runner_error(reason: str, message: str) -> RuntimeConfigError: + return RuntimeConfigError( + PRESET_POLICY_VIOLATION, + message, + field_path="runtime.runner", + reason=reason, + ) + + +def dispatch_configured_runtime( + runtime_dir: Union[str, Path], registry: RuntimeRegistry +) -> dict[str, Any]: + """Validate one registered config and invoke the sealed code-owned dispatcher. + + This gives retained offline script shims the same config, environment, and + sealed-dispatch gates as ``bt-runtime run``. It never calls a runner with + an unsealed configuration: POSIX dispatch still withholds the mutable + runtime pathname and Windows keeps its directory lease for the runner call. + A configuration that requires explicit live confirmation is intentionally + refused here; only the public CLI exposes that confirmation control. + """ + + effective = validate_runtime_config(runtime_dir, registry) + # Import lazily to avoid a runner/CLI import cycle. The same fixed list is + # used by the public CLI, so a legacy standalone shim cannot become an + # environment-override bypass. + from .cli import _environment_override_errors + + environment_errors = _environment_override_errors(os.environ) + if environment_errors: + raise environment_errors[0] + if effective.requires_live_confirmation: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "a direct config-first dispatch cannot confirm live execution; use bt-runtime run " + "--strategy-dir --confirm-live", + field_path="--confirm-live", + reason="live_confirmation_required", + ) + return dispatch_registered_runtime(effective, registry) + + +def _runner_path_access_error() -> RuntimeConfigError: + """Reject a runner-facing attempt to recover the mutable directory path. + + POSIX dispatch intentionally gives a runner a directory descriptor + capability instead of the registered pathname. The small views below + preserve every reviewed policy value a runner needs while making an + accidental path recovery a deterministic policy rejection rather than an + attribute leak. + """ + + return _runner_error( + "runner_runtime_path_access_denied", + "a POSIX-dispatched runner must use the runtime-directory capability instead of a pathname", + ) + + +def _copy_runner_parameter_value(value: Any) -> Any: + """Return a detached JSON-shaped parameter value without loader internals.""" + + if isinstance(value, Mapping): + return {key: _copy_runner_parameter_value(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_copy_runner_parameter_value(item) for item in value] + return value + + +@dataclass(frozen=True) +class _RunnerRuntimeConfig: + """Path-free projection of the sealed configuration for a POSIX runner.""" + + strategy_id: str + mode: str + preset: str + parameters: Mapping[str, Any] + secrets_ref: str + config_digest: str + + @property + def strategy_dir(self) -> Path: + raise _runner_path_access_error() + + @property + def source_path(self) -> Path: + raise _runner_path_access_error() + + def as_public_dict(self) -> dict[str, Any]: + return { + "strategy": {"id": self.strategy_id}, + "runtime": {"mode": self.mode, "preset": self.preset}, + "parameter_keys": tuple(sorted(self.parameters)), + "secrets_ref": "none" if self.secrets_ref == "none" else "configured", + "config_digest": self.config_digest, + } + + def parameter_dict(self) -> dict[str, Any]: + return cast(dict[str, Any], _copy_runner_parameter_value(self.parameters)) + + +@dataclass(frozen=True) +class _RunnerRegistration: + """Path-free subset of a registration required by reviewed runners.""" + + strategy_id: str + allowed_presets: Tuple[str, ...] + allowed_parameter_keys: Tuple[str, ...] + allowed_secrets_refs: Tuple[str, ...] + available_capabilities: Tuple[str, ...] + offline_managed_execution: bool + sandbox_write_policy: str + approval_receipt_digest: Optional[str] + runtime_id: Optional[str] + runner_module: Optional[str] + runner_entrypoint: str + capability_modules: Tuple[str, ...] + digest: str + + @property + def runtime_dir(self) -> Path: + raise _runner_path_access_error() + + @property + def directory_identity(self) -> object: + raise _runner_path_access_error() + + @property + def runtime_dir_lookup_key(self) -> str: + raise _runner_path_access_error() + + +@dataclass(frozen=True) +class _RunnerEffectiveConfig: + """Path-free effective-config contract for a POSIX-dispatched runner.""" + + config: _RunnerRuntimeConfig + registration: _RunnerRegistration + policy: Any + order_route: Optional[str] + account_access: Optional[str] + required_capabilities: Tuple[str, ...] + allows_network: bool + allows_external_writes: bool + allows_production_writes: bool + allows_hypothetical_fills: bool + requires_approval: bool + requires_live_confirmation: bool + effective_digest: str + profile: Optional[RuntimeProfile] + profile_digest: Optional[str] + + @property + def strategy_id(self) -> str: + return self.config.strategy_id + + @property + def mode(self) -> str: + return self.config.mode + + @property + def preset(self) -> str: + return self.config.preset + + @property + def parameters(self) -> Mapping[str, Any]: + return self.config.parameters + + @property + def config_digest(self) -> str: + return self.config.config_digest + + @property + def profile_dispatch_available(self) -> bool: + if self.profile is None: + return self.profile_digest is None + return self.profile_digest is not None and self.profile.digest == self.profile_digest + + def as_public_dict(self) -> dict[str, Any]: + return { + "strategy_id": self.strategy_id, + "mode": self.mode, + "preset": self.preset, + "environment": self.policy.environment, + "order_route": self.order_route, + "account_access": self.account_access, + "required_capabilities": self.required_capabilities, + "allows_network": self.allows_network, + "allows_external_writes": self.allows_external_writes, + "allows_production_writes": self.allows_production_writes, + "allows_hypothetical_fills": self.allows_hypothetical_fills, + "requires_approval": self.requires_approval, + "requires_live_confirmation": self.requires_live_confirmation, + "config_digest": self.config_digest, + "registration_digest": self.registration.digest, + "effective_digest": self.effective_digest, + "profile": None + if self.profile is None + else {"mode": self.profile.mode, "preset": self.profile.preset}, + "profile_digest": self.profile_digest, + } + + +class _RunnerRegistry: + """Opaque registry token for a POSIX-dispatched runner. + + The resolver below checks the token by object identity. It intentionally + has no backing registry attribute, so a runner cannot reach the reviewed + directory bindings through the normal registry API. + """ + + __slots__ = ("__weakref__",) + + @property + def registrations(self) -> Tuple[object, ...]: + raise _runner_path_access_error() + + @property + def runtime_sets(self) -> Tuple[object, ...]: + raise _runner_path_access_error() + + def require_runtime_dir(self, *_args: Any, **_kwargs: Any) -> object: + raise _runner_path_access_error() + + def require_runtime_set(self, *_args: Any, **_kwargs: Any) -> Tuple[object, ...]: + raise _runner_path_access_error() + + +# The grant table holds only weak references to path-free view objects. In +# particular, it never stores the original EffectiveRuntimeConfig or +# RuntimeRegistry, so normal module inspection cannot recover the mutable +# pathname which POSIX dispatch deliberately withholds. +_RUNNER_DISPATCH_GRANTS: dict[ + int, Tuple[weakref.ReferenceType, weakref.ReferenceType, Optional[str]] +] = {} + + +def _runner_effective_view(effective: EffectiveRuntimeConfig) -> _RunnerEffectiveConfig: + """Project one sealed effective config without its directory-bearing fields.""" + + config = effective.config + registration = effective.registration + profile = None if effective.profile is None else replace(effective.profile) + return _RunnerEffectiveConfig( + config=_RunnerRuntimeConfig( + strategy_id=config.strategy_id, + mode=config.mode, + preset=config.preset, + parameters=config.parameters, + secrets_ref=config.secrets_ref, + config_digest=config.config_digest, + ), + registration=_RunnerRegistration( + strategy_id=registration.strategy_id, + allowed_presets=registration.allowed_presets, + allowed_parameter_keys=registration.allowed_parameter_keys, + allowed_secrets_refs=registration.allowed_secrets_refs, + available_capabilities=registration.available_capabilities, + offline_managed_execution=registration.offline_managed_execution, + sandbox_write_policy=registration.sandbox_write_policy, + approval_receipt_digest=registration.approval_receipt_digest, + runtime_id=registration.runtime_id, + runner_module=registration.runner_module, + runner_entrypoint=registration.runner_entrypoint, + capability_modules=registration.capability_modules, + digest=registration.digest, + ), + policy=effective.policy, + order_route=effective.order_route, + account_access=effective.account_access, + required_capabilities=effective.required_capabilities, + allows_network=effective.allows_network, + allows_external_writes=effective.allows_external_writes, + allows_production_writes=effective.allows_production_writes, + allows_hypothetical_fills=effective.allows_hypothetical_fills, + requires_approval=effective.requires_approval, + requires_live_confirmation=effective.requires_live_confirmation, + effective_digest=effective.effective_digest, + profile=profile, + profile_digest=None if profile is None else profile.digest, + ) + + +@contextmanager +def _runner_dispatch_views( + effective: EffectiveRuntimeConfig, +) -> Generator[Tuple[_RunnerEffectiveConfig, _RunnerRegistry], None, None]: + """Yield one active, path-free POSIX runner contract. + + Identity grants are removed immediately after the runner returns. A + retained view therefore cannot be replayed through + ``resolve_runner_effective_config`` outside the original dispatch. + """ + + runner_effective = _runner_effective_view(effective) + runner_registry = _RunnerRegistry() + effective_identifier = id(runner_effective) + + def discard(reference: weakref.ReferenceType) -> None: + current = _RUNNER_DISPATCH_GRANTS.get(effective_identifier) + if current is not None and current[0] is reference: + _RUNNER_DISPATCH_GRANTS.pop(effective_identifier, None) + + effective_reference = weakref.ref(runner_effective, discard) + registry_reference = weakref.ref(runner_registry) + _RUNNER_DISPATCH_GRANTS[effective_identifier] = ( + effective_reference, + registry_reference, + runner_effective.profile_digest, + ) + try: + yield runner_effective, runner_registry + finally: + current = _RUNNER_DISPATCH_GRANTS.get(effective_identifier) + if current is not None and current[0] is effective_reference: + _RUNNER_DISPATCH_GRANTS.pop(effective_identifier, None) + + +def _require_runner_dispatch_view( + runtime_dir: Optional[Union[str, Path]], registry: object, effective: object +) -> _RunnerEffectiveConfig: + """Authenticate one active path-free runner view without filesystem I/O.""" + + if not isinstance(effective, _RunnerEffectiveConfig) or not isinstance( + registry, _RunnerRegistry + ): + raise _runner_error( + "runner_dispatch_context_invalid", + "the runner did not receive the active POSIX dispatch contract", + ) + if runtime_dir is not None: + raise _runner_path_access_error() + grant = _RUNNER_DISPATCH_GRANTS.get(id(effective)) + if grant is None or grant[0]() is not effective or grant[1]() is not registry: + raise _runner_error( + "runner_dispatch_context_expired", + "the POSIX dispatch contract is no longer active", + ) + if ( + effective.profile_digest != grant[2] + or (None if effective.profile is None else effective.profile.digest) != grant[2] + or ( + effective.profile is not None + and (effective.profile.mode, effective.profile.preset) + != (effective.mode, effective.preset) + ) + ): + raise _runner_error( + "runner_dispatch_context_expired", + "the POSIX profile contract no longer matches its active dispatch grant", + ) + return effective + + +class RuntimeDirectoryCapability: + """A borrowed POSIX directory capability for runtime-relative local I/O. + + A descriptor continues to address the reviewed directory after another + process renames its pathname. Passing that descriptor as an opaque + capability lets a runner create a small, explicitly reviewed set of local + artifacts without reopening the mutable runtime pathname. The capability + deliberately does not expose a filesystem path or its file descriptor. + + It is valid only for the duration of ``dispatch_registered_runtime``. + Every path component is opened relative to the preceding descriptor with + ``O_NOFOLLOW``; a symlink or a ``..`` component is therefore rejected + instead of escaping the reviewed directory. + """ + + __slots__ = ("__directory_fd", "__active") + + def __init__(self, directory_fd: int) -> None: + if os.name != "posix" or not isinstance(directory_fd, int) or directory_fd < 0: + raise _runner_error( + "runtime_directory_capability_unavailable", + "a POSIX runtime-directory capability is unavailable", + ) + self.__directory_fd = directory_fd + self.__active = True + + def invalidate(self) -> None: + """Make a retained capability fail closed after its runner returns.""" + + self.__active = False + + def _require_active(self) -> int: + if not self.__active: + raise _runner_error( + "runtime_directory_capability_expired", + "the runtime-directory capability is no longer valid", + ) + return self.__directory_fd + + @staticmethod + def _relative_parts(relative_path: Union[str, os.PathLike]) -> Tuple[str, ...]: + try: + value = os.fspath(relative_path) + except TypeError: + raise _runner_error( + "runtime_directory_capability_path_invalid", + "a runtime-directory capability path must be a relative string", + ) from None + if not isinstance(value, str) or not value or value.startswith("/"): + raise _runner_error( + "runtime_directory_capability_path_invalid", + "a runtime-directory capability path must be relative", + ) + parts = tuple(value.split("/")) + if any(part in ("", ".", "..") for part in parts): + raise _runner_error( + "runtime_directory_capability_path_invalid", + "a runtime-directory capability path contains an unsafe component", + ) + return parts + + @contextmanager + def _open_directory( + self, parts: Tuple[str, ...], *, create: bool + ) -> Generator[int, None, None]: + """Yield a no-follow descriptor for one relative directory.""" + + try: + current = os.dup(self._require_active()) + os.set_inheritable(current, False) + except OSError: + raise _runner_error( + "runtime_directory_capability_io_failed", + "the runtime-directory capability could not be duplicated", + ) from None + + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + try: + for part in parts: + try: + child = os.open(part, flags, dir_fd=current) + except FileNotFoundError: + if not create: + raise + os.mkdir(part, 0o700, dir_fd=current) + child = os.open(part, flags, dir_fd=current) + try: + child_stat = os.fstat(child) + if not stat.S_ISDIR(child_stat.st_mode): + raise NotADirectoryError(part) + except BaseException: + os.close(child) + raise + os.close(current) + current = child + yield current + except RuntimeConfigError: + raise + except OSError: + raise _runner_error( + "runtime_directory_capability_io_failed", + "runtime-directory capability I/O was rejected", + ) from None + finally: + try: + os.close(current) + except OSError: + pass + + def write_text(self, relative_path: Union[str, os.PathLike], text: str) -> None: + """Create one UTF-8 file below the reviewed directory without overwrite. + + Parents are created at mode ``0700``. The final file is opened with + ``O_EXCL`` and ``O_NOFOLLOW`` so a racing entry cannot redirect or + replace the artifact. This intentionally small primitive is enough + for runner-owned markers; larger legacy output trees require their own + descriptor-aware writer before they may use this capability. + """ + + if not isinstance(text, str): + raise _runner_error( + "runtime_directory_capability_content_invalid", + "runtime-directory capability text must be a string", + ) + parts = self._relative_parts(relative_path) + parent_parts, filename = parts[:-1], parts[-1] + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + try: + with self._open_directory(parent_parts, create=True) as parent_fd: + descriptor = os.open(filename, flags, 0o600, dir_fd=parent_fd) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + except BaseException: + try: + os.unlink(filename, dir_fd=parent_fd) + except OSError: + pass + raise + except RuntimeConfigError: + raise + except OSError: + raise _runner_error( + "runtime_directory_capability_io_failed", + "runtime-directory capability could not create the local artifact", + ) from None + + +def _runner_accepts_runtime_directory_capability(runner: Any) -> bool: + """Return whether a runner opted into the POSIX capability keyword. + + A legacy reviewed runner which has not opted in still receives ``None`` as + its positional runtime directory on POSIX. It therefore fails closed if + it tries path I/O, rather than regaining the mutable pathname merely for + compatibility. New runners should declare the keyword and use the + capability for narrowly reviewed runtime-relative artifacts. + """ + + try: + signature = inspect.signature(runner) + except (TypeError, ValueError): + return False + for parameter in signature.parameters.values(): + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + return True + if parameter.name == "runtime_directory": + return True + return False + + +def _is_link_or_reparse(stat_result: os.stat_result) -> bool: + """Recognise both POSIX links and Windows directory/file reparse links.""" + + reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + attributes = getattr(stat_result, "st_file_attributes", 0) + return stat.S_ISLNK(stat_result.st_mode) or bool(attributes & reparse_point) + + +def _regular_source_identity(source_path: Path) -> Tuple[int, int, int]: + try: + source_stat = os.lstat(str(source_path)) + except OSError: + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner source is unavailable" + ) from None + if _is_link_or_reparse(source_stat) or not stat.S_ISREG(source_stat.st_mode): + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner source is not a regular file" + ) + return source_stat.st_dev, source_stat.st_ino, source_stat.st_mode + + +def _trusted_runner_directory(directory: Path) -> None: + try: + directory_stat = os.lstat(str(directory)) + except OSError: + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner package is unavailable" + ) from None + if _is_link_or_reparse(directory_stat) or not stat.S_ISDIR(directory_stat.st_mode): + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner package is not a concrete directory" + ) + + +def _read_trusted_runner_source(source_path: Path) -> bytes: + """Read a fixed runner file without allowing a leaf-path replacement.""" + + path_identity = _regular_source_identity(source_path) + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(str(source_path), flags) + except OSError: + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner source cannot be opened" + ) from None + try: + descriptor_stat = os.fstat(descriptor) + descriptor_identity = ( + descriptor_stat.st_dev, + descriptor_stat.st_ino, + descriptor_stat.st_mode, + ) + if not stat.S_ISREG(descriptor_stat.st_mode) or descriptor_identity != path_identity: + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner source changed while opening" + ) + chunks = [] + remaining = _MAX_RUNNER_SOURCE_BYTES + 1 + while remaining: + chunk = os.read(descriptor, remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + source = b"".join(chunks) + except RuntimeConfigError: + raise + except OSError: + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner source cannot be read" + ) from None + finally: + try: + os.close(descriptor) + except OSError: + pass + if len(source) > _MAX_RUNNER_SOURCE_BYTES: + raise _runner_error("runner_origin_mismatch", "the reviewed runner source is too large") + if _regular_source_identity(source_path) != path_identity: + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner source changed while reading" + ) + return source + + +@dataclass(frozen=True) +class _InventoryRunnerBinding: + """One exact, code-owned runner-source binding. + + ``runner_source_root`` is the directory from which the private loader + reads a reviewed runner. ``backtrader_source_root`` is independently the + root that owns the matching core package. They are the same for source + examples, but a packaged L2 fixture lives below the installed package + root and must still reject a CWD ``backtrader`` shadow. + """ + + runner_source_root: Path + backtrader_source_root: Path + module_prefix: Tuple[str, ...] + source_path_prefix: Tuple[str, ...] + + +def _inventory_runner_binding( + registration: RegisteredRuntime, + *, + runner_module: Optional[str] = None, +) -> Optional[_InventoryRunnerBinding]: + """Return a fixed binding only for an exact shipped inventory record. + + Test and deployment registries may deliberately carry local in-memory + runner modules. No arbitrary ``backtrader_runtime.*`` module earns the + private-loader or provenance guard: the package fixture is admitted only + through its own two-record, code-owned registry. + """ + + module_name = registration.runner_module if runner_module is None else runner_module + if module_name is None: + return None + from . import inventory + + for reviewed in inventory.iteration41_runtime_registry().registrations: + if reviewed == registration and module_name.startswith("examples."): + source_root = Path(inventory.SOURCE_ROOT) + return _InventoryRunnerBinding( + runner_source_root=source_root, + backtrader_source_root=source_root, + module_prefix=("examples",), + source_path_prefix=("examples",), + ) + + package_prefix = ("backtrader_runtime", "_iteration41_l2_fixture") + package_prefix_text = ".".join(package_prefix) + for reviewed in inventory.iteration41_l2_fixture_registry().registrations: + if reviewed == registration and ( + module_name == package_prefix_text or module_name.startswith(package_prefix_text + ".") + ): + # ``inventory.py`` is always adjacent to the installed + # ``backtrader`` package. Keep core-package provenance separate + # from the fixture directory that contains the runner sources. + installed_package_root = Path(inventory.__file__).resolve().parent.parent + return _InventoryRunnerBinding( + runner_source_root=Path(inventory.PACKAGE_L2_FIXTURE_ROOT), + backtrader_source_root=installed_package_root, + module_prefix=package_prefix, + source_path_prefix=(), + ) + backtest_prefix = ("backtrader_runtime", "_iteration41_backtest_fixture") + backtest_prefix_text = ".".join(backtest_prefix) + for reviewed in inventory.iteration41_backtest_fixture_registry().registrations: + if reviewed == registration and ( + module_name == backtest_prefix_text + or module_name.startswith(backtest_prefix_text + ".") + ): + installed_package_root = Path(inventory.__file__).resolve().parent.parent + return _InventoryRunnerBinding( + runner_source_root=Path(inventory.PACKAGE_BACKTEST_FIXTURE_ROOT), + backtrader_source_root=installed_package_root, + module_prefix=backtest_prefix, + source_path_prefix=(), + ) + return None + + +def _managed_l2_required_capability_modules( + registration: RegisteredRuntime, +) -> Tuple[str, ...]: + """Return required imports only for the four exact reviewed managed L2 records.""" + + from . import inventory + + reviewed_registrations = ( + inventory.ITERATION41_013_3_MANAGED_REPLAY_REGISTRATION, + inventory.ITERATION41_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION, + inventory.ITERATION41_PACKAGE_013_3_MANAGED_REPLAY_REGISTRATION, + inventory.ITERATION41_PACKAGE_CTP_MECHANICAL_MANAGED_REPLAY_REGISTRATION, + ) + if not any(registration == reviewed for reviewed in reviewed_registrations): + return () + return ("bt_api_execution", "bt_api_risk", "bt_api_monitor") + + +def _inventory_source_root(registration: RegisteredRuntime) -> Optional[Path]: + """Return the fixed runner-source root for legacy callers/tests.""" + + binding = _inventory_runner_binding(registration) + return None if binding is None else binding.runner_source_root + + +def _path_is_within(path: object, source_root: Path) -> bool: + try: + candidate = Path(cast(Any, path)).resolve(strict=False) + root = source_root.resolve(strict=False) + candidate.relative_to(root) + except (OSError, RuntimeError, TypeError, ValueError): + return False + return True + + +def _cached_backtrader_is_trusted(source_root: Path) -> bool: + """Check every already-cached Backtrader module without evicting it.""" + + for name, module in tuple(sys.modules.items()): + if name != "backtrader" and not name.startswith("backtrader."): + continue + origins = [] + module_file = getattr(module, "__file__", None) + if module_file is not None: + origins.append(module_file) + module_path = getattr(module, "__path__", None) + if module_path is not None: + origins.extend(module_path) + if not origins or any(not _path_is_within(origin, source_root) for origin in origins): + return False + return True + + +def _has_concrete_backtrader_package(source_root: Path) -> bool: + """Return whether this fixed source root owns a concrete core package. + + Some unit/deployment registries intentionally point the private inventory + loader at a small runner-only source tree. Such a tree cannot responsibly + assert provenance for the process-wide ``backtrader`` package, so retain + its fixed runner-file protection without applying the core import guard. + A source root which does contain a core package must expose only concrete, + non-link package entries before it can be used as that provenance anchor. + """ + + package_directory = source_root / "backtrader" + try: + package_stat = os.lstat(str(package_directory)) + except FileNotFoundError: + return False + except OSError: + raise _runner_error( + "runner_origin_mismatch", "the reviewed Backtrader package cannot be inspected" + ) from None + if _is_link_or_reparse(package_stat) or not stat.S_ISDIR(package_stat.st_mode): + raise _runner_error( + "runner_origin_mismatch", "the reviewed Backtrader package is not a concrete directory" + ) + + initializer = package_directory / "__init__.py" + try: + initializer_stat = os.lstat(str(initializer)) + except FileNotFoundError: + return False + except OSError: + raise _runner_error( + "runner_origin_mismatch", "the reviewed Backtrader package cannot be inspected" + ) from None + if _is_link_or_reparse(initializer_stat) or not stat.S_ISREG(initializer_stat.st_mode): + raise _runner_error( + "runner_origin_mismatch", "the reviewed Backtrader package initializer is invalid" + ) + return True + + +@contextmanager +def _trusted_backtrader_import_context( + source_root: Optional[Path], +) -> Generator[None, None, None]: + """Keep legacy imports on the reviewed source tree for one runner call.""" + + if source_root is None: + yield + return + _trusted_runner_directory(source_root) + if not _has_concrete_backtrader_package(source_root): + # The fixed private runner loader still protects every ``examples`` + # source path. Only process-wide core import provenance is absent. + yield + return + if not _cached_backtrader_is_trusted(source_root): + raise _runner_error( + "backtrader_origin_mismatch", + "a cached Backtrader module is outside the reviewed runner source tree", + ) + original_sys_path = tuple(sys.path) + try: + sys.path.insert(0, str(source_root)) + yield + finally: + sys.path[:] = original_sys_path + + +def _install_private_namespace(package_name: str, directory: Path) -> types.ModuleType: + """Install a namespace package that cannot collide with ``examples``.""" + + package = types.ModuleType(package_name) + package.__package__ = package_name + package.__path__ = [str(directory)] + specification = importlib.machinery.ModuleSpec(package_name, loader=None, is_package=True) + specification.submodule_search_locations = [str(directory)] + package.__spec__ = specification + sys.modules[package_name] = package + if "." in package_name: + parent_name, attribute = package_name.rsplit(".", 1) + parent = sys.modules[parent_name] + setattr(parent, attribute, package) + return package + + +class _PrivateInventoryImportlib: + """Expose stdlib importlib while resolving one bound runner tree privately.""" + + def __init__(self, loader: "_PrivateInventoryRunnerLoader") -> None: + self._loader = loader + + def import_module(self, name: str, package: Optional[str] = None) -> types.ModuleType: + if self._loader.is_bound_module_name(name): + if package is not None: + raise _runner_error( + "runner_origin_mismatch", + "the reviewed runner requested a relative public runner import", + ) + return self._loader.load_module(name) + return importlib.import_module(name, package) + + def __getattr__(self, name: str) -> Any: + return getattr(importlib, name) + + +class _PrivateInventoryRunnerLoader: + """Load fixed inventory source under a one-shot private namespace. + + The public source namespace is never read, modified, or evicted. Relative + imports resolve under this private tree. The entry runner retains its + canonical name for internal registration comparisons; nested modules use + their private name so classes defined there resolve through ``sys.modules``. + """ + + def __init__( + self, + source_root: Path, + *, + root_module_name: str, + module_prefix: Tuple[str, ...] = ("examples",), + source_path_prefix: Tuple[str, ...] = ("examples",), + ) -> None: + _trusted_runner_directory(source_root) + if ( + not module_prefix + or any(not component for component in module_prefix) + or any(not component for component in source_path_prefix) + ): + raise _runner_error("runner_origin_mismatch", "the reviewed runner module is invalid") + self.source_root = source_root + self.module_prefix = module_prefix + self.source_path_prefix = source_path_prefix + if not self.is_bound_module_name(root_module_name): + raise _runner_error("runner_origin_mismatch", "the reviewed runner module is invalid") + self.root_module_name = root_module_name + self.namespace = self._new_namespace() + _install_private_namespace(self.namespace, source_root) + self._importlib_proxy = _PrivateInventoryImportlib(self) + + @staticmethod + def _new_namespace() -> str: + while True: + candidate = "_backtrader_runtime_inventory_{0}".format( + next(_PRIVATE_NAMESPACE_SEQUENCE) + ) + if candidate not in sys.modules: + return candidate + + def is_bound_module_name(self, canonical_name: str) -> bool: + components = tuple(canonical_name.split(".")) + return components[: len(self.module_prefix)] == self.module_prefix + + def _private_name(self, source_components: Tuple[str, ...]) -> str: + return self.namespace + "." + ".".join(source_components) + + def _source_location(self, canonical_name: str) -> Tuple[Path, Path, Tuple[str, ...]]: + components = tuple(canonical_name.split(".")) + if ( + not components + or any(not component for component in components) + or components[: len(self.module_prefix)] != self.module_prefix + or len(components) == len(self.module_prefix) + ): + raise _runner_error("runner_origin_mismatch", "the reviewed runner module is invalid") + source_components = self.source_path_prefix + components[len(self.module_prefix) :] + source_directory = self.source_root + for component in source_components[:-1]: + source_directory = source_directory / component + _trusted_runner_directory(source_directory) + return ( + source_directory, + source_directory / (source_components[-1] + ".py"), + source_components, + ) + + def _ensure_private_parents(self, components: Tuple[str, ...]) -> None: + source_directory = self.source_root + package_name = self.namespace + for component in components[:-1]: + source_directory = source_directory / component + package_name = package_name + "." + component + if package_name not in sys.modules: + _install_private_namespace(package_name, source_directory) + + def load_module(self, canonical_name: str) -> types.ModuleType: + source_directory, source_path, source_components = self._source_location(canonical_name) + del source_directory + private_name = self._private_name(source_components) + existing = sys.modules.get(private_name) + if isinstance(existing, types.ModuleType): + return existing + self._ensure_private_parents(source_components) + source = _read_trusted_runner_source(source_path) + loader = importlib.machinery.SourceFileLoader(private_name, str(source_path)) + specification = importlib.util.spec_from_file_location( + private_name, str(source_path), loader=loader + ) + if specification is None or specification.origin is None: + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner source has no fixed origin" + ) + if os.path.normcase(os.path.abspath(specification.origin)) != os.path.normcase( + os.path.abspath(str(source_path)) + ): + raise _runner_error( + "runner_origin_mismatch", "the reviewed runner origin does not match inventory" + ) + module = importlib.util.module_from_spec(specification) + # Keep only the entry runner's canonical spelling for narrow runtime + # checks. A nested module's classes use __module__ during definition; + # that name must exist in sys.modules while the class factory runs. + module.__name__ = ( + canonical_name if canonical_name == self.root_module_name else private_name + ) + sys.modules[private_name] = module + try: + code = compile(source, str(source_path), "exec") + exec(code, module.__dict__) + except RuntimeConfigError: + sys.modules.pop(private_name, None) + raise + except Exception: + sys.modules.pop(private_name, None) + raise + module.__dict__["importlib"] = self._importlib_proxy + parent_name, attribute = private_name.rsplit(".", 1) + setattr(sys.modules[parent_name], attribute, module) + return module + + def close(self) -> None: + for module_name in tuple(sys.modules): + if module_name == self.namespace or module_name.startswith(self.namespace + "."): + sys.modules.pop(module_name, None) + + +@contextmanager +def _loaded_shipped_inventory_runner( + module_name: str, + source_root: Path, + *, + module_prefix: Tuple[str, ...] = ("examples",), + source_path_prefix: Tuple[str, ...] = ("examples",), +) -> Generator[types.ModuleType, None, None]: + """Yield one fixed runner and remove its private namespace afterwards.""" + + loader = _PrivateInventoryRunnerLoader( + source_root, + root_module_name=module_name, + module_prefix=module_prefix, + source_path_prefix=source_path_prefix, + ) + try: + yield loader.load_module(module_name) + finally: + loader.close() + + +@contextmanager +def _loaded_registered_runner( + registration: RegisteredRuntime, + *, + runner_module: Optional[str] = None, + source_root: Optional[Path] = None, + module_prefix: Tuple[str, ...] = ("examples",), + source_path_prefix: Tuple[str, ...] = ("examples",), +) -> Generator[types.ModuleType, None, None]: + """Yield one reviewed runner without exposing inventory imports to CWD.""" + + module_name = registration.runner_module if runner_module is None else runner_module + if module_name is None: + raise _runner_error( + "runner_not_registered", "the registered runtime has no code-owned runner entrypoint" + ) + if source_root is None: + binding = _inventory_runner_binding(registration, runner_module=module_name) + if binding is not None: + source_root = binding.runner_source_root + module_prefix = binding.module_prefix + source_path_prefix = binding.source_path_prefix + if source_root is None: + yield importlib.import_module(module_name) + return + with _loaded_shipped_inventory_runner( + module_name, + source_root, + module_prefix=module_prefix, + source_path_prefix=source_path_prefix, + ) as module: + yield module + + +def dispatch_registered_runtime( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> dict[str, Any]: + """Run the exact code-owned entrypoint bound to a resolved runtime. + + A Windows runner receives the immutable effective config plus the same + trusted registry while a delete-denying directory lease remains active. + A POSIX runner receives a sealed, path-free projection and opaque registry + token instead, together with a descriptor capability for reviewed local + artifacts. It must recheck strategy-specific restrictions before + importing legacy strategy code and must not reopen ``config.yaml`` on the + CLI dispatch path. + """ + + # This public function can receive an object constructed outside the CLI. + # Re-resolve it against the same trusted registry *before* looking at its + # runner module. In particular, never let a forged ``registration`` field + # select an import path merely because the config portion looks valid. + # ``resolve_runner_effective_config`` validates the object identity seal + # before it reads any public EffectiveRuntimeConfig field. + resolved = resolve_runner_effective_config(None, registry, effective=effective) + registration = resolved.registration + profile = resolved.profile + if profile is not None and resolved.mode == "live": + # A code-owned runner path, a digest-shaped approval field, and the + # CLI's confirmation flag do not establish provider/account admission. + # Keep this final dispatch boundary closed until an independently + # verified production admission can be passed and rechecked here. + raise _runner_error( + "live_execution_admission_required", + "live runner dispatch requires verified production execution admission", + ) + if profile is not None: + if not resolved.profile_dispatch_available: + raise _runner_error( + "profile_dispatch_unavailable", + "the selected profile is outside the offline runner dispatch contract", + ) + module_name = profile.runner_module + entrypoint = profile.runner_entrypoint + capability_modules = profile.capability_modules + required_capability_modules: Tuple[str, ...] = () + else: + module_name = registration.runner_module + entrypoint = registration.runner_entrypoint + capability_modules = registration.capability_modules + required_capability_modules = _managed_l2_required_capability_modules(registration) + if module_name is None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime has no code-owned runner entrypoint", + field_path="runtime.runner", + reason="runner_not_registered", + ) + if resolved.mode == "live": + # Preserve the historical registration diagnostic ordering: an + # unbound legacy runner is reported before the live admission gate. + raise _runner_error( + "live_execution_admission_required", + "live runner dispatch requires verified production execution admission", + ) + with registry.verified_runtime_directory(registration) as directory_fd: + runtime_directory: Optional[RuntimeDirectoryCapability] = None + if directory_fd is not None: + runtime_directory = RuntimeDirectoryCapability(directory_fd) + elif os.name == "posix": + # A POSIX runner must never fall back to a mutable pathname. If a + # platform cannot supply the descriptor opened by the registry, + # reject before runner import or local side effects instead. + raise _runner_error( + "runtime_directory_capability_unavailable", + "the POSIX runtime-directory capability is unavailable", + ) + runner_started = False + try: + binding = _inventory_runner_binding(registration, runner_module=module_name) + runner_source_root = None if binding is None else binding.runner_source_root + backtrader_source_root = None if binding is None else binding.backtrader_source_root + module_prefix = ("examples",) if binding is None else binding.module_prefix + source_path_prefix = ("examples",) if binding is None else binding.source_path_prefix + if required_capability_modules: + if runner_source_root is None: + raise _runner_error( + "runner_not_registered", + "the managed replay route has no reviewed runner source binding", + ) + missing_module = first_unavailable_trusted_capability_module( + runner_source_root, + registration.capability_modules, + required_capability_modules, + ) + if missing_module is not None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the managed replay requires local capability module '{0}'; install the " + "reviewed SDK dependency in the bt-runtime environment, then rerun this " + "registered command".format(missing_module), + field_path="runtime.capabilities.{0}".format(missing_module), + reason="capability_dependency_missing", + ) + with _trusted_backtrader_import_context( + backtrader_source_root + ), trusted_capability_import_context( + backtrader_source_root, capability_modules + ), _loaded_registered_runner( + registration, + runner_module=module_name, + source_root=runner_source_root, + module_prefix=module_prefix, + source_path_prefix=source_path_prefix, + ) as module: + runner = getattr(module, entrypoint, None) + if not callable(runner): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime runner has no callable entrypoint", + field_path="runtime.runner", + reason="runner_entrypoint_missing", + ) + runner_started = True + if runtime_directory is None: + # Windows holds a delete-denying lease for the full call. + # Preserve the established pathname runner interface there. + report = runner(registration.runtime_dir, effective=resolved, registry=registry) + else: + # An open POSIX directory does not stop rename(2). Do not + # disclose a pathname that could subsequently resolve to a + # replacement directory. The runner-facing effective + # config and registry are path-free views as well; a + # reviewed runner must use the explicit capability for + # runtime-relative local I/O. + with _runner_dispatch_views(resolved) as ( + runner_effective, + runner_registry, + ): + if _runner_accepts_runtime_directory_capability(runner): + report = runner( + None, + effective=runner_effective, + registry=runner_registry, + runtime_directory=runtime_directory, + ) + else: + report = runner( + None, + effective=runner_effective, + registry=runner_registry, + ) + except RuntimeConfigError: + raise + except Exception as error: + reason = "runner_execution_failed" if runner_started else "runner_import_failed" + message = ( + "the registered runtime runner did not complete safely" + if runner_started + else "the registered runtime runner is unavailable" + ) + # The private namespace context has already restored its own + # modules before this public error is emitted. + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + message, + field_path="runtime.runner", + reason=reason, + ) from error + finally: + if runtime_directory is not None: + runtime_directory.invalidate() + if not isinstance(report, Mapping): + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "the registered runtime runner returned an invalid report", + field_path="runtime.runner", + reason="runner_report_invalid", + ) + return dict(report) + + +__all__ = [ + "dispatch_configured_runtime", + "dispatch_registered_runtime", + "resolve_runner_effective_config", +] diff --git a/backtrader_runtime/test_execution_profile.py b/backtrader_runtime/test_execution_profile.py new file mode 100644 index 00000000..d2c26621 --- /dev/null +++ b/backtrader_runtime/test_execution_profile.py @@ -0,0 +1,796 @@ +"""Offline, fail-closed contracts for a future sandbox test-execution profile. + +This module is intentionally narrower than a provider adapter. It validates a +bounded, short-lived profile and an in-memory preflight context without reading +credentials, importing a provider SDK, connecting a session, or dispatching an +order. Even when an injected offline verifier accepts the canonical profile, +the returned observation is explicitly non-authoritative. + +The contract is useful for building and testing a managed sandbox admission +path. It is not a default runtime route and it must not be interpreted as a +simulation-account, deployment, or execution authorization. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import math +import re +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from typing import Any, Optional, Protocol, Sequence, Tuple, Union + + +TEST_EXECUTION_PROFILE_SCHEMA_VERSION = "bt-test-execution-profile/v2" +TEST_EXECUTION_PROFILE_PRECHECKED = "TEST_EXECUTION_PROFILE_PRECHECKED" +MAX_TEST_EXECUTION_PROFILE_BYTES = 64 * 1024 +MAX_TEST_EXECUTION_PROFILE_INSTRUMENTS = 64 +MAX_TEST_EXECUTION_PROFILE_INSTRUMENT_LENGTH = 128 +MAX_TEST_EXECUTION_PROFILE_EXTERNAL_WRITES = 1_000 +MAX_TEST_EXECUTION_PROFILE_LIFETIME_SECONDS = 24 * 60 * 60 + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_INSTRUMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$") +_DECIMAL_RE = re.compile(r"^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$") +_SANDBOX_ENVIRONMENTS = frozenset(("demo", "sandbox", "simnow", "testnet")) +_PROFILE_FIELDS = frozenset( + ( + "account_fingerprint_sha256", + "allowed_instruments", + "approval_receipt_digest", + "artifact_sha256", + "capability_receipt_digest", + "cleanup_required", + "created_at", + "effective_config_digest", + "environment", + "expires_at", + "max_external_writes", + "max_quantity", + "profile_id", + "provider", + "reconciliation_required", + "schema_version", + "valid_from", + ) +) + + +SerializedTestExecutionProfile = Union[str, bytes, bytearray] + + +class TestExecutionProfileError(ValueError): + """A redacted, deterministic rejection of an offline profile check.""" + + def __init__(self, reason: str, message: str) -> None: + self.reason = reason + super().__init__(message) + + +def _reject(reason: str, message: str) -> None: + raise TestExecutionProfileError(reason, message) + + +def _identifier(value: Any, field_name: str) -> str: + if ( + type(value) is not str + or len(value) > 128 + or value != value.strip() + or not _IDENTIFIER_RE.fullmatch(value) + ): + _reject("invalid_identifier", "invalid {0}".format(field_name)) + return value + + +def _provider(value: Any, field_name: str) -> str: + if ( + type(value) is not str + or len(value) > 64 + or value != value.strip() + or not _PROVIDER_RE.fullmatch(value) + ): + _reject("invalid_provider", "invalid {0}".format(field_name)) + return value + + +def _sandbox_environment(value: Any, field_name: str) -> str: + environment = _provider(value, field_name) + if environment == "production": + _reject( + "production_environment_forbidden", + "test execution profiles must never target production", + ) + if environment not in _SANDBOX_ENVIRONMENTS: + _reject( + "environment_not_sandbox", + "test execution profile environment is not a reviewed sandbox environment", + ) + return environment + + +def _sha256(value: Any, field_name: str) -> str: + if type(value) is not str or len(value) != 64 or not _SHA256_RE.fullmatch(value): + _reject("invalid_digest", "invalid {0}".format(field_name)) + return value + + +def _timestamp(value: Any, field_name: str) -> float: + if type(value) not in (int, float): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + try: + normalized = float(value) + except (OverflowError, TypeError, ValueError): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + if not math.isfinite(normalized): + _reject("invalid_timestamp", "invalid {0}".format(field_name)) + return 0.0 if normalized == 0.0 else normalized + + +def _quantity(value: Any, field_name: str) -> str: + if ( + type(value) is not str + or len(value) > 64 + or value != value.strip() + or not _DECIMAL_RE.fullmatch(value) + ): + _reject("invalid_quantity", "invalid {0}".format(field_name)) + try: + parsed = Decimal(value) + except (InvalidOperation, ValueError): + _reject("invalid_quantity", "invalid {0}".format(field_name)) + if not parsed.is_finite() or parsed <= Decimal("0"): + _reject("invalid_quantity", "invalid {0}".format(field_name)) + rendered = format(parsed.normalize(), "f") + if "." in rendered: + rendered = rendered.rstrip("0").rstrip(".") + return rendered + + +def _max_external_writes(value: Any, field_name: str) -> int: + if type(value) is not int or isinstance(value, bool): + _reject("invalid_external_write_count", "invalid {0}".format(field_name)) + if value < 0 or value > MAX_TEST_EXECUTION_PROFILE_EXTERNAL_WRITES: + _reject("invalid_external_write_count", "invalid {0}".format(field_name)) + return value + + +def _instruments(value: Any, field_name: str) -> Tuple[str, ...]: + if type(value) not in (list, tuple) or not value: + _reject("invalid_instruments", "invalid {0}".format(field_name)) + if len(value) > MAX_TEST_EXECUTION_PROFILE_INSTRUMENTS: + _reject("profile_too_large", "profile contains too many allowed instruments") + instruments = tuple(value) + if any( + type(instrument) is not str + or len(instrument) > MAX_TEST_EXECUTION_PROFILE_INSTRUMENT_LENGTH + or instrument != instrument.strip() + or not _INSTRUMENT_RE.fullmatch(instrument) + for instrument in instruments + ): + if any( + type(instrument) is str + and len(instrument) > MAX_TEST_EXECUTION_PROFILE_INSTRUMENT_LENGTH + for instrument in instruments + ): + _reject("profile_too_large", "allowed instrument name is too large") + _reject("invalid_instruments", "invalid allowed instrument") + if len(set(instruments)) != len(instruments): + _reject("invalid_instruments", "duplicate allowed instrument") + return tuple(sorted(instruments)) + + +def _required_true(value: Any, field_name: str) -> bool: + if type(value) is not bool: + _reject("invalid_boolean", "invalid {0}".format(field_name)) + if value is not True: + _reject("required_safety_control_missing", "{0} must be true".format(field_name)) + return True + + +def _boolean(value: Any, field_name: str) -> bool: + if type(value) is not bool: + _reject("invalid_boolean", "invalid {0}".format(field_name)) + return value + + +@dataclass(frozen=True) +class TestExecutionProfile: + """A parsed, still-untrusted bounded sandbox test profile. + + This class contains neither credentials nor a secret reference. Account + identity is represented only by a SHA-256 fingerprint and remains redacted + from the public diagnostic projection. + """ + + profile_id: str + provider: str + environment: str + account_fingerprint_sha256: str = field(repr=False) + approval_receipt_digest: str + effective_config_digest: str + artifact_sha256: str + capability_receipt_digest: str + allowed_instruments: Tuple[str, ...] + max_quantity: str + max_external_writes: int + cleanup_required: bool + reconciliation_required: bool + created_at: float + valid_from: float + expires_at: float + schema_version: str = TEST_EXECUTION_PROFILE_SCHEMA_VERSION + + def __post_init__(self) -> None: + object.__setattr__(self, "profile_id", _identifier(self.profile_id, "profile_id")) + object.__setattr__(self, "provider", _provider(self.provider, "provider")) + object.__setattr__( + self, "environment", _sandbox_environment(self.environment, "environment") + ) + object.__setattr__( + self, + "account_fingerprint_sha256", + _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256"), + ) + object.__setattr__( + self, + "approval_receipt_digest", + _sha256(self.approval_receipt_digest, "approval_receipt_digest"), + ) + object.__setattr__( + self, + "effective_config_digest", + _sha256(self.effective_config_digest, "effective_config_digest"), + ) + object.__setattr__( + self, "artifact_sha256", _sha256(self.artifact_sha256, "artifact_sha256") + ) + object.__setattr__( + self, + "capability_receipt_digest", + _sha256(self.capability_receipt_digest, "capability_receipt_digest"), + ) + object.__setattr__( + self, + "allowed_instruments", + _instruments(self.allowed_instruments, "allowed_instruments"), + ) + object.__setattr__(self, "max_quantity", _quantity(self.max_quantity, "max_quantity")) + object.__setattr__( + self, + "max_external_writes", + _max_external_writes(self.max_external_writes, "max_external_writes"), + ) + object.__setattr__( + self, "cleanup_required", _required_true(self.cleanup_required, "cleanup_required") + ) + object.__setattr__( + self, + "reconciliation_required", + _required_true(self.reconciliation_required, "reconciliation_required"), + ) + created_at = _timestamp(self.created_at, "created_at") + valid_from = _timestamp(self.valid_from, "valid_from") + expires_at = _timestamp(self.expires_at, "expires_at") + if valid_from < created_at: + _reject("invalid_timestamp", "profile validity cannot precede creation") + if expires_at <= valid_from: + _reject("invalid_timestamp", "profile expiry must follow its validity start") + if expires_at - valid_from > MAX_TEST_EXECUTION_PROFILE_LIFETIME_SECONDS: + _reject("profile_lifetime_too_long", "test execution profile lifetime is too long") + object.__setattr__(self, "created_at", created_at) + object.__setattr__(self, "valid_from", valid_from) + object.__setattr__(self, "expires_at", expires_at) + if ( + type(self.schema_version) is not str + or self.schema_version != TEST_EXECUTION_PROFILE_SCHEMA_VERSION + ): + _reject("unsupported_schema", "test execution profile schema is not supported") + + def as_wire(self) -> dict[str, Any]: + """Return the exact canonical field set for the offline verifier.""" + + return { + "account_fingerprint_sha256": self.account_fingerprint_sha256, + "allowed_instruments": list(self.allowed_instruments), + "approval_receipt_digest": self.approval_receipt_digest, + "artifact_sha256": self.artifact_sha256, + "capability_receipt_digest": self.capability_receipt_digest, + "cleanup_required": self.cleanup_required, + "created_at": self.created_at, + "effective_config_digest": self.effective_config_digest, + "environment": self.environment, + "expires_at": self.expires_at, + "max_external_writes": self.max_external_writes, + "max_quantity": self.max_quantity, + "profile_id": self.profile_id, + "provider": self.provider, + "reconciliation_required": self.reconciliation_required, + "schema_version": self.schema_version, + "valid_from": self.valid_from, + } + + def as_public_dict(self) -> dict[str, Any]: + """Return a diagnostic projection that does not expose account identity.""" + + return { + "account_fingerprint_bound": True, + "allowed_instruments": self.allowed_instruments, + "approval_receipt_digest": self.approval_receipt_digest, + "artifact_sha256": self.artifact_sha256, + "capability_receipt_digest": self.capability_receipt_digest, + "cleanup_required": self.cleanup_required, + "effective_config_digest": self.effective_config_digest, + "environment": self.environment, + "expires_at": self.expires_at, + "max_external_writes": self.max_external_writes, + "max_quantity": self.max_quantity, + "profile_id": self.profile_id, + "provider": self.provider, + "reconciliation_required": self.reconciliation_required, + "valid_from": self.valid_from, + } + + +@dataclass(frozen=True) +class TestExecutionPreflightContext: + """Pure in-memory facts that a future provider preflight must bind exactly. + + ``cleanup_ready`` and ``reconciliation_ready`` only describe that a future + caller supplied a plan/evidence handle. They do not run cleanup, reconcile + an account, or establish a provider session. + """ + + provider: str + environment: str + account_fingerprint_sha256: str = field(repr=False) + approval_receipt_digest: str + effective_config_digest: str + artifact_sha256: str + capability_receipt_digest: str + instrument: str + quantity: str + requested_external_writes: int + cleanup_ready: bool + reconciliation_ready: bool + + def __post_init__(self) -> None: + object.__setattr__(self, "provider", _provider(self.provider, "provider")) + object.__setattr__( + self, "environment", _sandbox_environment(self.environment, "environment") + ) + object.__setattr__( + self, + "account_fingerprint_sha256", + _sha256(self.account_fingerprint_sha256, "account_fingerprint_sha256"), + ) + object.__setattr__( + self, + "approval_receipt_digest", + _sha256(self.approval_receipt_digest, "approval_receipt_digest"), + ) + object.__setattr__( + self, + "effective_config_digest", + _sha256(self.effective_config_digest, "effective_config_digest"), + ) + object.__setattr__( + self, "artifact_sha256", _sha256(self.artifact_sha256, "artifact_sha256") + ) + object.__setattr__( + self, + "capability_receipt_digest", + _sha256(self.capability_receipt_digest, "capability_receipt_digest"), + ) + instrument = _instruments((self.instrument,), "instrument") + object.__setattr__(self, "instrument", instrument[0]) + object.__setattr__(self, "quantity", _quantity(self.quantity, "quantity")) + object.__setattr__( + self, + "requested_external_writes", + _max_external_writes(self.requested_external_writes, "requested_external_writes"), + ) + object.__setattr__(self, "cleanup_ready", _boolean(self.cleanup_ready, "cleanup_ready")) + object.__setattr__( + self, + "reconciliation_ready", + _boolean(self.reconciliation_ready, "reconciliation_ready"), + ) + + +class TestExecutionProfileVerifier(Protocol): + """A deployment-owner supplied, offline verifier for an exact profile. + + Implementations may inspect preloaded trust material only. They must not + resolve credentials, import provider SDKs, start a provider preflight, or + turn a profile into execution authority. + """ + + def verify(self, profile: TestExecutionProfile, canonical_payload: bytes) -> bool: + """Return whether the exact profile has offline trust binding.""" + + +class RejectingTestExecutionProfileVerifier: + """Default verifier: reject every profile until a deployment owner supplies trust.""" + + def verify(self, profile: TestExecutionProfile, canonical_payload: bytes) -> bool: + del profile, canonical_payload + return False + + +@dataclass(frozen=True) +class TestExecutionProfileObservation: + """A verified offline observation that cannot grant any execution authority.""" + + profile_id: str + profile_sha256: str + provider: str + environment: str + approval_receipt_digest: str + instrument: str + quantity: str + requested_external_writes: int + checked_at: float + valid_until: float + status: str = TEST_EXECUTION_PROFILE_PRECHECKED + profile_binding_valid: bool = True + profile_verifier_accepted: bool = True + preflight_authorized: bool = False + execution_authorized: bool = False + provider_preflight_started: bool = False + provider_connected: bool = False + external_writes_started: bool = False + + def __post_init__(self) -> None: + if ( + self.status != TEST_EXECUTION_PROFILE_PRECHECKED + or self.profile_binding_valid is not True + or self.profile_verifier_accepted is not True + or self.preflight_authorized is not False + or self.execution_authorized is not False + or self.provider_preflight_started is not False + or self.provider_connected is not False + or self.external_writes_started is not False + ): + raise ValueError("test execution profile observation cannot grant authority") + checked_at = _timestamp(self.checked_at, "checked_at") + valid_until = _timestamp(self.valid_until, "valid_until") + if valid_until <= checked_at: + raise ValueError("test execution profile observation must retain a future deadline") + object.__setattr__(self, "profile_id", _identifier(self.profile_id, "profile_id")) + object.__setattr__(self, "profile_sha256", _sha256(self.profile_sha256, "profile_sha256")) + object.__setattr__(self, "provider", _provider(self.provider, "provider")) + object.__setattr__( + self, + "approval_receipt_digest", + _sha256(self.approval_receipt_digest, "approval_receipt_digest"), + ) + object.__setattr__( + self, "environment", _sandbox_environment(self.environment, "environment") + ) + instrument = _instruments((self.instrument,), "instrument") + object.__setattr__(self, "instrument", instrument[0]) + object.__setattr__(self, "quantity", _quantity(self.quantity, "quantity")) + object.__setattr__( + self, + "requested_external_writes", + _max_external_writes(self.requested_external_writes, "requested_external_writes"), + ) + object.__setattr__(self, "checked_at", checked_at) + object.__setattr__(self, "valid_until", valid_until) + + def __bool__(self) -> bool: + """Prevent a successful offline check from being used as a permission token.""" + + raise TypeError( + "TestExecutionProfileObservation is not an admission decision; do not use it as a boolean" + ) + + def as_public_dict(self) -> dict[str, Any]: + """Return a safe, explicit non-authoritative diagnostic projection.""" + + return { + "checked_at": self.checked_at, + "environment": self.environment, + "execution_authorized": self.execution_authorized, + "external_writes_started": self.external_writes_started, + "instrument": self.instrument, + "preflight_authorized": self.preflight_authorized, + "profile_binding_valid": self.profile_binding_valid, + "profile_id": self.profile_id, + "profile_sha256": self.profile_sha256, + "profile_verifier_accepted": self.profile_verifier_accepted, + "provider": self.provider, + "approval_receipt_digest": self.approval_receipt_digest, + "provider_connected": self.provider_connected, + "provider_preflight_started": self.provider_preflight_started, + "quantity": self.quantity, + "requested_external_writes": self.requested_external_writes, + "status": self.status, + "valid_until": self.valid_until, + } + + +def _reject_json_constant(_: str) -> None: + _reject("invalid_json", "profile contains a non-finite JSON value") + + +def _reject_duplicate_fields(pairs: Sequence[Tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + _reject("invalid_json", "profile JSON contains duplicate object keys") + result[key] = value + return result + + +def _coerce_profile_wire( + value: Union[Mapping[str, Any], SerializedTestExecutionProfile] +) -> Mapping[str, Any]: + if type(value) is bytearray: + if len(value) > MAX_TEST_EXECUTION_PROFILE_BYTES: + _reject("profile_too_large", "profile exceeds the maximum supported size") + value = bytes(value) + if type(value) is bytes: + if len(value) > MAX_TEST_EXECUTION_PROFILE_BYTES: + _reject("profile_too_large", "profile exceeds the maximum supported size") + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + _reject("invalid_json", "profile must be UTF-8 JSON") + if type(value) is str: + if len(value) > MAX_TEST_EXECUTION_PROFILE_BYTES: + _reject("profile_too_large", "profile exceeds the maximum supported size") + try: + encoded_length = len(value.encode("utf-8")) + except UnicodeEncodeError: + _reject("invalid_json", "profile must be UTF-8 JSON") + if encoded_length > MAX_TEST_EXECUTION_PROFILE_BYTES: + _reject("profile_too_large", "profile exceeds the maximum supported size") + try: + parsed = json.loads( + value, + object_pairs_hook=_reject_duplicate_fields, + parse_constant=_reject_json_constant, + ) + except TestExecutionProfileError: + raise + except (json.JSONDecodeError, RecursionError, ValueError): + _reject("invalid_json", "profile is not valid JSON") + # JSON decoders can accept different nesting depths when pytest + # plugins or applications change the process recursion limit. This + # wire is shallow by contract, so reject deep containers explicitly + # before classifying a successfully decoded non-object value. + pending = [(parsed, 0)] + while pending: + node, depth = pending.pop() + if depth > 64: + _reject("invalid_json", "profile JSON nesting exceeds the supported limit") + if type(node) is dict: + pending.extend((child, depth + 1) for child in node.values()) + elif type(node) is list: + pending.extend((child, depth + 1) for child in node) + if type(parsed) is not dict: + _reject("invalid_wire", "profile wire must be an object") + return parsed + if type(value) is not dict: + _reject("invalid_wire", "profile wire must be a plain JSON object") + return dict(value) + + +def parse_test_execution_profile( + value: Union[Mapping[str, Any], SerializedTestExecutionProfile] +) -> TestExecutionProfile: + """Strictly parse a profile without consulting a trust store or provider.""" + + wire = _coerce_profile_wire(value) + keys = tuple(wire) + if any(type(key) is not str for key in keys) or set(keys) != _PROFILE_FIELDS: + _reject("invalid_wire", "profile wire fields do not match the test execution contract") + profile = TestExecutionProfile( + profile_id=wire["profile_id"], + provider=wire["provider"], + environment=wire["environment"], + account_fingerprint_sha256=wire["account_fingerprint_sha256"], + approval_receipt_digest=wire["approval_receipt_digest"], + effective_config_digest=wire["effective_config_digest"], + artifact_sha256=wire["artifact_sha256"], + capability_receipt_digest=wire["capability_receipt_digest"], + allowed_instruments=wire["allowed_instruments"], + max_quantity=wire["max_quantity"], + max_external_writes=wire["max_external_writes"], + cleanup_required=wire["cleanup_required"], + reconciliation_required=wire["reconciliation_required"], + created_at=wire["created_at"], + valid_from=wire["valid_from"], + expires_at=wire["expires_at"], + schema_version=wire["schema_version"], + ) + if len(canonical_test_execution_profile(profile)) > MAX_TEST_EXECUTION_PROFILE_BYTES: + _reject("profile_too_large", "profile exceeds the maximum supported size") + return profile + + +def canonical_test_execution_profile(profile: TestExecutionProfile) -> bytes: + """Return the exact deterministic bytes supplied to an offline verifier.""" + + if type(profile) is not TestExecutionProfile: + raise TypeError("profile must be a TestExecutionProfile") + return json.dumps( + TestExecutionProfile.as_wire(profile), + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def test_execution_profile_sha256(profile: TestExecutionProfile) -> str: + """Return a stable profile digest suitable for an offline audit record.""" + + return hashlib.sha256(canonical_test_execution_profile(profile)).hexdigest() + + +# Keep this public API discoverable without letting pytest mistake it for a +# test when a consumer imports it into a ``test_*.py`` module. +test_execution_profile_sha256.__test__ = False + + +def _require_equal(actual: str, expected: str, reason: str, message: str) -> None: + if actual != expected: + _reject(reason, message) + + +def _require_digest_equal(actual: str, expected: str, reason: str, message: str) -> None: + if not hmac.compare_digest(actual, expected): + _reject(reason, message) + + +def _validate_profile_lifecycle(profile: TestExecutionProfile, checked_at: float) -> None: + if checked_at < profile.valid_from: + _reject("profile_not_yet_valid", "test execution profile is not yet valid") + if checked_at >= profile.expires_at: + _reject("profile_expired", "test execution profile has expired") + + +def _validate_profile_context( + profile: TestExecutionProfile, context: TestExecutionPreflightContext +) -> None: + _require_equal( + context.provider, + profile.provider, + "provider_mismatch", + "preflight provider does not match the test execution profile", + ) + _require_equal( + context.environment, + profile.environment, + "environment_mismatch", + "preflight environment does not match the test execution profile", + ) + _require_digest_equal( + context.account_fingerprint_sha256, + profile.account_fingerprint_sha256, + "account_fingerprint_mismatch", + "preflight account fingerprint does not match the test execution profile", + ) + _require_digest_equal( + context.approval_receipt_digest, + profile.approval_receipt_digest, + "approval_receipt_digest_mismatch", + "preflight approval binding does not match the test execution profile", + ) + _require_digest_equal( + context.effective_config_digest, + profile.effective_config_digest, + "effective_config_digest_mismatch", + "preflight configuration does not match the test execution profile", + ) + _require_digest_equal( + context.artifact_sha256, + profile.artifact_sha256, + "artifact_mismatch", + "preflight artifact does not match the test execution profile", + ) + _require_digest_equal( + context.capability_receipt_digest, + profile.capability_receipt_digest, + "capability_receipt_digest_mismatch", + "preflight capability receipt does not match the test execution profile", + ) + if context.instrument not in profile.allowed_instruments: + _reject("instrument_not_allowed", "preflight instrument is not allowed by the test profile") + if Decimal(context.quantity) > Decimal(profile.max_quantity): + _reject("quantity_limit_exceeded", "preflight quantity exceeds the test profile limit") + if context.requested_external_writes > profile.max_external_writes: + _reject( + "external_write_limit_exceeded", + "preflight external write count exceeds the test profile limit", + ) + if context.cleanup_ready is not True: + _reject("cleanup_not_ready", "test execution profile requires a cleanup plan") + if context.reconciliation_ready is not True: + _reject("reconciliation_not_ready", "test execution profile requires reconciliation") + + +def validate_test_execution_profile( + value: Union[Mapping[str, Any], SerializedTestExecutionProfile], + *, + context: TestExecutionPreflightContext, + verifier: Optional[TestExecutionProfileVerifier] = None, +) -> TestExecutionProfileObservation: + """Check an offline profile/context binding before any provider preflight. + + The default verifier rejects. A verifier that accepts only produces a + non-authoritative observation: it never resolves credentials, connects a + provider, starts a preflight, or permits an external write. + """ + + if type(context) is not TestExecutionPreflightContext: + raise TypeError("context must be a TestExecutionPreflightContext") + profile = parse_test_execution_profile(value) + checked_at = _timestamp(time.time(), "now") + _validate_profile_lifecycle(profile, checked_at) + _validate_profile_context(profile, context) + + canonical_payload = canonical_test_execution_profile(profile) + snapshot_profile = parse_test_execution_profile(canonical_payload) + verifier_profile = parse_test_execution_profile(canonical_payload) + selected_verifier: TestExecutionProfileVerifier + if verifier is None: + selected_verifier = RejectingTestExecutionProfileVerifier() + else: + selected_verifier = verifier + try: + trusted = selected_verifier.verify(verifier_profile, canonical_payload) + except Exception: + _reject("profile_verifier_failed", "test execution profile verifier failed") + if trusted is not True: + _reject("profile_untrusted", "test execution profile is not trusted") + try: + post_verifier_payload = canonical_test_execution_profile(verifier_profile) + except Exception: + _reject("profile_mutated_by_verifier", "test execution profile verifier changed its input") + if not hmac.compare_digest(canonical_payload, post_verifier_payload): + _reject("profile_mutated_by_verifier", "test execution profile verifier changed its input") + + post_verify_checked_at = _timestamp(time.time(), "post_verify_now") + _validate_profile_lifecycle(snapshot_profile, post_verify_checked_at) + return TestExecutionProfileObservation( + profile_id=snapshot_profile.profile_id, + profile_sha256=hashlib.sha256(canonical_payload).hexdigest(), + provider=snapshot_profile.provider, + environment=snapshot_profile.environment, + approval_receipt_digest=snapshot_profile.approval_receipt_digest, + instrument=context.instrument, + quantity=context.quantity, + requested_external_writes=context.requested_external_writes, + checked_at=post_verify_checked_at, + valid_until=snapshot_profile.expires_at, + ) + + +__all__ = [ + "MAX_TEST_EXECUTION_PROFILE_BYTES", + "MAX_TEST_EXECUTION_PROFILE_EXTERNAL_WRITES", + "MAX_TEST_EXECUTION_PROFILE_INSTRUMENT_LENGTH", + "MAX_TEST_EXECUTION_PROFILE_INSTRUMENTS", + "MAX_TEST_EXECUTION_PROFILE_LIFETIME_SECONDS", + "RejectingTestExecutionProfileVerifier", + "TEST_EXECUTION_PROFILE_PRECHECKED", + "TEST_EXECUTION_PROFILE_SCHEMA_VERSION", + "TestExecutionPreflightContext", + "TestExecutionProfile", + "TestExecutionProfileError", + "TestExecutionProfileObservation", + "TestExecutionProfileVerifier", + "canonical_test_execution_profile", + "parse_test_execution_profile", + "test_execution_profile_sha256", + "validate_test_execution_profile", +] diff --git "a/docs/_internal/opts/requirements/\350\277\255\344\273\24341-\345\256\236\347\233\230\346\211\247\350\241\214\351\243\216\346\216\247\347\233\221\346\216\247\344\270\216\347\244\272\344\276\213\346\236\266\346\236\204\351\207\215\346\236\204/ctp-current-acceptance-matrix.md" "b/docs/_internal/opts/requirements/\350\277\255\344\273\24341-\345\256\236\347\233\230\346\211\247\350\241\214\351\243\216\346\216\247\347\233\221\346\216\247\344\270\216\347\244\272\344\276\213\346\236\266\346\236\204\351\207\215\346\236\204/ctp-current-acceptance-matrix.md" new file mode 100644 index 00000000..4a653840 --- /dev/null +++ "b/docs/_internal/opts/requirements/\350\277\255\344\273\24341-\345\256\236\347\233\230\346\211\247\350\241\214\351\243\216\346\216\247\347\233\221\346\216\247\344\270\216\347\244\272\344\276\213\346\236\266\346\236\204\351\207\215\346\236\204/ctp-current-acceptance-matrix.md" @@ -0,0 +1,423 @@ +# CTP 同配置当前开发与 QA 验收矩阵 + +## 2026-09-28 当前验收状态 + +当前 Iteration 41 inventory 有 17 个注册项,其中包括 007 suite 根零写 `simulation/sandbox` runtime/front-check 路由。suite 根 protected/ignored `config.yaml` 和目录 ACL 已与 013_3 对齐;离线 `doctor` exit 0(`provider_preflight_started=false`、5 pairs、`preflight_available=false`)。普通 `preflight` 仍 fail-closed,等待有界 Windows Job supervisor 和独立验收。2026-09-28 的一次显式 credential-free `check-ctp-fronts` TCP 检查 exit 0 并选中索引 3:该对 MD/TD 均为 3/3,其余索引 0/1/2/4 均为 0/3。它是本机当时的传输观察,不证明账号登录、行情订阅、结算、报单或撤单。Suite 根 `bt-runtime run` 以 `profile_dispatch_unavailable`/exit 2 拒绝,`provider_preflight_started=false`,因为该只读 registration 没有 runner。该路由没有认证 case runner、交易 runner 或写权限。 + +用户已取消“整条 CLI 命令必须在 0.8 秒内结束”的硬时限要求。历史 `G1_STRICT_WHOLE_COMMAND = NO_GO` 仍作为旧时限方案的审计证据保留;它不代表当前时限要求,也不构成 G1 通过。普通 `preflight` 仍关闭,直到 Windows Job 子进程终止与清理行为可验证并完成独立验收。当前 Gateway 源码与 parent SDK pins 已有提交候选,尚待独立来源与兼容性审查;候选提交不代表真实 provider 已通过。 + +007 的 33 个 SimNow 认证案例现有独立的 `config.yaml`、`_strategy.py` 与 `run.py` staging 目录;[本次验收记录](evidence/iteration41-007-simnow-33-case-staging-acceptance-2026-09-28.md)记载 33 个入口全部 `BLOCKED`/退出码 2、真实案例 `PASS=0`、真实 order/cancel writes 为 0。该早前检查点的完整 `tests/unit/live_certification` 为 351 passed / 1 个现存 pytest 配置 warning;同一检查点的完整 `tests/unit/runtime` 为 2,131 passed / 30 skipped / 2 xfailed。同一检查点的入口、案例封装和节点选择四文件焦点为 63 passed / 1 个现存 warning。离线 CI smoke 的 14 个可运行注册中 12 个通过,另 2 个因本机缺少 `bt_api_execution` capability 被拒绝。上述结果均不构成 TCP、真实 provider 或交易验收。策略文件仍是待接入的真实动作与证据计划,尚无 provider 执行。此项不改变 `NO_WRITE / LIVE_NO_GO`。 + +当前 Store 是 CTP API route-identity R2,SHA-256 `CE04ECBADDD3D3EA01C707313EA9B144650C47E322D0BDFC915000C0110094CA`。其[主树集成归档](evidence/iteration41-ac41-63-store-api-route-identity-r2-main-2026-09-28/README.md)记录 28 项 focus 通过;guarded Store 为 730 passed / 13 skipped / 18 个精确 CTP 可选 node ID deselected,Runtime 为 2,010 passed / 30 skipped / 14 个精确 CTP node ID deselected / 2 xfailed / 0 failures。首次无效 harness 结果已保留但不计入接受结果。当前 official inventory SHA-256 为 `A959A3BC6284232EA90191F13ADC71A6931F5DFCBA6DB16E7AC72B1476B9D142`,460/460 active dispositions 仍为 `REVIEW_REQUIRED / NOT_AVAILABLE`;相较 CE04 时点的 `0874A81A…`,仅四个 locator 行由 491 更新至 495,disposition 文件 SHA-256 `B55A054A8E53CEC27A7B20AD43A653CEE07082FEBE51844CCCB129C0ED365426` 未变,scanner contracts 15 项通过。条件 route-identity guard 仍有自定义 API 通过 `__getattribute__` 隐藏 CTP `exchange_kwargs` 并到达本地 fake sink 的残余,不构成 writer closure、provider authority 或写入许可。 + +G5 [V21 offline ActionRef ledger audit r0](evidence/iteration41-g5-v21-offline-actionref-ledger-audit-r0-2026-09-28/README.md) 是隔离的离线 projector,10 项 synthetic tests 及 independent QA 通过;它不建立 G5 authority、ledger cutover、native ActionRef floor、账户级 writer fence 或写入路由。Default CTP/SimNow writes、live dispatch 与 production admission 继续 `NO_WRITE / LIVE_NO_GO`。 + +此前的[干净 parent 三包候选](evidence/iteration41-g4-clean-parent-triplewheel-candidate-2026-09-28.md)完成了确定性构建与隔离安装静态检查,但该历史 `bt_api_py 0.15` 源码缺少当前受管 CTP 所需的 credential binding、execution authorization、runtime plugins、合同 DTO 与 reservation mirror 五组模块路径,请求构造器要求 0.15.5;后续隔离源码差异审计位于 `D:/temp/iteration41-g4-parent-compat-gap-analysis-20260928`,当时没有制作混合来源兼容补丁或 wheel。当前 Gateway 源码与 parent SDK pins 已有提交候选,但仍待独立来源/兼容性与真实 native/provider 验收;提交候选不构成 G4 接受,也不证明真实 provider 已通过。G6-S 的[订阅 ACK 源码审计](evidence/ctp-md-subscription-ack-correlation-source-audit-2026-09-28.md)确认原生订阅请求无调用方 request ID,当前高层回调也不保留可验证的订阅关联;行情 Feed 仍未接入。 + +G1 overlapped-I/O custody 的[主树归档](evidence/iteration41-g1-overlapped-io-custody-main-2026-09-28/README.md)只记录 local subgate:main guarded focus 33 passed、相邻 guarded I13/I15 suite 305 passed,independent QA 为 `GO_LOCAL_SUBGATE`。用户已取消整条 CLI 命令 0.8 秒硬时限;`G1_STRICT_WHOLE_COMMAND = NO_GO` 是旧要求下的历史结论,不代表当前时限要求或 G1 通过。普通 CTP preflight 仍关闭,直到 Windows Job 子进程终止与清理可验证并完成独立验收;该 subgate 不授权 live dispatch 或 writes。 + +AC41-63 的三个 007 示例 helper 均已在主树作窄范围 fail-close:`create_live_broker()`、`add_live_feeds()` 和 `run_cerebro_with_timeout()` 在检查各自输入或进入 legacy Store/Broker/Feed/Cerebro 路径前拒绝。[Broker archive](evidence/iteration41-ac41-63-007-broker-failclose-main-2026-09-28/README.md)记录 focus 5/5;[Feed archive](evidence/iteration41-ac41-63-007-live-feed-helper-failclose-main-2026-09-28/README.md)记录 focus 6/6,archive-final QA 20/20 payload、21/21 sums、18/18 links;[timeout-helper archive](evidence/iteration41-ac41-63-007-timeout-helper-failclose-main-2026-09-28/README.md)记录 guarded focus 7/7、empty guard,独立 QA 27/27 payload、28/28 sums、20/20 links。当前 007 support source SHA-256 `37536B9598E3EE2414DE2AF787EA1E15C8C52E42558E9679107DB4F3F34C273A`,timeout test SHA-256 `C5F88209C110CABB57F71003CFF9B56C0DA4D54DB43B3675ECB7414840DE5EF3`。这些只关闭具名 helper;一般 `Cerebro.run()`、generic Timer use 与 public Store/Broker/Feed constructors 仍可直接使用,不构成整体 writer closure。 + +013_1/013_2 的 `ctp_example_support` R4 已在主树集成八项 legacy helper fail-close。当前 source SHA-256 分别为 `560B5D60DF8BFDDDDABDCB8F6EB53468498328FA1E541B5323EBFFA4F968EBAC` 与 `675A5E3315559C90A1D7E39AC7D3E1ED2F9555E4A27F6C8A9F5DB0D8196D40A9`;新增 test `tests/unit/test_iteration41_legacy_ctp_support_inert_import.py` SHA-256 `3832B447E9606BC00D75316B0BCF29FAF87404969B9DAB520784BAE209D45069`。有效主树安全焦点为 32 passed / 1 个 private-config node deselected,独立 candidate guard 1 passed、guard empty;Ruff source 仍报告四项未改行的既有 `SIM112`。原 33-run 因读取两个未跟踪 config 而标为 `INVALID` 且不计入验收,原始 log/JUnit 不归档,不披露 private config 内容。首版 archive 因旧 candidate manifest 保留未限定的 33-pass 声明而被退回。新版[canonical R4 archive](evidence/ac41-63-013-legacy-ctp-support-r4-main-integration-2026-09-28/README.md)已通过独立终审:32/32 payload、33/33 sums、3 个本地链接、34-member ZIP CRC PASS;9 处 33-pass 声明均 `INVALID / EXCLUDED`,原始 33-run log/JUnit/config 不在归档中。README SHA-256 `71C09D95BB1C87446DBD4091A795F0D27FF06DBC4C4DB487EE195D3C9CBE9E07`,manifest SHA-256 `E7110B9D59925F553A13F2807983C193131D7C192D3C0946D1FD1CD8E21FB83D`,SHA256SUMS SHA-256 `6462C1E33946A8ABDF4BBDB5588C4A567815DFF3F3E28CC0D95E48A71CA50E83`。当前 inventory 仅四个 locator 行从 491 更新至 495,checklist `B55A054A…` 不变,verifier 460/460,scanner contracts 15 passed。以上不改变 `NO_WRITE / LIVE_NO_GO`。 + +## 2026-09-27 历史验收快照 + +Store sdk_api=None r1 与后续 CTP generic queue fail-close r4 在当时集成(历史 Store SHA-256 A0393FC4F0B7212C6EE4B4F32DE2AA9E6E9A0ECFC5FE976F72160E2EC11F6ABE);mechanical fallback fail-close 已集成(source SHA-256 549276111279275BEB000D8104C4330A6D11B7C181AE66087079A555AF26D81F)。r4 的 Store/Runtime 加新增队列合同主树宽回归为 2,740 passed / 43 skipped / 2 xfailed / 0 failed。此前 r1 guarded Store/mechanical focus 为 38/38;这些结果都是本地回归证据,不构成真实 SDK/provider、账户授权或交易验收。 + +| 范围 | 当前裁决 | 证据边界 | +| --- | --- | --- | +| Store sdk_api=None 与 mechanical fail-close | 已集成;38/38 guarded focus | [Store r1 archive](evidence/iteration41-store-sdk-api-none-r1-main-integration-2026-09-27/README.md);fake API 与离线测试 | +| Fake/offline main integration | 97 passed / 10 skipped | optional source-gated tests 未执行;CTP account/native-adjacent 与 deployment interop 被静态排除;不代表 live acceptance | +| Store getter proxy r0 | NO_MERGE | same-process reflection 可恢复 raw API;不是安全隔离边界 | +| G4 base/CTP pinned-wheel rebuild | CTP exact pinned wheel 未复现;G4 closed | 未进行 fresh install/native acceptance | +| G5/V21 current-source cross-package fake contract | PASS for LOCAL_FAKE_ONLY; NO_AUTHORITY / NO_WRITE / LIVE_NO_GO / NO_MERGE | [QA archive](evidence/iteration41-g5-v21-current-contract-independent-qa-2026-09-27/QA-INDEX.md):current G5 verifier SHA 7CAED2A8… with V21 Store claim mapping gate; focus 4/4, full suite 275 passed / 2 optional SDK-import tests blocked+skipped. Missing mapping rejects before READY-to-CLAIMED and fake sender. Historical dual-ledger repro is only the old SDK allocator candidate SHA 8E7ABDD2…, not the active verifier; no trusted native floor or account-wide fence | +| I22 read-only port replacement | NO_MERGE_AS_REPLACEMENT / FAKE_PORT_ONLY | 11-node local semantic slice,未接入生产 Store;报告仍在 D:\temp | +| AC41-63 writer inventory | 2026-09-27 R2 scanner snapshot; later refreshed after CE04 R2 | At that snapshot: 363 writer + 97 dynamic / 355 files + six tombstones; 460/460 verifier; active rows REVIEW_REQUIRED / NOT_AVAILABLE. Current inventory and hash are in the 2026-09-28 section above | +| Store lazy-connect audit | NO_MERGE_PATCH / BLOCKED_BY_SHARED_I22_LIFECYCLE | [Report](evidence/ensure-api-ready-lazy-connect-audit-2026-09-27/report.md). Fake routes: get_balance(force=True), get_symbol_info(), direct/gateway/forwarding start(), example-shaped btapi/direct CTP flow; zero fake order/cancel writes. Fourteen I22-related bounded-probe test functions were statically identified in the I21 test file, not run; shared CTP deny would block typed read-only lifecycle | + + +Default CTP/SimNow writes, live dispatch, and production admission remain closed: NO_WRITE / LIVE_NO_GO. 下方较早的 r2b/r2c、Store R5 与 CTP 诊断段落为时间序列历史记录,不能覆盖本节当前裁决。 +[Store r2b 主树集成回归](evidence/ctp-account-actor-r2b-main-integration-2026-09-27/REPORT.md)在精确补丁应用后跑完整 `tests/unit/stores tests/unit/runtime`:`258 failed / 2403 passed / 43 skipped / 2 xfailed`;撤回同一补丁后为 `2636 passed / 43 skipped / 2 xfailed`。隔离的 55 项正例不足以支持合入,r2b 已撤回且主树 Store SHA 恢复。旧 nodeid 的 258 项须逐类审查和迁移,不得通过 skip/xfail 或重开 CTP direct 写路由消除;`R2B_MAIN_MERGE_REJECTED / G6-P CLOSED / F14 CLOSED`。 + +[258 项独立只读失败分组](evidence/ctp-account-actor-r2b-failure-analysis-2026-09-27/README.md)指出 Iter22 的 200 项均在 CTP 构造门前截断;其余 58 项含 49 个 CTP、6 个模糊 route、3 个 unsupported provider 早拒绝。r2c 宽测分组为 249 项 external account actor unavailable、6 项 store route ambiguous、3 项 store provider unsupported;I22 的 158 个 query/evidence nodeid 仅迁移 1 个,余 157 项在构造门结束,没有执行 shared-session read-side query。013_3 零写回放仍构造 CTP Store,须改本地专用组合。该分析只解释失败,不证明后续原生调用安全;[独立主树修订 QA](evidence/ctp-account-actor-main-store-wiring-r2b-independent-qa-2026-09-27/MAIN-TREE-AMENDMENT.md)覆盖先前隔离合入建议。 + +[r2b route-test migration 独立复核](evidence/ctp-account-actor-r2b-route-test-migration-r2-2026-09-27/independent-qa/REPORT.md)为 `SAFE_LOCAL_TEST_MIGRATION / NO_WRITE`:隔离 fake-only 35/35,保留同步 `Store.start()/stop()` 生命周期断言;r1 已被 r2 supersede。此状态只接受测试迁移,不改变 r2b 主树 258 个旧 nodeid 失败的合入阻断,也不证明 Actor/provider 权威。 + +[纯 query/evidence seam r2](evidence/ctp-query-pure-seam-r2b-r2-2026-09-27/README.md)为 `PACKET_COMPLETE / LOCAL_TEST_CONTRACT_PASS / FAKE_LOCAL / OFFLINE`。19 项 focused logic pass 与三路由拒绝来自 r1 独立逻辑复核;r2 只完成自包含包装/15 项 payload 与 ZIP 校验,未重跑逻辑测试。Iteration22 原 158 个 query/evidence nodeid 仅迁移 1 个,余 157 项仍未解决;该纯接缝不是 query producer、外部 Actor 或 provider 证明。 + +截至本检查点,Store r2b 主树集成 258 项旧用例失败后已精确撤回;r2c 仍是隔离修复/逐例迁移工作,尚无可替代该主树回归结果的接受证据。r2b/r2c 均未合入;不得把 258 项失败归为 r2c 已完成修复,也不得把局部假测试迁移解释为 Actor 或 provider 权威。 + +[AC41-63 受控 writer 切片及独立复核](evidence/ac41-63-controlled-writer-slice-independent-qa-2026-09-27/reviewer/QA-REPORT.md)只覆盖 389 条静态候选中的 10 条:主树直接 Store/gateway 在假 delegate 中可达,默认 013_3 CLI preflight 在私有配置/provider 前拒绝;另 379 条未审。该结果确认了继续收敛 Store 写入口的必要性,不能视为账户隔离或全 writer closure;AC41-63 `NOT_ACCEPTED`。 + +[G6-S 结算 consumer r2 独立 QA](evidence/ctp-g6s-settlement-consumer-contract-independent-qa-r2-2026-09-27/REPORT.md)核验 92 项冻结 payload、39 项焦点与 34 项对抗,r1 的 bool 代次/零码反例在精确整数门下拒绝;ConfirmDate-only 仍只形成字段形状回执。注入 attestor 的属性可在两次读取间换成另一 callable,公开 receipt DTO 也可由调用方手工置 true;函数自身返回的来源可信/执行授权均为 false。模块尚未接主 runtime,无受信 provider/SDK/native 证据,仅 `FAKE_LOCAL_CONTRACT_ONLY / G6-S CLOSED / G7-S CLOSED`。 + +[G5 send-entry r2 独立 QA](evidence/iteration41-g5-worker-send-entry-r2-independent-qa-2026-09-27/REPORT.md)核验冻结 R1→R2 补丁、18 项候选测试、SDK 原源 57/57 对候选 53/57,并在独立 R1 副本重现 3 个红色反例。r2 移除调用方 `sender`,默认同步端口缺可信 pin/时间/代次时零 native 调用并拒绝;CANCEL 在 claim 后同库重读过期投影而拒绝,崩溃重开 UNKNOWN 不重派。但端口没有真实 SDK `Req*` 调用,故未证明真实发送直前时效;同进程代码替换私有 `_native_send_port` 仍可先执行假副作用。`COMPLETED` 只表示本地 `REJECTED` 回执持久化,G5 `NOT_ACCEPTED`。 + +[G6-S 结算 consumer r1 独立 QA](evidence/ctp-g6s-settlement-consumer-contract-independent-qa-2026-09-27/REPORT.md)核验冻结 24 项 payload、35 项原焦点与 21 项对抗;候选只消费注入的假 attestor 形状,未接主 readiness。负测发现代次 `True` 可与整数 `1` 相等、`False` 可被零错误码检查接纳,仍生成 `consumer_contract_satisfied=true`;回执的 `provider_source_trusted=false` 与 `execution_authorized=false` 保持正确。字段合同 r1 不完整,真实来源/SDK pin/回调和 G6-S/G7-S 均未验收。 + +[G6-S `.pyd` 加载边界独立 QA](evidence/ctp-g6s-pyd-loader-boundary-independent-qa-2026-09-27/QA-REPORT.md)核对 R2 的 191 项冻结 payload 和 3 项 Windows custody 测试,并用官方 CPython 3.11.5 源码确认扩展加载走 `ExtensionFileLoader(path)` → `_imp.create_dynamic` → `LoadLibraryExW(path, NULL, flags)`;保留句柄不会直接交给该加载器。假 `.pyd` 后缀文本文件的写入/替换被限制共享句柄挡住,只证明普通路径 custody,没有加载真实映像、观测 file ID 或封印依赖 DLL 链。仅 `PARTIAL_PATH_CUSTODY_ONLY / NO_G4 / NO_G6-S`。 + +[AC41-63 零写运行轨迹独立 QA](evidence/ac41-63-zero-write-independent-qa-2026-09-27/REPORT.md)在隔离副本复跑本地四根 K 线 fixture 与 4 项焦点,389 个静态候选只观察到 5 个调用点、0 个 writer 调用,384 项仍未执行,分布于 145 个路径组。负测证明 Python 审计钩子未计入对预先打开管道的 `os.write`,且缩窄的源码复扫发现不了从 `examples/` 清单删掉的 writer;这不是 OS 隔离或全路径闭包。候选明确 `writer_closure_established=false`,AC41-63 保持 `NOT_ACCEPTED`。 + +[G1 整命令截止惰性探针独立 QA](evidence/iteration41-g1-whole-command-feasibility-2026-09-27/independent-qa-2026-09-27/QA-REPORT.md)核对冻结脚本并在新 Win32 目录原样复跑两组 700 ms 试验:受控 `WaitForSingleObject(INFINITE)` 与 API 前 sleep 在 D 时均有 2 个 Job 成员,`UNKNOWN` **分类采样**分别晚 D 2.596/2.380 ms,清理后才见 Job=0。原 JSON 的 `bounded_function_return_qpc` 实为清理前分类采样,不能称函数返回;精确 Job-zero 时刻也未记录。用户态调用前 marker 不是 ETW/kernel 栈,T0 不含顶层启动,更未测 native/SCM/P14。仅 `FEASIBILITY_ONLY`,整命令硬截止及 G1/普通预检继续关闭。 + +[BtApiStore Actor 接线 r2a 独立 QA](evidence/ctp-account-actor-main-store-wiring-r2a-independent-qa-2026-09-27/REPORT.md)核验 679/679 冻结 payload、r2→r2a 两段补丁重放、4/18/25 项焦点;显式/嵌套 CTP 在环境、调用方对象、凭据、resolver 与 SDK 前固定拒绝,OKX raw-API 正例保留。旧 Store 30 项精确基线 `30/30`,候选仍 `9 passed / 21 failed`,逐项对应 8 个 fail-close 断言及 13 个未来外部 Actor 迁移。r2 的显式 CTP 环境读取顺序缺口已在隔离候选修正,但主仓 Store 未合入、Actor 未部署、写入未授权,G6-P/F14 不升级。 + +[统一离线 wheelhouse/结算探针独立 QA](evidence/unified-offline-wheelhouse-settlement-independent-qa-r1-2026-09-27/independent-qa/QA-REPORT.md)核验 150/150 冻结 payload、52/52 哈希锁定 wheel、隔离 CPython 3.11.5 无索引安装、四个根包 PEP 610 来源、13,147 条 RECORD 与 `pip check`;CTP 探针双构建字节一致,旧同版本碰撞未进入最终 lock。该检查没有加载 `_ctp`,`core-reference` 缺 `bt_api_binance` extra,结算 helper 比对回调 `ConfirmDate` 与独立查询来源 TradingDay,却未接入主 readiness。只接受 `FAKE_ONLY / NO_RELEASE` 的制品消费证据,G1–G5、结算准入及真实 SimNow/production 均不升级。 + +[G5 typed durable projection consumer 独立 QA](evidence/iteration41-g5-worker-projection-durable-independent-qa-2026-09-27/independent-qa-report.md)核验冻结输入/输出与补丁,复跑候选 26 项及对抗 8 项;冻结 SDK 原源 `57/57`,候选 `53/57`(3 个 schema-v5 旧预期、1 个缺 typed projection/ActionRef 的旧 CANCEL fixture)。同库行/哈希/一次性消费只证明本地字节;同步 wrapper 可先进入 sender 再返回 awaitable,事后 UNKNOWN 不能保证零发送;sender 内跨 TTL 仍可能报完成。无 native query 生产者、受信水位/时钟或兼容 V21 worker,G5 `BLOCKED / NOT_ACCEPTED`。 + +### AC41-63 direct MechanicalCycle / SimNowLiveRunner fail-close r1 main integration (2026-09-27) + +[Canonical main integration archive](evidence/ac41-63-direct-mechanical-cycle-simnow-failclose-main-integration-2026-09-27/README.md) records only the five-file direct-dispatch fail-close slice. All five main-tree raw hashes match candidate r1. The exact three-file lane passed 52, skipped 1 optional L2 integration before child launch, and had zero failures; Ruff and py_compile passed. Supplemental broad Store/Runtime run after the MechanicalCycle and 013_1/013_2 support main patches and before Store r5: `2,641 passed / 43 skipped / 2 xfailed / 0 failed`, one existing warning, in 112.27s. This is a regression snapshot, not causal attribution to the narrow fail-close patch or CTP acceptance; raw JUnit/log/exit evidence is retained in the linked archive. Independent QA established candidate and exact-base copies both hit the same fake `UNKNOWN` assertion, with zero socket attempts. The requested positive `9 submits / 1 cancel / 0 external writes` remains unverified. This does not establish provider authority, writer closure, or a live route: `NO_WRITE / LIVE_NO_GO`. + +### AC41-63 013_1/013_2 legacy CTP support import/helper r2 main integration (2026-09-27) + +[Canonical archive](evidence/ac41-63-013-legacy-ctp-support-r2-main-integration-2026-09-27/README.md) records two main-tree support modules plus the persistent import test. The r2 modules match the exact candidate hashes and remove import-time dotenv loading while retaining the four legacy helper fail-close guards. Main focus passed 33/33; test-only Ruff, py_compile, and diffcheck passed. Source-wide Ruff still reports four unchanged SIM112 alias findings outside the patch. Independent fake QA also verified zero dotenv or `.env` access, SDK/native load, network, production Store/Broker construction, or write. The explicit `load_dotenv_if_available()` helper remains callable and custom broker composition remains outside the guard; official inventory dispositions remain `REVIEW_REQUIRED / NOT_AVAILABLE`. `NO_WRITE / LIVE_NO_GO`. + +[BtApiStore Actor 接线 r2 独立 QA](evidence/ctp-account-actor-main-store-wiring-r2-independent-qa-2026-09-27/REPORT.md)核验 649/649 冻结 payload、三文件补丁重放、`14/14` 与 `21/21` 焦点;旧 30 项在精确基线 `30/30`,r2 为 `9 passed / 21 failed`,21 项精确对应 8 个 fail-close 断言迁移、13 个未来外部 Actor 迁移。独立环境探针发现显式 CTP 构造在 actor-unavailable 拒绝前读 `BT_STORE_PROVIDER`/`BT_GATEWAY_EXCHANGE_TYPE`,环境值可改变拒绝错误;未触达 caller 对象、凭据、SDK 或 provider 网络,但违反先拒绝的字面顺序。r2 是隔离候选,按此差异阻断合入,G6-P/F14 不变。 + +[G1 P14/channel-close 惰性探针独立 QA](evidence/iteration41-g1-r10-p14-channel-faults-independent-qa-2026-09-27/INDEX.md)复现非法/受限 Win32 句柄返回错误 6/5、合法 Job 活跃进程 1→0 及重复关闭错误 6;两次约 100 ms 卡顿均发生在目标 API 前,`API_REACHED=false`,由外层测试 Job 清理。它不覆盖真实内核 API 挂起、生产 supervisor 故障传播或整命令硬截止,只记 `FEASIBILITY_DIAGNOSTIC_ONLY`,G1 仍关闭。 + +[G6-S artifact binder r2 独立 QA](evidence/ctp-g6s-artifact-first-sdk-binding-independent-qa-r2-2026-09-27/QA-REPORT.md)核验 191/191 冻结 payload,隔离复跑 52 项焦点及 5 项导入链对抗。r1 的 `ExtensionFileLoader` 替换 hook 与 `ModuleSpec`/`module_from_spec`/`PathFinder` 变异在 r2 均被提前拒绝;fake `.pyd` 路径替换也被保留句柄阻止。但 CPython 原生扩展仍按 pathname 载入,没有保留句柄映像身份、真实 SDK/native pin 或受信进程边界。仅 `PARTIAL_CUSTODY`,G4/G6-S 继续 `BLOCKED`。 + +[G1 R10-r1 Windows 惰性服务独立 QA](evidence/iteration41-g1-r10-r1-independent-qa-2026-09-27/independent-qa-receipt.md)从冻结二进制及精确源码重建各复跑 15/15;迟到受理不再产生票据或 worker,失败的 Job 控制复制也不发 READY。冻结二进制的 P05 票据在 D+12.878 ms 才变 UNKNOWN,调用方在 D+28.725 ms 才观察到;启动期 Job API、channel-close、P14/SCM、真实内核挂起与整命令截止仍未证明,G1/普通 `preflight` 保持关闭。 + +[G6-S artifact-first binder r1 独立 QA](evidence/ctp-g6s-artifact-first-sdk-binding-independent-qa-r1-2026-09-27/qa-report.md)核验 141/141 冻结文件,复跑 47 项焦点及 8 项对抗测试。保留句柄挡住 fake `.pyd` 覆写/重命名,但 gate 后同进程替换 `ExtensionFileLoader` 可令惰性 hook 执行;没有真实 SDK/native pin 或受信导入边界。只接受局部 Windows 文件 custody,G4/G6-S 仍 `BLOCKED`。 + +[G5 worker handoff 独立 QA](evidence/iteration41-g5-order-authority-independent-qa-2026-09-27/INDEX.md)核验冻结候选 50 项输入、91 项输出,复跑候选 21 项、冻结 SDK 57 项及 3 项对抗探针。正向撤单路径依赖测试注入的内存目标投影;冻结 Store 没有持久 target-projection producer/readback,实际路径在生成 command/handoff 前拒绝。仅接受 consumer 侧局部检查,G5 与默认 CTP 写入仍关闭。 + +[G5/V21 投影与 worker 预审](evidence/iteration41-g5-v21-handoff-prereview-independent-2026-09-27/independent-pre-review.md)确认目标字段可映射,但 V21 worker 不接受新 `action_identity=`,且当时的候选 Store/G5 authority 各自分配 ActionRef;默认 native query verifier 拒绝、无真实生产者。该双分配结论限定于历史候选快照:独立 current-source QA 后来确认旧 G5 SDK allocator(SHA 8E7ABDD2…)不是 active G5 verifier(SHA 7CAED2A8…)。投影 session generation 不是 OS process/Job generation,异步 sender 后还须在实际 SDK send 前复核 TTL。该预审仍是历史接口设计证据,不是 G5 通过。 + +[BM58 双客户端诊断独立 QA](evidence/bm58-two-client-capacity-independent-qa-2026-09-27/independent-qa-report.md)核验作者 16/16 文件与 4 项测试;默认 fake smoke 完成 20/20,两次 40/s×1s、100ms 假延迟、队列界限 1 的压力运行均完成 40/40、零丢失,但各延至约 4.7–5.0 秒且累计背压等待为 7.218/8.375 秒。该 SQLite harness 未连接 Actor/Store/CTP,30 分钟 50/s profile 未运行,AC41-58 保持 `NOT_ACCEPTED`。 + +[AC41-63 writer 静态候选扩扫](evidence/ac41-63-writer-inventory-2026-09-27/REPORT.md)与[独立 QA](evidence/ac41-63-writer-inventory-independent-qa-2026-09-27/README.md)以受控目录基线覆盖 349 个 Python 文件、327 个 writer 候选、62 个 dynamic 候选、0 个解析错误;389 项仍全部 `REVIEW_REQUIRED / NOT_AVAILABLE`。独立复跑 12 项测试,合成新路径被标未分类,私有路径读取陷阱为零打开。该结果只证明静态候选发现,不证明 writer closure、运行时可达性或路由授权,AC41-63 不升级。 + +[V23 Execution + parent R4/R3r3 双 wheel 独立 QA](evidence/coordinated-v23-r4-r3r3-wheel-probe-independent-qa-2026-09-27/independent-qa-review.md)从封存的 1,015 项 payload 与自索引重建两个唯一临时版本;各自双构建字节一致,私有 venv 安装来源、17/143 个 wheel payload 与全部 RECORD 行、PEP 610 哈希及 `pip check` 通过。四组离线焦点 `73+20+6+44` 全部通过,共 143 次执行/123 个唯一节点;`bt_api_base` 仍为源码映射,`_ctp` 未加载。只接受可复现的假客户端制品消费,不是最终统一发行版、OS native-owner 或 G1/G4/G5 验收。 + +[BtApiStore Actor 接线 r1 独立 QA](evidence/ctp-account-actor-main-store-wiring-r1-independent-qa-2026-09-27/independent-qa-report.md)核验 629/629 冻结文件、三文件补丁逐字节重放、新边界 `14 passed` 与含旧 managed adapter 的焦点 `21 passed`。明确 OKX `api_cls` 注入正例恢复,模糊 `btapi` 只允许无本地派发的惰性投影;route 变异、CTP/unknown/forwarding 在读取 API/凭据前拒绝。旧 30 项在精确基线 30/30、r1 仅 9/30:其余 21 项预期直连本地 CTP SDK,与现有 fail-close 政策冲突。r1 仍仅隔离候选,未合入主仓或授予外部 Actor 权威,G6-P/F14 保持关闭。 + +[BtApiStore Actor 接线 r0 独立 QA](evidence/ctp-account-actor-main-store-wiring-r0-2026-09-27/ctp-account-actor-main-store-wiring-r0-independent-qa-2026-09-27.md)核验候选 613/613 文件与新增焦点 `11 passed`,但同一 30 项既有 Store 测试在精确主仓基线 `30 passed`、候选仅 `2 passed / 28 failed`;其中六个无实际 API 写入的 `btapi` adapter 投影正例在构造期被错误挡住。宽测 `386 failed / 68 passed / 145 skipped` 含旧 CTP fake 合同差异,不能全归为普通非 CTP 回归。r0 不合入;后续仅可保留未知路由的惰性 adapter 投影,实际 client/legacy 派发仍须重判并拒绝。G6-P/F14 不变。 + +[G6-S artifact-first SDK binder r0 独立 QA](evidence/ctp-g6s-artifact-first-sdk-binding-independent-qa-2026-09-27/REPORT.md)核验 139/139 冻结 payload、36 项原测试及 9 项对抗测试。哈希扫描后、模块导入前替换可写安装树的源码,替换代码先执行,随后哈希复核才拒绝;缓存 verifier 复用时也未重查 `sys.path`/`sys.meta_path`/`sys.path_hooks`。默认 pin 缺失时拒绝且不导入 SDK,但 r0 没有受保护路径/保留句柄、真实 wheel 或客户端构造绑定,不能接入交易 gate,G4/G6-S 继续 `BLOCKED`。 + +[G5 OrderRef/ActionRef 同库 authority 独立 QA](evidence/iteration41-g5-orderref-actionref-independent-qa-2026-09-27.md)核验冻结输入/输出 `50/50`、`87/87`,隔离候选 14 项、SDK 27 项通过;主仓四文件在禁用不兼容的 `pytest-asyncio` 插件后为 `49 passed / 47 skipped`,作者的 `54/47` 因缺原始 selector/JUnit 尚未独立复现。八进程假测试的 ActionRef 43–50 唯一且重开可读,UNKNOWN 不可重领;当前 worker 不接受 `action_identity=`,在调用体前抛 `TypeError`、零发送。水位为调用方提供、SDK 快照未清洁钉住,G5 仍关闭。 + +[AccountActorPort r4 独立 QA](evidence/ctp-account-actor-port-r4-independent-review-2026-09-27.md)核验 9/9 冻结 payload、原 34 项和补测后 41 项 fake 测试,仅接受局部收据/意图绑定。新进程可重新领取同一内存 intent;非 CTP 路由构造后若共享配置变为 CTP,缓存分类仍进入 legacy 提交。主仓尚未接线,外部主体与共同账户快照仍缺,G6-P 继续阻断。[G4-r2 结算查询来源验证器独立 QA](evidence/ctp-g4r2-settlement-query-evidence-independent-review-2026-09-27.md)在隔离源码通过 48 项 fake 测试、原生导入被拦截;它只生成内存摘要,尚未被主 runtime 消费或形成可信 provider 回执,G2/G4/G6-S 不升级。 + +[G6-P 本地 Actor 服务核心 r1 作者候选](evidence/g6-p-account-actor-server-core-r1-2026-09-27/INDEX.md)及[独立 QA](evidence/g6-p-account-actor-server-core-r1-independent-qa-2026-09-27.md)核验 11/11 冻结文件与 50 项 fake 测试;双进程/SQLite 账本、四域快照和最终版本重核仅是本地合同。独立负测复现快照 v77 授权后发布 v78,再次调用仍返回旧 `AUTHORIZED_LOCAL_OUTBOX`;内存假 HMAC 和同权限数据库写者也可伪造本地权威。没有 provider 派发、跨主机唯一写者或 CTP 同版本快照,G6-P 继续 `BLOCKED`,r1 不可接入。 + +[Actor 服务核心 r6 作者冻结](evidence/g6-p-account-actor-server-core-r6-2026-09-27/INDEX.md)与[独立 QA](evidence/g6-p-account-actor-server-core-r6-independent-qa-2026-09-27.md)核验 42/42 payload、56 项测试及 7 项生命周期焦点:v77 旧授权在 v78 后撤销,v1 行迁为 REVOKED,最终本地 claim 一次消费。CLAIMED 后崩溃无可信 finish/recovery,永久冻结;同权限 SQLite 写者仍可改回 AVAILABLE 并重领同一 dispatch,fake HMAC 仍可签合成快照。没有外部身份、provider 最终派发或共同账户版本,r6 仍仅是本地 fake 修复,G6-P 保持 `BLOCKED`。 + +[G6-S 主 runtime 结算消费端独立 QA](evidence/ctp-g6s-main-td-settlement-independent-review-2026-09-27.md)核验 117/117 冻结文件,隔离 22 项候选测试与 7 项 QA 负例通过,失败均零结算确认/报单/撤单。候选通过注入 verifier 的自报 manifest 字符串与可仿冒的类名检查,尚未核实际安装来源、RECORD、源码及原生扩展哈希;主仓生产文件未接线,G2/G4/G6-S 仍关闭。 + +[Actor r4 主仓调用面静态审计](evidence/ctp-account-actor-port-r4-main-integration-qa-2026-09-27.md)确认 `BtApiStore` 尚无 actor 接线;`forwarding` 后端的 CTP 判定提前返回 false,环境 provider 改写、注入 API 属性读取、legacy submit/cancel、SDK 队列、gateway 和原始 `ReqOrder*` 回退均需在构造或派发前封闭。审计没有实例化 Store 或执行 CTP 请求,仅界定需覆盖的旁路;不能将其当成已发生的交易或主仓动态利用。 + +[parent R4+Execution R3r3 源码合流 QA 归档](evidence/parent-r4-r3r3-composite-qa-2026-09-27/README.md)核验 118 项证据文件;隔离 focus 分别为 parent 44、主桥接 3、五文件 73、补充撤单 20 项通过。测试夹具改动与早期 4 项路径 setup 失败保留;parent 仍声明已占用的 `0.15.5`,无唯一 wheel/pin 或受信 monitor 单写者,因此不能把撤单控制升级为发行或真实交易 PASS。 + +[parent R4+R3r3 第二组独立 QA](evidence/parent-r4-r3r3-composite-independent-qa-2026-09-27/README.md)再次核验四组 `44/3/73/20` 离线焦点及原始失败、guard 与测试适配差异;77 份归档 payload 和 ZIP 完整性通过。`0.15.5` 仍对应不同源码,R3r3 原始文件保留;结论仍仅限 fake 本地合同,不授予真实撤单或安装制品身份。 + +[V23 唯一版本安装探针独立 QA](evidence/ctp-v23-disposable-installed-wheel-independent-qa-2026-09-27.md)从 44 份冻结文件重建出两份字节相同的临时 wheel;隔离安装的焦点/全包分别为 `186 passed / 2 skipped`、`322 passed / 2 skipped`,来源与 RECORD 核验通过。原始 guard 把本机 loopback 误挡导致 12 项 setup/teardown 错误,修正后原始失败日志仍保留。仅接受本地 wheel 与假模型消费;G1/G5 和默认 CTP 写入不变。[R9 Windows 惰性服务独立重放](evidence/iteration41-g1-r9-windows-inert-custodian-independent-qa-2026-09-27.md)确认 caller 死亡后 broker 完成 pending I/O 取消及两个 Job 清空,但三项同步截止仍晚 3–4 ms;P14 未测,G1 与普通预检仍关闭。 + +[G6-S 结算确认假候选独立 QA](evidence/ctp-g6s-settlement-attestation-independent-review-2026-09-27/review.md)复跑冻结 `75 passed`,隔离补测 ConfirmDate-only 行形状后 `77 passed`,缺失请求来源时保持零写入。干净 G4-r2 SDK 已有请求 filter 读回参数;旧缺失结论只适用于另一份脏源码。[冻结清单路径更正](evidence/ctp-g6s-settlement-attestation-author-candidate-2026-09-27.md)确认 r1 中文矩阵路径有效、无 U+FFFD;旧哈希只是矩阵后续编辑前的内容记录。SDK 来源封印、真实回调及主 runtime 准入尚未验,G6-S/G7-S 继续关闭。 + +**2026-09-27 独立负测补记:** [parent 撤单控制端 r3 独立 QA](evidence/ctp-parent-cancel-control-r3-independent-review-2026-09-27.md)核验冻结 payload/evidence `154/154`、`112/112`,独立副本 `15 passed`,但有 monitor DB 写权限的另一进程可在精确读回后、最终清锁前删除事件;首次 release 与 RELEASED replay 仍返回成功,两个风险锁被清除。[r4 离线桥接复核](evidence/ctp-parent-cancel-control-r4-independent-review-2026-09-27.md)在三原节点与五文件焦点分别 `3/3`、`73/73` 通过,普通 SQLite DELETE 被挡,但同用户可删除 trigger 后删行;CTP UNKNOWN 冻结仍不可解除。G1 双阶段 [r3](evidence/ctp-g1-two-stage-r3-independent-review-2026-09-27.md) 的 `83+4+1` 项 fake 检查修正了 exit-2 错误分类;[r4](evidence/ctp-g1-two-stage-r4-independent-review-2026-09-27.md) 独立核验 bootstrap 静态大小与摘要门。真实 Windows R7 和 teardown-r2 各重放 9 个 inert 场景,同步准入仍超过 1800 ms 截止,overlapped 取消无持久 I/O reaper,G1 继续关闭。[SimNow 与 production 最短关键路径独立审阅](evidence/ctp-simnow-production-shortest-path-independent-review-2026-09-27.md)整理了仍需外部主体证明的门槛。上述检查不含 provider 或真实账号。 + +[G4 typed lifecycle receipt r1 独立审查](evidence/ctp-native-lifecycle-receipt-r1-independent-rejection-2026-09-27.md)在作者三文件 `65 passed` 后发现 Trader receipt 的原生 API generation、epoch、source ID 与同一 API/SPI 当前代次不一致,fake 终态仍报 `clean=True`;r1 拒绝。[r2 独立源码 QA](evidence/ctp-native-lifecycle-receipt-r2-independent-review-2026-09-27.md)三文件 `66 passed`,blocked-Init exact-object 与异常负例验证同代次标签。此候选源不同于 e75 wheel,不提供受监督的真实 native close,G4 继续 `BLOCKED`。 + +[G4 r2 唯一探针 wheel 独立复核](evidence/ctp-g4-r2-disposable-wheel-independent-review-2026-09-27.md)确认干净 29f8 基线加两项冻结覆盖、两次隔离构建 wheel 字节相同、RECORD/无系统包安装来源正确,安装后 66 项 fake 测试通过。`_ctp` 导入被刻意拦截,真实 DLL 未加载;中间 OBJ 不同、诊断 `cl /Bv` 退出 2 与两类测试警告均保留。仅接受探针制品/离线消费,原生 Join/Release、监督链和默认 SDK pin 仍未通过。 + +[V23 G1 native-owner 进程绑定源码候选](evidence/ctp-execution-v23-g1-native-owner-process-binding-author-candidate-2026-09-27.md)及[独立 QA](evidence/ctp-execution-v23-g1-owner-binding-independent-review-2026-09-27.md)核验 44/44 冻结 payload、`20 passed` 焦点与 `322 passed / 2 skipped` 全包。旧无进程绑定的 CLAIMED 行迁至 UNKNOWN/POISONED;未配 native-owner adapter 的 READY 行也持久冻结且不触发通用 sender。测试只用假 attestor,未证明 OS 进程/Job 所有权;发行 metadata 仍为与旧包冲突的 `bt_api_execution 0.2.0`。[版本与 pin 迁移映射](evidence/ctp-v23-composite-version-pin-migration-map-2026-09-27.md)仍是只读清单,未形成最终合流 wheel。G5、安装发布及真实 CTP 写入继续关闭。 + +[G1 R8 独立 Windows 惰性重放](evidence/ctp-g1-r8-independent-review-2026-09-27.md)核验冻结 10/10 payload,并复跑 13/13 个候选预期断言。同步 2 MiB admission 的决定分别在 D=1200 ms 后的 1218 ms(冻结)和 1234 ms(独立)才出现;overlapped reaper 仍属于 caller PID,caller 死亡时 pending I/O 未验。整命令启动及 Job handle 转移在 request D 外,G1 与普通 native `preflight` 保持关闭。 + +[G1 R9 预启动服务拓扑](evidence/g1-r9-design-index-2026-09-27.md)已归档为只读设计:caller 只提交有界 ticket,独立 I/O broker 与常驻 reaper 分别持有 OVERLAPPED 和 Job/进程句柄,Job1 empty 后才允许 Job2,全部使用同一截止。若唯一 reaper 卡在终止、查询或关闭调用,仍无 Job-empty 证明;此设计未部署、未运行完整 Windows 负测,不改变 G1 关闭裁决。 + +[G1 R10 异步票据作者探针](evidence/iteration41-g1-r10-async-ticket-author-2026-09-27/AUTHOR_R10_EVIDENCE.md)在惰性测试中票据提交约 0.1 µs,但不可逆 UNKNOWN 在 D+3 ms 才观察到,故不能满足整命令硬截止。[R11 只读边界审计](evidence/iteration41-g1-r11-design-only-author-2026-09-27/R11_G1_HARD_DEADLINE_AUDIT.md)把外层启动、回执、I/O completion 与每个 Job-empty 均纳入同一 D,指出一般 Windows 用户态无法保证最外层 watchdog 按时获调度或同步调用返回;请求级已就绪服务口径是另一个需明确批准的需求变更,尚未采用。G1/普通预检维持关闭。 + +[R9 原生边界补件与 R10 独立 QA 索引](evidence/iteration41-g1-r9-r10-independent-evidence-index-2026-09-27.md)核验作者输入与隔离重建;R9 先前重放应描述为源码等价 EXE 而非字节相同 EXE。R10 23/23 冻结输入、9/9 惰性重放通过,但 QA-only 延迟提交在 D+65 ms 仍返回 `ACCEPTED`,D+81 ms 才变 `UNKNOWN`;broker 取消后还存在 `WaitForSingleObject(writer, INFINITE)` 路径。P14、真正 CreateProcessW 挂起及整命令服务启动均未证明,G1 保持关闭。 + +[G6-P 外部账户 actor 调用图审计](evidence/ctp-g6p-account-actor-architecture-audit-2026-09-27.md)以冻结源码列出 Store legacy submit/cancel 回退、旧 gateway 默认同进程 CTP runtime、SDK direct Req 三类旁路;新 gateway/ZMQ 只有本地路由/静态 peer ACL,没有跨主机 epoch 或共同账户快照。严格 F14 与 production 因此仍 `BLOCKED / LIVE_NO_GO`,该只读审计不构成服务实现或部署验收。 + +[AccountActorPort r2 独立负测](evidence/ctp-account-actor-port-r2-independent-rejection-2026-09-27.md)在 9/9 冻结 payload、15 项 fake 正例后拒绝接入:未知 provider 可在候选 harness 中回退本地客户端,嵌套 CTP 路由漏判,注入对象在拒绝前被读取;其收据只核 command ID,不能拒绝旧 epoch、错账户和重复 intent。主仓 Store 的 legacy 路由仅经静态审阅,未执行真实报撤单。G6-P 外部主体和共同账户快照仍缺,严格 F14 与 production 保持关闭。 + +[AccountActorPort r3-r1 独立 QA](evidence/ctp-account-actor-port-r3-r1-independent-review-2026-09-27.md)在 9/9 冻结文件及 24 项 fake 测试下修正未知 provider、原始 API 注入及两层嵌套 CTP 路由分类,拒绝前不再读取注入对象属性;只接受隔离 classifier/harness 合同。收据仍只核 command ID,旧 epoch、错账户和重放仍可进入假端口;主仓尚未接入此门,G6-P 不变。 + +[G6-P 外部 actor 独立威胁模型与验收路径](evidence/ctp-g6p-account-actor-independent-review-2026-09-27.md)核验 r2 候选与主仓调用面,要求服务端唯一凭据/网络出口、跨主机单调 epoch 与撤销、同一权威版本的资金/全账户委托/成交/持仓快照、服务内最终派发。公开 CTP 的独立查询终包无法提供共同版本;这份只读设计与 15 项 fake 检查均不构成部署证据,G6-P 保持阻断。 + +**2026-09-27 最新局部证据,裁决不变:** [R3r3+V21 合流源码独立 QA](evidence/ctp-r3r3-v21-composite-source-independent-review-2026-09-27.md)为 `290 passed / 2 SDK-import skipped`,仍未解决 CLAIMED 后进程崩溃的 G5 证明。[parent 撤单控制端 r2](evidence/ctp-parent-cancel-control-r2-independent-review-2026-09-27.md)独立 `23 passed`,后续首次释放回调/读回审计发现缺口,主桥接原有三项正例尚未复跑通过。[G1 双阶段 handler r1](evidence/ctp-g1-two-stage-handler-r1-independent-review-2026-09-27.md)仅 fake 编排通过,[r5 同步创建反例](evidence/ctp-g1-r5-prestarted-host-feasibility-rejection-2026-09-27.md)及[r6 同步入列反例](evidence/ctp-g1-r6-warmed-worker-custodian-rejection-2026-09-27.md)否定整命令 hard deadline。[G6-P/G8-P 实盘路径审计及合成已验签负测](evidence/ctp-g6p-g8p-production-path-audit-2026-09-27.md)保持零 dispatch,外部账户 actor 仍缺。因此普通 `preflight`、SimNow 写入与 production live 均保持关闭。 + +[主仓 runtime 测试模块隔离修复及全量复跑](evidence/ctp-runtime-yaml-test-isolation-2026-09-27.md)由 root 验证 `1957 passed / 30 skipped / 2 xfailed`,一项既有 pytest 配置警告;该结果只覆盖当前主树的离线单元测试,不替代上述各门证据。 + +**最新阻断:** [V20 r2 单账户绑定](evidence/ctp-execution-v20-r2-account-binding-independent-review-2026-09-27.md)仅局部独立通过,永久 owner 尚不能安全 clean-close 交接。[Execution R3r2 撤单 claim](evidence/ctp-execution-r3r2-cancel-claim-independent-review-2026-09-27.md)独立接受精确回执和整数 0 的 fail-closed 行为;主桥接 offline 撤单必须先取得 risk dispatch claim,ACKED 后的风险 latch 只能用专用可信终态证明解除。G1 output-channel [r3 伪造 Job-empty 反例](evidence/ctp-g1-output-channel-r3-independent-rejection-2026-09-27.md)由 [r4 本地 helper](evidence/ctp-g1-output-channel-r4-independent-review-2026-09-27.md)限定修复,[r6 post-resume](evidence/ctp-g1-output-channel-r6-post-resume-independent-review-2026-09-27.md)独立 56 项通过;完整双 Job 服务监督链仍待验收。真实交易门均未通过。 + +**当前增量裁决:** [V20 r0 结构反例和安全重启审计](evidence/ctp-v20-schema-counterexample-and-clean-handoff-audit-2026-09-26.md)已归档,Execution r1 仍被拒绝;r2 独立接受单账户实例绑定。[主仓共享工厂 r1](evidence/ctp-v20-shared-account-runtime-r1-independent-review-2026-09-27.md)独立 84 passed / 2 expected xfailed,只接受未注册 source bundle。[Guardian 编排前切片](evidence/ctp-g1-guardian-preorchestration-independent-review-2026-09-26.md)独立 41 项服务测试与 1 项 parent gate 通过;整命令监督、Session0 token 转换与受保护部署未通过。[G6 r4a](evidence/ctp-claimed-authority-r4a-expiry-independent-review-2026-09-27.md)独立 100 项接受离线有界时差修复,不构成真实写入许可。所有真实交易门保持原状态。 + +**状态快照:2026-09-27。** 本页把现行 CTP 配置、只读会话与 F14 写入门整理成可执行的开发/QA 顺序。[迭代计划](迭代计划.md)定义准入与政策,本矩阵记录逐门操作状态,[正式验收文档](验收文档.md)列出测试标准;证据更新后须同步这三处,冲突时不得自行将任一状态升级为 PASS。 + +本次并行实施的本地测试和阻断项另见[2026-09-26 检查点](evidence/iteration41-parallel-checkpoint-2026-09-26.md);其中标为待复跑、待 hosted CI 或待真实账号的项目不得计入本矩阵的 PASS。 + +**后续源码切片:** [G6 v2 字段绑定与 READY 延后重试](evidence/ctp-g6-v2-bound-fields-defer-independent-review-2026-09-26.md)独立通过 75 项,八个具有有效签名但不匹配 sealed config 的请求/session 均在真实 V18 Store claim 前拒绝;最后原生调用时的许可时效、撤销及外部 fence 尚在后续开发。V19 r0 的账户 UNKNOWN 撤单 gate 已在 live-bridge 接管用例通过,但独立 QA 随后复现正常释放后租约编号复用的 ABA;r1 修复待独立验收。共享候选的[唯一历史账本路径切换合同](evidence/ctp-v19-account-ledger-cutover-design-2026-09-26.md)已明确,尚不提供自动历史迁移或真实写入。下面各历史制品和测试数字仅适用于其记录的源码时点。 + +当前裁决为 `LOCAL_OFFLINE_CONTRACTS / NO_WRITE / LIVE_NO_GO`。离线合同、TCP 可达、受监督进程 containment、一个查询流的终包或本地 outbox 回执,都不能单独记作真实 CTP 会话、F14、SimNow 写入或 production PASS。F14 继续执行严格门槛。ADR-41-16 的 SimNow 操作性声明例外仍为 `PROPOSED`、未批准、未实施、未登记;不得按豁免处理。 + +[V19 r1 独立 Store 核心复验](evidence/ctp-execution-v19-r1-independent-review-2026-09-26.md)随后接受了上述租约 ABA 的限定修复,包含真实 close/reopen 及旧租约 mutation/release 拒绝;固定账本工厂和 clean-close owner handoff 不在该验收内。主仓扩大桥接回归为 63 passed / 4 failed,正在处理;[G1 anchor/Guardian 后续审查](evidence/ctp-g1-anchor-r5-guardian-r1-r2-independent-review-2026-09-26.md)也未关闭部署门。 + +**目标操作合同:** SimNow 写入验收后,生产账号沿用同一个受保护 `config.yaml`、同一个 `ctp:` 字段结构和同一个受管 CTP 执行 runner。操作者先结束旧 session,再编辑该文件的 `runtime.mode/preset`、账号认证、MD/TD 候选及合约范围,并以同一 runtime identity 和 runner 实现重新启动新 session;活动 session 不热切换。后端不因 set 名称或时间另选环境,也不要求创建生产专用 runner 或第二份配置。QA 须用同一 runner identity 与同一路径证明 stop/edit/restart 后两模式分别解析、封存和准入;旧 SimNow 审批、会话及账本范围不得跨账号/模式复用。当前默认 runner/write admission 尚未实现,此段是验收目标,不是已可下单的说明。 + +共享 runner 的具体代码接缝、实施顺序与两模式 QA 用例见[CTP 同配置共享 runner 开发与验收合同](CTP同配置共享Runner开发与验收.md)。 + +## 当前边界 + +| 能力 | 当前可证明的状态 | QA 记录标签 | +| --- | --- | --- | +| 单一配置与模式解析 | SimNow 与未来 production 共用 `examples/013_3_sa_midfreq_simnow/runtime-ctp-private/config.yaml` 及 canonical `ctp:` 字段。共享 parser 和 fail-closed 负测有离线合同;共享 CTP runner 的 live-mode dispatch/admission 尚不存在。 | `LOCAL_CONTRACT_ONLY` | +| 通用 profile dispatch / CTP managed selector | 通用 dispatch 只接受无 secrets、网络、外部写、managed capability 和审批的 replay/backtest;独立审核及 synthetic CLI run 已通过。新非授权 mode scope 可从同一 canonical `ctp:` 路径将 synthetic simulation/live 两 profile 绑定到同一 runner identity,但不授予 session。合成 SimNow `receipt_required` profile 可在显式 fake 依赖下进入 session,逐动作重封 config;默认 013_3 sandbox 的 `deny`/no runner 与 live unavailable 仍拒绝。最后检查到 native 调用的并发 config mutation 是 P2 上线门。 | `LOCAL_FAKE_ONLY / NO_WRITE` | +| 配置前置选择 | 单对按配置原样使用;多对只在 sealed `ctp.front_pairs` 的 1–8 组成对候选内做有界、无凭据 TCP 探测。MD 与 TD 各须达到重复采样的严格多数(当前三次采样为各至少 2/3)才参与比较,分数为两端成功样本中位延迟的较大值;最低分胜出,平分按配置顺序。诊断入口复核分数和最快候选。无可达整对则拒绝。不得发现配置外地址,也不得按 set、时钟、日历、TradingDay 或服务时段切换。 | `TRANSPORT_ONLY` | +| 最近记录的前置观察 | [2026-09-25 前置复查](evidence/ctp-configured-front-check-2026-09-25.md):15:55 UTC 的五组显式候选中,零基索引 3 双端各 3/3,按两端各至少 2/3 的新传输门槛被选中;其余四组均 0/3。15:40 UTC 旧门槛的无 pair 结果保留为历史证据。传输结果须运行前重新检查,不能据此宣称账号就绪。 | `TRANSPORT_ONLY / PAIR_3` | +| SimNow TD/MD 会话 | I11 MD-only 身份未验证、matching tick/TradingDay 未确认、Join pending;I12 TD-only 在 SDK artifact 阶段拒绝,未观察 login,queries 未验证,close 未尝试。两次 one-shot marker 都已消耗且不可重试;I14 同配置 TD→MD 方案仍是待实现设计。 | `NOT_ACCEPTED` | +| Native close | `Join` pending 曾在真实受监督观察中出现;供应商对精确 Windows 6.7.7 的退出/回调静止合同未确认。进程退出、Job empty、Python `stop()` 返回都不证明 native `Join`/`Release` 完成。 | `BLOCKED` | +| F14 写入账本、writer fence 与 outbox | 有非授权的离线合同和 fake 候选;`2.0.4+iteration41.i9` CTP 队列租约 wheel 满足总工程版本范围、双构建一致,隔离安装后 10 项队列 fake 和 2 项真实 `TraderClient` + fake API 桥接通过,但未进入默认 pin。默认 CTP Store 仍绑定 fail-closed placeholder。唯一 active authority/cutover、可信回调来源、跨用户/主机账户 fence、共同账户快照、真实逐动作审批与 provider ACK 尚未验收。 | `BLOCKED / NO_WRITE` | +| Production | 目标是未来在同一受保护配置中结束旧 session 后改 mode/preset 和账户、前置、合约字段,再以同一 runtime identity/受管 CTP runner 实现重启新 session;不支持活动 session 热切换,不维护第二份 production config 或 production 专用 runner。当前没有注册共享写入 runner,配置变化和重启本身都不会授权网络、下单或撤单。 | `NOT_RUN / LIVE_NO_GO` | + +[Native Join/Release fail-closed 源码候选](evidence/ctp-native-join-fail-closed-source-candidate-2026-09-26.md)的 MD/TD 纯 fake 测试各经独立复核,总计 31 项通过:并发 observer 只允许一次 Join,Release 异常后保留退休对象与 pending 栅栏,重复调用不再次 Release。隔离 CTP 子模块另把 Feed Ref guard 与 Join 完整祖先链无冲突合成,受影响 fake 测试 255 项通过;父 SDK Gitlink 和默认 pin 未指向此合成源码。候选尚未构建或经真实前置观察;供应商生命周期语义与有序 native close 仍未证实,G4 继续 `BLOCKED`。 + +[G4 假阻塞 Job containment 独立复核](evidence/ctp-g4-fake-stop-job-containment-independent-review-2026-09-27.md)证明在本机假 `RegisterSpi(None)`/`Release()` 阻塞子进程里,外层 Windows Job 可终止并观察 Job 清空;这不是 clean native close。当前 SDK 的 `stop_and_wait(timeout)` 在开始计时 Join 前同步进入这些关闭调用,不能单靠它约束整次关闭。更宽的 SDK 焦点保留一项并发 cleanup 断言失败;没有真实 mapped DLL、同代次原生 Join/Release 或已接线服务回执,G4 仍 `BLOCKED`。 + +[G4 fake cleanup 测试同步修复](evidence/ctp-g4-cleanup-test-sync-independent-review-2026-09-27.md)随后用假 API 完成事件等待关闭旧并发测试竞态,root 独立复跑 56 项通过。该修复仅修改隔离测试,未修改 e75 生产 SDK;早先失败日志仍归档,G4 原生正面证据仍缺。 + +**新增离线实现边界(2026-09-26):** `ctp_shared_managed_runner.py` 为两种模式实现同一路径的非授权准备层,复验两个 profile 的同一 runner identity、模式专属配置/审批摘要和精确所选 pair;对象完整性与从当前文件重封的 freshness 分开检查,后者仍有最终检查至原生调用的竞态。`ctp_shared_managed_runtime.py` 在同一入口按配置模式分派:合成 SimNow 可以注入 fake opener;live 因缺真实生产 session/账本 authority 在探测和读取凭据前拒绝。共享公开 context 不绑定密码/AuthCode 的私密轮换,真实动作必须由底层逐动作凭据绑定校验。 + +`ctp_f14_external_admission.py` 只定义外部账户写者栅栏与共同快照的逐动作接口,没有受信 authority 或调用接线;`ctp_simnow_operational_window.py` 只定义未获批准的 G6-S 有界窗口、逐动作许可与单流查询观察,固定不声明账户级排他、共同快照或写授权。`backtrader/stores/ctp_managed_projection_bridge.py` 只定义单一 outbox 投影桥,当前 SimNow session 缺匹配端口并在构造时拒绝。Store 的 `_sdk_order_request()` 与独立执行库的 reservation 仍有两个 `OrderRef` 来源,尚无共同事务/可信 reservation port;managed submit/cancel 因此在二次分配和派发前硬拒绝。Store managed 队列已经能在 worker 可见前落同一 typed receipt;但没有可信 caller 接入,v1 bridge 仍先 dispatch 后记 receipt,不能解除 hard reject。 + +Windows `ctp_config_action_linearization.py` 仍是未集成的本机诊断候选,现把公开名称改为 `ConfigPathReadLease` / `acquire_config_path_read_lease`,避免误称动作线性化。常规写/替换/重命名及非固定驱动器/硬链接的定向负测通过;另一个 `FILE_WRITE_ATTRIBUTES` 句柄在 fake native-action 窗口成功给被租约持有的配置文件设置自定义 reparse tag,证明单靠该共享租约不足以线性化最后重封与原生调用。新增 Python fake race 负测在两种动作的最后重封返回后改写合成配置,确认当前 submit/cancel 仍能进入 native adapter;预期结果为 `2 xfailed`,只记录当前 TOCTOU,不声称跨平台文件系统行为或通过验收,详见[配置动作租约及 Python dispatch 负测](evidence/ctp-config-action-lease-negative-2026-09-26.md)。未来须由受信执行边界同时控制配置/路径变更和唯一原生动作;目前只有类型合同,没有 broker 或 route。以上均不改变默认 `NO_WRITE / LIVE_NO_GO`。本轮主仓 runtime 回归 `1804 passed, 27 skipped, 1 existing warning`,Store 广域回归 `667 passed`,相关 Broker/集成 `23 passed`;SimNow 有界许可/strict F14 合同 `74 passed`,共享入口/配置候选 `26 passed, 1 skipped`。这些均属离线检查,不替代真实 provider 或写入验收。 + +[独立执行库单 worker 候选证据](evidence/ctp-execution-single-worker-candidate-2026-09-25.md)的后续 source-only commit 为 `b676fe6`:同一 SQLite 行的 queue receipt→claim→一次 sender 合同有 fake 测试;任何 claim 后裸 `REJECTED` 均转为 `UNKNOWN`,不相信 sender 自称无发送。它没有解决跨包 `OrderRef` 双来源,也没有 wheel 或真实发送,不能单独关闭 G5/G6。 + +Store/outbox 接口复核:新 `CtpManagedDispatchBinding` 已把稳定 `command_id`、OrderRef、审批使用、会话代次、RequestID/ActionRef、精确撤单目标和本地 receipt ID 定为完整 typed DTO;`BtApiStore` 的 managed queue 能在 worker 可见前持久化回执,并拒绝缺失 dispatcher 的旧通用发送。它仍未与独立执行库 SQLite 同一分配器、claim→native send→receipt/UNKNOWN worker 接通;现有 v1 adapter 仍先调用 `sdk_dispatch` 再记 queue receipt,因此受管下单/撤单硬拒绝不得解除。开发须先建立唯一持久派单权威、改用 v2 handoff 并淘汰 v1 顺序,再把 Store/Broker 投影接入;不得在现有队列外另写一份 outbox 记录充当“恢复证明”。 + +三 wheel [I9 artifact-set 离线候选](evidence/ctp-i9-execution-artifact-set-2026-09-26.md)在无 system-site 的隔离 venv 校验了 base、CTP I9、execution 0.2.0 的本地 wheel 与安装来源;其 Join 修改仍只是另一个源码候选,没有进入该 wheel。I13/I15 父级信任根仍缺独立 source pin/manifest 与外部 review receipt,现有预检显式拒绝 unset pin;不得以本地自签 pin 替代。 + +后续隔离的 `bt_api_ctp-2.0.4+iteration41.i9.join1` 把 I9 与 Join/Feed Ref 源码合成,Windows CPython 3.11 两个独立根构建为相同 wheel SHA-256 `8d2d845501f43939704c9e6c61610ec1747d613a4e07485245bbb006c8242fbe`。离线 fake 焦点 `254 passed, 1 deselected`;新建无 system-site 的三轮子环境核对 base/CTP/execution 来源和全部依赖 RECORD,补齐已缓存依赖闭包后 `pip check` 通过,未加载 `_ctp`。**此 wheel 与 G5 query-target 源码候选不兼容**:缺 `bt_api_ctp.order_action` 的 `CtpOrderActionEvidence`/回调历史,以及 `_QuerySource.request_filters`;不能用它运行该观察或接通撤单。I13 相关 API 仍只在另一候选源码中,统一来源、受信 pin、真实 native close 和默认路由都没有通过。 + +I13 ancestry 与 join1 的只读三路合并在 `client.py` 有 14 处原生生命周期/回调来源冲突;`get_order_action_evidence` 需要这些回调历史,不能只复制 DTO。后续在隔离源码候选中已人工合成 Join、managed cancel 和 query getter 回读并通过 fake 焦点;certificate/login observation、bounded stop、credential scope 和队列生命周期接口仍在逐片合成与独立审查。没有统一 wheel/pin,也未解除实际调用门;[本轮检查点](evidence/iteration41-parallel-checkpoint-2026-09-26.md)保留精确来源与测试范围。 + +[I13 MD 值脱敏源码候选](evidence/ctp-i13-md-value-free-source-review-2026-09-26.md)的 14 条目在独立 Windows clean checkout 与 Git blob 字节核验一致;它是范围有限的 SDK 源码审阅清单,不是父启动器需要的完整运行时 manifest,且无候选 `_ctp.pyd`、wheel、源码 pin 或真实诊断。不能用历史另一份扩展的 fake 测试替它报告通过,更不能据此推断真实账号错误或登录就绪。 + +**Windows inert guardian 增量(2026-09-26):** 当前未登记的 `request_inert_guardian` 向预启动 AF_PIPE 服务发送固定 sleep probe,descriptor 绑定源码、解释器和固定回执目录;它不是普通 CTP preflight 启动器。历史五模块 `77 passed` 已由七模块外层 supervisor 的 `84 passed` 检查点补充:owner 死亡后独立 supervisor 按同一 monotonic deadline 请求终止 Job,观察 service/child 退出,但回执可能晚于 deadline。最新 pipe 服务加入 protected 当前用户 DACL 与 handle 回读、远程客户端拒绝、两实例上限、HMAC 和请求消息各自 SID 检查,最终 service 文件作者与根代理独立复跑均 `11 passed`。完整 launcher/setup 仍未置于受监督 Job,descriptor 外部信任、回执目录 ACL/完整发布、全命令硬截止、native close 和普通 preflight 接线仍未验收;G1/G4 不通过。详见[本轮检查点](evidence/iteration41-parallel-checkpoint-2026-09-26.md)。 + +[G1 服务端截止假负例](evidence/ctp-g1-service-deadline-fake-counterexample-2026-09-27.md)进一步复现:worker Job 已空后同步 `create_receipt` 可超过请求 deadline 和外部 250 ms 等待仍阻塞 handler,放行后返回 `observed`;未写迟到响应。root 用源副本独立复跑。该 fake seam 不证明真实 SCM 进程行为,但足以拒绝当前整命令硬截止声明;独立 host supervisor、两阶段 Job 和真实部署仍待实现。 + +**新增离线审查边界(2026-09-25):** I13 MD-only 与 I15 TD-only 候选的受监督子进程已增加 `-I -S -B`、固定虚拟环境安装根和从已校验源码直接编译的导入方式;这些改动只处理子进程。当前调用它们的父进程会在源码核验前普通导入 runtime 模块,可能先执行同版本有效缓存。未登记的 stdlib-only sealed-import 离线切片现在将 Windows file-ID lease 接入 manifest seal 与 source loader;loader 编译经句柄身份和 hash 核验的同一份 bytes,且 checked import 拒绝伪造合法 spec 但未执行的缓存模块。9 项 fake 测试通过;外部 launcher、完整依赖闭包和来源 pin 未完成,因此真实一次性诊断仍列为 P1 阻断。两候选的源码 pin 均未启用,真实 marker、凭据和 provider 未使用;须完成完整父信任根与独立复核。执行侧另有隔离的 OrderRef 持久水位、源回调队列租约与桥接候选;它们不解决 F14 的账户级 writer fence、共同快照、正式三制品 pin 或真实逐动作审批,不能上升为报撤单准入。 + +日常允许的离线入口是 `bt-runtime doctor --strategy-dir examples/013_3_sa_midfreq_simnow/runtime-ctp-private`。013_3 的 doctor 以兼容性追加的 `operator_actions` 列出离线检查、TCP-only 前置检查、禁用的原生预检、不可用的 run/live;同一文件改为 live 时明确标为配置请求且 `authorization: not_granted`,旧 `next_actions` 保持兼容。`bt-runtime check-ctp-fronts --strategy-dir ...` 只提供配置候选的传输证据;它校验配置文件,但不调用凭据 resolver、不导入 CTP SDK、不登录。普通 `bt-runtime preflight --strategy-dir ...` 当前 fail-closed/不可作为 provider 命令;须先实现并独立验收覆盖父级探测、setup、child、native close 和清理的有界 Windows Job supervisor。I11/I12 监督尝试不解锁该 CLI。 + +[G5 I9 OrderRef/Store/SDK 离线复核](evidence/ctp-i9-orderref-bridge-audit-2026-09-26.md)将只读 reservation 镜像、当前架构 CTP Feed 源码候选与 Store 的精确类型/同 Store 检查分开记录。Store 负测、8 元组 guard 与真实 I9 源码的 fake stage/queue smoke 共 4 项通过;当前 pinned parent 的无会话旧入口经真实 DirectBackend→Feed 拼接,在 capability 门前拒绝且原生计数为零。子模块 `0609b05` 的 12 位 Ref 必填与原样透传经离线复核;隔离父仓 `7fe53a0` 仅更新 Gitlink,但没有受信 wheel 或默认 pin。独立审计确认 managed fake transport 仍可不用 I9 mirror 接受合法 Ref,普通 Python import 也会优先加载 site-packages CTP;源码路径测试不能代替安装来源验证。隔离 SDK `decd7600` 已在源码中让 managed 请求与镜像字段精确匹配,相关 fake 合同 403 passed、2 skipped;该提交的镜像来源可通过类名/模块名伪造;隔离后续 `5744d3c` 已改用 exact I9 0.2.0 类对象并通过 29 项独立负测,但版本元数据和导入文件尚无受信 wheel/RECORD/origin 证明。已安装受信 I9 wheel 的同一 worker、唯一 OrderRef 权威、forwarding/wire 身份传递、原生请求绑定及取消目标来源仍无完整证明,G5 为 `NOT_PASSED`。 + +转发层的历史 P1 负测依次发现空白 CTP venue 可绕客户端、`UNKNOWN___FUTURE` 等可跨 venue 送入 CTP adapter,以及直接构造 `OrderRouter` 时无 scope 仍放行。隔离源码 `c33ce478`、`e6cd73b`、`2fec245` 分别修补客户端、ZMQ 服务端和直接 Router/嵌入式入口;最新 exact-commit 独立 fake 复核 132 项通过,规范声明为 CTP 的 adapter 在三类命令、别名、直接 wire、跨账号和无 scope 情形下均为零 adapter 调用。该修复**仍只信 adapter 自报的静态 venue/account**:能把 CTP 实现伪装成 SIM 的同进程自定义 adapter 仍可被写入;ZMQ peer/token 认证缺失,客户端能力查询对部分别名也会误报。QA 应继续要求代码受信的 adapter/账号绑定或该 CTP 转发端固定零写;这些 source-only 负测不关闭 G5。详见上链证据。 + +隔离父 SDK 合成 `da228691` 已把上述转发补丁与 CTP 子模块 `9976bcbb` 的 Feed Ref 必填和 Join 源码链放在同一棵源码树;精确路径下父仓 fake 469 passed/21 async skipped、子模块 255 passed,独立 reviewer 又复跑 Feed/callback 18、Router scope 107、I9 consumer 29 项。原转发提交在该分支中为补丁等价 cherry-pick,并非精确祖先。`bt_api_execution` Gitlink 仍指向无包源码的初始提交;此合成没有 wheel、真实 native close、受信 I9 权威或默认写路由,G5 继续未通过。 + +另一条隔离父仓 `c4407b09` 仅把 execution Gitlink 快进到 I9 `b676fe6` 源码,保留 `installable=false` 和默认拒写。I9 子包 fake 133 passed/2 skipped、CTP 255 passed;父仓扩展测试 640 passed/2 skipped/19 failed,其中 5 个旧 arming fixture 未建立 I9 identity binding、14 个当前 risk pin 不含 `AccountScope`,两组失败在更新 execution Gitlink 前已复现。I9 全包另有 38 项 Ruff finding。该候选没有安装元数据、受信 wheel/RECORD/origin 或写入权威;不得因 Gitlink 指向 `0.2.0` 源码而将 G5 升级。 + +Risk 子包不能只选最早带 `AccountScope` 的 `7343430`:它缺当前 dispatch evidence/authority/resolution API,且旧 generic resolver 可清派发中的冻结状态。独立源码审查找到首个同时具备 typed proof 与禁止 generic 清 `dispatch-inflight` 的后继 `dce2c84d`;即便更新此 pin,父 runtime_plugins 仍须改为提交精确 journal proof 给 `resolve_dispatch_freeze`,不得以更新依赖掩盖旧通用调用。当前 14 个 fake-dispatch 失败继续保持未通过。 + +后续独立父仓 source-only 集成候选 `5de97235` 已将 I9 child 指向仅作质量整理的 `5579807`、risk 指向 `dce2c84`、monitor 指向干净的 `8498ca7`,并在父层增加 typed proof/authority fence 解析;五个旧 arming fixture 已改为先消费 fake I9 reservation,未放宽生产 guard。用上述精确干净源码路径(base=`3de0fa4`、CTP=`9976bcbb`)重跑父仓非网络 contract 加 fake journal:`1039 passed, 7 skipped, 7 warnings`;risk 子包 `159 passed`、monitor `40 passed`、I9 `133 passed, 2 skipped`。此前 `c4407b09` 的 19 项失败是旧候选历史结果,并未追溯修改。独立 risk 代码审查未找到绕过 journal authority 清除 `dispatch-inflight` 的路径;monitor 后继的独立源码审查确认其 durable outbox 对本地 fake 事实有提交/回读合同,但 control ledger 信任调用方自报的 issuer/authorization,不能当生产鉴权边界,consumer 也须按 event ID 去重。该组合仍无受信 wheel/RECORD/import-origin、共享远端可获取的全部 Gitlink、原生会话或可信撤单目标 issuer;execution 子模块的 `installable=false` 也未改变。以上只更新离线开发证据,**G5 仍未通过,默认 `NO_WRITE / LIVE_NO_GO`**;详情见 [G5 I9 源码审计](evidence/ctp-i9-orderref-bridge-audit-2026-09-26.md)。 + +G5 新的独立执行子包 `c10ccf5f` 增加持久撤单目标投影与单次消费合同,并把 stage/claim 新鲜度复核移至事务锁内、authority verifier 返回后及 stage 返回前;本地完整子包 `141 passed, 2 skipped`,父仓 source-only 集成 `097a98a7` 的非网络合同与 fake journal `1039 passed, 7 skipped`。独立审查确认其 verifier 仍为注入信任边界,原生查询证据尚不能独立绑定同 Store I9 reservation 与一条 OPEN/PARTIAL 订单,因此没有可信 pre-cancel issuer,也未接默认路由。**G5 继续 NOT_PASSED、NO_WRITE / LIVE_NO_GO。**详见 [G5 I9 源码审计](evidence/ctp-i9-orderref-bridge-audit-2026-09-26.md)。 + +[R3r2 安装 wheel G5 假链反例](evidence/ctp-g5-r3r2-poisoned-claim-independent-rejection-2026-09-27.md)进一步确认:合成最终准入拒绝时零原生 Req,family owner 已 POISONED 并在重启后拒绝新 owner,但旧命令仍 CLAIMED/inflight,无法写显式 UNKNOWN 回执。独立新 SQLite 与安装 CTP RECORD 核验通过;更宽组合仍有 18 项失败。原作者 JSON 中 Execution wheel SHA 多写一字符,v2 勘误核对了原统一消费者与当前字节。V21 专用终态化和全套复测未完成,G5 仍未通过。 + +[I13 完整运行时 Python 清单候选](evidence/ctp-i13-runtime-source-manifest-candidate-2026-09-26.md)涵盖当前脏工作树的 91 个源码文件并通过父 launcher 的格式解析;父启动器的离线 seam 现也核对 seal 返回的源码根与有序标准库路径,关闭失败明确拒绝,三组相关 fake 测试 42 项通过。但受信 manifest 路径仍不存在、代码 pin 全零,外部 descriptor、真实 Windows Job、SDK wheel/native 扩展和外部 review 均未绑定;它不能解除 G1 或用于真实只读重试。 + +[隔离 I13/I15 源码快照候选](evidence/ctp-i13-i15-source-snapshot-candidate-2026-09-26.md)把当时的 91 个源码文件逐字节固定于本机干净提交 `64c271bf`,post-G5 两种 schema 的 manifest 正验通过、单文件破坏负验拒绝;零差异结论仅对应该快照时点,后续主仓 managed allocation reader 等修改不在其中。它仅在本机,runtime manifest 路径仍缺、I13/I15 pin stub 均按设计拒绝,且没有 wheel/origin、受验完整 guardian 链、原生生命周期或外部审查;**G1 继续 NOT_PASSED**。 + +[I13/I15 outer watchdog 合同](evidence/ctp-i13-i15-outer-watchdog-contract-2026-09-26.md)与[独立 Windows Job 后端候选](evidence/ctp-i13-i15-windows-job-backend-2026-09-26.md)合计 28 项本地测试通过,其中一项仅启动无网络 Python 子进程;异常/超时结果要在长生命周期 custodian 确认接管句柄后才标记 `controls_retained=true`。即使 CreateProcessW 只返回非零线程句柄,也必须托管该线程句柄、Job 与 attribute backing,并保持 UNKNOWN,不能宣称子进程已退出。后端仍未接入受信源码清单、外部 descriptor、隔离解释器、marker、普通 CLI 或 CTP SDK;Python/native 调用自身也没有独立宿主提供的硬性整条命令截止保证。它是开发候选,**G1 仍未通过**,不得据此启动真实账号诊断。 + +[进程外 supervisor 缺口与验收合同](evidence/ctp-i13-i15-out-of-process-supervisor-gap-2026-09-26.md)明确当前 custodian 仅存在于调用它的进程内,无法在该进程挂死或退出后独立维持和核验控制句柄。后续必须由受信 guardian、长生命周期 Job 宿主和完整 parent launcher 三层共同使用一个绝对截止;本地 wrapper/fake 测试不能替代该宿主验收。 + +写入口静态清单初始扫描 349 个 Python 文件、327 个 writer 候选和 62 个动态调用点,零解析错误;受控基线覆盖 180 个普通 `examples/` 子目录和 2 个仓库根 Python 脚本。另将 27 个 `__pycache__` 目录、`examples/logs` 输出目录分类为生成物,并以一个摘要项标记私有 CTP runtime state(不枚举或扫描其内容);共发现并分类 211 条路径,当前 `UNCLASSIFIED` 路径为 0。初始 389 项处置当时全部为 `REVIEW_REQUIRED / NOT_AVAILABLE`。后续 r2c2 将清单扩展为 445 项,当前覆盖与处置状态见文末 Scanner disposition rebaseline R2 记录。路径分类与 AST 命中均不证明运行时可达性或 writer 关闭,也不授予路由。见[初始候选清单](evidence/live-execution-inventory-candidates.json)和[初始处置清单](evidence/live-execution-writer-dispositions.json)。 + +## 依赖顺序与验收矩阵 + +依赖按分支关闭:G0–G5 是共同前置;SimNow 有界操作性验收走 G6-S→G7-S,strict F14/production 另须 G6-P,严格 SimNow 声明才可走 G7-P,生产走 G8-P。任一所需门失败、未知或缺证据时停止该分支,不得用后阶段 fake 或配置结果倒推通过。G7-S 的有限结果不关闭 G6-P,也不解锁生产。 + +| 门 | 开发/QA 步骤 | PASS 所需证据 | 失败 / 当前状态 | +| --- | --- | --- | --- | +| G0 配置与传输 | 使用合成文件测试 schema、seal、单对/多对互斥、候选上限/去重、坏字段拒绝、Git 忽略与权限检查。需要查看现场传输时,只运行 `doctor` 和 `check-ctp-fronts`,保存脱敏候选索引、采样结果、配置摘要和交易/结算两个写计数。 | 只有配置中显式列出的整对参与选择;选择规则符合上表;私密值不出现在 stdout、日志、receipt、CI 或 Git index。TCP 结果明确标 `TRANSPORT_ONLY`、`provider_login_started=false`、SDK 未导入、交易/结算写入为 0。 | 配置外回退、set/time 规则、单端点可达仍选中、原值泄漏或将 TCP 写成登录 PASS,均拒绝。现场索引 3 的可达性是历史时点结果。 | +| G1 固定制品与有界监督 | 在新、独立的一次性只读尝试前,核验精确 SDK wheel、installed RECORD/import origin、隔离解释器和代码 pin;验证整条命令总预算确实覆盖父级探测、setup、child、close 与 Job 清理。每个尝试用新 ID/marker;已消耗 marker 不重置。 | 可复现的制品身份和 supervisor receipt;预算超时或 close 不确定时 Job containment 证据与 fail-closed 结果;前置 TCP socket 仅在凭据前使用且仅触达配置候选,凭据 resolver/SDK 只在全部适用的来源、制品、配置门通过后调用。 | 普通 CLI preflight 仍不可用。I11/I12 marker 已消耗;未登记的 inert guardian 与独立外层 Job supervisor 在 owner 死亡后验证了 deadline 时请求终止和 child 清理;最新[完整七模块复验](evidence/ctp-inert-supervisor-95-review-2026-09-26.md)为 `95 passed`,保留此前 `93/2` 等失败记录,且回执仍可能晚于 deadline。同步进程/Win32 创建、supervisor 自身卡住、受信 descriptor/ACL、来源 pin、native close 和普通命令整链仍缺,不能保证硬性总截止。 | +| G2 SimNow TD 只读 | 仅在 G1 关闭后,用独立受监督 TD child 重封同一 sealed config,并核对 config/registration digest、候选索引及精确 MD/TD pair。先确认身份,再执行合同要求的七类终态只读查询;不构造 MD client。 | 脱敏回执证明同一账户、TradingDay、连接代次和精确过滤范围;每条请求都有匹配的原生终态证据;scope 字段不完整或不一致时标 `unverified` 并失败;写入与结算计数为 0;TD native close 完整通过后才可开始 MD。 | I12 结果为 `sdk_artifact/runtime_policy_rejected`,login 未观察、查询均 unverified、close 未尝试,marker 已消耗。历史七项查询的终包不替代当前尝试,也不证明共同账户快照。 | +| G3 SimNow MD 只读 | 仅在 TD 查询和 close 均通过后,用另一受监督 child 重封并复核同一配置摘要与 pair,不重新选路。核验 login 原生提交返回、身份字段、精确合约订阅提交与匹配 ACK、首个匹配 tick 和同交易日证据。 | 各同步提交返回精确 `int 0` 后才能公布成功;登录回调身份与配置/会话精确匹配;订阅 ACK 对应所选合约和请求;首 tick 与同交易日正面观察均存在。ACK 单独不够。 | I11 虽观察到订阅 ACK,但身份仍 `identity_unverified`,tick/TradingDay 为 null,Join pending;不得记 MD 或 TD→MD 完整会话通过。I11 marker 已消耗。 | +| G4 MD/TD 原生关闭 | 分别验收 TD、MD close receipt 与创建时相同的 session generation;先确认 SDK/native API 生命周期与供应商对精确 DLL/header 的合同一致,再接入受管运行时。 | receipt 正面证明要求的 `Join` 返回、`Release` 完成和观察线程退出;没有活动 callback;父 Job 也正常清空。所有事实均匹配同一个会话代次。 | 任一 Join pending、Release 未证实、receipt 缺失/矛盾、异常或超时均为失败,保留账户 lease 并停止后续阶段。Job empty、进程退出或 `client_stop_returned=true` 均不替代 native close。 | +| G5 唯一账本与 OrderRef | 先按 D41-01/D41-19 用 ADR 冻结一个 active order authority 和 cutover;目标为独立 `bt_api_execution` SQLite。以 fake client 验证稳定 runtime intent/order/action identity、登录 `MaxOrderRef` 导入、持久 reservation-before-intent、12 位 `OrderRef` 分配、cancel action 到目标订单映射、HedgeFlag 原样传递及损坏 journal 拒绝。逐项核对 Store 命令、SDK outbox、原生请求及回调是否共用同一个持久 command ID。 | 所有 submit/cancel ID 均在同一权威账本中唯一、持久、可重启读回;旧映射导入无冲突且有审计;reservation 不复用;runtime ID 不截断构造 `OrderRef`/`OrderActionRef`;未确认账本/映射前 native dispatch 计数为 0。Store 不另持有可独立发送的第二队列权威。 | ADR 未冻结或出现两个 journal authority、Backtrader `OrderBase.ref` 被用作恢复主键、MaxOrderRef 未核验、映射/文件持久性未证实,均阻断。单独 resolver 测试不等于此门 PASS。 | +| G6-S SimNow 本机执行门 | 仅在 G0–G5 通过后,以 fake/隔离环境验证同一 OrderRef/command 权威、worker 回执栅栏、第二本机 writer 拒绝、逐动作操作窗口许可、当前账户/交易日结算查询、真实来源查询合同、重复/迟到/错账户回调、崩溃切点与 UNKNOWN 不重派。 | `CtpSimNowOperationalWindowPermit`(或经审查的等价类型)与 strict 外部 fence 不可互换;一行账本在发送前持久化 reservation/claim/queue receipt;本机 lease 连续持有;所有不明状态冻结;只报告单流查询和 `UNVERSIONED_QUIESCENCE_CHECK`,跨主机/手动写者排他与共同快照明确 `UNPROVEN`。 | 当前 S permit/真实 callback/唯一 OrderRef 接线未实现,`LOCAL_FAKE_ONLY / NO_WRITE`。不得用 fake strict fence、CTP `bIsLast`、两轮相等或配置字段伪造通过。 | +| G6-P 外部账户控制 | 对拟声明 strict F14 或 production 写入的精确账户/动作,由独立认证服务提供账户级跨主机 writer fence、撤销/转移语义,以及资金、全账户委托、成交、持仓同版本完整快照;测试旁路客户端、旧 epoch、断线与接管。 | 信任根/服务身份、签名或等效认证、共同版本和覆盖范围、逐动作 claim 及最后 dispatch 栅栏均可复核;本地租约和 SimNow S receipt 不能替代。 | 已审公开 CTP 查询接口不提供这些原语;当前无受信 authority 或网关,`F14_STRICT_BLOCKED / LIVE_NO_GO`。 | +| G7-S 真实 SimNow 最小报撤单 | 仅在 G0–G5、G6-S、实际 TD/MD/Join 和当前账户/TradingDay 结算就绪均通过后,在同一受保护 config 的固定合约范围内执行一笔风险受限、仍有可撤余量的限价单:报单→原生订单确认→精确撤单→剩余量终态及订单/成交/持仓/资金回查。先做取消正例;成交正例须待独立平仓路径完成后另做。真实强杀不作为最小真测前置;离线故障注入仍必须覆盖。 | 脱敏证据能对应每次请求、OrderRef、ActionRef、session、callback、查询及数量守恒;审批和本机 lease 始终有效;无未知状态、外来订单或不明敞口;native Join/Release 干净完成。结果只能记 `SIMNOW_OPERATIONAL_ATTESTATION_TESTED`,并注明跨主机写者排他/共同快照未证实。 | 当前 `NOT_RUN / NO_WRITE`;官方 7×24 不提供结算,若配置选中而无真实结算查询则零写结束,不按时间/set 自动切换。全成订单不能充当成功撤单;未知结果不得自动重试。 | +| G7-P strict SimNow F14(可选) | 仅在 G6-P 的外部账户级 fence 与同版本完整快照也对同一 SimNow 账户成立时,重做 G7-S 的逐动作验收并由独立 QA 审核严格外部证据。 | 可单独标 `F14_SIMNOW_ACCEPTED`,范围仅限该 SimNow 账户/窗口,不向 production 继承。 | 当前没有外部 authority;G7-S 结果不能升级成 G7-P。 | +| G8-P 同文件 production | 只用临时目录与合成字段证明 canonical `ctp:` 文件从 `simulation/sandbox` 切至 `live/managed_live_direct` 后仍由同一个受管 CTP runner 解析;旧 SimNow 许可、审批、会话、账本范围全失效。真实生产仅在独立生产制品/账号/session/风控/监控、G5、G6-P、真实权限与恢复验收后开放。 | 操作者届时只改同一受保护 config 的 mode/preset、账号认证、前置和合约;代码复用同一实现但重新办理生产准入,不能复用 SimNow 收据;每步有脱敏审计与失败零写证据。 | 当前默认 live route unavailable,缺生产 session、统一持久账本及外部控制;配置变化在凭据、SDK、网络和写入前拒绝,`NOT_RUN / LIVE_NO_GO`。 | + +[G6-P/G8-P 外部账户控制服务合同审计](evidence/ctp-g6p-g8p-external-account-control-service-contract-review-2026-09-26.md)列出可复用的本地接口、服务端最终 dispatch 栅栏、同版本全账户快照和跨主机/旁路验收计划;它是设计审查,没有外部服务或 production 正验,不改变上表状态。 + +2026-09-26 新增的 `backtrader_runtime/ctp_f14_signed_receipt_contract.py` 是离线、非授权的 +canonical receipt 合同验证候选;它要求调用方注入 issuer/key/audience pins,并提供可用注入 +公钥与 SHA-256 pin 校验真实 Ed25519 签名的本地 verifier,只返回 receipt-shape observation。 +它没有受信生产服务 pin、服务端 actor、跨主机或服务端 replay/epoch ledger、admission handle 或 +executor/native dispatch;验签不能证明真实账户排他或快照来源。此项不改变 +G6-P `BLOCKED / LIVE_NO_GO`、G8-P `NOT_RUN / LIVE_NO_GO` 或默认 `NO_WRITE`。 +显式注入的本机 SQLite observation fence 可跨进程和重启拒绝重放,但 ACL/owner 未验收且不是服务端 fence。 +此离线 slice 的 focused fake 与临时 Ed25519 密钥测试 `25 passed`,兼容 venv 复跑无 warning;Ruff 检查通过。 + +G5/G6-S 的新增负测必须区分三个状态:发送前已持久化 `queued=false` 才是本地拒绝;发送 claim 之后任何裸 `REJECTED` 或未知异常都须落 `UNKNOWN` 并停止重派;`QUEUED` 只表示本地队列接纳。受管 CTP 的公开裸 `enqueue_cancel` 在 recovery armed/unarmed 两种状态都须于请求构造和 worker 启动前拒绝,heap、SDK cancel、原生写入计数均为 0。真实 Store worker 只有在同一 SQLite 行已持久化确切 queue receipt 后才可看见命令;写入失败不能通知 worker。重复 worker、重启和多连接均须保持同一 OrderRef 与至多一次 native send,不得凭任意 sender payload 自称“无发送”而解除冻结。 + +G7-S 先验收成功撤单,成交另作需具备平仓/归零路径的正例;依据是 [SimNow 官方 CTP Mini API 手册](https://www.simnow.com.cn/DocumentDown/api_3/5_2_4/CTPIIMini_API_Ver1.0.pdf)把“已成交或已撤单”“报单全部成交”列为撤单失败的常见原因。两类正例均须受相同逐动作风控和账户对账门约束,不能为了取得撤单回执扩大订单量或敞口。[SimNow 官方产品说明](https://www.simnow.com.cn/product.action)明确第二套 7×24 环境仅提供 API 测试而不提供结算等服务;当前运行时仍只按配置前置和连通性选路,G7-S 的结算/资金验收必须以该次账户的实际正面证据为准,不能由端点名称或 TCP 连接推断通过。 + +[7×24 与结算就绪门槛审查](evidence/ctp-settlement-readiness-gate-audit-2026-09-25.md)确认:当前 managed TD readiness 要求当前账户/TradingDay 的真实结算确认查询记录,7×24 官方产品能力未承诺该记录。即使配置中的 7×24 pair 因 TCP 延迟最低而被选中,也只能继续做连接/只读 API 诊断;缺少该真实记录时写入门必须保持关闭。完整 G7 应由操作者在同一受保护 `config.yaml` 显式配置具备结算服务的正常环境,并重新完成全部会话和账户门;代码不根据地址、set 标签或时间自动切换。 + +[MD 回调源码复核](evidence/ctp-md-callback-source-audit-2026-09-25.md)已把 I2 的请求 ID 不匹配与 I4/I6 后续身份未验证分开;目前没有足以确认账号错误的证据。G3 下一轮先用隔离 C++/SWIG director fake 验证 borrowed-pointer 与字段形状,再考虑任何新的受监督只读观察。此源码审计不改变 G1~G4 的阻断状态。 + +**Windows MD ABI/制品归属(2026-09-26):** I2/I4/I6/I7 已审计源码与 wheel payload 均指向 full CTP v6.7.7 MD DLL/header/SWIG 组合,哈希一致,未见 Mini 1.7.0 混入;这只排除已审计 payload 内的混装可能。四次 child 的 `thostmduserapi_se.dll` 实际 mapped-module receipt、官方归档及官方 SHA-256 均缺失,因此不能证明运行时实际 DLL,也不能解释 request ID、空身份字段或 Join pending。根因未定,G1–G4 阻断不变。详见 [I2/I4/I6/I7 Windows MD ABI/制品归属离线核对](evidence/ctp-i2-i4-i6-i7-windows-abi-artifact-audit-2026-09-26.md)。 + +[已映射模块身份回执候选](evidence/ctp-mapped-module-identity-candidate-2026-09-26.md)在隔离本地提交 `a3b3fd89` 中提供当前进程模块表的 pathless/文件身份接缝,16 项 fake 测试通过;尚未在真实受监督 child 内调用 Windows API,也不能排除路径重解析到网络、并发改写、映射后文件替换或内存中字节差异。G1 通过前不接诊断入口,MD 根因仍未定。 + +候选代码变更后的本地最低复测集只使用合成配置和 fake dependency;在仓库根目录运行并保存精确 revision、完整命令、退出码及脱敏日志。该结果只能记 `LOCAL_CONTRACT_PASS`: + +```powershell +$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD = "1" +python -m pytest -q -p no:asyncio --tb=short ` + tests/unit/runtime/test_runtime_config.py ` + tests/unit/runtime/test_ctp_private_config_setup.py ` + tests/unit/runtime/test_ctp_private_runtime_git_hygiene.py ` + tests/unit/runtime/test_ctp_front_pair_probe.py ` + tests/unit/runtime/test_ctp_configured_front_check.py ` + tests/unit/runtime/test_ctp_simnow_readonly_runtime.py ` + tests/unit/runtime/test_ctp_simnow_native_readiness.py ` + tests/unit/runtime/test_ctp_managed_reconciliation.py ` + tests/unit/runtime/test_ctp_queue_receipt.py ` + tests/unit/runtime/test_ctp_simulation_execution.py ` + tests/unit/stores/test_managed_ctp_store_adapter.py +``` + +若涉及 `bt_api_execution`,还须在其冻结候选及隔离环境上单独复跑 SDK 的账本、callback/outbox 与崩溃恢复焦点集,并记录独立制品来源;不要把另一个工作树或旧 wheel 的数字并入主仓结果。此处列出的本地集不包含 provider/真实账号步骤,也不解锁任何已消耗的一次性 marker。 + +## 可接受的状态跃迁 + +| 标签 | 可写入条件 | +| --- | --- | +| `LOCAL_CONTRACT_PASS` | 精确 revision 的 fake/schema 测试通过;明确注明未访问 provider,不能抬升为会话或写入结果。 | +| `TRANSPORT_ONLY` | 配置候选中有完整 MD/TD TCP reachability;明确注明时点、候选索引、采样和零认证/SDK/写入。 | +| `SIMNOW_READ_ONLY_ACCEPTED` | G1–G4 每项均有同一代码/制品/配置摘要/pair 的正面原生证据;未知与历史回执不计入。 | +| `SIMNOW_OPERATIONAL_ATTESTATION_TESTED` | 仅当 [ADR-41-16 option B](ADR-41-16-simnow-f14-writer-fence-proposal.md) 获正式批准、G0–G5/G6-S/G7-S 对具名 SimNow 账户和窗口逐项验收通过,才可记录一次有界操作性结果;账户级跨主机排他与共同快照仍为 `UNPROVEN`,不等于 F14 或生产 PASS。当前 `NOT_RUN`。 | +| `F14_SIMNOW_WRITE_ACCEPTED` | 只有严格 F14、G5/G6-P/G7-P 全部完成,且服务端账户覆盖/共同快照/账户级 writer fence 经独立 QA 验证后,才能申请裁决;不能由 S 级结果、自签收据或本文件宣布通过。 | +| `PRODUCTION_ACCEPTED` | 另一个独立生产阶段完成同文件 fail-closed 与正向 route、账户/制品/会话、审批、risk/monitor、服务端 fence、恢复及真实证据验收;不得由 SimNow PASS 自动继承。 | + +发现任何配置/账户/候选/制品变化、原生状态未知、回调无法同源关联、账本冲突、超时、进程异常退出、非预期写入或日志脱敏失败,立即标记当前门失败/未知、停止后续门并保留冻结;不得复位已消耗的一次性 marker。S 与 strict F14 状态分别依据[唯一决策 ADR](ADR-41-16-simnow-f14-writer-fence-proposal.md)、[计划中的 F14 顺序和外部依赖](迭代计划.md)、[验收门槛](验收文档.md)及本目录的增量证据更新。 + +## 相关证据与操作文档 + +- [I13/I15 外层父进程信任根设计与离线切片](evidence/ctp-i13-i15-parent-launcher-trust-root-design-2026-09-25.md):pre-import stdlib launcher、stdlib pycache 旁路、PyYAML origin pin、volume/file-ID source lease 与 no-system venv 的设计;manifest/source loader lease 与 inert launcher 已有未登记的离线切片,受信生产入口和完整制品 pin 仍未验收,不授权真实只读尝试。 +- [I13/I15 惰性进程独立截止监督切片](evidence/ctp-i13-i15-inert-deadline-supervisor-process-slice-2026-09-26.md):owner 死亡后的 outer Job 终止请求与回执时点分离,只是 inert Windows 进程证据,G1 未通过。 +- [配置与运行模式规格](配置与运行模式规格.md):唯一配置路径、canonical `ctp:` 和 front-pair 规则。 +- [实施状态与验收快照](实施状态与验收快照.md):当前 `NO_WRITE / LIVE_NO_GO`、普通 `preflight` fail-closed 状态。 +- [开发与 QA 交接清单](开发与QA交接清单.md):F14、Store/CTP typed handoff 与真实账号阶段证据要求。 +- [前置传输复查](evidence/ctp-configured-front-check-2026-09-25.md):同一配置的脱敏 TCP 观察。 +- [I11 MD-only 诊断](evidence/ctp-i11-md-diagnostic-2026-09-25.md)及[I12 TD-only 诊断](evidence/ctp-i12-td-only-diagnostic-2026-09-25.md):已消耗的一次性只读尝试结果。 +- [I14 TD→MD 设计](evidence/ctp-i14-combined-readonly-plan-2026-09-25.md):当前仍待实现的只读验收候选。 +- [Native Join 源码审查](evidence/ctp-native-join-source-review-2026-09-25.md):已证实源码顺序、未证实根因和待厂商确认问题。 +- [离线写入边界增量证据](evidence/ctp-offline-write-boundary-progress-2026-09-25.md):当前 managed handoff、outbox、回调、账户 fence 与账本边界。 +- [ADR-41-16 F14 writer-fence 提案](ADR-41-16-simnow-f14-writer-fence-proposal.md):`PROPOSED` 的未批准选项;严格 F14 仍是当前决定。 + +### 迭代41 r2b credential/projection 测试迁移独立复核(2026-09-27) + +[独立 QA 归档](evidence/ctp-account-actor-r2b-test-migrations-independent-qa-2026-09-27/README.md):credential safety 为 `SAFE_LOCAL_TEST_MIGRATION`(隔离 8/8);projection bridge 为 `SAFE_LOCAL_TEST_MIGRATION_WITH_COVERAGE_LIMIT`(逆补丁 18 failed/14 passed,迁移后 32/32)。projection 测试仍覆盖 adapter projection/replay 与 NON_CTP private-queue 原语,但正向 submit/cancel 不再覆盖 Store→adapter wiring,旧 raw CTP cancel queue/worker side-effect 断言改成 constructor early-reject。不得把 18 个旧失败 nodeid 的映射称为完整业务/Store 验收。r2b/r2c 仍未合主树;Store 258 项旧用例失败的集成阻断不变,Actor/provider 权威与写入路线未获接受。 + +### 013_3 local replay r2 主树验收(2026-09-27) + +[冻结结果与独立 QA 收据](evidence/iteration41-0133-local-replay-r2-main-integration-2026-09-27/README.md)接受 `LOCAL_REPLAY_ONLY_ACCEPTED / NO_WRITE / LIVE_NO_GO`:主树焦点 `28/28`;完整 `tests/unit/stores tests/unit/runtime` 为 `2638 passed / 43 skipped / 2 xfailed / 0 failed`。相同测试范围应用前 `2636 passed / 43 skipped / 2 xfailed`,本次净增两项测试、没有旧 nodeid 失败。此结果仅覆盖 013_3 合成本地回放组合结构,不能作为 SimNow、CTP 原生会话/报撤单或 production 准入。主树 CTP Store SHA 与 Actor r2b/r2c 状态未改变。同源 patches 1–4 broad 候选仍被拒绝:14 个基线失败、252 个候选失败,其中 238 个为新增失败。正式 patch 5(013_3 r2)在该 broad run 后才应用,并单独通过 guarded 3/3 focus;patch 5 后未重跑 broad suite。 + + +## 2026-09-27 supplemental local QA status + +- [I22 stable runtime-ID r1/r2 QA](evidence/i22-stable-runtime-id-independent-qa-2026-09-27/README.md): r1 is `PARTIAL / NEEDS_REVISION` because the candidate test removed actual `_sdk_order_request` assertions for zero binding lookups, zero reservations, and unchanged `order.info`. R2 independently passes the focused local fake test and restores these actual-method checks. This narrow PASS is not external Actor/provider authority, G6-P, or production acceptance. +- [AccountActorPort r5 QA](evidence/ctp-account-actor-port-r5-independent-qa-2026-09-27/README.md): author reported 40/40, but independent QA found a constructor-time route TOCTOU: an injected resolver changes a shared descriptor from NON_CTP to CTP after classification and before the fake gateway factory, which is called once; later submission rejection does not undo construction. Status `PARTIAL / NEEDS_REVISION`; G6-P/G6-S/G7-S stay closed. +- [Store r2c independent QA](evidence/ctp-account-actor-store-r2c-independent-qa-2026-09-27/REPORT.md): `LOCAL_SAFETY_INCREMENT_ONLY / MAIN_MERGE_REJECTED`. Guarded full Store/Runtime run: 2,460 passed, 258 failed, 28 skipped, 2 xfailed (30 skipped records in JUnit); the failed node-ID set exactly matches the r2b set. Failure classes: 249 external account actor unavailable, 6 store route ambiguous, and 3 store provider unsupported, exactly matching r2b. Of 158 I22 query/evidence nodeids, one was migrated and 157 still stop at Store construction; shared-session read-side query behavior is untested. The separate 44 route-focus, 7 query-evidence-focus, and 22 optional cases passed, but do not clear the broad regression or establish an external Actor. Store r2b/r2c remain unmerged. +- [Real-account acceptance gap plan](evidence/iteration41-real-account-acceptance-gap-plan-2026-09-27.md) is a read-only dependency/acceptance plan only, not a G0–G8 or real-trading pass. + + +## AccountActorPort r6 independent QA — local contract only (2026-09-27) + +[Author candidate and independent QA archive](evidence/ctp-account-actor-port-r6-independent-qa-2026-09-27/README.md) is `LOCAL_FAKE_CONTRACT_PASS_WITH_LIMITS`. Author manifest SHA-256 `c410b68aa39f476818afe517a0974bbbf428497236c20174d1d61a0830440d59`; independent QA manifest `360812153de614f9bd6df5c1e82c36cec6ba64eaf75376c3a44ed048a6bd798f`; independent receipt `f1c14a6978b51dd5b78829abfcec31e8343b37535359435a3a320250fc8d0dea`. The prior stable synchronous resolver mutation is rejected before gateway/API factory calls. This does not establish callback confinement: a transient route mutation restored before callback return is accepted, and a gateway factory may be called once before a post-callback check rejects construction and withholds publication. No external Actor/provider, durable cross-process authority, Store integration, G6-P/F14, or real-account acceptance is established. + +AC41-63 raw-fallback-removal slice: [main integration archive](evidence/ac41-63-raw-fallback-removal-main-integration-2026-09-27/README.md) records RAW_FALLBACK_REMOVAL_LOCAL_ACCEPTED / NO_WRITE / LIVE_NO_GO and independent SAFE_TO_APPLY_RAW_FALLBACK_REMOVAL_ONLY. Main-tree focus passed 138/138; root-reported Store/Runtime broad run passed 2,641, skipped 43, xfailed 2, failed 0; Ruff, py_compile, and diff check were clean. This accepts only the raw-fallback-removal slice, not AC41-63 writer closure, CTP writes, or live operation. + +G4 parent SDK status: [canonical QA archive](evidence/iteration41-g4-parent-sdk-status-qa-2026-09-27/README.md) is DISPOSABLE_PARENT_ARTIFACT_INTEGRITY_ONLY / THREE_PACKAGE_PIN_NOT_AVAILABLE / G4_CLOSED / NO_WRITE. The disposable parent wheel and installed RECORD reproduction across two venvs establish artifact-integrity evidence only. The bt_api_py code pin and matching base 0.15.5 + CTP I2 + parent three-package wheel install/origin chain are unavailable; the embedded digest is not an accepted pin. G4, native lifecycle acceptance, and the default CTP route remain closed. + +AC41-63 controlled-writer slice 3: [independent static review archive](evidence/ac41-63-writer-slice3-independent-static-review-2026-09-27/README.md) records `PASS_STATIC_AUDIT_SCOPE_ONLY` for 21 new IDs, bringing cumulative reviewed coverage to 43/389 with 346 residual. All official dispositions remain `REVIEW_REQUIRED / NOT_AVAILABLE`. The direct registered-simulation arm path is a conditional static source finding only; no provider/native/account/write execution occurred. `NO_WRITE / LIVE_NO_GO` remains in force, and AC41-63 writer closure is not accepted. + +### AC41-63 scanner expansion r2c2 main integration (2026-09-27; historical snapshot) + +[Historical canonical archive](evidence/ac41-63-scanner-expansion-r2c2-main-integration-2026-09-27-final/README.md) records the earlier 445-row snapshot (344 writer, 101 dynamic). Its counts are superseded by the R2 rebaseline below; archive hashes and historical test results remain preserved. Both are scanner/checklist integrity only, not writer closure or route authorization. + +### AC41-63 scanner disposition rebaseline R2 (2026-09-27) + +[Canonical R2 archive](evidence/ac41-63-scanner-disposition-rebaseline-r2-main-integration-2026-09-27/README.md) records `SCANNER_COVERAGE_LOCAL_ACCEPTED / NO_WRITE / LIVE_NO_GO`. The frozen patch's four exact main target hashes match. The current verifier reports 460 discovered / 460 checklist entries, six historical tombstones, all 460 active entries `REVIEW_REQUIRED / NOT_AVAILABLE`, and `reason_codes: []`; boundary `STATIC_DISPOSITION_INTEGRITY_ONLY_NOT_LIVE_ADMISSION`. Inventory: 363 writer and 97 dynamic candidates across 355 files, zero parse errors and unclassified paths. Main focus: 6 passed; Ruff, py_compile and diffcheck passed. This is static inventory/disposition integrity only; it is bound to the 2026-09-27 source snapshot, so later accepted source changes require a fresh rebaseline. No writer closure, route admission, provider authority or write permission follows. + +### AC41-63 full 460-candidate writer review (2026-09-27; historical snapshot) + +[Canonical review archive](evidence/iteration41-ac41-63-writer-review-460-2026-09-27/README.md) maps all 460 inventory positions to source reviews and independent checks against prior A039-era inventory SHA-256 `03658257D97E31C2D8F8BFD48685441533552B32674F5D63810141E3038AB456` (363 writer, 97 dynamic, 355 files). Coverage found 460 unique positions with no gaps or overlap; 20/20 checksums and 14/14 links verified. Findings are limited to the mutable wrapper flag reaching a fake sink after deliberate same-process mutation, `create_live_broker` accepting a caller-supplied Store, and `RuntimeRegistry(trusted=True)` not being code-enforced. The archive also records 31 stale line-only checklist locators across eight files; verification still passes and all official dispositions remain `REVIEW_REQUIRED / NOT_AVAILABLE`, with six tombstones. This is a historical static review: at the CE04 refresh checkpoint the inventory was `0874A81A…`, later superseded by the official `A959A3BC…` inventory; ordered candidate identities remain unchanged. It does not establish current-source writer closure, provider authority, or write permission (`NO_WRITE / LIVE_NO_GO`). + +### Store AccountActor r2d and I22 read-port r2d (2026-09-27) + +- [Store AccountActor r2d independent QA](evidence/iteration41-store-account-actor-r2d-independent-qa-2026-09-27/README.md) is `LOCAL_COMPATIBILITY_CANDIDATE_PASS / CTP_WRITE_NO_GO`; its six focused candidate tests pass for unsupported placeholder-provider compatibility. The r2c broad integration still has 258 failures, and r2d has not been applied to the main Store. +- [I22 shared-session read-only port r2d QA](evidence/iteration41-i22-shared-session-readonly-port-r2d-independent-qa-2026-09-27/README.md) is `FAKE_READ_PORT_CONTRACT_ONLY / NO_WRITE / LIVE_NO_GO`: 3/200 original nodeids migrated and 197 untouched; seven focused QA tests passed. It depends on unmerged r2c and establishes no account/session authority, common snapshot, settlement authority, or production read source. Neither archive changes the main tree or accepts a CTP route. + +AC41-63 007 certification-helper fail-close integration: [canonical evidence report](evidence/ac41-63-certification-helper-fail-close-main-integration-2026-09-27/README.md) records only the narrow SimNow/Hongyuan helper behavior and local tests. The two main source files match their reviewed candidate hashes; `tests/unit/live_certification` passed 92 tests with one existing warning, against a 90-pass baseline. Independent AST-helper checks passed 5 SimNow fakes and 3 Hongyuan scenarios, with caller reviews 17/17 and 24/24. This is not SDK, account, or provider authority. It does not prove direct runtime-module imports avoid `.env`/credential access; the 013_1/013_2 helper r1 remains held because `load_dotenv_if_available()` runs at import. `NO_WRITE / LIVE_NO_GO` remains. + +### G5 dual ActionRef collision — independent fake-only QA (2026-09-27) + +[归档记录](evidence/iteration41-g5-dual-actionref-collision-proof-2026-09-27/README.md)保存作者和独立 QA 的两次 fake-only SQLite 重放:同一合成账户下 G5 identity authority 与 V21 Store allocator 分别发出 `native_action_ref=1`,输出字节一致。独立 QA 核对冻结哈希并复跑两次。探针只调用 V21 内部分配 primitive,没有 stage 完整 cancel,V21 mapping row 为空,也没有 provider 重复报单证据。这是账户级共同分配器缺失的结构性阻断证据,不构成 G5 通过。`G5 NOT_ACCEPTED / NO_WRITE / LIVE_NO_GO`。 + +### G5/V21 ActionRef unification design blocker (2026-09-27) + +[Canonical archive](evidence/iteration41-g5-v21-actionref-unification-design-blocker-2026-09-27/README.md) records the source/AST-only design review and independent static QA. It confirms separate G5 and V21 allocator namespaces, incompatible worker/Store call contracts, schema 5 versus 21, and no migration bridge for the G5 reservation tables. No patch was produced; a safe candidate needs one account-keyed allocator, coordinated migration with stale-writer fencing, and a separately trusted native ActionRef floor/cutover. The earlier collision is synthetic-only and did not stage a full V21 cancel or mapping row, so it does not show provider receipt or acceptance of a duplicate. `DESIGN_ONLY / BLOCKED_FOR_PATCH / G5 NOT_ACCEPTED / NO_WRITE / LIVE_NO_GO`. + + + +### Store R5、Gateway wrapper R2 与 normalized-test R3(2026-09-27) + +- [Store R5 main regression archive](evidence/iteration41-store-r5-main-integration-2026-09-27/README.md):main `btapistore.py` SHA-256 `B9E1BCD3EFA6CF57BF8D3029D60AE89A7158D5332B313EE8DFE12A4A0CA557FF`;Store focus 143/143。Store/Runtime broad 回归的第一次运行有一项顺序相关失败(pytest 全局 `sys.modules` 检查),修正测试隔离后为 2,716 passed / 43 skipped / 2 xfailed / 0 failed,110.01s,一个既有 pytest 配置警告。两个原始 JUnit/log/exit 均保留;broad 是 Store/Runtime 回归快照,不归因单一 patch,也不构成 CTP/Actor 验收。状态 `LOCAL_STORE_GUARD_REGRESSION_ONLY / NO_WRITE / LIVE_NO_GO`。 +- [Gateway wrapper R2 main integration evidence](evidence/iteration41-ctp-gateway-wrapper-r2-main-integration-2026-09-27/README.md):候选基于上述 Store SHA,独立 AST/fake focus 6/6 与四个精确非 CTP allowlist fake probe 通过。范围只含 wrapper 公共方法的 CTP default-deny;raw `_client`、直接 GatewayClient、custom API injection、同进程 mutation 和 CTP read/connect 不受该 gate 完整覆盖。`LOCAL_DEFAULT_DENY_WRAPPER_GUARD_ONLY / NO_WRITE / LIVE_NO_GO`。 +- [Normalized Store test migration R3](evidence/iteration41-store-normalized-test-migration-r3-2026-09-27/README.md):7/7 fake cases independently pass,R3 恢复 `poll_broker_update()` 公共事件轮询及原事件/费用断言,CTP fake submit/cancel 仍零 dispatch。main test snapshot另有等价 `dict.fromkeys` lint cleanup;仅接受测试迁移,不代表 Store/SDK 行为或 writer closure。 + +### Local fake AccountActor main-tree snapshot(2026-09-27) + +Scanner R2 已完成静态 inventory/disposition rebaseline,460 个活动条目仍全部为 REVIEW_REQUIRED;这不扩大 fake 的权限。 + +### G1、G4 最新独立复核与测试树恢复(2026-09-27) + +- [G1 整条命令监督审查](evidence/iteration41-g1-whole-command-supervisor-no-go-2026-09-27/README.md):独立 inert/fake 测试为 65/65 supervisor 与 1/1 普通 CLI 拒绝;证据 ZIP 的 29 个声明 payload 和归档内 40 个文件复核通过。ZIP 缺两个测试直接使用的 helper,复跑借用了当时主树的 105 个支持文件;两次中间运行各有五个原因未定的超时。父进程创建及同步清理没有可证的整条命令硬时限,预启动服务的 request-only SLA 属于需求变更。结论 `NO_GO_IMPLEMENTATION_FOR_CURRENT_G1_WORDING`,普通 013_3 `preflight` 仍关闭。 +- [G4 MSVC/PE timestamp 审查](evidence/g4-msvc-pe-timestamp-author-review-2026-09-27/README.md):严格隔离 C 构建与 pin 的原生镜像同长,只在两个 PE timestamp 字段的六个字节不同;`/Brepro` A/B 彼此相同但原生镜像比 pin 大 512 字节。更正后的 author archive 与独立复核、后续惰性 linker 输入研究均已归档;最终 49 项 checksum 核对通过。本机受审的 `LINK /?` 与构建命令没有可控时间戳赋值参数,本轮未重建或加载 native。没有精确 pin、受信三包安装链或原生验收;结论 `NO_EXACT_PIN_REBUILD / NO_G4`。 +- [测试树 Ruff 事故恢复记录](evidence/iteration41-test-tree-formatter-incident-recovery-2026-09-27.md):保留事故后 1,736 个 Python 文件快照;已恢复 1,267 个原干净 tracked、24 个原带修改 tracked 和 67 个 untracked 测试,移除三个事故新增路径。各恢复源的隔离 Ruff 输出与事故快照字节吻合,原先 31 个 tracked 测试 diff 路径仍是 31;Ruff 无唯一逆变换,未归档的 style-only 修改不能从格式化输出重建。恢复后 scanner/inert-import 焦点 16/16,Store/Runtime 基线复跑 `2725 passed / 43 skipped / 2 xfailed / 0 failed`。 + +CTP generic SDK queue fail-close r3 是**被拒绝的主树候选**:独立 fake-only 焦点通过,但当时主树 Store/Runtime 加新增测试的 broad 结果为 `2724 passed / 43 skipped / 2 xfailed / 13 failed`。失败集中在 typed managed cancel、direct `_invoke_sdk_command` 预算能力合同和 recovery-exit 早拒清理。补丁及新增测试当时从主树撤回,Store 恢复至 SHA-256 `A028A68DF87ABE84A1D38F4020D81AF56E0DFB860DED43D36C3830933106696D`。另有独立负测证明,若 `_sdk_exchanges` 快照与注入 API 的实际 CTP 路由不一致,r3 的通用 sink 仍可能触发 fake dispatch;它本来也未覆盖 `_execute_sdk_command` 的 managed typed 分支。后续 r4 的接受范围见下方当前记录。 + +[Store 条件写入路径窄审计](evidence/iteration41-store-write-narrow-fake-bypass-audit-2026-09-27/README.md)又用隔离 r3 源码复现两类注入身份不一致:`provider=binance` 但注入 API 标为 CTP 时,公开 submit/cancel 可到四次 fake sink;`provider=btapi` 的 API 在构造后由 BINANCE 改为 CTP 时,公开方法产生队列回执、手工执行到两次 fake async sink。正确标记 `provider=ctp` 的对照为零调用。它们是同进程注入/变异条件反例,不证明默认受信路由或真实 provider 写入;r4 未解决该身份快照残余,AC41-63 writer closure 未通过。 + +[G5/V21 ActionRef 离线 cutover validator r1](evidence/iteration41-g5-v21-offline-cutover-validator-r1-2026-09-27/README.md)是未接运行时的纯内存候选。独立 exact replay 及 28/28 测试、五项状态/边界/恶意回调负测通过;它要求两份快照的调用方提供 boundary label 与每条 action state 精确一致,拒绝 UNKNOWN。历史 manifest 的错误 r0 commit 指针已更正并经独立复核,代码与测试字节不变。调用方提供的边界、摘要和完整性声明均未认证,没有已部署 exporter、原生 ActionRef floor、账户级 writer fence 或迁移过程。结论仅 `LOCAL_TYPED_CONTRACT_ONLY / NO_AUTHORITY / NOT_A_MIGRATION_TOOL / NO_WRITE / LIVE_NO_GO`。 + +[G6-P external AccountActor 本地资源审计](evidence/g6p-account-actor-resource-discovery-2026-09-27/README.md)为只读源码审查:旧 `D:/source_code/bt_api_py` Gateway 有真实 CTP client 代码,但命令入口没有账户级认证/逐动作授权,identity map 仅内存,默认 client 可同进程起 runtime;脏的 `D:/bt_api_py` 有 Curve/ZAP、durable router/risk/writer 组件,但缺已部署受信的唯一凭据持有者、原生写适配、跨 host/手工终端 writer fence 和同版本完整账户快照。未找到可直接使用的已部署外部 Actor。结论 `NO_TRUSTED_EXTERNAL_ACCOUNT_ACTOR_FOUND / G6-P BLOCKED / SIMNOW_STRICT_NO_GO / PRODUCTION_LIVE_NO_GO`;没有服务或 provider 测试。 + +### AC41-63 CTP generic SDK queue fail-close r4 主树集成(2026-09-27;历史快照) + +[r4 证据归档](evidence/iteration41-ac41-63-ctp-generic-queue-failclose-r4-2026-09-27/README.md)记录从精确 A028 Store 和两个既有测试文件、一个不存在的新测试路径重放的四文件补丁;该历史 Store SHA-256 为 `A0393FC4F0B7212C6EE4B4F32DE2AA9E6E9A0ECFC5FE976F72160E2EC11F6ABE`,现由上方 CE04 R2 current snapshot supersede。独立 fake-only QA 的 12 项新合同、四种 recovery_exit、三项预算/零 writer lookup、十项 managed-cancel/forwarding 兼容均通过;`_cancel_managed` 与 A028 逐字节相同。主树 `tests/unit/stores tests/unit/runtime tests/test_ctp_generic_sdk_queue_failclose.py` 为 `2740 passed / 43 skipped / 2 xfailed / 0 failed`,仅一条既有 pytest 配置警告;Ruff、py_compile 与补丁精确哈希核对通过。该快照 [静态 inventory](evidence/live-execution-inventory-candidates.json) SHA-256 为 `03658257D97E31C2D8F8BFD48685441533552B32674F5D63810141E3038AB456`;verifier 460/460,scanner 15/15,所有活动条目仍 `REVIEW_REQUIRED / NOT_AVAILABLE`。r4 只封堵通用队列和 `_invoke_sdk_command` CTP fallback;已知注入 API/Store 路由快照不一致与更早的 typed `_execute_sdk_command` 分支仍是残余。结论 `LOCAL_FAIL_CLOSE_REGRESSION_ONLY / NO_WRITE / LIVE_NO_GO`,不构成 writer closure 或模拟盘/实盘验收。 + +上述主树宽回归包含 18 项既有 `optional_sdk("bt_api_ctp.ctp.client")` 测试;安装分发存在时该 helper 导入 SDK 模块。独立 fake-only QA 使用导入拦截,但主树宽回归未记录受信 native 映像身份或 provider 会话,因此不能据其声称 SDK/native 验收。 + +以上均未打开 CTP 写路由或真实 SimNow/production 会话;`NO_WRITE / LIVE_NO_GO` 不变。 + +### 007 SimNow C01 r3、G1 故障注入与 G4 来源复核(2026-09-28) + +- **C01 r3:source-only 集成已通过;案例仍 `INCOMPLETE`。** 17 个目标应用后的内容与冻结候选在 CRLF 归一化后完全一致;17 目标 Ruff 通过,`git diff --check` clean。候选完整套件 482 passed;集成后主树 `tests/unit/live_certification` 为 **488 passed、1 个既有 warning**。独立 schema QA 对精确 patch 给出 `GO for source-only integration`;alias/session 独立复核 197 passed。可信 SDK issuer ledger verifier 与 baseline/final query receipt path 仍未接通,也没有 native callback 来源认证、受信账号 owner 或 provider 证据。逐目录复查的 33 个入口均为 `BLOCKED` / exit 2 / `managed_ctp_certification_not_registered`,network/order_write 均为 0,真实 `PASS` 为 0。八 ID ledger(auth/login + baseline/final 三类查询)16 项 fake-only 测试只证明本地相关性。详情见[007 staging acceptance 证据](evidence/iteration41-007-simnow-33-case-staging-acceptance-2026-09-28.md)。 +- **G1 strict whole-command:`NO_GO`。** 2026-09-28 独立审查对 backend 创建、launcher resume、Job termination、handle release、control escrow 与回执 `fsync` 做六项 fake 同步故障注入,6 passed;注入调用在配置 deadline 后才返回,显示当前同步链无法中断这些调用。没有测试真实 CTP/Win32 卡顿或 provider 行为,也未开放普通 `preflight`。 +- **G4 managed SDK 来源:`HOLD_PROVENANCE`。** 来源审计和独立 QA 指出,兼容 gateway 实现来自本地未跟踪源码快照,上游记录的 gitlink 树不含该实现。候选 wheel/installed RECORD/fake consumer 的内部一致性不能把临时快照提升为受信来源 pin,也不能验收 native 生命周期或写入路线。 + +主树回归和独立 source-only schema QA 已完成;以上结果限于离线/source-only 范围。C01 仍为 `INCOMPLETE`,保持 `NO_WRITE / LIVE_NO_GO`。 diff --git "a/docs/_internal/opts/requirements/\350\277\255\344\273\24341-\345\256\236\347\233\230\346\211\247\350\241\214\351\243\216\346\216\247\347\233\221\346\216\247\344\270\216\347\244\272\344\276\213\346\236\266\346\236\204\351\207\215\346\236\204/evidence/iteration41-007-simnow-33-case-staging-acceptance-2026-09-28.md" "b/docs/_internal/opts/requirements/\350\277\255\344\273\24341-\345\256\236\347\233\230\346\211\247\350\241\214\351\243\216\346\216\247\347\233\221\346\216\247\344\270\216\347\244\272\344\276\213\346\236\266\346\236\204\351\207\215\346\236\204/evidence/iteration41-007-simnow-33-case-staging-acceptance-2026-09-28.md" new file mode 100644 index 00000000..ed4551a5 --- /dev/null +++ "b/docs/_internal/opts/requirements/\350\277\255\344\273\24341-\345\256\236\347\233\230\346\211\247\350\241\214\351\243\216\346\216\247\347\233\221\346\216\247\344\270\216\347\244\272\344\276\213\346\236\266\346\236\204\351\207\215\346\236\204/evidence/iteration41-007-simnow-33-case-staging-acceptance-2026-09-28.md" @@ -0,0 +1,950 @@ +# 007 SimNow 33-case staging acceptance record — 2026-09-28 + +**Status:** `STAGING_ONLY / NO_WRITE / LIVE_NO_GO` +**Evidence boundary:** Offline configuration and entry-contract checks plus one credential-free TCP front-reachability probe. No SimNow session, provider login, market-data subscription, CTP SDK import, or trading write is evidenced here. + +## Staging inventory and offline checks + +The current tree contains 33 case directories at +`examples/007_ctp/live_certification/simnow_penetration/cases//`. Each has +`config.yaml`, `_strategy.py`, and `run.py`. The per-case configuration is +scenario configuration; it does not carry account credentials. The strategy +files state intended real actions and required evidence. They do not implement +provider execution. The case-local `run.py` files delegate to +`managed_case_entry`; the historical flat `cases/*.py` files remain closed +source. + +The suite-root protected `config.yaml` was prepared from the 013_3 protected +configuration by rebinding only `strategy.id` to +`example.007_ctp.simnow_penetration`. It remains Git-ignored and protected; +the suite-root config and runtime-directory ACL match the 013_3 private +directory. Its contents and front addresses are intentionally not reproduced +in this record. The current inventory has 17 registrations, including a 007 +zero-write `simulation/sandbox` runtime/front-check route. This is not a +certification case runner, trading runner, or write authorization. + +Root's final offline check verified all 33 case configurations with +`backtrader_runtime.config.load_runtime_config` without registry resolution: +schema-v4 `simulation/sandbox`, `secrets_ref: none`, and zero private blocks. +All 33 new case directories have exactly the three requested files; their +33 strategy-plan files have no fixture, fake, local-replay, or seed-bar +references. The historical flat case sources are separate and closed. +The check did not expose configuration contents. Root also ran each of the 33 +case entry scripts in an individual child process. Every result was exit code +2, `status=BLOCKED`, `reason=managed_ctp_certification_not_registered`, with +`network=0` and `order_write=0`. Therefore the verified counts are 0 real +`PASS`, 0 real order writes, and 0 real cancel writes. These are offline +fail-closed entry results, not SimNow case runs or provider evidence. + +The root-reported combined focus across six relevant runtime test files +passed 176 tests, skipped 13, and reported one existing pytest-configuration +warning with `-p no:asyncio`. The run was fake/offline contract testing; it +does not establish TCP, provider, account, or trading behavior. The latest +`tests/unit/live_certification` run passed 170 offline tests with the same +configuration warning. The `runtime_not_registered` doctor result predates the 007 route +registration and is superseded by the current status below. + +After adding the 007 read-only registration, a full `tests/unit/runtime` +regression initially found four local failures: a test still expected the +frozen R5 worker hash instead of the recorded overlapped-I/O successor, one +synthetic registry clone included an unrelated private binding, the offline +smoke selector assumed only one private CTP route, and the new suite-root +`secrets.yaml` ignore rule was missing. The test now verifies both frozen +source identities through the preserved manifest, the clone and selector +recognize the exact 007 binding, and the ignore rule is present. The full +runtime suite then passed **2,118 tests**, skipped 30, and reported 2 expected +failures with one existing pytest configuration warning (108.83 seconds). +This is offline regression coverage, not provider or account acceptance. +The separate offline smoke command skipped both private CTP read-only routes +and the public shadow route before loading their configs, as designed. Of its +14 eligible offline runtimes, 12 passed and two managed L2 replay runtimes +returned `capability_dependency_missing` for `bt_api_execution` in the current +Python environment. No package was installed to mask this result, and the +smoke run is not counted as fully passing. + +## Current 007 read-only route status + +The suite-root offline `bt-runtime doctor` exited 0 with +`provider_preflight_started=false`, reports 5 configured front pairs, and +reports `preflight_available=false`. Ordinary provider `preflight` remains +fail-closed pending a bounded Windows Job supervisor and independent +acceptance. Separately, on 2026-09-28 root explicitly ran +`bt-runtime check-ctp-fronts` against those configured pairs. It exited 0 with +`status=selected`, `reason=selected_configured_pair`, and selected index 3; +both MD and TD had 3/3 TCP successes at index 3, while indexes 0, 1, 2, and 4 +each had 0/3. The probe was credential-free and reported +`tcp_probe_only=true`, `sdk_imported=false`, `provider_login_started=false`, +and `trading_writes=0`. This is an immediate transport observation from this +host on this date, not a durable front guarantee or evidence of CTP login, +market-data subscription, settlement, order, or cancel behavior. The +registered 007 route is code-connected for zero-write sandbox configuration +and front checking only; it does not execute certification cases or submit or +cancel orders. + +## Subsequent case and gate work (still unaccepted) + +The unregistered `managed_case_scope.py` now binds one of the 33 exact case +identities to the sealed suite runtime and records digests of that case's +`config.yaml`, static `_strategy.py` plan, and `run.py`. The new +`common/case_engine.py` parses all 33 plans without importing or executing +them; it separates provider callbacks from runtime intents, monitor records, +local validation, and external controls. Its strongest result is +`REVIEW_REQUIRED`, never certification `PASS`. Focused scope and engine tests +passed 27 cases. Re-running the actual 33 entry scripts after these edits +still yielded 33 `BLOCKED`, 0 unexpected results, and 0 real `PASS`. + +The unregistered typed decision engine defines an explicit intent and +observation rule for each of the 33 cases. A diagnostic converter binds its +plan digest to the sealed case/runtime digests. Without a trusted observation +authenticator it remains `BLOCKED`; with a test-only verifier it can reach only +`REVIEW_REQUIRED`, with `dispatch_permitted=false` and certification +`PASS=false`. The scope/case-engine/decision-engine focus passed 38 offline +tests. There is no production authenticator or provider execution adapter. + +The unregistered pure-data `common/completion_invariants.py` adds 33-case +closure checks for canonical scenario/provenance evidence, managed request +receipts, native order/trade lifecycle, and final order/position/account query +snapshots. Missing scenario evidence keeps all 33 cases below review; order +cases also require terminality, request/trade quantities, no remaining open +orders, and coherent position/funds observations. B01 partial orders, B02 +cancel/fill races, repeated-order thresholds, and external error/emergency +conditions have explicit negative branches. Its maximum result is +`REVIEW_REQUIRED` with `certification_pass=false`, +`dispatch_permitted=false`, and `source_authenticity_verified=false`. The +case/decision/completion focus passed 45 synthetic tests; the full +certification unit suite passed 170. The 33-ID negative loop checks only that +missing scenario evidence cannot advance to review; it does not exercise all +positive and negative paths for every case class. Independent QA found and +the author fixed missing cross-checks for trade/order external IDs and +managed submit quantities. Accepted and partial facts now conserve +`traded + remaining`, filled facts require full submitted quantity, and +canceled facts retain a separate trade-callback check. It cannot authenticate in-process +callbacks or prove provider/account ownership, and cash, fee, and margin +effects after fills require independent review. It is not registered or used +by the 33 entrypoints. + +A subsequent independent read-only pass found an ordinary order case could +still end in a provider `REJECTED` fact after earlier acceptance. The checker +now marks such a terminal rejection contradictory for required-order cases, +while preserving the explicitly expected E01/E02/E03/EM01 remote-rejection +paths. A synthetic partial-fill-then-cancel example confirms that matching +trade quantity and signed position change can still reach only +`REVIEW_REQUIRED`. These narrow checks remain unauthenticated source-shape +contracts; they do not prove the real terminal sequence or account delta. + +The historical reconciliation table was corrected so E03 and EM01 require +an order request, B01 requires actual trade activity, and cancellation cases +can carry a reconciled fill race. B01's derived `partial_count` no longer +accepts an event-name set, caller-supplied count, or local partial label; +distinct sourced CTP partial-order callbacks are needed. These are local +evidence contracts, not authenticated provider records. + +The legacy SimNow evidence reader also stopped accepting caller-supplied +labels and unsourced local disconnect/reconnect logs as provider facts. It +requires stronger order/position snapshot and trade reconciliation shape. +Unsourced error, emergency-control, and repeat-order events are rejected +because the legacy JSONL reader cannot verify the corresponding provider, +monitor, or control-plane origin. This prevents a local log from being +reported as a real case pass; it does not create real evidence. + +The legacy `CaseTimer.pass_result()` now returns `FAIL` without a trusted +post-reconciliation evidence adapter and cannot derive certification events or +fields from caller-controlled details. `CaseResult.to_dict()`, +`CaseResult.exit_code()`, and `save_result()` also demote a hand-built or +mutated `PASS` before export, process exit, or persistence. The full +`tests/unit/live_certification` rerun passed 170 offline tests, including these +negative cases. Root then reran all 33 individual `run.py` entrypoints: +33 returned `BLOCKED` / exit 2 with zero network and order-write requests; +there were no unexpected results or real passes. + +The unregistered G1 inert Job candidate now reserves at least 0.5 seconds +between worker stop and whole-command deadline; its two focused test lanes +passed 10 and 23 cases. The synchronous native/OS boundary remains unbounded, +so ordinary preflight stays closed. The managed CANCEL verifier now requires +a fresh account-wide G5/V21 ActionRef mapping and native-floor snapshot at +claim and final recheck; absent a trusted producer, it rejects. Its focused +and adjacent fake/offline run passed 48 cases and skipped 4. Independent review +found that a newer epoch could regress the earlier ActionRef floor or mapping, +and that CANCEL target fields were not required to match its logical payload. +The verifier now rejects these changes within one process and requires exact +target-field equality; the adjacent rerun passed 54 cases and skipped 4. +Source digests are still syntax-only and the verifier's epoch memory does not +survive restart. With no trusted producer, this remains contract-only and +cannot authorize CANCEL. The unregistered I9 session candidate now requires +final native-call admission before constructing a CANCEL field; the +dependency-free negative/ordering focus passed 2 tests. Its three SDK +source-root integration cases were skipped and are not counted as passing. +The local fake +AccountActor remains isolated from production imports (one focused test +passed); it is not an external account writer fence or a coherent snapshot. + +An isolated G4 rebuild produced matching A/B wheel bytes for the base, CTP, +and parent packages and verified installed wheel payloads, hashed `RECORD` +rows, and wheel-bound `direct_url.json` in two clean virtual environments. +This is a reproducible artifact candidate only: its parent wheel used a +156-file hash-bound composite source snapshot that differs from the clean +parent Git checkout (7 matching, 55 different, 94 missing files), the base +and CTP hashes differ from existing code pins, and there is no code-owned +parent pin. No SDK/native load was intentionally run, and no native lifecycle +or provider session was accepted. G4 remains `NOT_ACCEPTED`; the candidate +does not change 007 routing or permit an order or cancel. + +A second isolated G4 candidate rebuilt all three wheels twice from a clean +parent Git checkout, and the wheel bytes and two offline installed RECORD and +direct URL inspections matched. That clean parent builds as `bt_api_py 0.15` +and its archive lacks the managed authorization modules required by current +CTP source; it also carries its own native CTP payload. Its build used a +temporary NumPy include shim and its new CTP `.pyd` differs from the existing +pin. This removes the composite-source mismatch for that candidate but does +not make it a compatible parent package or close G4. No native lifecycle was +run and default pins remain unchanged. + +A follow-up static compatibility audit of that clean `bt_api_py 0.15` source +found that five parent module paths needed by the current managed CTP client +and request builder are absent, including credential binding, execution +authorization, runtime plugins, contract DTOs, and the reservation mirror. +The request builder also requires `bt_api_py 0.15.5`. The earlier 156-file +composite source differs from the clean parent in 55 files and lacks 94 clean +counterparts; its central `BtApi` file is substantially expanded. A small +import-stub patch would not supply those contracts. The isolated report at +`D:/temp/iteration41-g4-parent-compat-gap-analysis-20260928` records the +static comparison and checksums. No compatibility patch/wheel was produced; +G4 remains `NOT_ACCEPTED` pending a clean compatible source and native tests. + +Native CTP `SettlementInfoConfirm` rows contain `ConfirmDate` and +`SettlementID`, but no `TradingDay`. The unregistered TD readiness helper now +rejects a confirmation-only query and invokes owner cleanup. A separate +offline dual-query shape candidate joins `SettlementInfo.TradingDay` and +confirmation by account and `SettlementID`; it does not attest SDK source or +enable settlement readiness. An unregistered typed pair-port contract now +requires both query results, post-close request-ID readback, and a complete +native stop receipt. The currently pinned child SDK exposes typed confirmation +query but lacks the matching high-level SettlementInfo query and callback +archive; its confirmation verifier treats `ConfirmDate` as a possible +`TradingDay`, which cannot establish the session's settlement day. The latest +four-file focused readiness/query run passed 41 tests and skipped 1. These +changes leave the G6-S and G7-S real-provider gates closed. + +An isolated G6-S SDK source patch candidate adds typed SettlementInfo query +archival and removes the `ConfirmDate` fallback, but its baseline is an +already-dirty child SDK working file; three AST checks and syntax compilation +do not prove the native ABI, real callbacks, close behavior, or the dual-query +join. It was not applied to the pinned SDK or connected to 007 readiness. + +A separate unregistered, read-only MD tick bridge passed 21 fake-source +tests. It validates an already-owned stream's lease, selected front/account, +instrument, generation, subscription and first-tick watermarks, and poisons +on stale or mismatched ticks. It does not create a Feed or native client. +The current pinned child has a public MD identity, but its public tick callback +does not carry callback-bound generation or sequence and its subscription +callback drops native request ID and terminal flag. The managed owner therefore +cannot yet supply the bridge's required stream provenance; no live Feed is +connected to a 007 strategy. +The [pinned child subscription source audit](ctp-md-subscription-ack-correlation-source-audit-2026-09-28.md) +confirms that native `SubscribeMarketData` accepts no caller request ID. Its +callback exposes `nRequestID`, but the high-level wrapper discards it and +the source does not tie it to a particular request or connection generation. +A single-pending-instrument Python policy could limit ambiguity but cannot +be treated as provider-authenticated ACK correlation. + +An isolated broker-session adapter candidate at +`D:/temp/iteration41-007-managed-broker-session-adapter-r2-final3` projects +bounded local SUBMIT/CANCEL request shapes and correlates already-observed TD +status, but its `submit` and `cancel` methods always reject before approval +issuance or session/native dispatch. Its 15 fake tests passed. Independent +static QA verified the candidate's 13-file checksum sidecar, manifest, and +ten bound main-source preimages. The adapter has no direct provider query or +write path; an arbitrary injected `query_evidence_verifier.verify()` could +have side effects, so that static conclusion applies only to the adapter's +own call path and the pure test fake. The candidate is not installed in the +repository or connected to a 007 runner. It does not close G1/G4/G5/G6 or +enable real orders. + +An offline `bt-runtime run --strategy-dir ` safety check returns +exit 2 with `profile_dispatch_unavailable` and +`diagnostic.provider_preflight_started=false`. The newly registered 007 +read-only route has no runner; this rejection is separate from the retained +local replay runtime at `examples/007_ctp/runtime`. + +## Route and front boundary + +The 013_3 selected-front facility remains a private read-only route. The 007 +zero-write runtime/front-check route is now separately registered and +code-connected. The one recorded TCP probe selected configured pair index 3 +for this host and time only. It does not establish CTP login, market-data +subscription, TradingDay settlement, orders, or cancellations. 007 still has +no certification case runner, real-trading runner, or write permission. + +Selection checks only the sealed 1–8 configured MD/TD pairs. With three +samples per endpoint, both endpoints need at least two successful TCP +connections. Each eligible pair scores the larger of the MD and TD successful +sample median latencies; the lowest score wins, with configuration order as +the tie-breaker. A single reachable address is insufficient, and the selected +whole pair is passed unchanged. The selection does not test native login or +trade permission. + +SimNow's 7x24 label does not establish account settlement readiness. The current +acceptance matrix requires actual account and TradingDay settlement evidence +for G7-S and records the current G7-S result as `NOT_RUN / NO_WRITE`. No case +below may replace a missing external condition with a local fixture, fake, +synthetic market event, hand-filled log, or TCP success. + +## Per-class real acceptance evidence and external dependencies + +The rows describe evidence the planned case would need in a future reviewed +managed route. They are requirements, not claims that any case has run. + +| Class / cases | Required real evidence | External condition and limits | +|---|---|---| +| **C01 — authentication/login** | Native authentication/login callbacks with exact request IDs, ErrorID, locally captured arrival order/time, and login callback FrontID/SessionID/TradingDay; clean stop; zero order/cancel requests. The auth callback has no native front/session/day or provider timestamp/sequence. | Valid account/authentication and real MD/TD session callbacks. A TCP probe or local lifecycle event cannot prove login. | +| **T01–T03 — open, close, cancel** | For each request, trace ID, exact request/order references, provider acceptance and terminal callback, fill/cancel state, and before/after order, position, and funds reconciliation. T02 also needs a confirmed closeable position; T03 needs a still-cancellable accepted order and its cancel acknowledgement. | Approved account, instrument, quantity, market state, budget, and provider acceptance. Open/close may change positions or funds; every remaining order must be reconciled. | +| **M01–M03 — connection status** | Native MD/TD connection and readiness callbacks with locally captured arrival times and actual session transitions. M02 requires evidence of the disconnect source. M03 requires a genuine reconnect transition with session/generation identifiers and restored readiness. | Real session and, for M02/M03, a controlled real disconnect/reconnect condition. Calling local `stop()` or observing TCP reachability alone does not prove transport-loss detection or recovery. | +| **M04–M05 — request counts** | Monitor events whose order/cancel counts reconcile to real provider request references and locally captured callback times; final open-order reconciliation. | Actual bounded order/cancel requests accepted through the reviewed route. Market movement and fills remain possible; count-only local events are insufficient. | +| **O01–O03 — repeated requests** | Real repeated-intent key, threshold/window, count, timestamps, and corresponding monitor/risk event; per-order/cancel outcomes and final reconciliation. | The duplicate intent must be genuinely repeated within the configured window. Requests can reach the counter; quantities must be bounded and all resulting orders/fills reconciled. | +| **TH01/TH03/TH05 — threshold settings** | Runtime-resolved effective threshold values and a real monitor summary. TH05 also requires the effective repeat window. No order activity is required for these setting cases. | Managed runtime must actually load the setting and emit its summary. A YAML value by itself is not runtime evidence. | +| **TH02/TH04/TH06 — threshold alerts** | `risk_threshold_triggered` with effective threshold and observed count; TH06 also needs repeat key/window/count; correlate the alert with provider-backed request references and reconcile all orders. | Actual bounded request activity is required to cross the configured threshold. Synthetic counters or fabricated alerts cannot prove this behavior. | +| **V01–V03 — validation** | Broker-originated validation rejection tied to the submitted instrument, tick, or maximum-size evidence; prove rejection occurred before provider dispatch and that no order remains. | Current contract metadata and limits must have an authoritative source. Injected `contract_metadata` may exercise local code but cannot establish real-contract facts or count as provider acceptance. | +| **E01–E03 — counter errors** | Correlated remote `order_reject_remote`, counter `ErrorID`/`ErrorMsg`/`StatusMsg`, request references, timestamped source callback, and final order/fill/account reconciliation. | E01 needs a genuine insufficient-funds condition; E02 a genuine insufficient-position condition; E03 a naturally occurring counter-reported market-state rejection. These conditions and messages must not be synthesized. A 7x24 endpoint does not guarantee settlement or any of these rejections. | +| **EM01–EM03 — emergency actions** | EM01 needs evidence that account trading permission was actually restricted and a subsequent request was denied. EM02 needs the real pause command/state transition, strategy identity, and proof that later callbacks/new requests stopped. EM03 needs an external logout/session-termination acknowledgement, resulting disconnect, and proof that new requests stopped. | EM01's broker-local `disable_trading()` is not account-level permission evidence. EM03's local `stop()` or a changed boolean is not proof of external forced logout. EM01 and EM03 require the corresponding authorized external control; these conditions cannot be fabricated. | +| **B01–B02 — batch cancel** | B01 requires provider execution reports showing each targeted order is genuinely partially filled (`filled > 0`, `remaining > 0`), then batch-cancel requests and per-order terminal cancel/remainder callbacks. B02 requires multiple accepted working orders and per-order cancel acknowledgements. Both require trade, position, funds, and no-open-order reconciliation. | Active market liquidity, real partial/working states, bounded accepted orders, and exact order-reference mapping. B01's partial-fill state cannot be supplied by a fixture, a status label, or an assumed fill. | +| **L01–L04 — logging** | L01 correlates real request, order, fill/trade IDs, trace, and account deltas. L02 ties local startup/stop records to native connect/login/ready callbacks. L03 ties monitor metrics to their source events and request counts. L04 retains the original typed error callback, source classification, locally captured time, and correlation ID. | Real provider callbacks and runtime logger output. L01 requires actual fills; L03 requires the observed monitor inputs; L04 must distinguish a local validation rejection from a counter rejection. Hand-authored log entries are not evidence. | + +## Acceptance gates + +The following gates have not passed for this 007 certification work. The current +matrix remains authoritative for full gate definitions and evidence: + +| Gate | Current disposition | Blocking boundary | +|---|---|---| +| **G1 — whole-command deadline supervision** | `NO_GO_IMPLEMENTATION_FOR_CURRENT_G1_WORDING` | No accepted hard deadline covers whole command creation and cleanup. | +| **G4 — exact native artifact/pin** | `NO_EXACT_PIN_REBUILD / NO_G4` | No accepted exact pin, trusted three-package install chain, or native lifecycle acceptance. | +| **G5 — ActionRef authority** | `NOT_ACCEPTED` | Local/fake contracts do not establish one trusted account-wide allocator, native floor, or provider duplicate-action evidence. | +| **G6-P — external account actor** | `BLOCKED` | No trusted deployed external AccountActor with account-wide identity, writer fence, and common account snapshot was found. | +| **G6-S — SimNow TD/MD readiness** | `NOT_ACCEPTED` | No callback-correlated MD stream, authoritative settlement TradingDay join, or accepted native close lifecycle. | +| **G7-S — minimum real SimNow order/cancel** | `NOT_RUN / NO_WRITE` | Requires G0–G5, G6-S, actual TD/MD/native lifecycle, and account/TradingDay settlement readiness before any bounded request. | + +G7-S cannot be inferred from +front selection, a 7x24 label, a TCP result, an offline test, or this staging +record. Until the required gates and evidence are independently accepted, the +suite remains `NO_WRITE / LIVE_NO_GO`. + +## Later 2026-09-28 continuation: typed case logic and independent review + +All 33 case strategy files now contain unregistered, read-only typed observation +logic while retaining their static plans. The common decision, case, and +completion contracts can reach at most `REVIEW_REQUIRED`; they cannot attest +source authenticity, dispatch, or certification `PASS`. The case-local +`run.py` files still call the fixed fail-closed staging entry. A fresh +individual-process run of all 33 returned 33 `BLOCKED` / exit 2 with reported +network and order-write counts zero. **Real SimNow acceptance remains 0/33.** +The same 33 individual entries were rerun after the later candidate fixes: +all again returned the expected `BLOCKED` / exit 2, with zero reported +network/order-write requests and zero real `PASS`. + +Independent review found source-shape contradictions that previously reached +review state. The common checks now reject M02/M03 query/callback sequence +inversion, same-generation restoration, and mismatched control receipts; +managed submit/cancel receipts outside the before/after account snapshot +window or after corresponding native callbacks; C01 login before +authentication or account queries before login; L01 trade-log fields that +disagree with native trade facts; and mismatched EM01/EM02/EM03 external +control references. V01/V02/V03 now require an actual unknown-instrument, +off-tick-price, or above-limit-size condition from supplied reference facts, +with historical and typed field aliases agreeing. V02/V03 bound decimal +shapes before exact arithmetic. TH04 now counts unique +dispatched submit and cancel request IDs against a combined threshold and +checks config, monitor, request, and native-event references. These remain +pure-data or synthetic-verifier checks; they do not authenticate a real CTP +source or grant execution. + +A later independent negative probe found that O01 accepted a different-contract, +ten-year-old, or semantically rejected/negative-quantity supporting order; +T01/B01 accepted managed submit receipts naming another contract, and T01 +accepted stale or cross-account synthetic evidence. The unregistered typed +candidate now checks provider session/day/generation, occurrence time, native +order status/quantity, contract correlation, and account fingerprint across +native events, authenticator receipts, and managed receipts. The common order +candidate checks a caller-supplied evaluation time, contract identity across +subscription/tick/managed/native facts, and a scope-bound account fingerprint +across submit and six baseline/final query families. Missing sealed case-scope +identity or a trusted event authenticator leaves the current production +candidate `INCOMPLETE`. +An independent post-fix synthetic rerun closed all 12 recorded negative +probes by record rejection or `INCOMPLETE` (36 focused tests passed); see +`D:/temp/ac41-007-read-only-candidate-qa-after-fix-20260928/after-fix-report.md`. +The 007 case-scope binder now derives a scheme-marked pseudonymous account +digest from the already validated in-memory sealed CTP config, incorporates +it into a v2 case-scope hash, and passes it to `DecisionScope`. The binder +rejects hand-built/replaced scope objects in this process. The digest is an +unkeyed, potentially guessable logical-account identifier, not a provider +signature, credential-version proof, or writer authority. The binder uses no +separate case credential copy. Its dedicated synthetic tests passed 13, and +the full live-certification suite then passed 320. The later 007/static runtime +focus passed 48. The 33 individual entry points still all returned BLOCKED. + +At this checkpoint, `tests/unit/live_certification` passed **320 tests** with +one pre-existing pytest configuration warning using `-p no:asyncio`; broad +Ruff checks passed for the suite's common modules and certification tests. +The full `tests/unit/runtime` rerun passed **2,124**, skipped 30, and xfailed +2 after relocating a typed helper outside the historical flat `cases/*.py` +directory; the count there remains exactly 33. `tests/unit/stores` passed +**742** and skipped 13, and `tests/unit/brokers/test_btapibroker.py` passed +**149**. These are offline regressions, not provider runs. + +The G4 audit located the exact old pinned base and CTP wheels and matching +clean source commits. A clean `bt_api_py 0.15.5` parent wheel and those pinned +base/CTP wheels passed isolated static installation `RECORD` and source-link +checks. A later isolated run closed 38 dependency wheels from a pre-existing +local wheelhouse, passed `pip check` and 5,416 hashed installed dependency +`RECORD` rows, and imported the pinned CTP `.pyd` with its expected hash. +One urllib3 import-time local IPv6 `socket.bind` probe was blocked by a Python +audit hook; that hook does not cover native Winsock. No client was constructed +or native lifecycle or provider session run. The original CTP wheel build +recipe is not reproduced. G4 remains `NOT_ACCEPTED`; see +`D:/temp/iteration41-g4-pin-parent-abi-check-20260928/REPORT.md`. + +An independent G1 audit confirmed the strict whole-command hard deadline +does not cover highest-level synchronous Windows service and OS calls. The +ordinary preflight route remains closed. A separately scoped deadline for a +request inside a protected, already-running service is a possible new +contract, but it would need an explicit acceptance change and its own tests; +it is not a G1 pass. An isolated Windows Job prototype for that narrower +already-READY request boundary passed 8 inert tests with a single absolute +deadline and `UNKNOWN` on timeout or cleanup ambiguity. It did not exercise +native calls and cannot bound the highest-level service call or scheduler; +see `D:/temp/ac41-g1-request-boundary-prototype-20260928/README.md`. +No real CTP order or cancel has been accepted. + +The separate G6-P review reproduced 56 isolated fake account-actor tests: +one winner under same-local-database contention and fail-closed freeze after +writer crashes. The current local lock, SQLite journal, and injected +`writer_fence` do not attest an external authority, exclude other hosts or +manual terminals, or prove a common-version provider snapshot across account +queries. G6-P remains blocked; see +`D:/temp/iteration41-g6p-account-actor-review-20260928/G6P-MIN-SERVICE-BOUNDARY-REVIEW.md`. + +The G5 source audit confirms that the checked CTP 6.7.7 login response +exposes `MaxOrderRef`, not an ActionRef maximum; `OrderActionRef` is a +separate cancel field, and the checked API exposes no `ReqQryOrderAction`. +The G5 and V21 local allocators can each allocate ActionRef 1 for the same +synthetic account. Current G5 snapshot verification has no authenticated +issuer or durable account-wide floor. A single externally fenced account +actor and canonical durable ActionRef ledger remain prerequisite to a real +cancel path; see +`D:/temp/iteration41-g5-actionref-trusted-producer-gap-20260928/DESIGN.md`. + +Isolated G6-S source candidates now preserve MD subscription ACK callback +request ID, terminal flag, instrument, connection generation, and the first +matching post-ACK tick (4 fake tests), and separately collect SettlementInfo +and SettlementInfoConfirm query envelopes under one session identity (17 fake +tests). `SubscribeMarketData` has no caller request ID, so the MD candidate +permits only one submitted instrument per client lifetime; the callback ID +is not used as a caller correlation token. The checked settlement confirmation +row has `ConfirmDate` but no `TradingDay`; the existing high-level SDK helper +falls back to ConfirmDate, while the repository's TD trading-readiness gate +already rejects confirmation-only evidence. The isolated SDK copy now makes +that old single-query helper fail closed and clear readiness/grant state; +the pinned SDK itself is unchanged. Neither isolated candidate has +native/provider acceptance, durable authenticated source ownership, or a +registered route. See `D:/temp/ac41-g6s-md-callback-archive-20260928/README.md` +and `D:/temp/g6s_settlement_dual_query_candidate_20260928/README.md`. + +## Further 2026-09-28 independent real-path audit + +The exact 007 protected suite config is present, and the previously recorded +credential-free front check observed one reachable configured whole MD/TD pair. +An independent source/deployment audit found no executable **007 real read-only +probe** under the current gates: ordinary `preflight` rejects before config or +SDK access, the candidate guardian worker and output binding are fixed to +013_3, its protected deployment/pins and service are absent, and strict G1 +remains `NO_GO`. The pinned SDK client also imports order/cancel modules on the +nominal read-only path, so it cannot be described as a query-only import +surface. The audit did not read protected values or start a native session; +see `D:/temp/iteration41-007-real-readonly-probe-audit-20260928.md`. + +A follow-up exact-pin import audit confirmed that the `bt_api_ctp` package +eagerly imports the full Trader API and a single generated `_ctp` extension +exports `ReqOrderInsert` and `ReqOrderAction`. A Python facade over that wheel +cannot constitute a query-only native/API boundary. The proposed remedy is a +separately generated, allowlisted native extension and separately routed +query-only client/package; `ReqSettlementInfoConfirm` must remain excluded as +a state-changing request. Sixteen static assertions and a fresh-process import +probe passed, but no restricted wheel or native client was built in that audit; +see `D:/temp/ac41-007-query-only-sdk-boundary-20260928/REPORT.md`. + +An independent C01 source-contract audit found that the present read-only +result preserves generation/day and digest summaries, but loses the individual +TD authentication/login callbacks and their request IDs, plus per-query raw +facts needed for distinct baseline/final snapshots. No lossless adapter to C01 +exists; its static missing-contract guards passed 5 tests. The checked CTP +`OnRspAuthenticate` callback itself has no FrontID, SessionID, TradingDay, +timestamp, or provider sequence; those values must not be invented or labeled +as native auth fields. SDK callback archive and source-attributed C01 schema +candidates are being developed in isolation. See +`D:/temp/ac41-c01-readonly-adapter-contract-r1-20260928/README.md`. + +The 33-row real-case feasibility audit classifies 10 cases as requiring a real +read-only session, 10 as requiring bounded managed actions, and 13 as also +depending on external account controls or genuine market outcomes. Every case +remains `BLOCKED`. B01 and B02 require at least two simultaneous working orders, +contradicting the current one-live-order limit. B01 further requires positive +filled **and** remaining quantities on each of at least two orders; with +integer futures lots, each such order needs at least two lots, contradicting +the present one-lot per-order and gross-position limits. No default limit was +changed. See `D:/temp/ac41_007_real_case_feasibility_20260928.md`. + +An independent legacy-entry audit found no reachable bypass in the checked-in +007 scripts: the flat entrypoints stop before Store construction and the new +33 `run.py` files return `BLOCKED`. Guarded legacy tests passed 26 selected +cases, managed-entry tests passed 8, and 33 separate child-process entries all +reported `BLOCKED` with zero reported network/order writes. This is static and +local-test evidence only, not real provider or write acceptance. + +The independent native order/cancel/trade adapter audit found no lossless 007 +adapter in the inspected SDK source. Order and trade callbacks enter separate +volatile queues without a shared callback-bound sequence, capture time, +generation envelope, event ID, or durable artifact. The active client does not +capture cancel-response callbacks; an isolated candidate's action response +only acknowledges the request and cannot prove terminal cancellation. Query +rows cannot be renamed as `OnRtnOrder` or `OnRtnTrade` callbacks. Therefore no +adapter was emitted and G7-S remains `NOT_RUN / NO_WRITE`; see +`D:/temp/ac41-007-native-callback-adapter-audit-20260928/README.md`. + +An isolated G1 integration exercised the repository's actual outer watchdog +and Windows Job backend around a fake service worker: 4 Windows test methods, +16 normal/fault scenarios passed. A stalled outer watchdog made the caller +return `UNKNOWN` **after** the strict deadline, and the caller could not +independently observe the lost watchdog's Job-empty/native-close facts. This +does not satisfy the current whole-command G1 wording. The proposed +already-READY request deadline is a different, still-unaccepted contract; +neither the CLI nor acceptance matrix changed. See +`D:/temp/ac41-g1-whole-command-inert-20260928/README.md`. + +The clean SDK MD login source passes its connection generation as the native +`ReqUserLogin` request ID; the SWIG wrapper preserves that integer in both +directions. An isolated fake callback with ID zero after request ID one was +correctly rejected without marking login complete or reading account identity. +The historical real callback ID zero therefore remains unexplained; the +checked source does not justify accepting zero or attributing it to the vendor +without process-mapped module and callback evidence. See +`D:/temp/g6s-md-login-path-audit-20260928/`. + +A further isolated SDK candidate captures authentication, login, and all seven +read-only TD query callback families with native request ID/ErrorID/IsLast and +explicitly local arrival time, sequence, and baseline/final round labels. +Seven fake tests passed. Authentication callback front/session/day fields stay +unset because the vendor callback does not provide them. The archive is +volatile and is not connected to the one-pass, digest-only main preflight; +it cannot yet provide C01 values or source authentication. See +`D:/temp/ac41-g6-query-callback-archive-candidate-20260928/CANDIDATE-REPORT.md`. +Independent QA reran the seven fake tests and confirmed the source/field +attribution, but found that normal generic `OnRspError` for auth/login is not +archived, the row-digest redaction list omits BrokerID/UserID, and fake tests +do not exercise the patched client wiring. The current C01 schema still +requires provider session/day/front on the pre-login auth callback, which the +vendor row cannot provide. See +`D:/temp/ac41-g6-query-callback-archive-independent-qa-20260928/QA-REPORT.md`. + +The separate order/trade callback archive prototype passed four fake tests +and independent QA confirmed its local stale-generation/session rejection. +QA also found that its records omit a poison/completeness marker, a bad +capture does not itself revoke client readiness or a write gate, and the +prototype was built against the older `bt_api_py` layout rather than the +clean pinned `bt_api_ctp` source. It supplies no durable/source-authenticated +007 evidence or G7-S acceptance; see +`D:/temp/ac41-007-native-callback-archive-independent-qa-20260928/report.md`. + +The main-tree, unregistered `managed_case_invocation.py` now binds an issued +case scope to a freshly revalidated sealed runtime, one configured MD/TD pair, +the full TCP evidence ranking, account/contract identity, case-file digests, +and optional lease generation. It rejects candidate-order/score and file-drift +contradictions; endpoint values are omitted from its redacted summary. Root's +focused rerun passed 9 synthetic tests, Ruff, and a fresh import check that +loaded no CTP SDK modules. The TCP samples are unauthenticated transport +observations, the in-process object checks are not a security authority, and +all 33 case entrypoints remain `BLOCKED`. + +A separate read-only structure audit confirmed 33/33 case directories each +contain the matching `_strategy.py`, `config.yaml`, and `run.py`; the child +configs have scenario parameters only and no account/front fields. The suite +root protected config matches the 013_3 private source at every YAML leaf +except the rebound 007 strategy identity, with all five MD/TD pairs in the +same order. Its Git-ignore rule and directory/config ACL match the private +source. The selector and invocation binder implement the same complete-pair +median-latency ranking. Root reran four focused offline entry/scope/probe test +files: 63 passed with one existing pytest-configuration warning. This audit +did not perform a new network, native, login, or trading operation. + +The combined MD/settlement SDK source candidate produced two byte-identical +Windows CPython 3.11 wheels under candidate-only MSVC `/Brepro` flags (SHA-256 +`5a68fd2b5968a889e64e5382b7ecd278d8a039cfe16b1cb85d21b170cd051378`). +Independent QA verified its source delta, 86 hashed wheel RECORD rows, an +isolated no-system-site install, installed RECORD/direct-url hashes, `pip +check`, pure imports, and 56 fake tests. The exact build invocation and epoch +log was not retained, and the wheel still contains write-capable CTP APIs. +The fail-closed settlement helper intentionally conflicts with ten old +readiness-promotion fake tests. There was no native client/lifecycle/provider +test, no pin/route change, and G4/G6 remain unaccepted; see +`D:/temp/ac41-g6s-independent-qa-20260928/INDEPENDENT-QA.md`. + +A separately logged, one-shot rebuild reproduced all 87 wheel-member payloads, +their compressed streams, the RECORD payload, and the native extension bytes. +Its whole-wheel SHA-256 differs from the frozen two-wheel pair because every +ZIP entry timestamp is eight hours earlier. The original pair's build epoch +was not retained, so the exact frozen wheel hash was not reproduced and the +G4/G6 gates remain closed. The command, environment, toolchain, and comparison +are in `D:/temp/ac41-g6s-single-rebuild-logged-20260928/REBUILD-RESULT.md`. + +An isolated Windows Job supervisor prototype also passed three inert tests, +including a child blocked in an infinite wait and a hanging child/grandchild +tree. It confirmed process termination after the local timeout, but the +supervisor's synchronous Job creation, process launch, termination, wait, and +close calls remain outside an enforceable whole-command hard deadline. Forced +termination does not prove native SDK `Release` or a clean provider session. +Thus strict G1 remains `NO_GO`, with no CLI route change; see +`D:/temp/ac41-007-job-supervisor-prototype-20260928/README.md`. + +An independently checked query-only SDK proof of concept was built as a +separate Windows CPython 3.11 wheel from the clean SDK source pin. Its generated +SWIG surface includes nine TD reads and MD login/market-data callbacks, while +order/cancel, settlement-confirm **write**, transfer, and password-change +requests are absent. Wheel RECORD, isolated import, installed payloads, and +three fake callback tests passed. The bundled vendor DLLs remain full runtimes; +this is a candidate API boundary, not process confinement, 007 integration, or +native/provider acceptance. See +`D:/temp/ac41-007-query-only-sdk-poc-independent-qa-20260928/QA-REPORT.md`. + +The revised isolated query-only wheel v0.0.4 was independently checked against +the clean source pin, generated Python/C++ API and callback allowlists, wheel +RECORD, isolated install/import and five synthetic callback tests. It adds MD +generic `OnRspError` handling and an unsubscribe-error test while retaining +only the nine TD read requests. The full vendor DLLs remain bundled, MD ACK +correlation is not provider-authenticated, and no native/provider session was +run. See +`D:/temp/ac41-007-query-only-sdk-poc-v004-independent-qa-20260928/QA-REPORT.md`. + +The isolated clean-SDK order/trade callback archive port passed 44 fake and +focused regression tests. Its six hooks include SPI origin checks, expose +archive poison, revoke execution proof on bad capture, and keep +cancel request ACK distinct from terminal order status. Records remain volatile, +not atomically linked to the existing queue/query history and without a shared +query/order sequence or durable journal. It is not 007 or G7-S acceptance; see +`D:/temp/ac41-007-native-callback-archive-port-candidate-20260928/CALLBACK-ARCHIVE-PORT-REPORT.md`. +Independent QA reproduced a stronger blocking defect: after reconnect on the +same API/SPI, an old `OnRspOrderInsert` callback was archived with the new +session/generation while the archive remained healthy. The candidate is +`NOT_ACCEPTED` pending an issued-request ledger and cross-generation fence; +see `D:/temp/ac41-007-native-callback-archive-independent-qa-g4audit-20260928-01/QA-REPORT.md`. + +The isolated order/trade callback archive v2 keeps lifetime request-ID +tombstones and poisons ambiguous reuse before native send. Independent QA +reran 88 fake/focused tests and reproduced the old unissued callback and two +old-issued-callback reconnect routes (same SPI and new SPI): all poisoned with +zero archived rows. This fixes those local regressions, but the provider does +not authenticate callback generation or quiescence in this evidence, so the +archive remains unregistered and not accepted. The candidate report's commit +SHA has a transcription error; QA verified the candidate and clean pin both +at `28157ce33009f932fbf8b3a78f7e77cb42c9cc4b`. See +`D:/temp/ac41-007-native-callback-archive-v2-independent-qa-g4audit-20260928-01/QA-REPORT.md`. + +An unregistered read-only baseline/final query-round coordinator passed 44 +focused fake tests and strict schema checks. It binds request IDs, closure, +ErrorID/IsLast, client/generation/session, and local arrival order, but the +frozen v1 archive exposes only row digests/counts. Raw reconciliation values +and C01 completion therefore remain false; see +`D:/temp/ac41-c01-readonly-query-rounds-20260928/CANDIDATE-REPORT.md`. + +Root reran the complete main-tree `tests/unit/live_certification` after the +source-plan and invocation-binding correction: 336 passed, with one existing +pytest-configuration warning. This is offline regression evidence only; no +managed case execution, SDK session, or provider session was accepted by that +test command. + +Independent audit of the unregistered invocation binder passed its nine +focused tests and five adversarial QA probes. It confirmed exact case-file, +sealed-config, and full candidate-ranking consistency, but a caller-created +active lease snapshot can be replayed and synthetic TCP successes can bind +without a socket. One internally contradictory success sample was also +accepted. This binding is non-authorizing; it needs a current lease-owner +verifier and source-backed probe receipt before a future runner can use those +claims. See +`D:/temp/ac41-007-managed-case-invocation-independent-qa-20260928/QA-REPORT.md`. + +The independently reviewed minimum correction was integrated into the main +tree. It rejects all caller-supplied active lease snapshots until a trusted +current owner verifier exists, and rejects contradictory connected/failure +and latency-component TCP samples. Coherent synthetic probe samples remain +unauthenticated. The focused main test passed 16 cases, Ruff passed, and the +complete live-certification suite passed 336 cases with one existing pytest +configuration warning. The review record is +`D:/temp/ac41-007-managed-case-invocation-review-20260928/independent-review.md`. + +An isolated query-only callback client prototype passed 13 fake tests, but +independent QA found that a late MD subscription ACK for an expired token could +complete a new same-instrument token because the native ACK has no caller +token. A separate v2 prototype fences that instrument for the rest of the +local generation (15 fake tests passed). A local generation change cannot +prove native session replacement or callback quiescence, so neither prototype +is registered or accepted; see +`D:/temp/ac41-007-query-only-client-port-qa-20260928/QA-REPORT.md` and +`D:/temp/ac41-007-query-only-client-port-v2-20260928/README.md`. +Independent v2 QA then modeled same-API reuse with a newly registered SPI: +old in-flight TD and MD callbacks delivered through that SPI were accepted +under the new local generation. The synthetic model is conditional on API +reuse; the candidate cannot exclude it without native replacement and +quiescence evidence. See +`D:/temp/ac41-007-query-only-client-port-v2-qa-20260928/QA-REPORT.md`. + +Independent QA of the SDK query archive v2 reproduced a mapped generic +`OnRspError(ErrorID=0, IsLast=True)` incorrectly satisfying local round +completion and found that the protected row fact can be serialized with +`pickle`/`dataclasses.asdict` despite a "no serialization" claim. It passed +13 existing fake tests but is held for correction; its in-process row values +are not a secrecy boundary. See +`D:/temp/ac41-g6-query-callback-archive-v2-independent-qa-20260928/QA-REPORT.md`. + +The corrected isolated query callback archive v3 passed 15 candidate fake +tests and four supplemental current/stale SPI probes under independent QA. +Mapped generic `OnRspError`, including `ErrorID=0`, remains a blocker rather +than closing a query round; `pickle` is rejected for protected row facts. +In-process introspection can still see typed values, and one candidate wiring +test uses a `native_api=None` shortcut, so this is no native/provider or C01 +acceptance. See +`D:/temp/ac41-g6-query-callback-archive-v3-independent-qa-20260928/QA-REPORT.md`. + +A proposed 16-file C01 native/local source-schema patch is also held after +independent QA. A duplicate native RequestID across auth/login with distinct +caller-supplied local generations reached `REVIEW_REQUIRED`; no outbound +issued-request ledger verifies the echo. M02/M03 still attached later-login +session/day identity to payload-less `OnFrontConnected`, and callback-time, +sequence, and lower-level tracker checks were inconsistent. No part of that +patch was integrated. See +`D:/temp/iteration41-c01-source-schema-qa-20260928.md`. + +The isolated C01 v2 patch corrected the duplicate native RequestID and +payload-less front callback cases in 105 focused tests, but independent QA +kept it on hold. A caller-injected verifier whose `verify()` always returned +true promoted the public strategy-level result to `REVIEW_REQUIRED`; the +default path and separate completion tracker remained incomplete. Two new +Ruff findings (`B023`, `SIM114`) were also confirmed in patch contents. No +part of v2 was integrated; see +`D:/temp/c01-v2-independent-qa-20260928/QA-REPORT.md`. + +Independent 33-case logic review found two additional review-state gaps: +EM01 did not require its submitted request and permission-denied rejection to +occur strictly between the external disable and restore events; O02 did not +require a close offset, opposite position side, or bounded closeable quantity. +These could admit internally consistent but wrong-scenario facts to +`REVIEW_REQUIRED`; neither can generate certification `PASS`. Isolated +fail-closed corrections are in progress, and no real source has been admitted. +The first integration patch is held after independent QA reproduced five +remaining false `REVIEW_REQUIRED` paths: a pre-disable EM01 scenario submit +event paired with an in-window receipt, a permission-control `ErrorID` +mismatch, an O02 trade with a different close offset, two dispatched O02 +submits plus one blocked retry, and an O02 request tagged to a prior provider +session. Scalar net position and per-instrument closeable quantity also lack +CTP side/today-yesterday bucket proof. See +`D:/temp/case33-em01-o02-independent-qa-20260928/QA-REPORT.md`. No part of +that patch was integrated. + +The later EM01/O02 v3 correction was independently reviewed and integrated. +It requires EM01 submit/rejection timing and account/error scope to match the +permission-control window. O02 now requires one dispatched and one blocked +repeat close, matching account/session/trading day and native order/trade +facts, plus typed long/short and today/yesterday closeable buckets; generic +`close` remains incomplete. The focused main test passed 53, Ruff passed, and +the complete live-certification suite passed **351** with one existing pytest +configuration warning. Typed account labels remain unauthenticated source +claims, and all certification/dispatch/authenticity flags remain false. See +`D:/temp/em01-o02-v3-independent-qa-20260928/QA-REPORT.md` and +`D:/temp/case33_em01_o02_v3_independent_qa_20260928/QA-REPORT.md`. + +The private CTP preparation CLI now supports the exact registered 007 runtime +ID while retaining 013_3 as its default. Public path and arbitrary strategy +overrides are closed, and existing private files are never overwritten. The +target-focused main test passed 58 with three platform skips, Ruff passed, +and the complete runtime suite passed **2,131** with 30 skips, two expected +failures, and one existing pytest configuration warning. No protected source, +provider SDK, network, or write route was used by these tests. + +The unregistered local TCP front-selection receipt was independently reviewed +and integrated after a frozen patch hash and apply check. It binds the exact +issued 007 case scope and freshly resealed suite configuration to a selected +configured pair index, ordered pair-set digest, and bounded TCP checker +counts; a caller-built clone and config or pair-order drift reject. It +contains no endpoint or secret values and grants no provider or write +authority. The 22-test main focus, project Ruff gate, and full +`tests/unit/live_certification` suite passed; the latter now has **357** +offline tests with one existing pytest configuration warning. The independent +report is at +`D:/temp/ac41-007-front-selection-receipt-v4-independent-qa-20260928-01/REPORT.md`. + +A separate source review mapped the future `PASS` boundary: a supervised +collector must retain raw native callback and query artifacts, an external +verifier must attest their hashes and source domains, the account owner must +attest the exact account/run scope, and a separate adjudicator must review the +verified projection and sign the outcome. The current strategy, case, and +completion checkers remain review-only and cannot self-attest source or +account ownership. This is an implementation plan, not an accepted route; +see `D:/temp/ac41-007-source-authenticity-adjudication-plan-20260928.md`. + +A further static review compared every new case strategy with its historical +flat script. It records all 33 legacy action/`PASS` gaps, the native and +account evidence each case needs, and external conditions that automation +cannot synthesize. The report is +`iteration41-007-simnow-33-case-legacy-vs-current-review-2026-09-28.md` +(SHA-256 `C958FC7015A9E10F45EE7234C4EDDC49BDAEE0A7401A29E36A668BF8BF27ADE2`). +The latest individual-process rerun again produced 33 `BLOCKED`/exit 2 +results with zero recorded network requests and trading writes. + +An isolated prestarted Windows guardian prototype passed three inert tests +and showed that a guardian-owned Job can outlive its caller and contain an +inert blocked worker. It did not hard-bound synchronous command, IPC, Job, +or disk operations and did not test a CTP native client. Forced termination +would skip native Release. Its verdict remains `G1 NO_GO`; no default route +or private preflight was changed. The frozen report and evidence are at +`D:/temp/ac41-007-guardian-service-boundary-prototype-20260928/README.md`. + +The managed-write parent SDK pin is also still `HOLD_PROVENANCE`. Its clean +parent commit points at a gateway Gitlink whose tree contains only README; +the old available gateway wheel lacks the parent-required constructor API +and failed four fake dispatch tests. A temporary gateway source snapshot and +temporary updated parent Gitlink produced reproducible wheels and a four-pass +isolated fake consumer with matching installed RECORD/PEP 610 hashes. The +gateway snapshot is not an upstream-retrievable reviewed source pin, so this +does not accept a managed SDK bundle or any CTP write. Independent QA verified +the final/old artifact separation at +`D:/temp/ac41-parent-sdk-pin-independent-qa-20260928-02/QA-INDEPENDENT.md`. + +## Source references + +- `examples/007_ctp/live_certification/simnow_penetration/README.md` — staging + boundary, private suite configuration description, route separation, and + historical-case status. +- `examples/007_ctp/live_certification/simnow_penetration/managed_case_entry.py` + — code-owned 33-scenario registry, config/path checks, unconditional redacted + `BLOCKED` result, and zero external request counters. +- `examples/007_ctp/live_certification/simnow_penetration/cases//` — each + staged case plan and entry wrapper. +- `examples/013_3_sa_midfreq_simnow/README.md` — private read-only TCP front + reachability boundary; TCP is not login or settlement evidence. +- `../ctp-current-acceptance-matrix.md` — current gate definitions and status. +- `iteration41-g1-whole-command-supervisor-no-go-2026-09-27/README.md`, + `iteration41-g1-current-boundary-decision-2026-09-28.md`, + `iteration41-g4-triplewheel-candidate-2026-09-28.md`, + `iteration41-g4-clean-parent-triplewheel-candidate-2026-09-28.md`, + `g4-msvc-pe-timestamp-author-review-2026-09-27/README.md`, + `iteration41-g5-v21-current-contract-independent-qa-2026-09-27/QA-INDEX.md`, + and `g6p-account-actor-resource-discovery-2026-09-27/README.md` — gate-specific + evidence records. + +## 2026-09-28 C01 r3、G1 与 G4 来源状态增补 + +### C01 r3 主树集成与 source-only QA + +C01 r3 的 17 个目标已主树集成。冻结候选完整套件为 **482 passed**;主树 `tests/unit/live_certification` 集成后实测 **488 passed、1 个既有 warning**。17 个目标 Ruff 通过,应用后内容与冻结候选在 CRLF 归一化后逐字一致,`git diff --check` clean。 + +独立 r3 source/schema QA 对精确 patch 给出 **`GO for source-only integration`**(报告 SHA-256 `5B2BB518996622ADA2539F53DAF593FB0DB38706A8875D6355A87C432C5ACF30`):隔离副本八模块焦点 **319 passed**,选定 C01/alias/EM01/O02 回归 **83 passed**。r3 alias/session 独立增量审查为 **197 passed**(报告 SHA-256 `CD5500243A16633AEF218418EE0BD4A57E08C34BCE89ED88B397C222560E8933`)。r3 patch SHA-256 为 `A89BAB7A8D8A80D54DD93299DA386DDB1E4BF4C0B31C384940FF9A77525BBA83`。这些结论只接受精确源码/测试补丁的离线集成,不表示 provider、callback 来源或案例认证通过。 + +C01 仍为 **`INCOMPLETE`**:可信 SDK issuer 私有 RequestID ledger verifier 与 baseline/final query receipt path 未接通,也缺 native callback 来源认证、受信账号 owner 和 provider 回执。逐目录重跑的 33 个 `run.py` 均为 `BLOCKED` / exit 2,原因 `managed_ctp_certification_not_registered`;network/order_write 为 0,真实 `PASS` 为 0。 + +### Issuer RequestID ledger 原型(fake-only) + +独立 issuer-side 原型按完整 C01 序列需要八个不同 RequestID 建模:auth、login,以及 baseline/final 各自的 orders、positions、funds 查询。16 项 fake-only 测试通过,验证本地分配/调用/回调关联、重连和终态约束(原型研究报告 SHA-256 `741ED98137A5963AC32A3DA870017EA8A712F94F6145A932A476A57CE8EF5328`)。它不调用真实 native API;fake 可直接调用 Python SPI,故不证明 callback 的 native/provider 来源,也未接入受信 SDK verifier。该结果与 C01 `INCOMPLETE` 一致。 + +### G1 strict whole-command deadline 的六项故障注入 + +2026-09-28 的只读审查对现有 outer watchdog 和 receipt writer 做六个 fake 同步延迟注入:backend creation、launcher resume、Job termination、handle release、control escrow、receipt `fsync`。本地测试 **6 passed**;注入调用均先超过配置 deadline 才返回。独立审计报告 SHA-256 `B8F08446FCE669DE907BB8F0F16B267C4553EC287BCA2C6F17DFA847F5EAB8B9`。结论为 **`G1_STRICT_WHOLE_COMMAND = NO_GO`**。测试没有调用真实 Win32 API 或 CTP,也没有制造真实磁盘/内核故障;普通 preflight 仍 fail-closed,未开放任何写或 provider 路由。 + +### G4 parent/gateway source provenance 与独立 QA + +G4 来源审计与独立复核均标记 **`HOLD_PROVENANCE`**。兼容 parent API 所需的 gateway 实现来自本地未跟踪源码快照;记录的 upstream gitlink 树只有 README。来源审计报告 SHA-256 `A5509C8A0434DD694B2C31D930CF342EEA69BE299218E1CF813F79F61907931A`;独立 QA 报告 SHA-256 `E6730BDD15FA066F8CDEE3142D6E20ECC39E40FD02583CA9DDA9F10542338159`。本轮来源独立 QA 核对候选提交、源码清单、Git archive、两次 wheel 构建以及 69 项 gateway fake 和 4 项 parent binding fake 测试记录;此前的制品独立 QA 核对了 installed RECORD 与 PEP 610 来源;这些本地检查彼此一致,但不能认证 upstream 源代码或发布者。它不构成 CTP native、provider、G4 生命周期或写入验收。 + +`NO_WRITE / LIVE_NO_GO` 保持有效。 + +## 2026-09-28 further isolated, non-authorizing slices + +The C01 issuer-ledger/schema projection research candidate is held outside +the repository at D:/temp/c01-r3-issued-ledger-candidate-20260928/REPORT.md +(SHA-256 EDE03BE15947BB1284F11CC15FE6840E36CB91B946C8EEEDD0C8816FB734C2B1). +Its 26 fake-only tests exercise eight native RequestID values and partial +query rows. A fake client can still invoke the Python callback directly; the +C01 tracker and completion report remain INCOMPLETE, with certification PASS, +dispatch, and source authenticity all false. Disposition: HOLD_LOCAL_RESEARCH. + +The MD subscription local-epoch candidate is also outside the repository at +D:/temp/iteration41-ctp-md-subscription-ack-local-correlation-candidate-20260928/REPORT.md +(corrected report SHA-256 D8D39312182C78D06ADE5CE10CB3BEDFC8DA3A7BAB2490BAB7016D6145B90814; +the earlier EA34EFB5... report is superseded). Twelve fake-only tests and +SDK-configured Ruff passed. Native SubscribeMarketData has no caller token, +and ACK/tick callbacks have no callback-bound connection generation. The +single-API local epoch cannot become a native/provider subscription proof. +Disposition: PASS_LOCAL_BEHAVIOR / HOLD_NATIVE_CORRELATION; G6-S remains closed. + +The 33-case certification-profile dependency audit is at +D:/temp/ac41-007-certification-profile-gap-20260928/REPORT.md +(SHA-256 B496113399DB9A0084A8118F2A42E0937A62668E10E555C1E8A4FFC8E7A3FE27). +It groups session-only, single-action, repeated/threshold, remote-error/ +permission, and batch cases. Existing TestExecutionProfile observations hard +code zero execution/provider/write authority; the one-lot operational window +cannot cover B01/B02. Batch figures in the audit are design examples, not +approved risk limits. Trusted account ownership/fencing, atomic account-wide +budget, one-use action approval, supervised TTL, cleanup and reconciliation +remain required before a 33-case runner can be registered. + +These held candidates did not change the main runtime, default registration, +protected config, native SDK, provider state, or write permissions. The latest +individual 33-entry check is still 33 BLOCKED / zero real PASS. + +G1 的预启动服务请求合约设计审查保存在 +`D:/temp/ac41-g1-prestarted-service-contract-review-20260928/DESIGN.md` +(SHA-256 `51A4CBDBA3691E8CA1C7FAF537F5D4BD5CD84E4C2D9461F38F8B4CF706491961`)。 +设计将服务端单调 `T0/D`、票据消费和 `ACCEPTED` 原子持久化置于 worker Resume 前, +并明确晚到或不明的同步调用只能得到 `UNKNOWN`。它只提出与原整条 CLI 命令截止 +不同的服务请求级验收目标,未运行新测试、未打开普通 preflight,原 G1 仍为 +`NO_GO`。是否采用新目标待用户决定。 + +同日 G4 只读远端来源复查见 +`D:/temp/ac41-g4-parent-source-provenance-audit-20260928/REPORT.md` +(SHA-256 `D5AB993346BC71254EE8AA17EAD6D03987AEEC43C8CAB458B2B38D6BA6CCF5D5`)。 +公开 gateway `dev/master` 指向仅含 README 的 `44fd2fe…`;本地所需实现 +`7f54c21…` 并非公开发布。父包 `76d5e0e…` 仅在本地分支,当前公开父包 refs +也没有与其完整候选源对应的 release。故 `HOLD_PROVENANCE` 仍不解除:需要 +上游发布包含 gateway 实现的可重取提交,以及精确绑定该 gitlink 和完整 API +源的父包提交,之后从 clean clone 重建并独立审查三包 pin。 + +SDK 私有 issued-request ledger r2 仍在隔离目录:补丁 SHA-256 +`03B233DDBF2B5CACCB6A26AB5F08003063840FF13B31E1A4850DFEF3D66F727A`, +独立 QA 报告 +`D:/temp/c01-sdk-issued-ledger-r2-independent-qa-20260928/review.md` +的 SHA-256 为 `6135E02578E5CD0DCB78B4803C291427C8B5B9AF0982A0307366BD958F06CAF1`。 +22 项 fake/旧回调焦点通过,Ruff 与 Python 3.9 grammar 通过。独立 fake 可确定性复现 +账本登记后、native 调用前替换 API 时,旧 API 仍收到 Authenticate、Login 或只读查询; +最终 query/ledger 保守判为不完整,没有测试出假终态,但无法撤销已发出的旧 API 调用。 +单独再检查一次绑定不能原子封闭该窗口。结论 `HOLD / NO_MERGE / NO_AUTHORITY`; +C01 仍 `INCOMPLETE`,默认 SDK 与主树路由未采用该候选。 + +r3 未产生 production patch。隔离设计报告 +`D:/temp/c01-sdk-issued-ledger-r3-hold-design-20260928/REPORT-r3-HOLD_DESIGN.md` +(SHA-256 `5C382D2D795DCE171B8D1BD9A0B60BB0D641AF23011C4F835B996140CC060732`) +用四项 fake probe 进一步复现 API 替换与 reconnect 的 issue-to-call 窗口。 +跨 native `Req*` 持锁或等待 active-dispatch lease 均需证明不会与 inline/异线程 +callback、reconnect、stop/Release 自等待;缺少精确 pinned native 阻塞语义时 +结论为 `HOLD_DESIGN / NO_PATCH / NO_MERGE`。真实会话和 33 案例状态不变。 + +## Additional credential-free TCP snapshot (2026-09-28 12:05 UTC) + +From the local D: working tree, root ran `python -m backtrader_runtime.cli +check-ctp-fronts --strategy-dir` once for each protected 013_3 and 007 +runtime. Both commands exited 0. For both configs, candidate indexes 0–3 each +returned MD 3/3 and TD 3/3 TCP connections; index 4 returned MD 0/3 and TD +0/3. The 013_3 command selected index 0 and the 007 command selected index 2. +The selector uses the lower configured-pair latency score among eligible pairs +(with config index as a tie breaker), so the selected index is an observation, +not a permanent preferred front. Both receipts reported +`tcp_probe_only=true`, `authentication_attempted=false`, +`credential_resolver_invoked=false`, `sdk_imported=false`, +`provider_login_started=false`, and zero settlement/trading writes. The +private addresses and credentials are omitted. This later transport snapshot +does not supersede the zero real-PASS case result or certify MD/TD login, +subscription, orders, or cancels. diff --git a/examples/007_ctp/live_certification/hongyuan_penetration/run_case.py b/examples/007_ctp/live_certification/hongyuan_penetration/run_case.py index 3a240052..17fe0967 100644 --- a/examples/007_ctp/live_certification/hongyuan_penetration/run_case.py +++ b/examples/007_ctp/live_certification/hongyuan_penetration/run_case.py @@ -17,6 +17,33 @@ """ from __future__ import annotations +# This fence must remain before every legacy framework, CTP, or provider import. +import sys as _iteration41_sys +from pathlib import Path as _Iteration41Path + +_ITERATION41_RUNTIME_DIR = _Iteration41Path(__file__).resolve().parents[2] / "runtime" +_ITERATION41_REPOSITORY_ROOT = _ITERATION41_RUNTIME_DIR.parents[2] +if str(_ITERATION41_REPOSITORY_ROOT) not in _iteration41_sys.path: + _iteration41_sys.path.insert(0, str(_ITERATION41_REPOSITORY_ROOT)) + +from backtrader_runtime.legacy import ( # noqa: E402 + legacy_direct_execution_error as _iteration41_legacy_direct_execution_error, + run_legacy_config_first_cli as _iteration41_run_legacy_config_first_cli, +) + + +def _run_config_first_cli(argv=None) -> int: + return _iteration41_run_legacy_config_first_cli(_ITERATION41_RUNTIME_DIR, argv) + + +def main(*args, **kwargs): + del args, kwargs + raise _iteration41_legacy_direct_execution_error("examples/007_ctp/live_certification/hongyuan_penetration/run_case.py") + + +if __name__ == "__main__": + raise SystemExit(_run_config_first_cli()) + import argparse import json import os @@ -76,6 +103,10 @@ def _discover_cases(): def run_case(case_id: str, report_root: Path, timeout: int = DEFAULT_TIMEOUT) -> dict: + raise _iteration41_legacy_direct_execution_error( + "examples/007_ctp/live_certification/hongyuan_penetration/run_case.py" + ) + """Run a single case in an isolated subprocess, return result dict.""" case_file = CASE_REGISTRY.get(case_id) if case_file is None: @@ -203,8 +234,12 @@ def print_summary(results: list[dict], report_root: Path): # --------------------------------------------------------------------------- -def main(): +def _legacy_main(): """Main entry point for running Hongyuan certification cases.""" + raise _iteration41_legacy_direct_execution_error( + "examples/007_ctp/live_certification/hongyuan_penetration/run_case.py" + ) + parser = argparse.ArgumentParser( description="Run Hongyuan penetration certification cases", ) @@ -261,5 +296,7 @@ def main(): print_summary(results, report_root) -if __name__ == "__main__": +# Iteration 41 retains this historical body for review only; direct execution is disabled. +if False: # pragma: no cover - retired direct entrypoint + main() diff --git a/examples/007_ctp/live_certification/simnow_penetration/README.md b/examples/007_ctp/live_certification/simnow_penetration/README.md index 02782f7f..0b189cc5 100644 --- a/examples/007_ctp/live_certification/simnow_penetration/README.md +++ b/examples/007_ctp/live_certification/simnow_penetration/README.md @@ -1,60 +1,123 @@ -# SimNow 穿透式认证场景集 +# SimNow 穿透式认证场景集(历史 source,当前禁用) -基于 SimNow 7x24 看穿式前置环境,覆盖监管测试报告中的 **33 个穿透式认证测试点**。 +这 33 个场景仍保留为历史认证设计和后续 managed migration 的测试素材,但当前代码包含 +旧的 direct CTP/SimNow provider、报单和撤单路径。它们不属于 Iteration 41 的受管执行 +入口,不能用于账户、沙盒或生产验收。 -## 前置条件 +所有 `run_case.py`、`run_all.py` 和 `cases/*.py` 直启路径现在都会在 Backtrader、CTP +或 provider 导入前 fail-closed。旧参数(包括 `--list`、场景 ID、`--all`、 +`--report-root`)也不能恢复 direct child process。真实认证操作是 `NOT_SUPPORTED`,直到 +独立的 managed TestExecutionProfile、账户预算/TTL、清理对账和 provider admission 都 +完成审查。 -1. SimNow CTP 账户凭据,通过环境变量或 `.env` 设置: - - `SIMNOW_USER_ID` / `simnow_user_id` - - `SIMNOW_PASSWORD` / `simnow_password` -1. `bt_api_py` 已安装或在 `PYTHONPATH` 中 -2. `backtrader` 项目根目录在 `PYTHONPATH` 中(脚本会自动添加) - -## 可选环境变量 - -| 变量 | 默认值 | 说明 | - -|------|--------|------| - -| `SIMNOW_ENV` | `new_7x24` | SimNow 环境键名 | - -| `SIMNOW_ORDER_SYMBOL` | `rb2610` | 委托测试合约 | - -| `SIMNOW_TICK_SYMBOL` | `rb2610` | 行情测试合约 | - -## 运行方式 - -```bash - -# 从项目根目录运行 - -# 列出所有场景 - -python examples/007_ctp/live_certification/simnow_penetration/run_case.py --list - -# 运行单个场景 - -python examples/007_ctp/live_certification/simnow_penetration/run_case.py C01 - -# 运行多个场景 - -python examples/007_ctp/live_certification/simnow_penetration/run_case.py C01 T01 T02 T03 - -# 运行全部 33 个场景 - -python examples/007_ctp/live_certification/simnow_penetration/run_case.py --all - -# 或使用快捷脚本 - -python examples/007_ctp/live_certification/simnow_penetration/run_all.py - -# 指定报告目录 - -python examples/007_ctp/live_certification/simnow_penetration/run_case.py --all --report-root ./my_reports +007 原有的本地零 I/O replay migration report 仍保留: ```bash - -## 目录结构 +bt-runtime bootstrap --strategy-dir examples/007_ctp/runtime +bt-runtime run --strategy-dir examples/007_ctp/runtime +``` + +`runtime/config.yaml` 必须存在且由 Git 忽略。缺少时返回 `CONFIG_REQUIRED`;已有文件 +不会被 bootstrap 覆盖(`CONFIG_EXISTS`)。输出 `LOCAL_REPLAY_ONLY` 仅证明 config gate +与 no-action probe;它不是 CTP 连通、SimNow/宏源账户、报撤单、成交、PnL 或实盘准入证据。 +目前 inventory 共 17 个注册项,其中包括 007 suite 根的 `simulation/sandbox` 零写只读 +runtime/front-check 注册。受保护配置和目录 ACL 已与 013_3 对齐。离线 `doctor` 成功 +(exit 0,`provider_preflight_started=false`,识别到 5 组 front pairs, +`preflight_available=false`)。普通 `preflight` 仍 fail-closed,等待有界 Windows Job +supervisor 的子进程终止与清理行为可验证,并通过独立验收。用户已取消“整条 CLI 命令 +必须在 0.8 秒内结束”的硬时限要求;历史 `G1_STRICT_WHOLE_COMMAND = NO_GO` 仅保留为 +旧时限方案的审计证据,不代表当前时限要求,也不构成 G1 通过。当前 Gateway 源码与 +parent SDK pins 已有提交候选,仍待独立来源与兼容性审查;候选提交不证明真实 provider +已通过。2026-09-28 root 使用显式 +`check-ctp-fronts` 做了一次无凭据 TCP 检查:索引 3 的 MD/TD 各 3/3 可达并被选择, +其余索引 0/1/2/4 均为 0/3。它只是本机当时的 TCP 可达性观察,不证明账号登录、行情 +订阅、报单、撤单或后续持续可达。 +选择只比较配置内完整的 MD/TD 配对:每端三次 TCP 采样至少成功两次,再用两端成功 +样本延迟中位数的较大值评分,选分数最低的一对;同分按配置顺序。单个地址可达不足以 +入选,也不会按时钟、set 名称或配置外地址切换。 +对 suite 根执行 `bt-runtime run --strategy-dir ` 仍以 +`profile_dispatch_unavailable`/exit 2 拒绝,且 `provider_preflight_started=false`;该 +注册没有 runner。上面的本地零 I/O replay 命令使用另一个既有 007 runtime 目录。 + +## Managed 真实认证迁移计划(尚未接入) + +已为 33 个真实 SimNow 认证案例建立独立目录和显式入口。当前 `_strategy.py` 保存待接入的真实动作与证据计划,以及未注册的只读 typed 观察逻辑;尚未实现 provider 执行: + +```text +simnow_penetration/ +├── config.yaml # suite 共享的本地受保护配置,Git 忽略 +└── cases/ + ├── C01/ + │ ├── config.yaml # 仅案例参数;不放账号或认证字段 + │ ├── C01_strategy.py + │ └── run.py + ├── T01/ + │ ├── config.yaml + │ ├── T01_strategy.py + │ └── run.py + └── ... # 共 33 个案例目录 +``` + +suite 根 `config.yaml` 已从 013_3 的受保护配置准备,并只将 `strategy.id` 重绑为 +`example.007_ctp.simnow_penetration`。它是 schema-v4 `simulation/sandbox` 配置,包含 +五组显式 MD/TD 前置候选;文件仍受本机 ACL 保护并由 Git 忽略。007 已有对应的 sandbox +零写只读 runtime/front-check 注册,但没有认证 case runner、交易 runner 或写权限。 +新部署若需生成这个受保护文件,可显式使用 +`bt-runtime prepare-ctp-config --runtime-id example.007_ctp.simnow_penetration.ctp_private --source-env `。 +生成工具只接受代码登记的 007/013_3 目标,默认仍是 013_3;已有文件不会被覆盖, +生成配置不授予登录或交易权限。 +每个案例目录里的 `config.yaml` 只保存该案例所需的非秘密参数,33 个案例共用 suite +根受保护配置,不复制凭据。 + +未注册的 `managed_case_scope.py` 可将案例配置、静态策略计划、`run.py` 的摘要与 +受封装的 suite runtime 配置摘要、33 项代码自有场景身份绑定,供未来真实回调证据核对。 +这个绑定不导入策略、不启动 SDK, +也不授权行情或报撤单。历史计划曾把其中 5 项标为可选;本次目标要求 33 项全部取得 +真实通过证据,不能跳过这些项目。 +`common/case_engine.py` 对 33 项计划做静态检查,并为回调、监控和外部控制证据定义 +不同的来源要求;即使资料齐备也只返回 `REVIEW_REQUIRED`,不会产生真实认证 `PASS`。 +未注册的 `common/decision_engine.py` 为 33 项分别定义 typed 动作候选及真实观察前置条件; +`managed_case_scope.decision_scope_from_case_scope()` 将已封装的 suite 与案例摘要绑定到该 +决策契约。没有受信来源验证器时一律 `BLOCKED`,即使离线合约资料齐备也不提供派发权限或 +认证 `PASS`。 +未注册的 `managed_case_invocation.py` 进一步核对单次案例的配置、策略和入口文件摘要, +并把受封装的 suite 配置、完整 MD/TD 候选集合与 TCP 选择证据绑定到同一调用身份。 +调用方构造的活跃账户租约快照会被拒绝,直到受信租约所有者验证器接入;TCP 样本的 +内部一致性会检查,但不能证明其真实采集来源,也不授权 SDK 会话、行情或报撤单。 + +未注册的 `managed_case_front_selection.py` 将当前受封装配置与一次有界、无凭据的 +TCP 前置节点检查结果绑定成只含摘要、索引和计数的本地回执。其独立复核与主树回归通过; +它不验证 provider 登录、行情、账户或交易,也未接入案例入口。 + +007 的零写 sandbox/front-check 路由已在代码中接入;2026-09-28 的一次显式检查选择了 +索引 3,但该结果仅说明当时本机到该 MD/TD pair 的 TCP 连接可达。它不证明登录、行情 +订阅、结算、报单、撤单或 007 案例准入。普通 `preflight` 仍 fail-closed,等待有界 +Windows Job supervisor 和独立验收。`managed_case_entry` +对 33 个认证案例仍返回 `BLOCKED`,因为认证 case runner 尚未注册;这是路由不可用结果, +不能计为真实 SimNow `PASS`。 + +最新逐目录子进程复跑的 33 个 `run.py` 均以 exit 2 返回 `BLOCKED`,真实案例 `PASS=0`, +真实报单与撤单写入均为 0。六个相关 runtime 文件的较早 fake/offline 集成焦点为 +176 passed、13 skipped;较早一次 `tests/unit/live_certification` 复跑为 357 passed, +完整 `tests/unit/runtime` 为 2,131 passed、30 skipped、2 xfailed,均有 1 个现存 pytest +配置 warning。离线测试不建立真实 provider 或交易验收。 + +旧版结果构造器现在会把没有受信执行后证据适配器的 `PASS` 请求降为 `FAIL`; +序列化、计算退出码或保存结果时也会拒绝手工构造或篡改的 `PASS`。历史 JSONL 只作诊断记录,不能凭 +调用方字段或伪造回调升级认证状态。33 个新入口仍全部返回 `BLOCKED`。 +未注册的 `common/completion_invariants.py` 为 33 项提供纯数据的请求、委托、成交和 +最终账户快照闭环检查;资料完整时最多返回 `REVIEW_REQUIRED`,且不能验证进程内资料 +的真实来源,不会派发交易或生成认证 `PASS`。其中 33 例统一负测只证明缺少场景证据时 +不会升入复核,尚未覆盖 33 类全部正反边界。 + +当前平铺的 `cases/*.py` 是历史来源和已封闭路径,只供阅读旧案例设计。它们经过 +fail-closed 入口,不能驱动上述新案例,也不能作为真实认证结果。新案例的 `run.py` 和 +策略须使用受管运行时提供的真实 provider 数据与回调证据;本地 fixtures、fake、合成 +行情或 `live_seed_bar` 回退不能产生真实案例 `PASS`。在 provider admission、隔离监督、 +预算与 TTL、终态清理和对账等条件完成独立审查前,真实认证仍为 `NOT_SUPPORTED`,写入 +仍为 `NO_WRITE / LIVE_NO_GO`。 + +## 已封闭的历史目录结构 ```bash simnow_penetration/ @@ -118,7 +181,7 @@ simnow_penetration/ ├── error.log └── order.log -```bash +``` ## 33 场景清单 @@ -260,12 +323,16 @@ simnow_penetration/ |------|--------|------| -| `PASS` | 0 | 场景验证通过 | +| `PASS` | 0 | 旧结果模型中的场景状态;本身不是 SimNow 或实盘准入证据 | | `FAIL` | 1 | 场景验证失败(代码或逻辑错误) | | `BLOCKED` | 2 | 外部条件不满足(SimNow 不稳定、市场关闭等) | +此表描述历史案例结果格式。对于新迁移入口,`managed_case_entry` 的 `BLOCKED` 不能被 +退出码、旧日志或人工补填改写成 `PASS`;只有未来经审查的受管真实运行及其权威证据, +才可能支持真实案例结论。 + ## 已知高风险场景 以下场景在 SimNow 7x24 环境下可能无法稳定复现,会输出 `BLOCKED` 并记录证据: @@ -274,3 +341,13 @@ simnow_penetration/ - **E01 / E02 / E03**:远端错误码 - **EM03**:强制账号退出 - **B01**:部分成交后批量撤单 + +## 当前审查增补(2026-09-28) + +C01 r3 的 17 个目标已主树集成。冻结候选完整套件为 482 passed;主树 `tests/unit/live_certification` 回归为 **488 passed、1 个既有 warning**。17 个目标 Ruff 通过,集成内容与冻结候选在 CRLF 归一化后逐字一致,`git diff --check` clean。独立 schema QA 对精确 r3 patch 的结论为 `GO for source-only integration`;独立 alias/session 复核为 197 passed。这些结果只覆盖离线源码合同。C01 仍为 `INCOMPLETE`:可信 SDK issuer ledger verifier 和 baseline/final query receipt path 尚未接通,也没有可认证的 native callback 来源、受信账号 owner 或 provider 证据。 + +逐目录检查的 33 个 `run.py` 均为 `BLOCKED` / exit 2,原因是 `managed_ctp_certification_not_registered`;network/order_write 均为 0,真实 `PASS` 为 0。不能将这些入口或离线用例描述为真实 SimNow 通过。 + +隔离 ledger 原型按完整 C01 序列建模八个 RequestID(auth、login、baseline 与 final 的 orders/positions/funds 查询),16 项 fake-only 测试通过。它只证明本地请求/回调关联合同,不能证明真实 native/provider callback,也没有接入当前 runner。 + +历史 G1 六项故障注入审计结论为 `G1_STRICT_WHOLE_COMMAND = NO_GO`:backend 创建、launcher resume、Job termination、句柄释放、control escrow 和回执写入中的同步调用都可能超过当时配置期限后才返回。用户已取消整条 CLI 命令 0.8 秒硬时限;该 NO_GO 仅保留为旧要求的历史审计证据,不代表当前时限要求或 G1 通过。普通 `preflight` 仍关闭,直至 Windows Job 子进程终止与清理可验证并完成独立验收。此前 G4 parent/gateway 来源审计的 `HOLD_PROVENANCE` 仍是历史证据;当前 Gateway 源码与 parent SDK pins 已有提交候选,但尚未完成独立来源/兼容性及真实 native/provider 验收,不能据此称真实 provider 已通过。以上均未启用 preflight、真实 provider 或任何写路由。详细记录见[验收证据增补](../../../../docs/_internal/opts/requirements/迭代41-实盘执行风控监控与示例架构重构/evidence/iteration41-007-simnow-33-case-staging-acceptance-2026-09-28.md)。 diff --git a/examples/007_ctp/live_certification/simnow_penetration/_typed_scenario_state_candidate.py b/examples/007_ctp/live_certification/simnow_penetration/_typed_scenario_state_candidate.py new file mode 100644 index 00000000..6cd744b4 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/_typed_scenario_state_candidate.py @@ -0,0 +1,1121 @@ +"""Unregistered, review-only typed state checks for selected 007 cases. + +This candidate consumes already normalized observations and managed-runtime +receipts. It performs no provider, SDK, network, config, Store, or order work. +It requires an injected authenticator and always returns a non-authorizing +snapshot; fake authenticators used by tests do not establish source authority. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from typing import Any + +_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") +_MAX_PROVIDER_EVENT_AGE = timedelta(minutes=5) +_POLICY = { + "auth_success": ("ctp_provider_callback", "OnRspAuthenticate"), + "login_success": ("ctp_provider_callback", "OnRspUserLogin"), + "front_connected": ("ctp_provider_callback", "OnFrontConnected"), + "market_subscription_ack": ("ctp_provider_callback", "OnRspSubMarketData"), + "market_tick": ("ctp_provider_callback", "OnRtnDepthMarketData"), + "position_query": ("ctp_provider_callback", "OnRspQryInvestorPosition"), + "order_accepted": ("ctp_provider_callback", "OnRtnOrder"), + "order_canceled": ("ctp_provider_callback", "OnRtnOrder"), + "order_query": ("ctp_provider_callback", "OnRspQryOrder"), + "order_admission": ("managed_runtime_receipt", ""), + "order_submit_receipt": ("managed_runtime_receipt", ""), + "order_cancel_receipt": ("managed_runtime_receipt", ""), + "repeat_guard": ("runtime_monitor_receipt", ""), + "monitor_configuration": ("runtime_monitor_receipt", ""), + "monitor_trigger": ("runtime_monitor_receipt", ""), +} +_REQUIRED_FIELDS = { + "order_accepted": ( + "order_ref", + "external_order_id", + "instrument_id", + "status", + "remaining_quantity", + ), + "order_canceled": ( + "order_ref", + "external_order_id", + "instrument_id", + "status", + "remaining_quantity", + ), +} + + +@dataclass(frozen=True) +class ManagedIntentReceipt: + """Typed receipt shape required from a managed runtime producer.""" + + request_id: str + intent_id: str + action: str + dispatch_state: str + order_ref: str + instrument_id: str + repeat_key: str + account_identity_sha256: str + configuration_digest: str + threshold: int + window_seconds: float + occurred_at_utc: str + sequence: int + evidence_sha256: str + source: str = "managed_runtime_receipt" + + +@dataclass(frozen=True) +class ScenarioStateSnapshot: + case_id: str + status: str + certification_pass: bool + dispatch_permitted: bool + source_authenticity_verified: bool + missing_conditions: tuple[str, ...] + rejected_evidence: tuple[str, ...] + + +_PROFILES: dict[str, dict[str, Any]] = { + "O01": { + "action": "open", + "receipt_actions": {"open"}, + "repeat_metric": "repeat_order_count", + "repeat": True, + }, + "O02": { + "action": "close", + "receipt_actions": {"close"}, + "repeat_metric": "repeat_order_count", + "repeat": True, + }, + "O03": { + "action": "cancel", + "receipt_actions": {"cancel"}, + "repeat_metric": "repeat_cancel_count", + "repeat": True, + }, + "TH02": { + "action": "open", + "receipt_actions": {"open"}, + "threshold_metric": "submitted_order_count", + "threshold": 2, + "count_actions": {"open"}, + }, + "TH04": { + "action": "cancel", + "receipt_actions": {"open", "cancel"}, + "threshold_metric": "combined_order_cancel_count", + "threshold": 3, + "count_actions": {"open", "cancel"}, + }, + "TH06": { + "action": "open", + "receipt_actions": {"open"}, + "threshold_metric": "repeat_order_count", + "threshold": 2, + "repeat": True, + "count_actions": {"open"}, + }, +} + + +def _value(item: Any) -> str: + value = getattr(item, "value", item) + return str(value) + + +def _utc(value: Any) -> datetime | None: + if not isinstance(value, str): + return None + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if result.tzinfo is None or result.utcoffset() != timedelta(0): + return None + return result.astimezone(timezone.utc) + + +def _sha256(value: Any) -> bool: + return isinstance(value, str) and _SHA256.fullmatch(value) is not None + + +def _nonempty(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +class TypedScenarioStateCandidate: + """Case-specific checks layered over the unregistered typed decision API.""" + + CASE_ID = "" + + def __init__(self, decision_engine: Any, authenticator: Any): + if self.CASE_ID not in _PROFILES: + raise ValueError("unsupported read-only scenario case") + if getattr(getattr(decision_engine, "plan", None), "case_id", None) != self.CASE_ID: + raise ValueError("decision engine plan does not match this case") + if getattr(getattr(decision_engine, "scope", None), "case_id", None) != self.CASE_ID: + raise ValueError("decision scope does not match this case") + self.engine = decision_engine + self.authenticator = authenticator + self.profile = _PROFILES[self.CASE_ID] + self._events: list[Any] = [] + self._receipts: list[ManagedIntentReceipt] = [] + self._event_ids: set[str] = set() + self._sequences: dict[tuple[str, str, str, str], int] = {} + self._receipt_ids: set[str] = set() + self._receipt_sequence = 0 + self._rejected: list[str] = [] + + def record(self, event: Any) -> bool: + return self._with_rejection_latch(self._record_unlatched, event) + + def _record_unlatched(self, event: Any) -> bool: + """Record a required event through the decision engine's verifier.""" + kind = _value(getattr(event, "kind", "")) + if kind not in _POLICY or kind == "monitor_configuration": + raise ValueError("event kind is not a native decision-engine observation") + self._check_identity_and_sequence(event) + if _value(getattr(event, "source_domain", "")) == "ctp_provider_callback": + account_problem = self._provider_account_identity_problem(event) + if account_problem: + self._rejected.append(f"{event.event_id}:{account_problem}") + return False + if kind in {"order_accepted", "order_canceled"}: + problems = _native_order_mismatches(kind, getattr(event, "fields", {})) + if problems: + self._rejected.extend( + f"{getattr(event, 'event_id', '')}:{problem}" for problem in problems + ) + return False + accepted = self.engine.record(event) + if accepted: + self._remember_event(event) + else: + self._rejected.append(f"{getattr(event, 'event_id', '')}:decision_engine_denied") + return accepted + + def record_monitor_configuration(self, event: Any) -> bool: + return self._with_rejection_latch(self._record_monitor_configuration_unlatched, event) + + def _record_monitor_configuration_unlatched(self, event: Any) -> bool: + """Authenticate the case-local configuration event not admitted by the shared spec.""" + kind = _value(getattr(event, "kind", "")) + if kind != "monitor_configuration": + raise ValueError("only monitor_configuration is auxiliary evidence") + self._check_identity_and_sequence(event) + domain, callback = _POLICY[kind] + if _value(getattr(event, "source_domain", "")) != domain: + raise ValueError("monitor configuration must have monitor source domain") + if getattr(event, "callback_name", None) != callback: + raise ValueError("monitor configuration must not claim a provider callback") + fields = getattr(event, "fields", {}) + if not isinstance(fields, Mapping): + raise TypeError("monitor configuration fields must be a mapping") + if ( + not _nonempty(fields.get("metric")) + or not _is_positive_int(fields.get("threshold")) + or not _sha256(fields.get("configuration_digest")) + or not _sha256(fields.get("monitor_digest")) + ): + raise ValueError("monitor configuration is missing typed threshold/digest fields") + if self.profile.get("repeat") and not _is_positive_number(fields.get("window_seconds")): + raise ValueError("repeat configuration requires a positive observation window") + if event.kind in self.engine.spec.required_kinds: + if not self.engine.record(event): + self._rejected.append(f"{event.event_id}:decision_engine_denied") + return False + elif not self._authenticate_event(event): + self._rejected.append(f"{event.event_id}:monitor_configuration_auth_denied") + return False + self._remember_event(event) + return True + + def record_supporting_observation(self, event: Any) -> bool: + return self._with_rejection_latch(self._record_supporting_observation_unlatched, event) + + def _record_supporting_observation_unlatched(self, event: Any) -> bool: + """Record an authenticated native order fact required by this candidate.""" + kind = _value(getattr(event, "kind", "")) + if self.CASE_ID not in {"O01", "O02", "TH04", "TH06"} or kind != "order_accepted": + raise ValueError("case does not permit this auxiliary native observation") + self._check_identity_and_sequence(event) + domain, callback = _POLICY[kind] + if _value(getattr(event, "source_domain", "")) != domain: + raise ValueError("supporting order fact must have provider callback source") + if getattr(event, "callback_name", None) != callback: + raise ValueError("supporting order fact has incorrect callback name") + fields = getattr(event, "fields", {}) + if not isinstance(fields, Mapping) or any( + not _nonempty(fields.get(name)) for name in _REQUIRED_FIELDS[kind] + ): + raise ValueError("supporting native order fact is missing required fields") + generation = fields.get("connection_generation") + if not _is_positive_int(generation): + self._rejected.append(f"{event.event_id}:provider_connection_generation_required") + return False + previous_generations = { + row.fields.get("connection_generation") + for row in self._events + if _value(getattr(row, "source_domain", "")) == "ctp_provider_callback" + and isinstance(getattr(row, "fields", None), Mapping) + and _is_positive_int(row.fields.get("connection_generation")) + } + if len(previous_generations) != 1 or generation not in previous_generations: + self._rejected.append( + f"{event.event_id}:provider_evidence_must_share_one_connection_generation" + ) + return False + account_problem = self._provider_account_identity_problem(event) + if account_problem: + self._rejected.append(f"{event.event_id}:{account_problem}") + return False + problems = _native_order_mismatches(kind, fields) + if problems: + self._rejected.extend(f"{event.event_id}:{problem}" for problem in problems) + return False + if event.kind in self.engine.spec.required_kinds: + if not self.engine.record(event): + self._rejected.append(f"{event.event_id}:decision_engine_denied") + return False + elif not self._authenticate_event(event): + self._rejected.append(f"{event.event_id}:supporting_native_auth_denied") + return False + self._remember_event(event) + return True + + def record_managed_receipt(self, receipt: ManagedIntentReceipt) -> bool: + return self._with_rejection_latch(self._record_managed_receipt_unlatched, receipt) + + def _record_managed_receipt_unlatched(self, receipt: ManagedIntentReceipt) -> bool: + """Accept only an authenticated managed-runtime request receipt.""" + if not isinstance(receipt, ManagedIntentReceipt): + raise TypeError("managed request receipt must use ManagedIntentReceipt") + if not _sha256(receipt.account_identity_sha256): + self._rejected.append( + f"{receipt.request_id}:managed_receipt_account_identity_sha256_required" + ) + return False + if ( + receipt.source != "managed_runtime_receipt" + or not _nonempty(receipt.request_id) + or not _nonempty(receipt.intent_id) + or receipt.action not in self.profile["receipt_actions"] + or receipt.dispatch_state not in {"dispatched", "blocked_pre_dispatch"} + or not _nonempty(receipt.order_ref) + or not _nonempty(receipt.instrument_id) + or not _nonempty(receipt.repeat_key) + or not _sha256(receipt.configuration_digest) + or not _is_positive_int(receipt.threshold) + or not _is_positive_number(receipt.window_seconds) + or not _utc(receipt.occurred_at_utc) + or not _is_positive_int(receipt.sequence) + or not _sha256(receipt.evidence_sha256) + ): + raise ValueError("managed receipt shape or provenance is invalid") + scope_account_digest = self._scope_account_identity_sha256() + if scope_account_digest is None: + self._rejected.append(f"{receipt.request_id}:account_identity_scope_binding_required") + return False + if receipt.account_identity_sha256.lower() != scope_account_digest: + self._rejected.append( + f"{receipt.request_id}:managed_receipt_account_identity_scope_mismatch" + ) + return False + if receipt.request_id in self._receipt_ids: + raise ValueError("managed receipt request_id must be unique") + if receipt.sequence <= self._receipt_sequence: + raise ValueError("managed receipt sequence must increase") + authenticate = getattr(self.authenticator, "authenticate_managed_receipt", None) + if not callable(authenticate): + self._rejected.append(f"{receipt.request_id}:managed_receipt_verifier_unavailable") + return False + proof = authenticate(receipt, self.engine.scope) + if ( + proof is None + or getattr(proof, "request_id", None) != receipt.request_id + or str(getattr(proof, "evidence_sha256", "")).lower() != receipt.evidence_sha256.lower() + or str(getattr(proof, "scope_sha256", "")).lower() + != str(self.engine.scope.scope_sha256).lower() + or getattr(proof, "source", None) != receipt.source + or str(getattr(proof, "account_identity_sha256", "")).lower() != scope_account_digest + or not _nonempty(getattr(proof, "verification_ref", None)) + ): + self._rejected.append(f"{receipt.request_id}:managed_receipt_auth_denied") + return False + self._receipt_ids.add(receipt.request_id) + self._receipt_sequence = receipt.sequence + self._receipts.append(receipt) + return True + + def evaluate(self, *, now_utc: datetime) -> ScenarioStateSnapshot: + """Return REVIEW_REQUIRED at best; this API can never pass or dispatch.""" + missing: list[str] = [] + if self.authenticator is None: + missing.append("trusted_authenticator_not_configured") + if self._rejected: + missing.append("rejected_evidence_present") + base = self.engine.evaluate(now_utc=now_utc) + allowed_inapplicable = set() + if self.CASE_ID == "O03": + # The shared spec omits login/front/tick kinds despite the generic + # repeat-cancel rule requiring them; O03's static plan is order scoped. + allowed_inapplicable.update( + {"provider_session_not_ready", "fresh_valid_market_tick_required"} + ) + if self.CASE_ID == "TH04": + # The static plan defines a combined submit+cancel threshold, while + # the generic engine currently counts canceled refs only. + allowed_inapplicable.add("threshold_trigger_not_correlated_to_native_activity") + missing.extend(item for item in base.missing_conditions if item not in allowed_inapplicable) + if base.status == "BLOCKED" and not missing: + missing.append("decision_engine_blocked") + if not any(event.kind.value == "monitor_configuration" for event in self._events): + missing.append("authenticated_monitor_configuration_required") + if self._scope_account_identity_sha256() is None: + missing.append("account_identity_scope_binding_required") + config = self._last_event("monitor_configuration") + if config is not None: + missing.extend(self._configuration_mismatches(config)) + missing.extend(self._provider_coherence_mismatches(now_utc, config)) + if self.profile.get("repeat"): + missing.extend(self._repeat_mismatches(config)) + if self.CASE_ID in {"O01", "O02", "O03"}: + missing.extend(self._admission_mismatches()) + else: + missing.extend(self._threshold_mismatches(config)) + status = "INCOMPLETE" if missing else "REVIEW_REQUIRED" + return ScenarioStateSnapshot( + case_id=self.CASE_ID, + status=status, + certification_pass=False, + dispatch_permitted=False, + source_authenticity_verified=False, + missing_conditions=tuple(dict.fromkeys(missing)), + rejected_evidence=tuple(self._rejected), + ) + + def _provider_coherence_mismatches(self, now_utc: datetime, config: Any) -> list[str]: + provider_events = [ + event + for event in self._events + if _value(getattr(event, "source_domain", "")) == "ctp_provider_callback" + ] + if not provider_events: + return ["native_provider_evidence_required"] + + problems: list[str] = [] + scope_account_digest = self._scope_account_identity_sha256() + if scope_account_digest is None: + problems.append("account_identity_scope_binding_required") + identity_events = [ + event + for event in provider_events + if _value(getattr(event, "kind", "")) + not in {"front_connected", "auth_success"} + ] + sessions = {getattr(event, "provider_session_id", "") for event in identity_events} + trading_days = {getattr(event, "trading_day", "") for event in identity_events} + if len(sessions) != 1 or not next(iter(sessions), ""): + problems.append("provider_evidence_must_share_one_session") + if len(trading_days) != 1 or not next(iter(trading_days), ""): + problems.append("provider_evidence_must_share_one_trading_day") + + has_front = any( + _value(getattr(event, "kind", "")) == "front_connected" + for event in provider_events + ) + has_auth = any( + _value(getattr(event, "kind", "")) == "auth_success" + for event in provider_events + ) + login = self._last_event("login_success") + if (has_front or has_auth) and login is None: + problems.append("native_login_identity_required_after_front_or_auth") + if login is not None: + login_scope = (login.provider_session_id, login.trading_day) + if not all(login_scope) or any( + (event.provider_session_id, event.trading_day) != login_scope + for event in identity_events + ): + problems.append("provider_callback_scope_must_match_native_login") + + generations = [] + for event in provider_events: + fields = getattr(event, "fields", {}) + # AUTH/FRONT callbacks have no native account identity field. Their + # account-scope pseudonym is local receive metadata with an explicit + # local_ prefix; other legacy callback projections use the same + # pseudonym under account_identity_sha256, which is compared only + # with the sealed case scope and is never treated as provider proof. + kind = _value(getattr(event, "kind", "")) + digest_field = ( + "local_account_identity_sha256" + if kind in {"auth_success", "front_connected", "front_disconnected"} + else "account_identity_sha256" + ) + account_digest = ( + fields.get(digest_field) if isinstance(fields, Mapping) else None + ) + if not _sha256(account_digest): + problems.append("local_account_scope_fingerprint_required") + elif scope_account_digest and account_digest.lower() != scope_account_digest: + problems.append("local_account_scope_fingerprint_mismatch") + generation = ( + fields.get("connection_generation") if isinstance(fields, Mapping) else None + ) + if not _is_positive_int(generation): + problems.append("provider_connection_generation_required") + continue + generations.append(generation) + if len(set(generations)) > 1: + problems.append("provider_evidence_must_share_one_connection_generation") + + cutoff = _as_utc_datetime(now_utc) + if cutoff is None: + problems.append("evaluation_time_must_be_utc") + else: + event_times = [ + _utc(getattr(event, "occurred_at_utc", None)) for event in provider_events + ] + if any(item is None for item in event_times): + problems.append("provider_event_timestamp_required") + else: + normalized_times = [item for item in event_times if item is not None] + if any(item > cutoff for item in normalized_times): + problems.append("provider_event_from_future") + if any(cutoff - item > _MAX_PROVIDER_EVENT_AGE for item in normalized_times): + problems.append("provider_event_stale_at_evaluation") + if max(normalized_times) - min(normalized_times) > _MAX_PROVIDER_EVENT_AGE: + problems.append("provider_evidence_exceeds_five_minute_window") + + if self.CASE_ID in {"O01", "O02", "TH02"}: + problems.extend(self._subscribed_instrument_mismatches()) + + problems.extend(self._receipt_instrument_mismatches()) + + receipts = sorted(self._receipts, key=lambda row: row.sequence) + if cutoff is not None: + receipt_times = [_utc(row.occurred_at_utc) for row in receipts] + if any(item is None for item in receipt_times): + problems.append("managed_receipt_timestamp_required") + elif any(item > cutoff for item in receipt_times if item is not None): + problems.append("managed_receipt_from_future") + elif any( + cutoff - item > _MAX_PROVIDER_EVENT_AGE + for item in receipt_times + if item is not None + ): + problems.append("managed_receipt_stale_at_evaluation") + + problems.extend(self._case_event_time_mismatches(config, receipts, cutoff)) + return problems + + def _subscribed_instrument_mismatches(self) -> list[str]: + subscription = self._last_event("market_subscription_ack") + tick = self._last_event("market_tick") + accepted = [event for event in self._events if event.kind.value == "order_accepted"] + if subscription is None or tick is None: + return ["current_subscription_and_market_tick_required"] + instrument = subscription.fields.get("instrument_id") + if ( + not _nonempty(instrument) + or tick.fields.get("instrument_id") != instrument + or any(event.fields.get("instrument_id") != instrument for event in accepted) + ): + return ["native_order_must_match_current_subscribed_tick_instrument"] + + front = self._last_event("front_connected") + login = self._last_event("login_success") + auth = self._last_event("auth_success") + if front is None or login is None or auth is None: + return ["current_authenticated_provider_session_required"] + auth_time = _utc(auth.occurred_at_utc) + login_time = _utc(login.occurred_at_utc) + front_time = _utc(front.occurred_at_utc) + subscription_time = _utc(subscription.occurred_at_utc) + tick_time = _utc(tick.occurred_at_utc) + if any( + item is None + for item in (auth_time, login_time, front_time, subscription_time, tick_time) + ): + return ["provider_session_event_timestamps_required"] + if not front_time <= auth_time <= login_time <= subscription_time <= tick_time: + return ["provider_auth_login_front_subscription_tick_order_invalid"] + if any(_utc(event.occurred_at_utc) < tick_time for event in accepted): + return ["native_order_must_follow_current_market_tick"] + return [] + + def _receipt_instrument_mismatches(self) -> list[str]: + provider_orders = [ + event + for event in self._events + if event.kind.value in {"order_accepted", "order_canceled"} + ] + problems: list[str] = [] + subscription = self._last_event("market_subscription_ack") + tick = self._last_event("market_tick") + subscribed_instrument = ( + subscription.fields.get("instrument_id") + if subscription is not None and tick is not None + else None + ) + for receipt in self._receipts: + matching = [ + event + for event in provider_orders + if event.fields.get("order_ref") == receipt.order_ref + ] + if not matching: + problems.append("managed_receipt_native_order_instrument_unresolved") + continue + if any( + event.fields.get("instrument_id") != receipt.instrument_id for event in matching + ): + problems.append("managed_receipt_instrument_must_match_native_order_ref") + if subscribed_instrument is not None and receipt.instrument_id != subscribed_instrument: + problems.append("managed_receipt_instrument_must_match_current_subscription") + return problems + + def _scope_account_identity_sha256(self) -> str | None: + value = getattr(self.engine.scope, "account_identity_sha256", "") + return value.lower() if _sha256(value) else None + + def _with_rejection_latch(self, operation: Any, evidence: Any) -> bool: + try: + return operation(evidence) + except Exception as exc: + evidence_id = self._safe_evidence_identifier(evidence) + rejection = f"{evidence_id or 'unknown-evidence'}:rejected_{type(exc).__name__}" + if rejection not in self._rejected: + self._rejected.append(rejection) + raise + + @staticmethod + def _safe_evidence_identifier(evidence: Any) -> str | None: + for attribute in ("event_id", "request_id"): + try: + value = getattr(evidence, attribute, None) + except Exception: + continue + if type(value) is str and value.strip(): + return value + return None + + def _provider_account_identity_problem(self, event: Any) -> str | None: + scope_digest = self._scope_account_identity_sha256() + if scope_digest is None: + return "account_identity_scope_binding_required" + fields = getattr(event, "fields", {}) + kind = _value(getattr(event, "kind", "")) + digest_field = ( + "local_account_identity_sha256" + if kind in {"auth_success", "front_connected", "front_disconnected"} + else "account_identity_sha256" + ) + event_digest = ( + fields.get(digest_field) if isinstance(fields, Mapping) else None + ) + if not _sha256(event_digest): + return "local_account_scope_fingerprint_required" + if event_digest.lower() != scope_digest: + return "local_account_scope_fingerprint_mismatch" + return None + + def _case_event_time_mismatches( + self, config: Any, receipts: list[ManagedIntentReceipt], cutoff: datetime | None + ) -> list[str]: + if cutoff is None: + return [] + problems: list[str] = [] + by_kind: dict[str, list[Any]] = {} + for event in self._events: + by_kind.setdefault(event.kind.value, []).append(event) + + def when(event: Any) -> datetime | None: + return _utc(getattr(event, "occurred_at_utc", None)) + + receipt_times = [_utc(row.occurred_at_utc) for row in receipts] + valid_receipt_times = [item for item in receipt_times if item is not None] + earliest_receipt = min(valid_receipt_times) if valid_receipt_times else None + latest_receipt = max(valid_receipt_times) if valid_receipt_times else None + + if self.CASE_ID in {"O01", "O02", "TH06"} and receipts: + accepted = by_kind.get("order_accepted", []) + if not accepted or not valid_receipt_times: + problems.append("repeat_order_requires_fresh_native_acceptance_and_receipts") + elif not any( + row.order_ref in {event.fields.get("order_ref") for event in accepted} + and earliest_receipt <= when(event) <= latest_receipt + for row in receipts + for event in accepted + if when(event) is not None + ): + problems.append("native_order_acceptance_must_fall_within_managed_repeat_window") + + if self.CASE_ID == "O02": + position = self._last_event("position_query") + if ( + position is None + or earliest_receipt is None + or when(position) is None + or when(position) > earliest_receipt + ): + problems.append("baseline_position_must_precede_close_intent_receipts") + + if self.CASE_ID == "O03": + accepted = by_kind.get("order_accepted", []) + query = self._last_event("order_query") + if ( + not accepted + or query is None + or earliest_receipt is None + or any(when(row) is None or when(row) > when(query) for row in accepted) + or when(query) is None + or when(query) > earliest_receipt + ): + problems.append("open_order_acceptance_and_query_must_precede_cancel_intents") + + if self.CASE_ID == "TH02": + for receipt in receipts: + matches = [ + event + for event in by_kind.get("order_accepted", []) + if event.fields.get("order_ref") == receipt.order_ref + ] + if ( + receipt.action == "open" + and receipt.dispatch_state == "dispatched" + and not any( + when(event) is not None and when(event) >= _utc(receipt.occurred_at_utc) + for event in matches + ) + ): + problems.append( + "threshold_order_acceptance_must_follow_matching_submit_receipt" + ) + + if self.CASE_ID == "TH04": + native_by_kind = { + "open": by_kind.get("order_accepted", []), + "cancel": by_kind.get("order_canceled", []), + } + for receipt in receipts: + matching = [ + event + for event in native_by_kind.get(receipt.action, []) + if event.fields.get("order_ref") == receipt.order_ref + ] + if receipt.dispatch_state == "dispatched" and not any( + when(event) is not None and when(event) >= _utc(receipt.occurred_at_utc) + for event in matching + ): + problems.append( + "combined_threshold_native_event_must_follow_matching_managed_receipt" + ) + + if self.CASE_ID in {"TH02", "TH04", "TH06"}: + trigger = self._last_event("monitor_trigger") + evidence_times = [ + item + for item in [*valid_receipt_times] + + [ + when(event) + for event in self._events + if event.kind.value in {"order_accepted", "order_canceled"} + ] + if item is not None + ] + if trigger is None or ( + evidence_times and when(trigger) is not None and when(trigger) < max(evidence_times) + ): + problems.append("threshold_trigger_must_follow_its_managed_and_native_sources") + elif receipts: + guard = self._last_event("repeat_guard") + if ( + guard is None + or latest_receipt is None + or when(guard) is None + or when(guard) < latest_receipt + ): + problems.append("repeat_guard_must_follow_managed_repeat_receipts") + + if config is not None: + config_time = when(config) + monitor = self._last_event("monitor_trigger") or self._last_event("repeat_guard") + monitor_time = when(monitor) if monitor is not None else None + if config_time is not None and monitor_time is not None and config_time > monitor_time: + problems.append("monitor_configuration_must_precede_its_observation") + return problems + + def _configuration_mismatches(self, config: Any) -> list[str]: + fields = config.fields + expected_metric = self.profile.get("repeat_metric", self.profile.get("threshold_metric")) + problems = [] + if fields.get("metric") != expected_metric: + problems.append("case_specific_monitor_metric_mismatch") + expected_threshold = self.profile.get("threshold") + if expected_threshold is not None and fields.get("threshold") != expected_threshold: + problems.append("configured_threshold_does_not_match_static_case_plan") + if self.profile.get("repeat") and not _is_positive_number(fields.get("window_seconds")): + problems.append("configured_repeat_window_missing") + return problems + + def _repeat_mismatches(self, config: Any) -> list[str]: + if config is None: + return ["authenticated_monitor_configuration_required"] + guard = self._last_event("repeat_guard") + problems: list[str] = [] + receipts = sorted(self._receipts, key=lambda row: row.sequence) + threshold = config.fields.get("threshold") if config else None + window = config.fields.get("window_seconds") if config else None + config_digest = config.fields.get("configuration_digest") if config else None + if len(receipts) < 2: + problems.append("two_managed_intent_receipts_required") + return problems + if not guard: + return [*problems, "authenticated_repeat_guard_required"] + fields = guard.fields + expected_action = self.profile["action"] + if fields.get("action_kind") != expected_action: + problems.append("repeat_guard_action_mismatch") + intent_ids = {row.intent_id for row in receipts} + order_refs = {row.order_ref for row in receipts} + repeat_keys = {row.repeat_key for row in receipts} + if len(intent_ids) != 1 or fields.get("intent_id") not in intent_ids: + problems.append("repeat_guard_not_bound_to_one_managed_intent") + if len(order_refs) != 1 or tuple(fields.get("order_refs", ())) != tuple(sorted(order_refs)): + problems.append("repeat_guard_order_ref_mismatch") + if len(repeat_keys) != 1 or fields.get("repeat_key") not in repeat_keys: + problems.append("repeat_guard_key_not_bound_to_receipts") + request_ids = tuple(row.request_id for row in receipts) + if tuple(fields.get("source_request_ids", ())) != request_ids: + problems.append("repeat_guard_request_source_mismatch") + if fields.get("repeat_count") != len(receipts) or len(receipts) < threshold: + problems.append("repeat_guard_count_does_not_match_receipt_count") + if fields.get("threshold") != threshold or fields.get("window_seconds") != window: + problems.append("repeat_guard_threshold_or_window_mismatch") + if fields.get("configuration_digest") != config_digest: + problems.append("repeat_guard_configuration_digest_mismatch") + if not _sha256(fields.get("monitor_digest")) or fields.get( + "monitor_digest" + ) != config.fields.get("monitor_digest"): + problems.append("repeat_guard_monitor_digest_mismatch") + if any(row.configuration_digest != config_digest for row in receipts): + problems.append("managed_receipt_configuration_digest_mismatch") + if any(row.threshold != threshold or row.window_seconds != window for row in receipts): + problems.append("managed_receipt_threshold_or_window_mismatch") + times = [_utc(row.occurred_at_utc) for row in receipts] + if None in times or max(times) - min(times) > timedelta(seconds=float(window)): + problems.append("managed_receipts_exceed_repeat_window") + states = {row.dispatch_state for row in receipts} + if not {"dispatched", "blocked_pre_dispatch"}.issubset(states): + problems.append("repeat_requires_dispatched_and_pre_dispatch_blocked_receipts") + if self.CASE_ID in {"O01", "O02", "O03", "TH06"} and len(order_refs) != 1: + problems.append("repeat_attempts_must_target_same_order_ref") + problems.extend(self._native_receipt_correlation(receipts)) + return problems + + def _native_receipt_correlation(self, receipts: list[ManagedIntentReceipt]) -> list[str]: + problems: list[str] = [] + refs = {row.order_ref for row in receipts} + accepted = { + event.fields.get("order_ref") + for event in self._events + if event.kind.value == "order_accepted" + } + canceled = { + event.fields.get("order_ref") + for event in self._events + if event.kind.value == "order_canceled" + } + if self.CASE_ID in {"O01", "O02", "TH06"} and not refs.intersection(accepted): + problems.append("managed_submit_receipt_not_correlated_to_native_order") + if self.CASE_ID == "O02": + position = self._last_event("position_query") + if not position or position.fields.get("phase") != "baseline": + problems.append("close_repeat_requires_native_baseline_position") + elif any( + event.kind.value == "order_accepted" + and event.fields.get("instrument_id") != position.fields.get("instrument_id") + for event in self._events + ): + problems.append("close_receipt_and_position_instrument_mismatch") + if self.CASE_ID == "O03": + query = self._last_event("order_query") + open_refs = set(query.fields.get("open_order_refs", ())) if query else set() + if not refs.intersection(accepted) or not refs.intersection(open_refs): + problems.append("repeat_cancel_target_not_native_open_order") + if not refs.intersection(canceled) and not any( + row.dispatch_state == "dispatched" for row in receipts + ): + # A provider cancel callback may be absent when the monitor + # blocks a retry; at least the dispatched receipt is required. + problems.append("repeat_cancel_has_no_dispatched_managed_request") + return problems + + def _admission_mismatches(self) -> list[str]: + admission = self._last_event("order_admission") + if admission is None: + return ["authenticated_case_bound_admission_required"] + fields = admission.fields + intents = {row.intent_id for row in self._receipts} + if ( + fields.get("case_id") != self.CASE_ID + or fields.get("intent_kind") != self.profile["action"] + or fields.get("intent_id") not in intents + or len(intents) != 1 + or fields.get("approval_state") != "REVIEW_ONLY" + or fields.get("dispatch_permitted") is not False + or not _nonempty(fields.get("approval_ref")) + ): + return ["managed_admission_not_bound_to_the_receipt_intent"] + try: + if float(fields.get("maximum_quantity")) <= 0: + return ["managed_admission_quantity_must_be_positive"] + except (TypeError, ValueError, OverflowError): + return ["managed_admission_quantity_must_be_positive"] + return [] + + def _threshold_mismatches(self, config: Any) -> list[str]: + trigger = self._last_event("monitor_trigger") + receipts = sorted(self._receipts, key=lambda row: row.sequence) + if config is None or trigger is None: + return ["authenticated_threshold_configuration_and_trigger_required"] + problems: list[str] = [] + cfg, observed = config.fields, trigger.fields + dispatched = [row for row in receipts if row.dispatch_state == "dispatched"] + expected_actions = self.profile["count_actions"] + if self.CASE_ID == "TH06": + counted = receipts + else: + counted = [row for row in dispatched if row.action in expected_actions] + if not counted: + problems.append("managed_dispatched_receipts_required_for_threshold_count") + request_ids = tuple(row.request_id for row in counted) + if observed.get("metric") != cfg.get("metric"): + problems.append("threshold_trigger_metric_mismatch") + if observed.get("threshold") != cfg.get("threshold"): + problems.append("threshold_trigger_threshold_mismatch") + if observed.get("observed_value") != len(counted): + problems.append("threshold_count_does_not_match_managed_receipts") + if ( + observed.get("source_request_ids") is None + or tuple(observed.get("source_request_ids", ())) != request_ids + ): + problems.append("threshold_trigger_request_source_mismatch") + if observed.get("configuration_digest") != cfg.get("configuration_digest"): + problems.append("threshold_trigger_configuration_digest_mismatch") + if observed.get("monitor_digest") != cfg.get("monitor_digest"): + problems.append("threshold_trigger_monitor_digest_mismatch") + if not _sha256(observed.get("monitor_digest")): + problems.append("threshold_trigger_monitor_digest_invalid") + if any(row.configuration_digest != cfg.get("configuration_digest") for row in counted): + problems.append("threshold_receipt_configuration_digest_mismatch") + if any(row.threshold != cfg.get("threshold") for row in counted): + problems.append("threshold_receipt_effective_threshold_mismatch") + native_ids = tuple( + event.event_id + for event in self._events + if event.kind.value + in ( + {"order_accepted"} + if self.CASE_ID == "TH02" + else {"order_accepted", "order_canceled"} + ) + ) + source_ids_field = ( + "source_native_event_ids" if self.CASE_ID == "TH04" else "source_event_ids" + ) + expected_native_ids = tuple(observed.get(source_ids_field, ())) + if expected_native_ids != native_ids: + problems.append("threshold_trigger_native_event_source_mismatch") + if self.CASE_ID == "TH02": + native_refs = { + event.fields.get("order_ref") + for event in self._events + if event.kind.value == "order_accepted" + } + if native_refs != {row.order_ref for row in counted}: + problems.append("submit_threshold_refs_do_not_match_native_orders") + elif self.CASE_ID == "TH04": + if len(counted) != self.profile["threshold"]: + problems.append("combined_submit_cancel_count_does_not_reach_configured_threshold") + if not any(event.kind.value == "order_canceled" for event in self._events): + problems.append("combined_threshold_requires_native_cancel_callback") + submit_refs = {row.order_ref for row in counted if row.action == "open"} + cancel_refs = {row.order_ref for row in counted if row.action == "cancel"} + native_submit_refs = { + event.fields.get("order_ref") + for event in self._events + if event.kind.value == "order_accepted" + } + native_cancel_refs = { + event.fields.get("order_ref") + for event in self._events + if event.kind.value == "order_canceled" + } + if submit_refs != native_submit_refs or cancel_refs != native_cancel_refs: + problems.append("combined_threshold_receipts_do_not_match_native_order_refs") + elif self.CASE_ID == "TH06": + # Repeat counts are based on attempted managed requests; a blocked + # retry must remain in the monitor's source set, unlike normal + # dispatched order-count thresholds. + repeat_ids = tuple(row.request_id for row in receipts) + guard = self._last_event("repeat_guard") + if tuple(observed.get("source_request_ids", ())) != repeat_ids: + problems.append("repeat_threshold_source_must_include_all_managed_attempts") + if not guard or guard.fields.get("repeat_count") != len(receipts): + problems.append("repeat_threshold_count_not_bound_to_repeat_guard") + if observed.get("observed_value") != len(receipts): + problems.append("repeat_threshold_value_not_bound_to_attempt_count") + return problems + + def _authenticate_event(self, event: Any) -> bool: + authenticate = getattr(self.authenticator, "authenticate", None) + if not callable(authenticate): + return False + proof = authenticate(event, self.engine.scope) + return bool( + proof is not None + and getattr(proof, "event_id", None) == event.event_id + and str(getattr(proof, "evidence_sha256", "")).lower() + == str(event.evidence_sha256).lower() + and str(getattr(proof, "scope_sha256", "")).lower() + == str(self.engine.scope.scope_sha256).lower() + and _value(getattr(proof, "trust_domain", "")) == _value(event.source_domain) + and str(getattr(proof, "account_identity_sha256", "")).lower() + == (self._scope_account_identity_sha256() or "") + and _nonempty(getattr(proof, "verification_ref", None)) + ) + + def _check_identity_and_sequence(self, event: Any) -> None: + kind = _value(getattr(event, "kind", "")) + event_id = getattr(event, "event_id", None) + digest = getattr(event, "evidence_sha256", None) + stream = getattr(event, "stream_id", None) + sequence = getattr(event, "sequence", None) + if kind not in _POLICY or not _nonempty(event_id) or not _sha256(digest): + raise ValueError("observation id, kind, or evidence digest is invalid") + if not _nonempty(stream) or not _is_positive_int(sequence): + raise ValueError("observation stream and sequence are required") + if event_id in self._event_ids: + raise ValueError("observation event_id must be unique") + domain = _value(getattr(event, "source_domain", "")) + provider_session = getattr(event, "provider_session_id", "") + trading_day = getattr(event, "trading_day", "") + fields = getattr(event, "fields", {}) + provider_identity_fields = ( + "provider_front_id", + "provider_session_id", + "trading_day", + ) + is_payloadless_front = ( + domain == "ctp_provider_callback" + and kind == "front_connected" + and getattr(event, "callback_name", None) == "OnFrontConnected" + ) + is_auth_callback = ( + domain == "ctp_provider_callback" + and kind == "auth_success" + and getattr(event, "callback_name", None) == "OnRspAuthenticate" + ) + if is_payloadless_front or is_auth_callback: + has_impossible_identity = ( + getattr(event, "provider_front_id", None) is not None + or bool(provider_session) + or bool(trading_day) + or not isinstance(fields, Mapping) + or any(fields.get(name) not in (None, "") for name in provider_identity_fields) + ) + if has_impossible_identity: + callback = "OnFrontConnected" if is_payloadless_front else "OnRspAuthenticate" + raise ValueError(f"{callback} cannot claim native login identity fields") + elif domain == "ctp_provider_callback" and (not provider_session or not trading_day): + raise ValueError("native callback requires provider session and trading day") + if domain == "ctp_provider_callback" and kind not in { + "front_connected", + "auth_success", + "login_success", + }: + login = self._last_event("login_success") + if login is not None and (provider_session, trading_day) != ( + login.provider_session_id, + login.trading_day, + ): + raise ValueError("native callback session/day must match native login identity") + if _utc(getattr(event, "occurred_at_utc", None)) is None: + raise ValueError("observation timestamp must be UTC") + key = (domain, stream, provider_session, trading_day) + if sequence <= self._sequences.get(key, 0): + raise ValueError("observation sequence must increase within source stream") + + def _remember_event(self, event: Any) -> None: + domain = _value(event.source_domain) + key = (domain, event.stream_id, event.provider_session_id, event.trading_day) + self._sequences[key] = event.sequence + self._event_ids.add(event.event_id) + self._events.append(event) + + def _last_event(self, kind: str) -> Any | None: + return next((row for row in reversed(self._events) if row.kind.value == kind), None) + + +def _is_positive_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def _is_positive_number(value: Any) -> bool: + if not isinstance(value, (int, float, Decimal)) or isinstance(value, bool): + return False + try: + number = Decimal(str(value)) + except (InvalidOperation, ValueError): + return False + return number.is_finite() and number > 0 + + +def _as_utc_datetime(value: Any) -> datetime | None: + if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() != timedelta(0): + return None + return value.astimezone(timezone.utc) + + +def _native_order_mismatches(kind: str, fields: Any) -> list[str]: + if not isinstance(fields, Mapping): + return ["native_order_fields_must_be_mapping"] + status = fields.get("status") + remaining = fields.get("remaining_quantity") + if not _nonempty(status): + return ["native_order_status_required"] + try: + remaining_quantity = Decimal(str(remaining)) + except (InvalidOperation, TypeError, ValueError): + return ["native_order_remaining_quantity_must_be_finite_decimal"] + if not remaining_quantity.is_finite(): + return ["native_order_remaining_quantity_must_be_finite_decimal"] + normalized_status = status.strip().lower() + if kind == "order_accepted": + problems = [] + if normalized_status not in {"accepted", "working"}: + problems.append("native_order_accepted_status_invalid") + if remaining_quantity <= 0: + problems.append("native_order_accepted_remaining_quantity_must_be_positive") + return problems + if kind == "order_canceled": + problems = [] + if normalized_status not in {"canceled", "cancelled"}: + problems.append("native_order_cancelled_status_invalid") + if remaining_quantity < 0: + problems.append("native_order_cancelled_remaining_quantity_must_not_be_negative") + return problems + return [] diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/B01/B01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/B01/B01_strategy.py new file mode 100644 index 00000000..d000b123 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/B01/B01_strategy.py @@ -0,0 +1,32 @@ +"""B01: case-specific real-evidence and action plan.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "B01" +CASE_NAME = "批量撤单:多笔部分成交委托" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证多笔真实部分成交委托可按剩余数量批量发起撤单并关联各自回报。", + "preconditions": ( + "获准的 SimNow 第一套环境、活跃交易时段及低风险测试账户。", + "经纪商确认批量撤单接口和订单状态同步方式。", + "可形成多笔部分成交且仍有未成交数量的实际委托。", + ), + "actions": ( + "提交经批准的受控委托,并等待真实部分成交回报。", + "核对每笔委托的已成交量、剩余量和当前状态。", + "对仍有效的委托执行批量撤单并跟踪逐笔最终状态。", + ), + "evidence": ( + "前置委托及成交回报、交易所委托标识和剩余数量快照。", + "批量撤单请求、逐笔撤单回报及运行日志中的关联标识。", + "撤单后委托与成交查询的对账记录。", + ), + "dependency": "部分成交由撮合状态决定,策略无法保证;需真实市场条件和柜台批量撤单能力。", +} + + +def create_strategy(plan, scope, authenticator): + """Construct the no-I/O typed batch cancel observer for an injected adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/B01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/B01/config.yaml new file mode 100644 index 00000000..a561d96d --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/B01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.B01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: B01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/B01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/B01/run.py new file mode 100644 index 00000000..724dbf2f --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/B01/run.py @@ -0,0 +1,14 @@ +"""Run B01 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("B01", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/B02/B02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/B02/B02_strategy.py new file mode 100644 index 00000000..4572c4f3 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/B02/B02_strategy.py @@ -0,0 +1,31 @@ +"""B02: case-specific real-evidence and action plan.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "B02" +CASE_NAME = "批量撤单:多笔已报未成交委托" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证多笔已被真实前置接受且仍有效的委托可批量撤销。", + "preconditions": ( + "获准的 SimNow 第一套环境、活跃交易时段和受控账户额度。", + "有多个已确认处于未成交有效状态的委托。", + ), + "actions": ( + "记录订单基线后提交经批准的低数量限价委托。", + "仅在逐笔收到有效受理状态后发起批量撤单。", + "等待每笔撤单最终回报,并查询委托和成交状态。", + ), + "evidence": ( + "订单受理回报、订单引用和撤单请求关联记录。", + "逐笔撤单结果及撤单期间发生的成交回报。", + "撤单后委托、成交和剩余持仓核对记录。", + ), + "dependency": "需要真实受理的活动委托;撤单与成交可能竞争,须按最终回报和查询结果对账。", +} + + +def create_strategy(plan, scope, authenticator): + """Construct the no-I/O typed batch cancel observer for an injected adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/B02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/B02/config.yaml new file mode 100644 index 00000000..6888c8ac --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/B02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.B02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: B02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/B02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/B02/run.py new file mode 100644 index 00000000..9e23496d --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/B02/run.py @@ -0,0 +1,14 @@ +"""Run B02 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("B02", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/C01/C01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/C01/C01_strategy.py new file mode 100644 index 00000000..ad7f99d4 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/C01/C01_strategy.py @@ -0,0 +1,22 @@ +"""Case plan and typed read-only authentication/login evidence strategy.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "C01" +CASE_NAME = "Counter authentication and account login" +CASE_PLAN = { + "evidence": ( + "Archive the native authentication and login callbacks with their request IDs, ErrorID, callback-local capture times, and order. Bind FrontID, SessionID, and TradingDay only from the login callback; redact account identifiers.", + "Correlate the session lifecycle with clean shutdown records and confirm there were no order or cancel requests.", + ), + "actions": ( + "Start one reviewed read-only SimNow session through the managed route and observe broker authentication and account-login callbacks.", + "Close the session cleanly and retain the locally observed callback sequence with private account fields redacted; do not label local sequence or time as provider-issued facts.", + ), +} + + +def create_strategy(plan, scope, authenticator): + """Build the no-I/O event state machine for an injected managed adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/C01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/C01/config.yaml new file mode 100644 index 00000000..11153422 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/C01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.C01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: C01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/C01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/C01/run.py new file mode 100644 index 00000000..4596d579 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/C01/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("C01", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E01/E01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/E01/E01_strategy.py new file mode 100644 index 00000000..83ed873f --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E01/E01_strategy.py @@ -0,0 +1,34 @@ +"""REAL evidence and action plan for E01.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = 'E01' +PLAN = { + "case_id": "E01", + "scenario_id": "ERROR-01", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Capture the counter response for an insufficient-funds rejection under an approved sandbox condition.", + "actions": [ + "Record the current account funds and the authorized bounded test condition before any request.", + "Submit one reviewed request only when the sandbox account owner has approved the resulting margin exposure.", + "Capture the counter response, cancel any remaining order, and reconcile fills and account state." + ], + "evidence": [ + "order_reject_remote event with the counter ErrorID, ErrorMsg, and StatusMsg.", + "Request reference, timestamp, and redacted pre-request account-state evidence.", + "Final order and fill reconciliation showing no unresolved order." + ], + "completion_criteria": [ + "A genuine counter-side insufficient-funds response is recorded and order/account state is reconciled." + ] +} + + +class E01ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind E01 to an external funds condition and positive provider ErrorID.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("E01 strategy requires the E01 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/E01/config.yaml new file mode 100644 index 00000000..6a92a8b8 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.E01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: E01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/E01/run.py new file mode 100644 index 00000000..0f786f77 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E01/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('E01', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E02/E02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/E02/E02_strategy.py new file mode 100644 index 00000000..df654ee0 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E02/E02_strategy.py @@ -0,0 +1,34 @@ +"""REAL evidence and action plan for E02.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = 'E02' +PLAN = { + "case_id": "E02", + "scenario_id": "ERROR-02", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Capture the counter response for an insufficient-position rejection under an approved sandbox condition.", + "actions": [ + "Read and record the account position and available closeable quantity for the selected contract.", + "Proceed only with a sandbox-owner-approved bounded close request that is expected to exceed available quantity.", + "Capture the counter response and reconcile positions, fills, and all remaining orders." + ], + "evidence": [ + "order_reject_remote event with the counter ErrorID, ErrorMsg, and StatusMsg.", + "Redacted pre-request position and available closeable quantity with source timestamp.", + "Final position, fill, and order reconciliation." + ], + "completion_criteria": [ + "A genuine counter-side insufficient-position response is recorded and the account state is reconciled." + ] +} + + +class E02ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind E02 to an external position condition and positive provider ErrorID.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("E02 strategy requires the E02 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/E02/config.yaml new file mode 100644 index 00000000..eae341b1 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.E02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: E02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/E02/run.py new file mode 100644 index 00000000..1f86b766 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E02/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('E02', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E03/E03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/E03/E03_strategy.py new file mode 100644 index 00000000..4d06c0c8 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E03/E03_strategy.py @@ -0,0 +1,34 @@ +"""REAL evidence and action plan for E03.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = 'E03' +PLAN = { + "case_id": "E03", + "scenario_id": "ERROR-03", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Capture a genuine counter response for an order rejected by the market state.", + "actions": [ + "Observe the counter-reported market state and retain its timestamped source evidence.", + "Proceed only if that real market state naturally provides the reviewed rejection condition; do not alter or synthesize it.", + "Capture the counter response and reconcile any order or fill state." + ], + "evidence": [ + "order_reject_remote event with the counter ErrorID, ErrorMsg, and StatusMsg.", + "Timestamped counter market-state evidence tied to the request.", + "Final order and fill reconciliation." + ], + "completion_criteria": [ + "A genuine market-state rejection is recorded; if the condition is absent, retain the evidence and leave the case pending." + ] +} + + +class E03ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind E03 to observed market state and positive provider ErrorID.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("E03 strategy requires the E03 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/E03/config.yaml new file mode 100644 index 00000000..3cc5d7fa --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.E03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: E03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/E03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/E03/run.py new file mode 100644 index 00000000..9afb676c --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/E03/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('E03', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/EM01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/EM01_strategy.py new file mode 100644 index 00000000..469eedc6 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/EM01_strategy.py @@ -0,0 +1,34 @@ +"""EM01: case-specific real-evidence and action plan.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = "EM01" +CASE_NAME = "暂停交易:账户交易权限限制" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证账户交易权限受限时,系统记录拒绝结果并阻止新增委托。", + "preconditions": ( + "经批准的 SimNow 测试账户、可交易品种和交易时段。", + "账户管理员可临时变更交易权限,并安排权限恢复。", + ), + "actions": ( + "记录账户和权限基线,由管理员临时撤销报单权限。", + "在获准范围内发送一笔受控委托,观察真实交易前置响应。", + "由管理员恢复权限,并记录恢复后的账户状态。", + ), + "evidence": ( + "权限变更与恢复的管理员审计记录及时间戳。", + "委托请求、前置拒绝回报和运行日志中的关联请求标识。", + "权限恢复后账户状态的核对记录。", + ), + "dependency": "账户权限变更须由 SimNow 或经纪商运维人员执行;策略不能自行制造账户权限状态。", +} + + +class EM01ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind EM01 to control-plane permission disable/restore and provider facts.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("EM01 strategy requires the EM01 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/config.yaml new file mode 100644 index 00000000..1892ce49 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.EM01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: EM01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/run.py new file mode 100644 index 00000000..b50ddc91 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM01/run.py @@ -0,0 +1,14 @@ +"""Run EM01 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("EM01", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/EM02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/EM02_strategy.py new file mode 100644 index 00000000..96a8bb11 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/EM02_strategy.py @@ -0,0 +1,34 @@ +"""EM02: case-specific real-evidence and action plan.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = "EM02" +CASE_NAME = "暂停交易:策略执行暂停" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证运行中的策略在收到暂停指令后停止后续策略回调和新委托请求。", + "preconditions": ( + "已获准运行的真实交易会话和可审计的策略暂停控制。", + "暂停前盘点未完成委托,并明确由谁负责后续撤单或对账。", + ), + "actions": ( + "记录运行进程、会话状态和暂停前的委托基线。", + "通过实际运维控制触发策略暂停,记录操作人和时间。", + "在约定观察窗口检查回调停止及新委托请求计数。", + ), + "evidence": ( + "暂停指令审计记录、运行日志和带时间戳的进程状态。", + "暂停前后委托请求及回报的关联记录。", + "观察窗口内无新增委托请求的监控记录。", + ), + "dependency": "本场景验证应用暂停控制;未完成委托仍需单独核对,暂停本身不代表撤单。", +} + + +class EM02ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind EM02 to an authorized pause and completed empty-order query.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("EM02 strategy requires the EM02 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/config.yaml new file mode 100644 index 00000000..4f185dfe --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.EM02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: EM02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/run.py new file mode 100644 index 00000000..d570a555 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM02/run.py @@ -0,0 +1,14 @@ +"""Run EM02 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("EM02", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/EM03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/EM03_strategy.py new file mode 100644 index 00000000..8e0e3cb4 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/EM03_strategy.py @@ -0,0 +1,34 @@ +"""EM03: case-specific real-evidence and action plan.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = "EM03" +CASE_NAME = "暂停交易:账户强制退出" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证外部终止交易会话后,系统识别断线并停止后续委托。", + "preconditions": ( + "经纪商或 SimNow 运维人员确认可用的服务端会话终止流程。", + "账户管理员批准测试时段,并明确未完成委托的处置责任。", + ), + "actions": ( + "建立并记录真实交易会话及未完成委托基线。", + "由运维人员通过其正式管理流程终止该会话。", + "记录前置断线回调、会话状态及后续委托门禁行为。", + ), + "evidence": ( + "运维会话终止记录和前置断线回调时间戳。", + "运行日志中连接状态变化及新委托请求计数。", + "断线期间未完成委托的查询与对账记录。", + ), + "dependency": "公开 API 手册记录客户端登出请求,未说明服务端强制踢出接口;须由运维确认机制。", +} + + +class EM03ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind EM03 to operator-authorized logout, disconnect and blocked-write facts.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("EM03 strategy requires the EM03 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/config.yaml new file mode 100644 index 00000000..2cacd0b4 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.EM03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: EM03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/run.py new file mode 100644 index 00000000..d093d1eb --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/EM03/run.py @@ -0,0 +1,14 @@ +"""Run EM03 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("EM03", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L01/L01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L01/L01_strategy.py new file mode 100644 index 00000000..ad976cab --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L01/L01_strategy.py @@ -0,0 +1,31 @@ +"""L01: case-specific real-evidence and action plan.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "L01" +CASE_NAME = "日志记录:真实交易信息" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证运行日志将真实委托、成交和撤单信息按标识关联记录。", + "preconditions": ( + "已批准的真实 SimNow 会话和日志留存策略。", + "取得独立批准的低风险委托与成交验证窗口。", + ), + "actions": ( + "记录进程、会话和日志文件基线。", + "在授权范围内完成一笔可核验委托及成交生命周期。", + "按交易日、订单引用和成交标识核对运行日志与前置查询。", + ), + "evidence": ( + "真实委托、成交回报及订单/成交查询结果。", + "订单日志和交易日志中的时间戳、订单引用及成交标识。", + "采集文件的路径、采集时间和完整性摘要。", + ), + "dependency": "成交记录需要真实撮合;无成交时只能说明委托日志行为,不能证明成交日志。", +} + + +def create_strategy(plan, scope, authenticator): + """Construct the no-I/O typed trade-log observer for an injected adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/L01/config.yaml new file mode 100644 index 00000000..2efe2b0f --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.L01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: L01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L01/run.py new file mode 100644 index 00000000..98761844 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L01/run.py @@ -0,0 +1,14 @@ +"""Run L01 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("L01", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L02/L02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L02/L02_strategy.py new file mode 100644 index 00000000..ecd7b097 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L02/L02_strategy.py @@ -0,0 +1,31 @@ +"""L02 plan and typed read-only system-lifecycle evidence strategy.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "L02" +CASE_NAME = "日志记录:系统运行信息" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证真实运行会话的启动、连接、就绪和退出状态均写入系统日志。", + "preconditions": ( + "已批准的只读连接窗口和可写入受控目录的日志配置。", + "记录预期连接前置与运行进程身份。", + ), + "actions": ( + "启动受管运行入口并保留原始标准输出及系统日志。", + "核对连接建立、会话就绪和受控退出的时间顺序。", + "将日志状态与实际进程和连接观测进行关联。", + ), + "evidence": ( + "进程标识、启动和退出时间及未修改的系统日志。", + "真实连接状态回调和运行就绪事件。", + "日志文件采集路径及完整性摘要。", + ), + "dependency": "本场景要求真实连接证据;本地运行或离线报告不能证明 SimNow 连通。", +} + + +def create_strategy(plan, scope, authenticator): + """Build the no-I/O event state machine for an injected managed adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/L02/config.yaml new file mode 100644 index 00000000..1f264abf --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.L02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: L02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L02/run.py new file mode 100644 index 00000000..ac83ae42 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L02/run.py @@ -0,0 +1,14 @@ +"""Run L02 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("L02", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L03/L03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L03/L03_strategy.py new file mode 100644 index 00000000..129cbf0e --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L03/L03_strategy.py @@ -0,0 +1,180 @@ +"""L03: case-specific real-evidence and action plan.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import re +from types import MappingProxyType +from typing import Mapping + +from common.case_engine import DescriptiveCasePlan +from common.decision_engine import ( + CASE_INTENT_SPECS, + CaseIntentDecisionEngine, + DecisionError, + DecisionScope, + NativeObservation, + ObservationAuthenticator, + ObservationKind, +) + +CASE_ID = "L03" +CASE_NAME = "日志记录:运行监测信息" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证监测日志记录真实会话状态及经授权交易请求的观察事件。", + "preconditions": ( + "已批准的真实会话及监控事件和日志字段定义。", + "如需观察报撤单指标,须另行批准对应交易操作和账户额度。", + ), + "actions": ( + "记录运行、连接和监控计数器基线。", + "观察真实会话中的监控事件,并关联已批准请求的请求标识。", + "核对监控日志与运行日志、前置回报的时间及状态一致性。", + ), + "evidence": ( + "带时间戳的连接、就绪及监控日志原文。", + "获准请求的请求标识、前置回报和计数器变化。", + "日志采集范围及完整性摘要。", + ), + "dependency": "若范围包含委托或撤单计数,需真实交易授权;监控记录不能替代柜台回报。", +} + + +_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$") + + +def _copy_event(event: NativeObservation) -> NativeObservation: + if not isinstance(event.fields, Mapping): + raise DecisionError("observation fields must be a mapping") + fields = {} + for key, value in event.fields.items(): + if not isinstance(key, str): + raise DecisionError("observation field names must be strings") + if isinstance(value, (str, bool, int, float, type(None))): + fields[key] = value + elif isinstance(value, (tuple, list)) and all( + isinstance(item, (str, bool, int, float, type(None))) for item in value + ): + fields[key] = tuple(value) + else: + raise DecisionError("observation fields must contain immutable scalar facts") + return replace(event, fields=MappingProxyType(fields)) + + +class L03Strategy(CaseIntentDecisionEngine): + """Bind monitor log events to authorized request and provider evidence.""" + + def __init__(self, plan, scope, authenticator): + super().__init__(plan, scope, authenticator) + kinds = ( + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.MONITOR_LOG, + ObservationKind.SYSTEM_LOG, + ) + self.spec = replace( + CASE_INTENT_SPECS[CASE_ID], + required_kinds=kinds, + rule="local_monitor_log", + admission_kind="open", + ) + + def record(self, event: NativeObservation) -> bool: + return super().record(_copy_event(event)) + + def _missing(self, now: datetime) -> tuple[str, ...]: + missing = list(super()._missing(now)) + if not self._session_ready(): + missing.append("provider_auth_login_front_and_subscription_success_required") + admission = self._last(ObservationKind.ORDER_ADMISSION) + if not admission or admission.fields.get("maximum_quantity") != 1: + missing.append("one_lot_review_only_request_admission_required") + + receipts = self._all(ObservationKind.ORDER_SUBMIT_RECEIPT) + accepted = self._all(ObservationKind.ORDER_ACCEPTED) + monitor_logs = self._all(ObservationKind.MONITOR_LOG) + system_logs = self._all(ObservationKind.SYSTEM_LOG) + if ( + len(receipts) != 1 + or len(accepted) != 1 + or len(monitor_logs) != 1 + or len(system_logs) != 1 + ): + missing.append("one_correlated_request_provider_ack_monitor_and_runtime_log_required") + return tuple(dict.fromkeys(missing)) + + receipt, order, monitor, runtime = receipts[0], accepted[0], monitor_logs[0], system_logs[0] + ref = receipt.fields.get("order_ref") + request_id = receipt.fields.get("request_id") + trace_id = receipt.fields.get("trace_id") + if ( + receipt.fields.get("dispatch_state") != "submitted" + or not isinstance(request_id, str) + or not request_id + or not isinstance(trace_id, str) + or not trace_id + or order.fields.get("order_ref") != ref + or order.fields.get("status") not in {"accepted", "working"} + ): + missing.append("managed_request_receipt_must_match_native_provider_acknowledgement") + if ( + monitor.fields.get("event_name") != "order_submit_request" + or monitor.fields.get("metric") != "submit_count" + or monitor.fields.get("count") != 1 + or monitor.fields.get("request_id") != request_id + or monitor.fields.get("request_receipt_event_id") != receipt.event_id + or monitor.fields.get("order_ref") != ref + or monitor.fields.get("trace_id") != trace_id + or not _HEX64.fullmatch(str(monitor.fields.get("monitor_digest", ""))) + ): + missing.append("monitor_log_must_bind_the_exact_authorized_request_receipt") + if ( + runtime.fields.get("event_name") != "monitor_observation" + or runtime.fields.get("monitor_event_id") != monitor.event_id + or runtime.fields.get("request_id") != request_id + or runtime.fields.get("order_ref") != ref + or runtime.fields.get("trace_id") != trace_id + or runtime.fields.get("session_id") != order.provider_session_id + or runtime.fields.get("gateway_key") != order.fields.get("gateway_key") + or not _HEX64.fullmatch(str(runtime.fields.get("log_digest", ""))) + ): + missing.append("runtime_log_must_bind_monitor_request_and_provider_session") + if ( + monitor.fields.get("session_id") != order.provider_session_id + or monitor.fields.get("gateway_key") != order.fields.get("gateway_key") + or monitor.fields.get("trading_day") != order.trading_day + or runtime.fields.get("trading_day") != order.trading_day + or runtime.fields.get("connection_generation") + != order.fields.get("connection_generation") + or monitor.fields.get("connection_generation") + != order.fields.get("connection_generation") + ): + missing.append("monitor_and_runtime_logs_must_match_provider_session_generation") + if not ( + _time(receipt.occurred_at_utc) + <= _time(monitor.occurred_at_utc) + <= _time(runtime.occurred_at_utc) + and _time(receipt.occurred_at_utc) <= _time(order.occurred_at_utc) + and _time(order.occurred_at_utc) <= _time(runtime.occurred_at_utc) + ): + missing.append("monitor_and_runtime_log_timestamps_must_follow_request_and_ack") + return tuple(dict.fromkeys(missing)) + + +def _time(value: str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def create_strategy( + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: ObservationAuthenticator | None, +) -> L03Strategy: + return L03Strategy(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/L03/config.yaml new file mode 100644 index 00000000..1007cc1a --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.L03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: L03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L03/run.py new file mode 100644 index 00000000..897c2136 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L03/run.py @@ -0,0 +1,14 @@ +"""Run L03 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("L03", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L04/L04_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L04/L04_strategy.py new file mode 100644 index 00000000..c05fa476 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L04/L04_strategy.py @@ -0,0 +1,34 @@ +"""L04: case-specific real-evidence and action plan.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = "L04" +CASE_NAME = "日志记录:错误提示信息" +CASE_PLAN = { + "evidence_basis": "real_runtime", + "scenario": "验证运行系统记录一条可归因的错误提示,并区分本地检查与前置拒绝。", + "preconditions": ( + "已批准的真实会话、错误日志目录和受控测试条件。", + "确认本场景采用运行时价格最小变动单位检查,且不会将请求发至前置。", + ), + "actions": ( + "记录合约价格最小变动单位及日志基线。", + "在运行时风控边界提交一笔不符合最小变动单位的受控请求。", + "确认请求在本地被拒绝,并检查关联错误日志及前置请求计数。", + ), + "evidence": ( + "合约元数据、请求参数和本地拒绝事件的原始记录。", + "错误日志中明确标注为本地检查的原因及请求关联标识。", + "本地拒绝前后前置请求计数,证明未将该请求作为柜台拒绝。", + ), + "dependency": "现有场景语义是本地价格步长校验;它不能证明 SimNow 服务端错误处理。", +} + + +class L04ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind L04 to local validator error provenance and zero-dispatch audit.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("L04 strategy requires the L04 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L04/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/L04/config.yaml new file mode 100644 index 00000000..2a517286 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L04/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.L04 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: L04 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/L04/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/L04/run.py new file mode 100644 index 00000000..dd9d8b3a --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/L04/run.py @@ -0,0 +1,14 @@ +"""Run L04 through the suite-managed case entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +from managed_case_entry import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main("L04", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M01/M01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M01/M01_strategy.py new file mode 100644 index 00000000..fa732b46 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M01/M01_strategy.py @@ -0,0 +1,22 @@ +"""Case plan and typed read-only connection/readiness evidence strategy.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "M01" +CASE_NAME = "Connection and readiness display" +CASE_PLAN = { + "evidence": ( + "Capture store_connected and store_ready records with callback-local capture times and independently observed login/session/front identities; do not label local times as provider timestamps.", + "Correlate both events to the same authenticated session and a clean shutdown record; redact account identifiers.", + ), + "actions": ( + "Start the reviewed sandbox session and wait for the provider-backed ready event.", + "Record the connection/readiness event sequence, then close cleanly without sending orders.", + ), +} + + +def create_strategy(plan, scope, authenticator): + """Build the no-I/O event state machine for an injected managed adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/M01/config.yaml new file mode 100644 index 00000000..35f72a97 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.M01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: M01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M01/run.py new file mode 100644 index 00000000..d60ed81f --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M01/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("M01", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M02/M02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M02/M02_strategy.py new file mode 100644 index 00000000..a1f04519 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M02/M02_strategy.py @@ -0,0 +1,500 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import re +from types import MappingProxyType +from typing import Mapping + +from common.case_engine import DescriptiveCasePlan +from common.decision_engine import ( + CASE_INTENT_SPECS, + CaseIntentDecisionEngine, + DecisionError, + DecisionScope, + EvidenceTrustDomain, + NativeObservation, + ObservationAuthenticator, + ObservationKind, +) + +CASE_ID = "M02" +CASE_NAME = "Controlled session disconnect display" +CASE_PLAN = { + "evidence": ( + "Capture store_disconnected, its timestamp, source transport event, and the active provider session identity.", + "Correlate the disconnect display with the observed TD/MD transport loss and retain supervised restoration evidence.", + ), + "actions": ( + "Under an approved supervised fault procedure, interrupt the active test session transport and observe store_disconnected; ordinary local stop is insufficient.", + "Restore the transport under supervision and reconcile the session and zero residual orders; leave the case BLOCKED if controlled fault capability is unavailable.", + ), +} + + +_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$") +_CALLBACKS = ( + "OnFrontConnected", + "OnRspAuthenticate", + "OnRspUserLogin", + "OnRspSubMarketData", +) + + +def _copy_event(event: NativeObservation) -> NativeObservation: + if not isinstance(event.fields, Mapping): + raise DecisionError("observation fields must be a mapping") + copied = {} + for key, value in event.fields.items(): + if not isinstance(key, str): + raise DecisionError("observation field names must be strings") + if isinstance(value, (str, bool, int, float, type(None))): + copied[key] = value + elif isinstance(value, (tuple, list)) and all( + isinstance(item, (str, bool, int, float, type(None))) for item in value + ): + copied[key] = tuple(value) + else: + raise DecisionError("observation fields must contain immutable scalar facts") + return replace(event, fields=MappingProxyType(copied)) + + +class M02Strategy(CaseIntentDecisionEngine): + """Require a supervised disconnect and a separate, successful recovery.""" + + def __init__(self, plan, scope, authenticator): + super().__init__(plan, scope, authenticator) + kinds = ( + ObservationKind.FRONT_DISCONNECTED, + ObservationKind.EXTERNAL_CONDITION, + ObservationKind.FRONT_CONNECTED, + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.SYSTEM_LOG, + ) + self.spec = replace(CASE_INTENT_SPECS[CASE_ID], required_kinds=kinds) + + def record(self, event: NativeObservation) -> bool: + try: + return self._record_observation(event) + except Exception as exc: + event_id = event.event_id if type(event) is NativeObservation else "invalid-event" + self._rejected.append(f"{event_id}:record_rejected:{type(exc).__name__}") + raise + + def _record_observation(self, event: NativeObservation) -> bool: + event = _copy_event(event) + self._validate_event(event) + if event.event_id in self._event_ids: + raise DecisionError("duplicate observation event_id") + if event.kind is ObservationKind.SYSTEM_LOG: + self._validate_lifecycle_log(event) + if self._authenticator is None: + self._rejected.append(f"{event.event_id}:authenticator_unavailable") + return False + receipt = self._authenticator.authenticate(event, self.scope) + if receipt is None or ( + receipt.event_id != event.event_id + or receipt.evidence_sha256.lower() != event.evidence_sha256.lower() + or receipt.scope_sha256.lower() != self.scope.scope_sha256.lower() + or not isinstance(receipt.account_identity_sha256, str) + or receipt.account_identity_sha256.lower() + != self.scope.account_identity_sha256.lower() + or receipt.trust_domain is not event.source_domain + or not receipt.verification_ref.strip() + ): + self._rejected.append(f"{event.event_id}:verification_receipt_mismatch") + return False + + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK: + generation = event.fields.get("connection_generation") + if type(generation) is not int or generation < 1: + raise DecisionError("provider callback requires a positive connection generation") + if ( + not event.client_instance_id + or event.arrival_generation != generation + or event.sequence_origin != "local_sdk_callback_arrival" + or event.timestamp_origin != "local_sdk_capture_clock" + or event.event_id_origin != "local_sdk_callback_arrival" + or event.provider_issued_event_id is not False + or type(event.arrived_monotonic) not in {int, float} + or isinstance(event.arrived_monotonic, bool) + or event.arrived_monotonic <= 0 + or _utc(event.arrived_at_utc) != _utc(event.occurred_at_utc) + ): + raise DecisionError("CTP callback requires same-client local arrival metadata") + if event.kind in { + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + }: + if event.connection_generation_origin != "local_connection_generation": + raise DecisionError("front callbacks require local connection-generation origin") + if event.session_identity_origin != "unavailable_on_native_front_connection_callback": + raise DecisionError("payloadless front callbacks cannot establish provider identity") + elif event.kind is ObservationKind.MARKET_SUBSCRIPTION_ACK: + if event.session_identity_origin != "derived_from_same_client_generation_native_login": + raise DecisionError("subscription scope must be derived from a same-client login") + elif event.kind not in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + } and event.session_identity_origin != "derived_from_same_client_generation_native_login": + raise DecisionError("provider scope must be derived from a same-client native login") + + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK: + sequence_key = (event.source_domain, event.client_instance_id, event.stream_id, "") + else: + sequence_key = ( + event.source_domain, + event.stream_id, + event.provider_session_id, + event.trading_day, + ) + if event.sequence <= self._sequences.get(sequence_key, 0): + raise DecisionError("observation sequence must increase within a source stream") + + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK: + provider_events = [ + item + for item in self._events + if item.source_domain is event.source_domain + and item.client_instance_id == event.client_instance_id + and item.stream_id == event.stream_id + ] + provider_logins = [ + item for item in provider_events if item.kind is ObservationKind.LOGIN_SUCCESS + ] + latest_login = provider_logins[-1] if provider_logins else None + generation = event.fields["connection_generation"] + if event.kind is ObservationKind.FRONT_DISCONNECTED: + if latest_login is not None and ( + latest_login.arrival_generation != generation + or latest_login.sequence >= event.sequence + ): + raise DecisionError("disconnect must follow the active native login generation") + elif event.kind is ObservationKind.FRONT_CONNECTED: + if latest_login is not None: + old_generation = latest_login.arrival_generation + if generation < old_generation: + raise DecisionError("front connection generation cannot move backwards") + if generation > old_generation and not any( + item.kind is ObservationKind.FRONT_DISCONNECTED + and item.arrival_generation == old_generation + and item.sequence < event.sequence + for item in provider_events + ): + raise DecisionError("new front generation requires prior local disconnect") + if generation == old_generation and any( + item.kind is ObservationKind.FRONT_DISCONNECTED + and item.arrival_generation == old_generation + and item.sequence > latest_login.sequence + and item.sequence < event.sequence + for item in provider_events + ): + raise DecisionError("reconnected front must use a new local generation") + elif event.kind is ObservationKind.AUTH_SUCCESS: + if not any( + item.kind is ObservationKind.FRONT_CONNECTED + and item.arrival_generation == generation + and item.sequence < event.sequence + for item in provider_events + ): + raise DecisionError("authentication requires prior same-generation front connection") + elif event.kind is ObservationKind.LOGIN_SUCCESS: + if latest_login is not None: + old_generation = latest_login.arrival_generation + new_scope = (event.provider_session_id, event.trading_day) + old_scope = (latest_login.provider_session_id, latest_login.trading_day) + if generation < old_generation: + raise DecisionError("login generation cannot move backwards") + if generation == old_generation and new_scope != old_scope: + raise DecisionError("same-generation login cannot change provider identity") + if generation > old_generation and not ( + any( + item.kind is ObservationKind.FRONT_DISCONNECTED + and item.arrival_generation == old_generation + and latest_login.sequence < item.sequence < event.sequence + for item in provider_events + ) + and any( + item.kind is ObservationKind.FRONT_CONNECTED + and item.arrival_generation == generation + and item.sequence < event.sequence + for item in provider_events + ) + ): + raise DecisionError("new native login requires a new local connection generation") + else: + if latest_login is None: + raise DecisionError("session-scoped callback requires prior native user login") + if ( + latest_login.arrival_generation != generation + or (event.provider_session_id, event.trading_day) + != (latest_login.provider_session_id, latest_login.trading_day) + or event.sequence <= latest_login.sequence + ): + raise DecisionError("provider callback scope must match prior same-generation login") + + if event.kind is ObservationKind.LOGIN_SUCCESS: + provider_scope = (event.provider_session_id, event.trading_day) + if self._provider_session is None: + self._provider_session = provider_scope + elif provider_scope != self._provider_session: + if self.plan.case_id not in {"M02", "M03"}: + raise DecisionError( + "one case decision cannot mix provider sessions or trading days" + ) + if latest_login is None or generation <= latest_login.arrival_generation: + raise DecisionError("provider identity may change only on a newer native login") + self._provider_session = provider_scope + elif event.kind not in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + }: + provider_scope = (event.provider_session_id, event.trading_day) + if self._provider_session is None or provider_scope != self._provider_session: + raise DecisionError("provider callback does not match the latest native login") + + self._sequences[sequence_key] = event.sequence + self._event_ids.add(event.event_id) + self._events.append(event) + return True + + def _validate_lifecycle_log(self, event: NativeObservation) -> None: + name = event.fields.get("event_name") + if name not in {"store_disconnected", "store_reconnect_success"}: + raise DecisionError("M02 runtime log must be a disconnect or recovery summary") + if ( + not isinstance(event.fields.get("session_id"), str) + or type(event.fields.get("connection_generation")) is not int + or event.fields.get("connection_generation") < 1 + or not isinstance(event.fields.get("provider_event_id"), str) + ) and name == "store_disconnected": + raise DecisionError("disconnect log must bind the old session generation and callback") + if name == "store_reconnect_success": + if ( + not isinstance(event.fields.get("session_id"), str) + or type(event.fields.get("connection_generation")) is not int + or event.fields.get("connection_generation") < 1 + or tuple(event.fields.get("callback_names", ())) != _CALLBACKS + or len(tuple(event.fields.get("provider_event_ids", ()))) != 4 + ): + raise DecisionError("recovery summary must name its four fresh native callbacks") + for key in ("auth_error_id", "login_error_id", "subscription_error_id"): + if type(event.fields.get(key)) is not int or event.fields[key] != 0: + raise DecisionError("recovery summary requires three zero provider error ids") + for key in ( + "authentication_succeeded", + "login_succeeded", + "subscription_succeeded", + ): + if event.fields.get(key) is not True: + raise DecisionError( + "recovery summary requires successful auth/login/subscription" + ) + if ( + not _HEX64.fullmatch(str(event.fields.get("snapshot_sha256", ""))) + or not event.fields.get("external_event_id") + or not event.fields.get("snapshot_event_id") + ): + raise DecisionError( + "recovery summary must bind the external condition and snapshot" + ) + + def _missing(self, now: datetime) -> tuple[str, ...]: + missing = list(super()._missing(now)) + + def exactly_one(kind): + rows = self._all(kind) + if len(rows) != 1: + missing.append(f"exactly_one_{kind.value}_required") + return None + return rows[0] + + disconnected = exactly_one(ObservationKind.FRONT_DISCONNECTED) + connected = exactly_one(ObservationKind.FRONT_CONNECTED) + authentication = exactly_one(ObservationKind.AUTH_SUCCESS) + subscribed = exactly_one(ObservationKind.MARKET_SUBSCRIPTION_ACK) + control_disconnect = next( + ( + item + for item in self._all(ObservationKind.EXTERNAL_CONDITION) + if item.fields.get("condition_id") == "external_disconnect" + and item.fields.get("state") == "satisfied" + ), + None, + ) + control_reconnect = next( + ( + item + for item in self._all(ObservationKind.EXTERNAL_CONDITION) + if item.fields.get("condition_id") == "external_reconnect" + and item.fields.get("state") == "satisfied" + ), + None, + ) + if len(self._all(ObservationKind.EXTERNAL_CONDITION)) != 2 or not ( + control_disconnect and control_reconnect + ): + missing.append("one_disconnect_and_one_reconnect_control_receipt_required") + logs = self._all(ObservationKind.SYSTEM_LOG) + by_name = {item.fields.get("event_name"): item for item in logs} + if len(logs) != 2 or set(by_name) != {"store_disconnected", "store_reconnect_success"}: + missing.append("disconnect_and_recovery_runtime_summaries_required") + + old_login = new_login = None + old_gen = new_gen = None + if disconnected is not None and connected is not None: + old_gen = disconnected.fields.get("connection_generation") + new_gen = connected.fields.get("connection_generation") + login_rows = self._all(ObservationKind.LOGIN_SUCCESS) + old_candidates = [ + item + for item in login_rows + if item.session_identity_origin == "native_login_response_fields" + and item.client_instance_id == disconnected.client_instance_id + and item.stream_id == disconnected.stream_id + and item.arrival_generation == old_gen + and item.fields.get("connection_generation") == old_gen + and item.sequence < disconnected.sequence + and _utc(item.occurred_at_utc) < _utc(disconnected.occurred_at_utc) + ] + new_candidates = [ + item + for item in login_rows + if item.session_identity_origin == "native_login_response_fields" + and item.client_instance_id == connected.client_instance_id + and item.stream_id == connected.stream_id + and item.arrival_generation == new_gen + and item.fields.get("connection_generation") == new_gen + and item.sequence > connected.sequence + and _utc(item.occurred_at_utc) > _utc(connected.occurred_at_utc) + ] + if len(old_candidates) != 1: + missing.append("one_pre_disconnect_native_login_binding_required") + else: + old_login = old_candidates[0] + if len(new_candidates) != 1: + missing.append("one_reconnect_native_login_binding_required") + else: + new_login = new_candidates[0] + + if not all((disconnected, connected, authentication, subscribed, old_login, new_login)): + return tuple(dict.fromkeys(missing)) + + old_id, old_day = old_login.provider_session_id, old_login.trading_day + new_id, new_day = new_login.provider_session_id, new_login.trading_day + callbacks = (connected, authentication, new_login, subscribed) + if ( + old_id == new_id + or old_day != new_day + or type(old_gen) is not int + or type(new_gen) is not int + or new_gen <= old_gen + or old_login.client_instance_id != disconnected.client_instance_id + or old_login.client_instance_id != connected.client_instance_id + or new_login.client_instance_id != old_login.client_instance_id + or any(item.stream_id != old_login.stream_id for item in callbacks) + or disconnected.stream_id != old_login.stream_id + or any(item.arrival_generation != new_gen for item in callbacks) + or any(item.fields.get("connection_generation") != new_gen for item in callbacks) + or connected.provider_session_id + or connected.trading_day + or authentication.provider_session_id + or authentication.trading_day + or (new_login.provider_session_id, new_login.trading_day) != (new_id, new_day) + or (subscribed.provider_session_id, subscribed.trading_day) != (new_id, new_day) + or subscribed.session_identity_origin + != "derived_from_same_client_generation_native_login" + or connected.fields.get("gateway_key") != disconnected.fields.get("gateway_key") + ): + missing.append("recovery_must_bind_native_login_scopes_to_local_generations") + ordered = (old_login, disconnected, connected, authentication, new_login, subscribed) + times = [_utc(item.occurred_at_utc) for item in ordered] + sequences = [item.sequence for item in ordered] + if times != sorted(times) or sequences != sorted(sequences): + missing.append("native_login_disconnect_reconnect_callbacks_must_follow_local_arrival_order") + if ( + authentication.client_instance_id != new_login.client_instance_id + or authentication.arrival_generation != new_login.arrival_generation + or authentication.request_generation == new_login.request_generation + or authentication.fields.get("request_id") == new_login.fields.get("request_id") + ): + missing.append("recovery_auth_login_must_use_distinct_same_client_requests") + if control_disconnect: + f = control_disconnect.fields + if ( + f.get("provider_event_ref") != disconnected.event_id + or f.get("session_id") != old_id + or f.get("connection_generation") != old_gen + or f.get("gateway_key") != disconnected.fields.get("gateway_key") + or not f.get("snapshot_event_id") + or not _HEX64.fullmatch(str(f.get("snapshot_sha256", ""))) + or _utc(control_disconnect.occurred_at_utc) < _utc(disconnected.occurred_at_utc) + or _utc(control_disconnect.occurred_at_utc) >= _utc(connected.occurred_at_utc) + ): + missing.append("external_disconnect_must_bind_old_callback_and_snapshot") + if control_reconnect: + f = control_reconnect.fields + if ( + f.get("disconnect_event_ref") != disconnected.event_id + or f.get("reconnect_event_ref") != connected.event_id + or f.get("previous_session_id") != old_id + or f.get("new_session_id") != new_id + or f.get("previous_connection_generation") != old_gen + or f.get("new_connection_generation") != new_gen + or f.get("gateway_key") != connected.fields.get("gateway_key") + or not f.get("snapshot_event_id") + or not _HEX64.fullmatch(str(f.get("snapshot_sha256", ""))) + or _utc(control_reconnect.occurred_at_utc) < _utc(subscribed.occurred_at_utc) + ): + missing.append("external_reconnect_must_bind_both_sessions_and_snapshot") + disconnect_log = by_name.get("store_disconnected") + summary = by_name.get("store_reconnect_success") + if disconnect_log and ( + disconnect_log.fields.get("session_id") != old_id + or disconnect_log.fields.get("connection_generation") != old_gen + or disconnect_log.fields.get("provider_event_id") != disconnected.event_id + or _utc(disconnect_log.occurred_at_utc) < _utc(disconnected.occurred_at_utc) + or _utc(disconnect_log.occurred_at_utc) >= _utc(connected.occurred_at_utc) + ): + missing.append("store_disconnected_log_must_reference_old_native_callback") + if summary and control_reconnect: + f = summary.fields + expected_ids = tuple(item.event_id for item in callbacks) + if ( + f.get("session_id") != new_id + or f.get("connection_generation") != new_gen + or tuple(f.get("callback_names", ())) != _CALLBACKS + or tuple(f.get("provider_event_ids", ())) != expected_ids + or f.get("external_event_id") != control_reconnect.event_id + or f.get("snapshot_event_id") != control_reconnect.fields.get("snapshot_event_id") + or f.get("snapshot_sha256") != control_reconnect.fields.get("snapshot_sha256") + or f.get("previous_session_id") != old_id + or f.get("new_session_id") != new_id + or f.get("previous_connection_generation") != old_gen + or f.get("new_connection_generation") != new_gen + or _utc(summary.occurred_at_utc) + < max(_utc(item.occurred_at_utc) for item in callbacks) + or _utc(summary.occurred_at_utc) < _utc(control_reconnect.occurred_at_utc) + ): + missing.append( + "recovery_summary_must_bind_fresh_callbacks_control_event_and_snapshot" + ) + return tuple(dict.fromkeys(missing)) + + + +def _utc(value: str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def create_strategy( + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: ObservationAuthenticator | None, +) -> M02Strategy: + return M02Strategy(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/M02/config.yaml new file mode 100644 index 00000000..cb6a2e06 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.M02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: M02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M02/run.py new file mode 100644 index 00000000..513996d8 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M02/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("M02", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M03/M03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M03/M03_strategy.py new file mode 100644 index 00000000..f29cd3ab --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M03/M03_strategy.py @@ -0,0 +1,499 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import re +from types import MappingProxyType +from typing import Mapping + +from common.case_engine import DescriptiveCasePlan +from common.decision_engine import ( + CASE_INTENT_SPECS, + CaseIntentDecisionEngine, + DecisionError, + DecisionScope, + EvidenceTrustDomain, + NativeObservation, + ObservationAuthenticator, + ObservationKind, +) + +CASE_ID = "M03" +CASE_NAME = "Stop and reconnect lifecycle" +CASE_PLAN = { + "evidence": ( + "Retain timestamped store_disconnected and store_reconnect_success events tied to the same account and distinct connection generations.", + "Confirm fresh provider login, subscription, and ready callbacks after restoration, then reconcile account scope before ending.", + ), + "actions": ( + "Start one reviewed session, interrupt and restore its transport through the approved supervised fault procedure, then observe automatic recovery.", + "Require store_reconnect_success and fresh provider-backed ready state; stop and record the case if retry or account identity becomes uncertain.", + ), +} + +_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$") +_CALLBACKS = ( + "OnFrontConnected", + "OnRspAuthenticate", + "OnRspUserLogin", + "OnRspSubMarketData", +) + + +def _copy_event(event: NativeObservation) -> NativeObservation: + if not isinstance(event.fields, Mapping): + raise DecisionError("observation fields must be a mapping") + copied = {} + for key, value in event.fields.items(): + if not isinstance(key, str): + raise DecisionError("observation field names must be strings") + if isinstance(value, (str, bool, int, float, type(None))): + copied[key] = value + elif isinstance(value, (tuple, list)) and all( + isinstance(item, (str, bool, int, float, type(None))) for item in value + ): + copied[key] = tuple(value) + else: + raise DecisionError("observation fields must contain immutable scalar facts") + return replace(event, fields=MappingProxyType(copied)) + + +class M03Strategy(CaseIntentDecisionEngine): + """Require a supervised disconnect and a separate, successful recovery.""" + + def __init__(self, plan, scope, authenticator): + super().__init__(plan, scope, authenticator) + kinds = ( + ObservationKind.FRONT_DISCONNECTED, + ObservationKind.EXTERNAL_CONDITION, + ObservationKind.FRONT_CONNECTED, + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.SYSTEM_LOG, + ) + self.spec = replace(CASE_INTENT_SPECS[CASE_ID], required_kinds=kinds) + + def record(self, event: NativeObservation) -> bool: + try: + return self._record_observation(event) + except Exception as exc: + event_id = event.event_id if type(event) is NativeObservation else "invalid-event" + self._rejected.append(f"{event_id}:record_rejected:{type(exc).__name__}") + raise + + def _record_observation(self, event: NativeObservation) -> bool: + event = _copy_event(event) + self._validate_event(event) + if event.event_id in self._event_ids: + raise DecisionError("duplicate observation event_id") + if event.kind is ObservationKind.SYSTEM_LOG: + self._validate_lifecycle_log(event) + if self._authenticator is None: + self._rejected.append(f"{event.event_id}:authenticator_unavailable") + return False + receipt = self._authenticator.authenticate(event, self.scope) + if receipt is None or ( + receipt.event_id != event.event_id + or receipt.evidence_sha256.lower() != event.evidence_sha256.lower() + or receipt.scope_sha256.lower() != self.scope.scope_sha256.lower() + or not isinstance(receipt.account_identity_sha256, str) + or receipt.account_identity_sha256.lower() + != self.scope.account_identity_sha256.lower() + or receipt.trust_domain is not event.source_domain + or not receipt.verification_ref.strip() + ): + self._rejected.append(f"{event.event_id}:verification_receipt_mismatch") + return False + + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK: + generation = event.fields.get("connection_generation") + if type(generation) is not int or generation < 1: + raise DecisionError("provider callback requires a positive connection generation") + if ( + not event.client_instance_id + or event.arrival_generation != generation + or event.sequence_origin != "local_sdk_callback_arrival" + or event.timestamp_origin != "local_sdk_capture_clock" + or event.event_id_origin != "local_sdk_callback_arrival" + or event.provider_issued_event_id is not False + or type(event.arrived_monotonic) not in {int, float} + or isinstance(event.arrived_monotonic, bool) + or event.arrived_monotonic <= 0 + or _utc(event.arrived_at_utc) != _utc(event.occurred_at_utc) + ): + raise DecisionError("CTP callback requires same-client local arrival metadata") + if event.kind in { + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + }: + if event.connection_generation_origin != "local_connection_generation": + raise DecisionError("front callbacks require local connection-generation origin") + if event.session_identity_origin != "unavailable_on_native_front_connection_callback": + raise DecisionError("payloadless front callbacks cannot establish provider identity") + elif event.kind is ObservationKind.MARKET_SUBSCRIPTION_ACK: + if event.session_identity_origin != "derived_from_same_client_generation_native_login": + raise DecisionError("subscription scope must be derived from a same-client login") + elif event.kind not in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + } and event.session_identity_origin != "derived_from_same_client_generation_native_login": + raise DecisionError("provider scope must be derived from a same-client native login") + + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK: + sequence_key = (event.source_domain, event.client_instance_id, event.stream_id, "") + else: + sequence_key = ( + event.source_domain, + event.stream_id, + event.provider_session_id, + event.trading_day, + ) + if event.sequence <= self._sequences.get(sequence_key, 0): + raise DecisionError("observation sequence must increase within a source stream") + + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK: + provider_events = [ + item + for item in self._events + if item.source_domain is event.source_domain + and item.client_instance_id == event.client_instance_id + and item.stream_id == event.stream_id + ] + provider_logins = [ + item for item in provider_events if item.kind is ObservationKind.LOGIN_SUCCESS + ] + latest_login = provider_logins[-1] if provider_logins else None + generation = event.fields["connection_generation"] + if event.kind is ObservationKind.FRONT_DISCONNECTED: + if latest_login is not None and ( + latest_login.arrival_generation != generation + or latest_login.sequence >= event.sequence + ): + raise DecisionError("disconnect must follow the active native login generation") + elif event.kind is ObservationKind.FRONT_CONNECTED: + if latest_login is not None: + old_generation = latest_login.arrival_generation + if generation < old_generation: + raise DecisionError("front connection generation cannot move backwards") + if generation > old_generation and not any( + item.kind is ObservationKind.FRONT_DISCONNECTED + and item.arrival_generation == old_generation + and item.sequence < event.sequence + for item in provider_events + ): + raise DecisionError("new front generation requires prior local disconnect") + if generation == old_generation and any( + item.kind is ObservationKind.FRONT_DISCONNECTED + and item.arrival_generation == old_generation + and item.sequence > latest_login.sequence + and item.sequence < event.sequence + for item in provider_events + ): + raise DecisionError("reconnected front must use a new local generation") + elif event.kind is ObservationKind.AUTH_SUCCESS: + if not any( + item.kind is ObservationKind.FRONT_CONNECTED + and item.arrival_generation == generation + and item.sequence < event.sequence + for item in provider_events + ): + raise DecisionError("authentication requires prior same-generation front connection") + elif event.kind is ObservationKind.LOGIN_SUCCESS: + if latest_login is not None: + old_generation = latest_login.arrival_generation + new_scope = (event.provider_session_id, event.trading_day) + old_scope = (latest_login.provider_session_id, latest_login.trading_day) + if generation < old_generation: + raise DecisionError("login generation cannot move backwards") + if generation == old_generation and new_scope != old_scope: + raise DecisionError("same-generation login cannot change provider identity") + if generation > old_generation and not ( + any( + item.kind is ObservationKind.FRONT_DISCONNECTED + and item.arrival_generation == old_generation + and latest_login.sequence < item.sequence < event.sequence + for item in provider_events + ) + and any( + item.kind is ObservationKind.FRONT_CONNECTED + and item.arrival_generation == generation + and item.sequence < event.sequence + for item in provider_events + ) + ): + raise DecisionError("new native login requires a new local connection generation") + else: + if latest_login is None: + raise DecisionError("session-scoped callback requires prior native user login") + if ( + latest_login.arrival_generation != generation + or (event.provider_session_id, event.trading_day) + != (latest_login.provider_session_id, latest_login.trading_day) + or event.sequence <= latest_login.sequence + ): + raise DecisionError("provider callback scope must match prior same-generation login") + + if event.kind is ObservationKind.LOGIN_SUCCESS: + provider_scope = (event.provider_session_id, event.trading_day) + if self._provider_session is None: + self._provider_session = provider_scope + elif provider_scope != self._provider_session: + if self.plan.case_id not in {"M02", "M03"}: + raise DecisionError( + "one case decision cannot mix provider sessions or trading days" + ) + if latest_login is None or generation <= latest_login.arrival_generation: + raise DecisionError("provider identity may change only on a newer native login") + self._provider_session = provider_scope + elif event.kind not in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + }: + provider_scope = (event.provider_session_id, event.trading_day) + if self._provider_session is None or provider_scope != self._provider_session: + raise DecisionError("provider callback does not match the latest native login") + + self._sequences[sequence_key] = event.sequence + self._event_ids.add(event.event_id) + self._events.append(event) + return True + + def _validate_lifecycle_log(self, event: NativeObservation) -> None: + name = event.fields.get("event_name") + if name not in {"store_disconnected", "store_reconnect_success"}: + raise DecisionError("M03 runtime log must be a disconnect or recovery summary") + if ( + not isinstance(event.fields.get("session_id"), str) + or type(event.fields.get("connection_generation")) is not int + or event.fields.get("connection_generation") < 1 + or not isinstance(event.fields.get("provider_event_id"), str) + ) and name == "store_disconnected": + raise DecisionError("disconnect log must bind the old session generation and callback") + if name == "store_reconnect_success": + if ( + not isinstance(event.fields.get("session_id"), str) + or type(event.fields.get("connection_generation")) is not int + or event.fields.get("connection_generation") < 1 + or tuple(event.fields.get("callback_names", ())) != _CALLBACKS + or len(tuple(event.fields.get("provider_event_ids", ()))) != 4 + ): + raise DecisionError("recovery summary must name its four fresh native callbacks") + for key in ("auth_error_id", "login_error_id", "subscription_error_id"): + if type(event.fields.get(key)) is not int or event.fields[key] != 0: + raise DecisionError("recovery summary requires three zero provider error ids") + for key in ( + "authentication_succeeded", + "login_succeeded", + "subscription_succeeded", + ): + if event.fields.get(key) is not True: + raise DecisionError( + "recovery summary requires successful auth/login/subscription" + ) + if ( + not _HEX64.fullmatch(str(event.fields.get("snapshot_sha256", ""))) + or not event.fields.get("external_event_id") + or not event.fields.get("snapshot_event_id") + ): + raise DecisionError( + "recovery summary must bind the external condition and snapshot" + ) + + def _missing(self, now: datetime) -> tuple[str, ...]: + missing = list(super()._missing(now)) + + def exactly_one(kind): + rows = self._all(kind) + if len(rows) != 1: + missing.append(f"exactly_one_{kind.value}_required") + return None + return rows[0] + + disconnected = exactly_one(ObservationKind.FRONT_DISCONNECTED) + connected = exactly_one(ObservationKind.FRONT_CONNECTED) + authentication = exactly_one(ObservationKind.AUTH_SUCCESS) + subscribed = exactly_one(ObservationKind.MARKET_SUBSCRIPTION_ACK) + control_disconnect = next( + ( + item + for item in self._all(ObservationKind.EXTERNAL_CONDITION) + if item.fields.get("condition_id") == "external_disconnect" + and item.fields.get("state") == "satisfied" + ), + None, + ) + control_reconnect = next( + ( + item + for item in self._all(ObservationKind.EXTERNAL_CONDITION) + if item.fields.get("condition_id") == "external_reconnect" + and item.fields.get("state") == "satisfied" + ), + None, + ) + if len(self._all(ObservationKind.EXTERNAL_CONDITION)) != 2 or not ( + control_disconnect and control_reconnect + ): + missing.append("one_disconnect_and_one_reconnect_control_receipt_required") + logs = self._all(ObservationKind.SYSTEM_LOG) + by_name = {item.fields.get("event_name"): item for item in logs} + if len(logs) != 2 or set(by_name) != {"store_disconnected", "store_reconnect_success"}: + missing.append("disconnect_and_recovery_runtime_summaries_required") + + old_login = new_login = None + old_gen = new_gen = None + if disconnected is not None and connected is not None: + old_gen = disconnected.fields.get("connection_generation") + new_gen = connected.fields.get("connection_generation") + login_rows = self._all(ObservationKind.LOGIN_SUCCESS) + old_candidates = [ + item + for item in login_rows + if item.session_identity_origin == "native_login_response_fields" + and item.client_instance_id == disconnected.client_instance_id + and item.stream_id == disconnected.stream_id + and item.arrival_generation == old_gen + and item.fields.get("connection_generation") == old_gen + and item.sequence < disconnected.sequence + and _utc(item.occurred_at_utc) < _utc(disconnected.occurred_at_utc) + ] + new_candidates = [ + item + for item in login_rows + if item.session_identity_origin == "native_login_response_fields" + and item.client_instance_id == connected.client_instance_id + and item.stream_id == connected.stream_id + and item.arrival_generation == new_gen + and item.fields.get("connection_generation") == new_gen + and item.sequence > connected.sequence + and _utc(item.occurred_at_utc) > _utc(connected.occurred_at_utc) + ] + if len(old_candidates) != 1: + missing.append("one_pre_disconnect_native_login_binding_required") + else: + old_login = old_candidates[0] + if len(new_candidates) != 1: + missing.append("one_reconnect_native_login_binding_required") + else: + new_login = new_candidates[0] + + if not all((disconnected, connected, authentication, subscribed, old_login, new_login)): + return tuple(dict.fromkeys(missing)) + + old_id, old_day = old_login.provider_session_id, old_login.trading_day + new_id, new_day = new_login.provider_session_id, new_login.trading_day + callbacks = (connected, authentication, new_login, subscribed) + if ( + old_id == new_id + or old_day != new_day + or type(old_gen) is not int + or type(new_gen) is not int + or new_gen <= old_gen + or old_login.client_instance_id != disconnected.client_instance_id + or old_login.client_instance_id != connected.client_instance_id + or new_login.client_instance_id != old_login.client_instance_id + or any(item.stream_id != old_login.stream_id for item in callbacks) + or disconnected.stream_id != old_login.stream_id + or any(item.arrival_generation != new_gen for item in callbacks) + or any(item.fields.get("connection_generation") != new_gen for item in callbacks) + or connected.provider_session_id + or connected.trading_day + or authentication.provider_session_id + or authentication.trading_day + or (new_login.provider_session_id, new_login.trading_day) != (new_id, new_day) + or (subscribed.provider_session_id, subscribed.trading_day) != (new_id, new_day) + or subscribed.session_identity_origin + != "derived_from_same_client_generation_native_login" + or connected.fields.get("gateway_key") != disconnected.fields.get("gateway_key") + ): + missing.append("recovery_must_bind_native_login_scopes_to_local_generations") + ordered = (old_login, disconnected, connected, authentication, new_login, subscribed) + times = [_utc(item.occurred_at_utc) for item in ordered] + sequences = [item.sequence for item in ordered] + if times != sorted(times) or sequences != sorted(sequences): + missing.append("native_login_disconnect_reconnect_callbacks_must_follow_local_arrival_order") + if ( + authentication.client_instance_id != new_login.client_instance_id + or authentication.arrival_generation != new_login.arrival_generation + or authentication.request_generation == new_login.request_generation + or authentication.fields.get("request_id") == new_login.fields.get("request_id") + ): + missing.append("recovery_auth_login_must_use_distinct_same_client_requests") + if control_disconnect: + f = control_disconnect.fields + if ( + f.get("provider_event_ref") != disconnected.event_id + or f.get("session_id") != old_id + or f.get("connection_generation") != old_gen + or f.get("gateway_key") != disconnected.fields.get("gateway_key") + or not f.get("snapshot_event_id") + or not _HEX64.fullmatch(str(f.get("snapshot_sha256", ""))) + or _utc(control_disconnect.occurred_at_utc) < _utc(disconnected.occurred_at_utc) + or _utc(control_disconnect.occurred_at_utc) >= _utc(connected.occurred_at_utc) + ): + missing.append("external_disconnect_must_bind_old_callback_and_snapshot") + if control_reconnect: + f = control_reconnect.fields + if ( + f.get("disconnect_event_ref") != disconnected.event_id + or f.get("reconnect_event_ref") != connected.event_id + or f.get("previous_session_id") != old_id + or f.get("new_session_id") != new_id + or f.get("previous_connection_generation") != old_gen + or f.get("new_connection_generation") != new_gen + or f.get("gateway_key") != connected.fields.get("gateway_key") + or not f.get("snapshot_event_id") + or not _HEX64.fullmatch(str(f.get("snapshot_sha256", ""))) + or _utc(control_reconnect.occurred_at_utc) < _utc(subscribed.occurred_at_utc) + ): + missing.append("external_reconnect_must_bind_both_sessions_and_snapshot") + disconnect_log = by_name.get("store_disconnected") + summary = by_name.get("store_reconnect_success") + if disconnect_log and ( + disconnect_log.fields.get("session_id") != old_id + or disconnect_log.fields.get("connection_generation") != old_gen + or disconnect_log.fields.get("provider_event_id") != disconnected.event_id + or _utc(disconnect_log.occurred_at_utc) < _utc(disconnected.occurred_at_utc) + or _utc(disconnect_log.occurred_at_utc) >= _utc(connected.occurred_at_utc) + ): + missing.append("store_disconnected_log_must_reference_old_native_callback") + if summary and control_reconnect: + f = summary.fields + expected_ids = tuple(item.event_id for item in callbacks) + if ( + f.get("session_id") != new_id + or f.get("connection_generation") != new_gen + or tuple(f.get("callback_names", ())) != _CALLBACKS + or tuple(f.get("provider_event_ids", ())) != expected_ids + or f.get("external_event_id") != control_reconnect.event_id + or f.get("snapshot_event_id") != control_reconnect.fields.get("snapshot_event_id") + or f.get("snapshot_sha256") != control_reconnect.fields.get("snapshot_sha256") + or f.get("previous_session_id") != old_id + or f.get("new_session_id") != new_id + or f.get("previous_connection_generation") != old_gen + or f.get("new_connection_generation") != new_gen + or _utc(summary.occurred_at_utc) + < max(_utc(item.occurred_at_utc) for item in callbacks) + or _utc(summary.occurred_at_utc) < _utc(control_reconnect.occurred_at_utc) + ): + missing.append( + "recovery_summary_must_bind_fresh_callbacks_control_event_and_snapshot" + ) + return tuple(dict.fromkeys(missing)) + + + +def _utc(value: str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def create_strategy( + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: ObservationAuthenticator | None, +) -> M03Strategy: + return M03Strategy(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/M03/config.yaml new file mode 100644 index 00000000..6d098fc5 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.M03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: M03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M03/run.py new file mode 100644 index 00000000..eea0dfee --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M03/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("M03", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M04/M04_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M04/M04_strategy.py new file mode 100644 index 00000000..270ef966 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M04/M04_strategy.py @@ -0,0 +1,194 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import re +from types import MappingProxyType +from typing import Mapping + +from common.case_engine import DescriptiveCasePlan +from common.decision_engine import ( + CASE_INTENT_SPECS, + CaseIntentDecisionEngine, + DecisionError, + DecisionScope, + NativeObservation, + ObservationAuthenticator, + ObservationKind, +) + +CASE_ID = "M04" +CASE_NAME = "Order-submission count" +CASE_PLAN = { + "evidence": ( + "Count real monitor.log order_submit_request records and distinct provider order references; retain monitoring_summary and provider acknowledgements.", + "Reconcile each submitted order with its cancellation/terminal status and the final open-order and position snapshots.", + ), + "actions": ( + "Under the reviewed per-case budget, submit up to three separately identified minimum-size open limit orders at bounded quote-relative prices.", + "Cancel each acknowledged order and compare the durable monitor count with provider order records; stop on any fill or risk-limit event.", + ), +} + + +_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$") + + +class _FrozenRefs(set): + """Read-only empty set accepted by the shared empty-order-query contract.""" + + def _deny(self, *args, **kwargs): + raise TypeError("provider reference snapshot is immutable") + + add = clear = difference_update = discard = intersection_update = pop = remove = ( + symmetric_difference_update + ) = update = _deny + __iand__ = __ior__ = __isub__ = __ixor__ = _deny + + +def _copy_event(event: NativeObservation) -> NativeObservation: + if not isinstance(event.fields, Mapping): + raise DecisionError("observation fields must be a mapping") + fields = {} + for key, value in event.fields.items(): + if not isinstance(key, str): + raise DecisionError("observation field names must be strings") + if isinstance(value, (str, bool, int, float, type(None))): + fields[key] = value + elif key == "open_order_refs" and isinstance(value, (tuple, list, set)) and not value: + fields[key] = _FrozenRefs() + elif isinstance(value, (tuple, list, set)) and all( + isinstance(item, (str, bool, int, float, type(None))) for item in value + ): + fields[key] = tuple(value) + else: + raise DecisionError("observation fields must contain immutable scalar facts") + return replace(event, fields=MappingProxyType(fields)) + + +class M04Strategy(CaseIntentDecisionEngine): + """Require monitor request counts to reconcile to runtime and provider facts.""" + + def __init__(self, plan, scope, authenticator): + super().__init__(plan, scope, authenticator) + kinds = ( + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_QUERY, + ObservationKind.POSITION_QUERY, + ObservationKind.MONITOR_LOG, + ) + self.spec = replace( + CASE_INTENT_SPECS[CASE_ID], + required_kinds=kinds, + rule="local_submit_count", + ) + + def record(self, event: NativeObservation) -> bool: + return super().record(_copy_event(event)) + + def _missing(self, now: datetime) -> tuple[str, ...]: + missing = list(super()._missing(now)) + if not self._session_ready(): + missing.append("provider_auth_login_front_and_subscription_success_required") + if not self._fresh_tick(now): + missing.append("fresh_valid_market_tick_required") + admission = self._last(ObservationKind.ORDER_ADMISSION) + if not admission or admission.fields.get("maximum_quantity") != 1: + missing.append("one_lot_review_only_case_admission_required") + + receipts = self._all(ObservationKind.ORDER_SUBMIT_RECEIPT) + logs = self._all(ObservationKind.MONITOR_LOG) + accepted = self._all(ObservationKind.ORDER_ACCEPTED) + canceled = self._all(ObservationKind.ORDER_CANCELED) + if not 1 <= len(receipts) <= 3: + missing.append("one_to_three_bounded_submit_request_receipts_required") + request_ids = [item.fields.get("request_id") for item in receipts] + order_refs = [item.fields.get("order_ref") for item in receipts] + if ( + any(not isinstance(value, str) or not value for value in request_ids) + or len(set(request_ids)) != len(request_ids) + or any(not isinstance(value, str) or not value for value in order_refs) + or len(set(order_refs)) != len(order_refs) + ): + missing.append("submit_receipts_must_have_distinct_request_and_provider_refs") + logs_by_request = {item.fields.get("request_id"): item for item in logs} + accepted_by_ref = {item.fields.get("order_ref"): item for item in accepted} + canceled_by_ref = {item.fields.get("order_ref"): item for item in canceled} + for index, receipt in enumerate(receipts, 1): + request_id = receipt.fields.get("request_id") + ref = receipt.fields.get("order_ref") + trace = receipt.fields.get("trace_id") + monitor = logs_by_request.get(request_id) + ack = accepted_by_ref.get(ref) + terminal = canceled_by_ref.get(ref) + if ( + receipt.fields.get("dispatch_state") != "submitted" + or not trace + or not monitor + or monitor.fields.get("event_name") != "order_submit_request" + or monitor.fields.get("metric") != "submit_count" + or monitor.fields.get("count") != index + or monitor.fields.get("request_receipt_event_id") != receipt.event_id + or monitor.fields.get("order_ref") != ref + or monitor.fields.get("trace_id") != trace + or not _HEX64.fullmatch(str(monitor.fields.get("monitor_digest", ""))) + ): + missing.append(f"submit_request_{index}_must_match_typed_monitor_receipt") + if not ack or ack.fields.get("status") not in {"accepted", "working"}: + missing.append(f"submit_request_{index}_must_match_native_order_acknowledgement") + if ( + monitor + and ack + and not ( + _utc(receipt.occurred_at_utc) + <= _utc(monitor.occurred_at_utc) + <= _utc(ack.occurred_at_utc) + ) + ): + missing.append(f"submit_request_{index}_receipt_monitor_and_ack_order_invalid") + if not terminal or terminal.fields.get("status") not in {"canceled", "cancelled"}: + missing.append(f"submit_request_{index}_must_have_native_cancel_reconciliation") + if terminal and ack and terminal.sequence <= ack.sequence: + missing.append(f"submit_request_{index}_cancel_must_follow_native_acceptance") + if ( + len(logs) != len(receipts) + or len(accepted) != len(receipts) + or len(canceled) != len(receipts) + ): + missing.append("submit_monitor_ack_and_cancel_cardinalities_must_match") + order_queries = self._all(ObservationKind.ORDER_QUERY) + position_queries = self._all(ObservationKind.POSITION_QUERY) + if not order_queries or ( + order_queries[-1].fields.get("open_order_refs") + or order_queries[-1].sequence <= max((item.sequence for item in canceled), default=0) + ): + missing.append("final_provider_order_query_must_be_empty_after_all_cancels") + if not position_queries or ( + position_queries[-1].fields.get("phase") != "final" + or position_queries[-1].fields.get("closeable_quantity") != 0 + or (order_queries and position_queries[-1].sequence <= order_queries[-1].sequence) + ): + missing.append("final_provider_position_snapshot_must_be_zero_after_order_query") + return tuple(dict.fromkeys(missing)) + + +def create_strategy( + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: ObservationAuthenticator | None, +) -> M04Strategy: + return M04Strategy(plan, scope, authenticator) + + +def _utc(value: str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M04/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/M04/config.yaml new file mode 100644 index 00000000..ab974b3a --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M04/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.M04 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: M04 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M04/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M04/run.py new file mode 100644 index 00000000..b360ea66 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M04/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("M04", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M05/M05_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M05/M05_strategy.py new file mode 100644 index 00000000..7222f803 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M05/M05_strategy.py @@ -0,0 +1,216 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import re +from types import MappingProxyType +from typing import Mapping + +from common.case_engine import DescriptiveCasePlan +from common.decision_engine import ( + CASE_INTENT_SPECS, + CaseIntentDecisionEngine, + DecisionError, + DecisionScope, + NativeObservation, + ObservationAuthenticator, + ObservationKind, +) + +CASE_ID = "M05" +CASE_NAME = "Cancel-request count" +CASE_PLAN = { + "evidence": ( + "Count real monitor.log order_cancel_request records by broker order reference; retain provider cancel acknowledgements and monitoring_summary.", + "Reconcile all case orders against a final provider open-order query and position snapshot.", + ), + "actions": ( + "Submit up to three separately identified minimum-size open limit orders within the approved budget.", + "Cancel each only after a real provider order reference is available, then reconcile the recorded request count and terminal states.", + ), +} + + +_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$") + + +class _FrozenRefs(set): + """Read-only empty set accepted by the shared empty-order-query contract.""" + + def _deny(self, *args, **kwargs): + raise TypeError("provider reference snapshot is immutable") + + add = clear = difference_update = discard = intersection_update = pop = remove = ( + symmetric_difference_update + ) = update = _deny + __iand__ = __ior__ = __isub__ = __ixor__ = _deny + + +def _copy_event(event: NativeObservation) -> NativeObservation: + if not isinstance(event.fields, Mapping): + raise DecisionError("observation fields must be a mapping") + fields = {} + for key, value in event.fields.items(): + if not isinstance(key, str): + raise DecisionError("observation field names must be strings") + if isinstance(value, (str, bool, int, float, type(None))): + fields[key] = value + elif key == "open_order_refs" and isinstance(value, (tuple, list, set)) and not value: + fields[key] = _FrozenRefs() + elif isinstance(value, (tuple, list, set)) and all( + isinstance(item, (str, bool, int, float, type(None))) for item in value + ): + fields[key] = tuple(value) + else: + raise DecisionError("observation fields must contain immutable scalar facts") + return replace(event, fields=MappingProxyType(fields)) + + +class M05Strategy(CaseIntentDecisionEngine): + """Correlate cancel monitor receipts to prior opens and native terminal state.""" + + def __init__(self, plan, scope, authenticator): + super().__init__(plan, scope, authenticator) + kinds = ( + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_QUERY, + ObservationKind.ORDER_ADMISSION, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_CANCELED, + ObservationKind.POSITION_QUERY, + ObservationKind.MONITOR_LOG, + ) + self.spec = replace( + CASE_INTENT_SPECS[CASE_ID], + required_kinds=kinds, + rule="local_cancel_count", + ) + + def record(self, event: NativeObservation) -> bool: + return super().record(_copy_event(event)) + + def _missing(self, now: datetime) -> tuple[str, ...]: + missing = list(super()._missing(now)) + if not self._session_ready(): + missing.append("provider_auth_login_front_and_subscription_success_required") + admission = self._last(ObservationKind.ORDER_ADMISSION) + if not admission or admission.fields.get("maximum_quantity") != 1: + missing.append("one_lot_review_only_cancel_admission_required") + + submissions = self._all(ObservationKind.ORDER_SUBMIT_RECEIPT) + accepted = self._all(ObservationKind.ORDER_ACCEPTED) + queries = self._all(ObservationKind.ORDER_QUERY) + cancel_logs = [ + item + for item in self._all(ObservationKind.MONITOR_LOG) + if item.fields.get("event_name") == "order_cancel_request" + ] + canceled = self._all(ObservationKind.ORDER_CANCELED) + if not 1 <= len(cancel_logs) <= 3: + missing.append("one_to_three_bounded_cancel_monitor_receipts_required") + submission_by_ref = {item.fields.get("order_ref"): item for item in submissions} + accepted_by_ref = {item.fields.get("order_ref"): item for item in accepted} + if len(submissions) != len(accepted) or any( + item.fields.get("dispatch_state") != "submitted" + or not item.fields.get("request_id") + or not item.fields.get("trace_id") + for item in submissions + ): + missing.append("every_cancel_target_must_have_a_managed_submit_receipt_and_native_ack") + if len(queries) != 2: + missing.append("pre_cancel_and_final_native_order_queries_required") + baseline = final_query = None + else: + baseline, final_query = queries[-2], queries[-1] + if baseline and ( + baseline.fields.get("complete") is not True + or not set(accepted_by_ref).issubset(set(baseline.fields.get("open_order_refs", ()))) + or baseline.sequence <= max((item.sequence for item in accepted), default=0) + ): + missing.append("pre_cancel_query_must_confirm_all_accepted_targets_open") + if final_query and ( + final_query.fields.get("complete") is not True + or final_query.fields.get("open_order_refs") + or final_query.sequence <= max((item.sequence for item in canceled), default=0) + ): + missing.append("final_native_order_query_must_be_empty_after_cancels") + + request_ids = [item.fields.get("request_id") for item in cancel_logs] + target_refs = [item.fields.get("order_ref") for item in cancel_logs] + if ( + any(not isinstance(value, str) or not value for value in request_ids) + or len(set(request_ids)) != len(request_ids) + or any(not isinstance(value, str) or not value for value in target_refs) + or len(set(target_refs)) != len(target_refs) + ): + missing.append("cancel_receipts_must_use_unique_request_ids_and_order_refs") + canceled_by_ref = {item.fields.get("order_ref"): item for item in canceled} + for index, receipt in enumerate(cancel_logs, 1): + ref = receipt.fields.get("order_ref") + order = accepted_by_ref.get(ref) + origin = submission_by_ref.get(ref) + terminal = canceled_by_ref.get(ref) + if ( + receipt.fields.get("metric") != "cancel_count" + or receipt.fields.get("count") != index + or not _HEX64.fullmatch(str(receipt.fields.get("monitor_digest", ""))) + or not receipt.fields.get("trace_id") + or not receipt.fields.get("request_receipt_ref") + or not baseline + or receipt.fields.get("source_query_event_id") != baseline.event_id + or not order + or not origin + or origin.fields.get("trace_id") != receipt.fields.get("origin_trace_id") + or ref not in set(baseline.fields.get("open_order_refs", ())) + ): + missing.append( + f"cancel_request_{index}_must_bind_monitor_receipt_to_open_provider_order" + ) + if order and origin and not _utc(origin.occurred_at_utc) <= _utc(order.occurred_at_utc): + missing.append(f"cancel_request_{index}_origin_receipt_must_precede_provider_ack") + if ( + baseline + and order + and not _utc(order.occurred_at_utc) <= _utc(baseline.occurred_at_utc) + ): + missing.append(f"cancel_request_{index}_baseline_query_must_follow_provider_ack") + if baseline and not _utc(baseline.occurred_at_utc) <= _utc(receipt.occurred_at_utc): + missing.append( + f"cancel_request_{index}_monitor_receipt_must_follow_open_order_query" + ) + if ( + not terminal + or terminal.fields.get("status") not in {"canceled", "cancelled"} + or terminal.fields.get("cancel_request_id") != receipt.fields.get("request_id") + or (order and terminal.sequence <= order.sequence) + or (terminal and _utc(terminal.occurred_at_utc) < _utc(receipt.occurred_at_utc)) + ): + missing.append(f"cancel_request_{index}_must_match_native_cancel_acknowledgement") + if len(canceled) != len(cancel_logs): + missing.append("cancel_monitor_receipt_and_native_ack_cardinalities_must_match") + positions = self._all(ObservationKind.POSITION_QUERY) + if not positions or ( + positions[-1].fields.get("phase") != "final" + or positions[-1].fields.get("closeable_quantity") != 0 + or (final_query and positions[-1].sequence <= final_query.sequence) + ): + missing.append("final_provider_position_snapshot_must_be_zero") + return tuple(dict.fromkeys(missing)) + + +def create_strategy( + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: ObservationAuthenticator | None, +) -> M05Strategy: + return M05Strategy(plan, scope, authenticator) + + +def _utc(value: str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M05/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/M05/config.yaml new file mode 100644 index 00000000..3880fb50 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M05/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.M05 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: M05 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/M05/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/M05/run.py new file mode 100644 index 00000000..07f288db --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/M05/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("M05", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O01/O01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/O01/O01_strategy.py new file mode 100644 index 00000000..f9d93fd9 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O01/O01_strategy.py @@ -0,0 +1,21 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from _typed_scenario_state_candidate import TypedScenarioStateCandidate + +CASE_ID = "O01" +CASE_NAME = "Repeated open-intent monitoring" +CASE_PLAN = { + "evidence": ( + "Capture each real order_submit_request, broker order reference, provider status, and monitor details that identify repeated open intents.", + "Require a risk_repeat_order_detected record with the exact repeat key, count, configured window, and source request references; reconcile all resulting orders and positions.", + ), + "actions": ( + "Within a separately reviewed repeat-action budget, present the same bounded one-lot open intent in a controlled sequence.", + "Observe the configured duplicate/risk behavior without bypassing it; cancel acknowledged live orders and stop on a fill or guard event.", + ), +} + + +# Unregistered typed review candidate; the literal CASE_PLAN remains static. +class O01TypedScenarioState(TypedScenarioStateCandidate): + CASE_ID = "O01" diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/O01/config.yaml new file mode 100644 index 00000000..959a0fd2 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.O01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: O01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/O01/run.py new file mode 100644 index 00000000..2c9f7833 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O01/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("O01", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O02/O02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/O02/O02_strategy.py new file mode 100644 index 00000000..c3f4bf67 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O02/O02_strategy.py @@ -0,0 +1,21 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from _typed_scenario_state_candidate import TypedScenarioStateCandidate + +CASE_ID = "O02" +CASE_NAME = "Repeated close-intent monitoring" +CASE_PLAN = { + "evidence": ( + "Capture real close order requests, broker order references, provider status/rejections, and monitor details for repeated close intents.", + "Require a risk_repeat_order_detected record with the exact repeat key, count, configured window, and source request references; attach provider position snapshots before each action and after final reconciliation.", + ), + "actions": ( + "Proceed only with a provider-confirmed position and a reviewed repeat-action budget; present bounded one-lot close intents in sequence.", + "Stop before any request could exceed confirmed closeable quantity, then reconcile every provider order and position update.", + ), +} + + +# Unregistered typed review candidate; the literal CASE_PLAN remains static. +class O02TypedScenarioState(TypedScenarioStateCandidate): + CASE_ID = "O02" diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/O02/config.yaml new file mode 100644 index 00000000..75c69ac9 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.O02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: O02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/O02/run.py new file mode 100644 index 00000000..7eb40fce --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O02/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("O02", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O03/O03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/O03/O03_strategy.py new file mode 100644 index 00000000..23e06e9e --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O03/O03_strategy.py @@ -0,0 +1,22 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from _typed_scenario_state_candidate import TypedScenarioStateCandidate + +CASE_ID = "O03" +CASE_NAME = "Repeated cancel-request monitoring" +CASE_PLAN = { + "evidence": ( + "Capture the real order_cancel_request, its broker order reference and provider response, plus the required risk_repeat_cancel_detected monitor record for the repeated intent.", + "Retain terminal order states and a final provider query proving no case order remains open.", + ), + "actions": ( + "Create one bounded pending order and confirm its real provider order reference and working state.", + "Issue a cancel intent, then repeat the cancel intent for that same order reference inside the configured repeat window; require the second intent to be stopped by the reviewed risk monitor before provider dispatch.", + "Require risk_repeat_cancel_detected with that repeat key/count, verify at most one provider cancel request, and reconcile the final order and fill state.", + ), +} + + +# Unregistered typed review candidate; the literal CASE_PLAN remains static. +class O03TypedScenarioState(TypedScenarioStateCandidate): + CASE_ID = "O03" diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/O03/config.yaml new file mode 100644 index 00000000..bac9f1ec --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.O03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: O03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/O03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/O03/run.py new file mode 100644 index 00000000..88bdef3b --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/O03/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("O03", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T01/T01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/T01/T01_strategy.py new file mode 100644 index 00000000..e4ea9149 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T01/T01_strategy.py @@ -0,0 +1,22 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "T01" +CASE_NAME = "Bounded open-order request and cleanup" +CASE_PLAN = { + "evidence": ( + "Capture monitor and order records for order_submit_request, the actual broker order reference, provider acknowledgement/status, and final order/position reconciliation.", + "Record the one-lot intent, limit price, timestamps, and cancellation or terminal result from the provider.", + ), + "actions": ( + "Under a reviewed order admission and account budget, submit one minimum-size open limit order using a current provider quote.", + "Cancel after the provider acknowledges the order; reconcile that no order remains open and no unintended position was created.", + ), +} + + +def create_strategy(plan, scope, authenticator): + """Construct the no-I/O typed order lifecycle observer for an injected adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/T01/config.yaml new file mode 100644 index 00000000..56e91194 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.T01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: T01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/T01/run.py new file mode 100644 index 00000000..8e90086b --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T01/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("T01", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T02/T02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/T02/T02_strategy.py new file mode 100644 index 00000000..a52a35a2 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T02/T02_strategy.py @@ -0,0 +1,22 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "T02" +CASE_NAME = "Close-order request against confirmed position" +CASE_PLAN = { + "evidence": ( + "Capture the authoritative position snapshot before and after the close request, order_submit_request, broker order reference, and provider acknowledgement or rejection reason.", + "Retain the final order and position queries; do not infer a fill from local submission.", + ), + "actions": ( + "Require a provider-confirmed closeable position before acting; submit at most one minimum-size close limit within that position.", + "Reconcile the terminal provider order state and resulting position; stop without submitting if the position is absent or insufficient.", + ), +} + + +def create_strategy(plan, scope, authenticator): + """Construct the no-I/O typed order lifecycle observer for an injected adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/T02/config.yaml new file mode 100644 index 00000000..7a227aed --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.T02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: T02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/T02/run.py new file mode 100644 index 00000000..d89d7867 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T02/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("T02", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T03/T03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/T03/T03_strategy.py new file mode 100644 index 00000000..4e34bc14 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T03/T03_strategy.py @@ -0,0 +1,22 @@ +"""Case-specific real evidence and action plan; descriptive data only.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "T03" +CASE_NAME = "Cancel request and terminal order state" +CASE_PLAN = { + "evidence": ( + "Correlate the real broker order reference with order_submit_request, order_cancel_request, provider cancel acknowledgement/status, and a final open-order query.", + "Retain the position reconciliation and provider timestamps for the submit/cancel sequence.", + ), + "actions": ( + "Submit one minimum-size bounded open limit under the reviewed order admission.", + "After the provider returns the real order reference, issue one cancel request and verify the provider terminal state and absence from open orders.", + ), +} + + +def create_strategy(plan, scope, authenticator): + """Construct the no-I/O typed order lifecycle observer for an injected adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/T03/config.yaml new file mode 100644 index 00000000..aafb8354 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.T03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: T03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/T03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/T03/run.py new file mode 100644 index 00000000..47ff9805 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/T03/run.py @@ -0,0 +1,12 @@ +"""Thin case entry point for the suite-managed case runner.""" +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main("T03", __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/TH01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/TH01_strategy.py new file mode 100644 index 00000000..da3e26cc --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/TH01_strategy.py @@ -0,0 +1,31 @@ +"""TH01 plan and typed read-only submit-threshold evidence strategy.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "TH01" +PLAN = { + "case_id": "TH01", + "scenario_id": "RISK-THRESHOLD-01", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Record the configured order-submission warning threshold from the runtime monitor.", + "actions": [ + "Start the approved sandbox runtime with the TradeLogger submit-count threshold set to 5.", + "Allow initialization and one normal market-data bar without submitting or cancelling orders.", + "Collect the monitor summary emitted by that runtime and retain its effective threshold value.", + ], + "evidence": [ + "Runtime-resolved TradeLogger threshold configuration.", + "monitoring_summary event containing submit_threshold or its documented thresholds.submit field.", + "Timestamped monitor log and runtime configuration identity.", + ], + "completion_criteria": [ + "The configured value is visible in runtime evidence; no order activity occurred." + ], +} + + +def create_strategy(plan, scope, authenticator): + """Build the no-I/O event state machine for an injected managed adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/config.yaml new file mode 100644 index 00000000..7eda3b7d --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.TH01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: TH01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/run.py new file mode 100644 index 00000000..02a51ab6 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH01/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('TH01', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/TH02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/TH02_strategy.py new file mode 100644 index 00000000..f9ac7792 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/TH02_strategy.py @@ -0,0 +1,30 @@ +"""REAL evidence and action plan for TH02.""" + +from _typed_scenario_state_candidate import TypedScenarioStateCandidate + +CASE_ID = "TH02" +PLAN = { + "case_id": "TH02", + "scenario_id": "RISK-THRESHOLD-02", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Observe an order-count warning at the configured submission threshold.", + "actions": [ + "Set the submit-count warning threshold to 2 in the approved sandbox runtime.", + "Submit two individually permitted orders using the reviewed scenario and bounded quantities.", + "Capture the threshold event, then cancel any remaining open orders and reconcile the final order list.", + ], + "evidence": [ + "risk_threshold_triggered monitor event with order threshold and submitted count.", + "Order request references and final broker order state.", + "Evidence that no order remains open when the run ends.", + ], + "completion_criteria": [ + "The monitor event corresponds to the observed order count and all remaining orders are reconciled." + ], +} + + +# Unregistered typed review candidate; the structured PLAN remains static. +class TH02TypedScenarioState(TypedScenarioStateCandidate): + CASE_ID = "TH02" diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/config.yaml new file mode 100644 index 00000000..3ef6263e --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.TH02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: TH02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/run.py new file mode 100644 index 00000000..aed0c002 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH02/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('TH02', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/TH03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/TH03_strategy.py new file mode 100644 index 00000000..140994e2 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/TH03_strategy.py @@ -0,0 +1,31 @@ +"""TH03 plan and typed read-only combined-threshold evidence strategy.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "TH03" +PLAN = { + "case_id": "TH03", + "scenario_id": "RISK-THRESHOLD-03", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Record the configured combined submit-and-cancel warning threshold.", + "actions": [ + "Start the approved sandbox runtime with submit_cancel_total_warn_threshold set to 10.", + "Allow monitor initialization and one normal market-data bar without order activity.", + "Collect the monitor summary and retain the effective threshold and cancellation-counter fields.", + ], + "evidence": [ + "Runtime-resolved combined threshold value.", + "monitoring_summary event and its documented cancel threshold/count fields.", + "Timestamped monitor log and runtime configuration identity.", + ], + "completion_criteria": [ + "The combined threshold is visible in runtime evidence and no order activity occurred." + ], +} + + +def create_strategy(plan, scope, authenticator): + """Build the no-I/O event state machine for an injected managed adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/config.yaml new file mode 100644 index 00000000..49f04a46 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.TH03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: TH03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/run.py new file mode 100644 index 00000000..28758795 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH03/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('TH03', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/TH04_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/TH04_strategy.py new file mode 100644 index 00000000..f7062f4b --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/TH04_strategy.py @@ -0,0 +1,31 @@ +"""REAL evidence and action plan for TH04.""" + +from _typed_scenario_state_candidate import TypedScenarioStateCandidate + +CASE_ID = "TH04" +PLAN = { + "case_id": "TH04", + "scenario_id": "RISK-THRESHOLD-04", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Observe the combined order and cancellation warning at its configured threshold.", + "actions": [ + "Set the combined submit-and-cancel warning threshold to 3 in the approved sandbox runtime.", + "Issue a bounded sequence of permitted order requests and cancellations until the recorded total reaches 3.", + "Capture the warning and both counters, then reconcile and cancel any remaining open orders.", + ], + "evidence": [ + "Native OnRspUserLogin FrontID/SessionID/TradingDay bound to the local connection generation before order callbacks.", + "risk_threshold_triggered event with the effective threshold and observed combined count.", + "Order and cancel request references with timestamps.", + "Final broker order state showing no unresolved open order.", + ], + "completion_criteria": [ + "The warning is tied to the observed combined count and remaining orders are reconciled." + ], +} + + +# Unregistered typed review candidate; the structured PLAN remains static. +class TH04TypedScenarioState(TypedScenarioStateCandidate): + CASE_ID = "TH04" diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/config.yaml new file mode 100644 index 00000000..96110376 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.TH04 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: TH04 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/run.py new file mode 100644 index 00000000..d9609495 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH04/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('TH04', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/TH05_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/TH05_strategy.py new file mode 100644 index 00000000..6d43a262 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/TH05_strategy.py @@ -0,0 +1,31 @@ +"""TH05 plan and typed read-only duplicate-threshold evidence strategy.""" + +from common.read_only_case_strategy import create_read_only_strategy + +CASE_ID = "TH05" +PLAN = { + "case_id": "TH05", + "scenario_id": "RISK-THRESHOLD-05", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Record the configured repeated-order threshold and its observation window.", + "actions": [ + "Start the approved sandbox runtime with duplicate_order_warn_threshold set to 3.", + "Allow monitor initialization and one normal market-data bar without submitting or cancelling orders.", + "Collect the monitor summary and retain the threshold and repeat-window values.", + ], + "evidence": [ + "Runtime-resolved repeat threshold value.", + "monitoring_summary event containing the repeat threshold and repeat window.", + "Timestamped monitor log and runtime configuration identity.", + ], + "completion_criteria": [ + "The threshold and observation window are visible in runtime evidence; no order activity occurred." + ], +} + + +def create_strategy(plan, scope, authenticator): + """Build the no-I/O event state machine for an injected managed adapter.""" + + return create_read_only_strategy(CASE_ID, plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/config.yaml new file mode 100644 index 00000000..994767cc --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.TH05 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: TH05 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/run.py new file mode 100644 index 00000000..368192bb --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH05/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('TH05', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/TH06_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/TH06_strategy.py new file mode 100644 index 00000000..a250ea15 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/TH06_strategy.py @@ -0,0 +1,31 @@ +"""REAL evidence and action plan for TH06.""" + +from _typed_scenario_state_candidate import TypedScenarioStateCandidate + +CASE_ID = "TH06" +PLAN = { + "case_id": "TH06", + "scenario_id": "RISK-THRESHOLD-06", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Observe a repeated-order warning within the configured observation window.", + "actions": [ + "Set duplicate_order_warn_threshold to 2 in the approved sandbox runtime.", + "Submit the same reviewed order intent twice within the configured repeat window using bounded quantities.", + "Capture the risk warning and repeat count, then cancel any remaining open orders and reconcile the order list.", + ], + "evidence": [ + "Native OnRspUserLogin FrontID/SessionID/TradingDay bound to the local connection generation before order callbacks.", + "risk_threshold_triggered event and duplicate_order_threshold_reached signal.", + "Repeated intent key, effective threshold, repeat count, and timestamps.", + "Final broker order state showing no unresolved open order.", + ], + "completion_criteria": [ + "The warning is supported by the observed repeated intent and all orders are reconciled." + ], +} + + +# Unregistered typed review candidate; the structured PLAN remains static. +class TH06TypedScenarioState(TypedScenarioStateCandidate): + CASE_ID = "TH06" diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/config.yaml new file mode 100644 index 00000000..8e9e331e --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.TH06 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: TH06 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/run.py new file mode 100644 index 00000000..5a6446b3 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/TH06/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('TH06', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V01/V01_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/V01/V01_strategy.py new file mode 100644 index 00000000..40a98f6b --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V01/V01_strategy.py @@ -0,0 +1,34 @@ +"""REAL evidence and action plan for V01.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = 'V01' +PLAN = { + "case_id": "V01", + "scenario_id": "VALIDATION-01", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Capture local rejection evidence for an invalid instrument identifier.", + "actions": [ + "Resolve the approved sandbox contract metadata and select an instrument identifier that is demonstrably invalid for that runtime.", + "Submit one bounded validation request and record whether local order validation rejects it before dispatch.", + "Reconcile the broker order list and retain the validation log." + ], + "evidence": [ + "order_validation_rejected event with instrument and error_msg fields.", + "Contract metadata source and request timestamp.", + "Evidence that no corresponding order was dispatched or left open." + ], + "completion_criteria": [ + "The local rejection is tied to the invalid instrument and no corresponding order remains open." + ] +} + + +class V01ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind V01 to local invalid-instrument rejection evidence only.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("V01 strategy requires the V01 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V01/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/V01/config.yaml new file mode 100644 index 00000000..d77c75c1 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V01/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.V01 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: V01 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V01/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/V01/run.py new file mode 100644 index 00000000..d31ed22e --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V01/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('V01', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V02/V02_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/V02/V02_strategy.py new file mode 100644 index 00000000..07341c2c --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V02/V02_strategy.py @@ -0,0 +1,34 @@ +"""REAL evidence and action plan for V02.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = 'V02' +PLAN = { + "case_id": "V02", + "scenario_id": "VALIDATION-02", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Capture local rejection evidence for an order price that does not align with the contract tick.", + "actions": [ + "Read the approved runtime contract tick and record its source.", + "Submit one bounded request at a price that violates that tick and record the local validation response.", + "Reconcile the broker order list and retain the validation log." + ], + "evidence": [ + "order_validation_rejected event containing requested price, contract tick, and error_msg.", + "Contract metadata source and request timestamp.", + "Evidence that no corresponding order was dispatched or left open." + ], + "completion_criteria": [ + "The rejection cites the observed tick mismatch and no corresponding order remains open." + ] +} + + +class V02ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind V02 to tick-reference and local price-step rejection evidence.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("V02 strategy requires the V02 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V02/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/V02/config.yaml new file mode 100644 index 00000000..f7babca6 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V02/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.V02 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: V02 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V02/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/V02/run.py new file mode 100644 index 00000000..734a5c39 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V02/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('V02', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V03/V03_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/cases/V03/V03_strategy.py new file mode 100644 index 00000000..050883ef --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V03/V03_strategy.py @@ -0,0 +1,34 @@ +"""REAL evidence and action plan for V03.""" + +from common.decision_engine import CaseIntentDecisionEngine, DecisionError + +CASE_ID = 'V03' +PLAN = { + "case_id": "V03", + "scenario_id": "VALIDATION-03", + "evidence_mode": "REAL", + "status": "PLANNED", + "objective": "Capture local rejection evidence for a quantity above the contract maximum per order.", + "actions": [ + "Read the approved runtime contract maximum order size and record its source.", + "Submit one validation request with a quantity above that limit, subject to the reviewed sandbox bound.", + "Reconcile the broker order list and retain the validation log." + ], + "evidence": [ + "order_validation_rejected event containing requested size, maximum order size, and error_msg.", + "Contract metadata source and request timestamp.", + "Evidence that no corresponding order was dispatched or left open." + ], + "completion_criteria": [ + "The rejection cites the observed size limit and no corresponding order remains open." + ] +} + + +class V03ReadOnlyStrategy(CaseIntentDecisionEngine): + """Bind V03 to review-only admission and local size validation evidence.""" + + def __init__(self, plan, scope, authenticator=None): + if plan.case_id != CASE_ID: + raise DecisionError("V03 strategy requires the V03 static plan") + super().__init__(plan, scope, authenticator) diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V03/config.yaml b/examples/007_ctp/live_certification/simnow_penetration/cases/V03/config.yaml new file mode 100644 index 00000000..6337b28c --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V03/config.yaml @@ -0,0 +1,8 @@ +config_schema_version: 4 +strategy: + id: example.007_ctp.simnow_penetration.V03 +runtime: + mode: simulation + preset: sandbox +parameters: + scenario: V03 diff --git a/examples/007_ctp/live_certification/simnow_penetration/cases/V03/run.py b/examples/007_ctp/live_certification/simnow_penetration/cases/V03/run.py new file mode 100644 index 00000000..9e573e4b --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/cases/V03/run.py @@ -0,0 +1,13 @@ +"""Run one case through the suite-root managed entry point.""" + +from pathlib import Path +import sys + +_SUITE_ROOT = Path(__file__).resolve().parents[2] +if str(_SUITE_ROOT) not in sys.path: + sys.path.insert(0, str(_SUITE_ROOT)) + +import managed_case_entry # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(managed_case_entry.main('V03', __file__)) diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/case_engine.py b/examples/007_ctp/live_certification/simnow_penetration/common/case_engine.py new file mode 100644 index 00000000..88680af7 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/common/case_engine.py @@ -0,0 +1,1745 @@ +"""Offline contract for evidence collected from real CTP provider callbacks. + +This module deliberately has no Store, Broker, SDK, network, or configuration +dependency. It is not registered as a case runner and has no order-writing +surface. A future adapter may call :meth:`CaseEvidenceEngine.record_callback` +directly from the named CTP SPI callback after attaching its redacted evidence +digest. The state ``EVIDENCE_COMPLETE_REQUIRES_REVIEW`` is not a certification +PASS; an in-process caller can always forge data, so this module is not an +authorization or authenticity boundary. +""" + +from __future__ import annotations + +import ast +import hashlib +import math +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Mapping + +from .certification import SCENARIOS_BY_CASE_ID, all_certification_scenarios + + +class PlanContractError(ValueError): + """Raised when a descriptive case plan is not statically checkable.""" + + +class EvidenceContractError(ValueError): + """Raised when callback evidence fails the provider event contract.""" + + +class EngineState(str, Enum): + WAITING_EXTERNAL = "WAITING_EXTERNAL" + COLLECTING = "COLLECTING_PROVIDER_EVIDENCE" + EXTERNAL_CONDITION_UNAVAILABLE = "EXTERNAL_CONDITION_UNAVAILABLE" + STOP_AND_RECONCILE = "STOP_AND_RECONCILE" + EVIDENCE_COMPLETE_REQUIRES_REVIEW = "EVIDENCE_COMPLETE_REQUIRES_REVIEW" + + +class DependencyState(str, Enum): + UNKNOWN = "UNKNOWN" + SATISFIED = "SATISFIED" + UNAVAILABLE = "UNAVAILABLE" + + +class EvidenceSource(str, Enum): + PROVIDER_CALLBACK = "provider_callback" + MANAGED_RUNTIME = "managed_runtime_receipt" + RUNTIME_MONITOR = "runtime_monitor_receipt" + LOCAL_VALIDATOR = "local_validator_receipt" + CONTROL_PLANE = "control_plane_receipt" + + +class CertificationState(str, Enum): + BLOCKED = "BLOCKED" + INCOMPLETE = "INCOMPLETE" + EXTERNAL_CONDITION_UNAVAILABLE = "EXTERNAL_CONDITION_UNAVAILABLE" + REVIEW_REQUIRED = "REVIEW_REQUIRED" + + +class ProviderFact(str, Enum): + ORDER_ACCEPTED = "order_accepted" + ORDER_PARTIAL = "order_partial" + ORDER_CANCELED = "order_canceled" + ORDER_FILLED = "order_filled" + ORDER_REJECTED = "order_rejected" + TRADE_EXECUTION = "trade_execution" + POSITION_SNAPSHOT = "position_snapshot" + ORDER_SNAPSHOT = "order_snapshot" + + +@dataclass(frozen=True) +class DescriptiveCasePlan: + case_id: str + name: str + values: Mapping[str, Any] + source_path: str + source_sha256: str + + +def load_descriptive_case_plan( + source_path: str | Path, + *, + expected_case_id: str | None = None, + expected_scenario_id: str | None = None, +) -> DescriptiveCasePlan: + """Read a legacy ``CASE_PLAN`` or structured ``PLAN`` without importing it. + + Python source is parsed as an AST and only literal assignments are accepted; + no strategy module code is executed. This provides a common static check + for all 33 case directories while the callback state model below covers the + first five order scenarios in detail. + """ + + path = Path(source_path) + try: + source_bytes = path.read_bytes() + source_text = source_bytes.decode("utf-8") + tree = ast.parse(source_text, filename=str(path)) + except (OSError, UnicodeDecodeError, SyntaxError) as exc: + raise PlanContractError(f"cannot parse plan source: {path}") from exc + + assignments: dict[str, Any] = {} + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name) or target.id not in { + "CASE_ID", + "CASE_NAME", + "CASE_PLAN", + "PLAN", + }: + continue + try: + assignments[target.id] = ast.literal_eval(node.value) + except (ValueError, TypeError) as exc: + raise PlanContractError(f"{target.id} must be a static literal in {path}") from exc + + case_id = assignments.get("CASE_ID") + if not isinstance(case_id, str) or not case_id: + raise PlanContractError(f"missing static CASE_ID in {path}") + if expected_case_id is not None and case_id != expected_case_id: + raise PlanContractError(f"CASE_ID {case_id!r} does not match {expected_case_id!r}") + + plan = assignments.get("CASE_PLAN", assignments.get("PLAN")) + if not isinstance(plan, dict): + raise PlanContractError(f"missing static CASE_PLAN/PLAN mapping in {path}") + plan_case_id = plan.get("case_id", case_id) + if plan_case_id != case_id: + raise PlanContractError(f"plan case_id {plan_case_id!r} does not match {case_id!r}") + + name = assignments.get("CASE_NAME") or str(plan.get("objective") or case_id) + if not isinstance(name, str) or not name.strip(): + raise PlanContractError(f"case name must be non-empty in {path}") + _require_text_sequence(plan, "actions", path) + evidence_key = "evidence" if "evidence" in plan else "completion_criteria" + _require_text_sequence(plan, evidence_key, path) + + if "evidence_mode" in plan and plan["evidence_mode"] != "REAL": + raise PlanContractError(f"structured plan must declare evidence_mode=REAL in {path}") + if "status" in plan and plan["status"] != "PLANNED": + raise PlanContractError(f"structured plan must remain PLANNED in {path}") + scenario_id = plan.get("scenario_id") + if expected_scenario_id is not None: + if scenario_id is not None and scenario_id != expected_scenario_id: + raise PlanContractError( + f"scenario_id {scenario_id!r} does not match {expected_scenario_id!r}" + ) + if scenario_id is None and expected_case_id is not None: + # The old descriptive form gets its authoritative scenario mapping + # from common.certification, passed by the caller for comparison. + scenario_id = expected_scenario_id + if scenario_id is not None and not isinstance(scenario_id, str): + raise PlanContractError(f"scenario_id must be a string in {path}") + + return DescriptiveCasePlan( + case_id=case_id, + name=name, + values=dict(plan), + source_path=str(path), + source_sha256=hashlib.sha256(source_bytes).hexdigest(), + ) + + +def _require_text_sequence(plan: Mapping[str, Any], key: str, path: Path) -> None: + value = plan.get(key) + if isinstance(value, str): + items = (value,) + elif isinstance(value, (list, tuple)): + items = value + else: + raise PlanContractError(f"{key} must be a non-empty text sequence in {path}") + if not items or any(not isinstance(item, str) or not item.strip() for item in items): + raise PlanContractError(f"{key} must contain non-empty text items in {path}") + + +@dataclass(frozen=True) +class EventProvenancePolicy: + source: EvidenceSource + callbacks: tuple[str, ...] = () + required_fields: tuple[str, ...] = () + requires_provider_scope: bool = False + requires_control_attestation: bool = False + + +# These rules cover every canonical event in common.certification. A local +# event name cannot satisfy a native callback rule; runtime intent/monitor and +# control-plane events each have a distinct evidence source contract. +EVENT_PROVENANCE_POLICIES: dict[str, EventProvenancePolicy] = { + "store_auth_success": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnRspAuthenticate",), + ("request_id", "is_last", "error_id", "authentication_succeeded"), + ), + "store_login_success": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnRspUserLogin",), + ( + "request_id", + "is_last", + "error_id", + "provider_front_id", + "provider_session_id", + "trading_day", + "login_succeeded", + ), + ), + "store_connected": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnFrontConnected", "OnRspUserLogin", "OnRspSubMarketData"), + requires_provider_scope=True, + ), + "store_disconnected": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, ("OnFrontDisconnected",), requires_provider_scope=True + ), + "store_reconnect_success": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ( + "OnFrontConnected", + "OnRspAuthenticate", + "OnRspUserLogin", + "OnRspSubMarketData", + ), + ( + "previous_session_id", + "new_session_id", + "auth_error_id", + "login_error_id", + "subscription_error_id", + "authentication_succeeded", + "login_succeeded", + "subscription_succeeded", + ), + requires_provider_scope=True, + ), + "store_ready": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnRspUserLogin", "OnRspSubMarketData"), + requires_provider_scope=True, + ), + "order_submit_request": EventProvenancePolicy( + EvidenceSource.MANAGED_RUNTIME, + required_fields=("trace_id", "invocation_id", "order_ref", "dispatch_state"), + ), + "order_cancel_request": EventProvenancePolicy( + EvidenceSource.MANAGED_RUNTIME, + required_fields=("trace_id", "invocation_id", "order_ref", "dispatch_state"), + ), + "batch_cancel_requested": EventProvenancePolicy( + EvidenceSource.MANAGED_RUNTIME, + required_fields=("trace_id", "invocation_id", "order_refs", "dispatch_state"), + ), + "order_status_accepted": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnRtnOrder",), + ("order_ref", "external_order_id", "provider_status"), + True, + ), + "order_status_canceled": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnRtnOrder",), + ("order_ref", "external_order_id", "provider_status"), + True, + ), + "trade_execution": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnRtnTrade",), + ("order_ref", "trade_id", "external_order_id"), + True, + ), + "order_reject_remote": EventProvenancePolicy( + EvidenceSource.PROVIDER_CALLBACK, + ("OnRspOrderInsert", "OnErrRtnOrderInsert", "OnRtnOrder"), + ("order_ref", "ErrorID", "ErrorMsg", "StatusMsg"), + True, + ), + "risk_repeat_order_detected": EventProvenancePolicy( + EvidenceSource.RUNTIME_MONITOR, + required_fields=("trace_id", "repeat_key", "repeat_count", "monitor_digest"), + ), + "risk_repeat_cancel_detected": EventProvenancePolicy( + EvidenceSource.RUNTIME_MONITOR, + required_fields=("trace_id", "repeat_key", "repeat_count", "monitor_digest"), + ), + "risk_threshold_configured": EventProvenancePolicy( + EvidenceSource.RUNTIME_MONITOR, + required_fields=("trace_id", "configuration_digest", "monitor_digest"), + ), + "risk_threshold_triggered": EventProvenancePolicy( + EvidenceSource.RUNTIME_MONITOR, + required_fields=("trace_id", "threshold", "observed_value", "monitor_digest"), + ), + "risk_monitor_event": EventProvenancePolicy( + EvidenceSource.RUNTIME_MONITOR, + required_fields=("trace_id", "metric", "monitor_digest"), + ), + "order_validation_rejected": EventProvenancePolicy( + EvidenceSource.LOCAL_VALIDATOR, + required_fields=( + "trace_id", + "validator_digest", + "reference_data_digest", + "validation_rule", + "error_msg", + "dispatch_absent", + ), + ), + "account_trading_disabled": EventProvenancePolicy( + EvidenceSource.CONTROL_PLANE, + ("OnRspOrderInsert",), + required_fields=( + "account_id_masked", + "reason", + "authorization_ref", + "independent_account_permission_evidence_ref", + "blocked_order_ref", + "ErrorID", + "ErrorMsg", + "permission_restored_ref", + ), + requires_provider_scope=True, + requires_control_attestation=True, + ), + "strategy_trading_paused": EventProvenancePolicy( + EvidenceSource.CONTROL_PLANE, + required_fields=("strategy_id", "reason", "authorization_ref"), + requires_control_attestation=True, + ), + "gateway_force_logout_requested": EventProvenancePolicy( + EvidenceSource.CONTROL_PLANE, + ("OnFrontDisconnected",), + ( + "gateway_key", + "reason", + "authorization_ref", + "gateway_released", + "operator_termination_evidence_ref", + "post_disconnect_write_guard_evidence_ref", + ), + True, + True, + ), +} + +_EXTRA_REQUIRED_EVENTS_BY_CASE: dict[str, tuple[str, ...]] = { + # Historical certification rows omit the triggering submit intent from + # these scenarios. The current real plans require it for correlation. + "T03": ("order_submit_request",), + "E01": ("order_submit_request",), + "E02": ("order_submit_request",), + "E03": ("order_submit_request",), + "EM01": ("order_submit_request", "order_reject_remote"), + "B01": ("order_submit_request",), + "B02": ("order_submit_request",), + # M02's supervised fault plan restores transport before its final account + # queries; the restored provider session needs its own callback evidence. + "M02": ("store_reconnect_success",), + "M03": ("store_disconnected",), +} +_EXPECTED_PROVIDER_REJECTION_CLASS = { + "E01": "insufficient_funds", + "E02": "insufficient_position", + "E03": "market_state", + "EM01": "account_permission_denied", +} +_EXPECTED_VALIDATION_RULE = { + "V01": "instrument", + "V02": "price_tick", + "V03": "max_order_size", +} + + +@dataclass(frozen=True) +class IssuedRequestReceipt: + """Typed local receipt for a request issued through the SDK boundary. + + This is a correlation record, not provider-issued evidence. A future trusted + adapter must verify it against its actual issuer-side ledger before admitting + C01 evidence. + """ + + request_kind: str + phase: str + request_id: int + request_generation: int + client_instance_id: str + arrival_generation: int + issued_at_utc: str + issued_monotonic: float + receipt_id: str + ledger_entry_sha256: str + send_return_code: int = 0 + ledger_origin: str = "local_sdk_request_ledger" + issued_at_origin: str = "local_sdk_call_boundary" + + +@dataclass(frozen=True) +class LocalCallbackArrival: + """Collector metadata kept separate from native callback payload fields. + + CTP does not supply a timestamp or monotonic event sequence for the C01 + authenticate/login responses. These values describe local SDK capture and + the client/connection that received the callback; they are never provider + issued identity or time. ``request_id`` is the echoed native nRequestID; + ``request_generation`` and ``arrival_generation`` are local correlators. + """ + + client_instance_id: str + request_generation: int + arrival_generation: int + source_sequence: int + arrived_at_utc: str + arrived_monotonic: float + issued_request_receipt: IssuedRequestReceipt | None = None + sequence_origin: str = "local_sdk_callback_arrival" + timestamp_origin: str = "local_sdk_capture_clock" + + +@dataclass(frozen=True) +class CertificationEvidence: + event_kind: str + source: EvidenceSource + event_id: str + evidence_sha256: str + occurred_at_utc: str + fields: Mapping[str, Any] + callback_names: tuple[str, ...] = () + provider_session_id: str = "" + trading_day: str = "" + provider_front_id: int | None = None + source_sequence: int = 0 + actor_id_hash: str = "" + signature_sha256: str = "" + callback_arrival: LocalCallbackArrival | None = None + + +class CertificationEvidenceTracker: + """Validate provenance for canonical evidence across all 33 case plans. + + Its best terminal state is REVIEW_REQUIRED. This tracker only validates + evidence shape and source lineage; it cannot authenticate the source or + declare a real provider result. + """ + + def __init__(self, case_id: str): + try: + self.scenario = SCENARIOS_BY_CASE_ID[case_id] + except KeyError as exc: + raise ValueError(f"unknown certification case {case_id!r}") from exc + self._events: list[CertificationEvidence] = [] + self._event_ids: set[str] = set() + self._sequences: dict[str, int] = {} + self._callback_arrivals: dict[tuple[str, int], int] = {} + self._provider_scope: tuple[str, str] | None = None + self._unavailable: dict[str, str] = {} + self._case_engine = CaseEvidenceEngine(case_id) if case_id in CASE_PLANS else None + + @property + def required_events(self) -> tuple[str, ...]: + extras = _EXTRA_REQUIRED_EVENTS_BY_CASE.get(self.scenario.case_id, ()) + return tuple(dict.fromkeys((*self.scenario.required_events, *extras))) + + def record(self, evidence: CertificationEvidence) -> None: + if evidence.event_kind not in self.required_events: + raise EvidenceContractError( + f"event {evidence.event_kind!r} is not required by {self.scenario.case_id}" + ) + policy = EVENT_PROVENANCE_POLICIES.get(evidence.event_kind) + if policy is None: + raise EvidenceContractError(f"no provenance policy for {evidence.event_kind!r}") + if evidence.source is not policy.source: + raise EvidenceContractError( + f"{evidence.event_kind} requires source {policy.source.value}" + ) + if not evidence.event_id or not _SHA256_RE.fullmatch(evidence.evidence_sha256): + raise EvidenceContractError("evidence requires an event id and SHA-256 digest") + if evidence.event_id in self._event_ids: + raise EvidenceContractError("duplicate evidence event_id") + try: + observed_at = datetime.fromisoformat(evidence.occurred_at_utc.replace("Z", "+00:00")) + except ValueError as exc: + raise EvidenceContractError("occurred_at_utc must be ISO-8601") from exc + if observed_at.tzinfo is None or observed_at.utcoffset() != timezone.utc.utcoffset( + observed_at + ): + raise EvidenceContractError("occurred_at_utc must carry a UTC offset") + missing_fields = [ + field_name + for field_name in policy.required_fields + if _is_missing(evidence.fields.get(field_name)) + ] + if missing_fields: + raise EvidenceContractError( + f"{evidence.event_kind} lacks source fields: {', '.join(missing_fields)}" + ) + if evidence.event_kind == "order_reject_remote": + callback_present = bool(set(policy.callbacks) & set(evidence.callback_names)) + else: + callback_present = set(policy.callbacks).issubset(evidence.callback_names) + if policy.callbacks and not callback_present: + missing = sorted(set(policy.callbacks) - set(evidence.callback_names)) + raise EvidenceContractError( + f"{evidence.event_kind} lacks native callbacks: {', '.join(missing)}" + ) + if evidence.event_kind in {"store_auth_success", "store_login_success"}: + _validate_c01_callback_evidence(evidence) + arrival = evidence.callback_arrival + arrival_key = (arrival.client_instance_id, arrival.arrival_generation) + previous_arrival = self._callback_arrivals.get(arrival_key, 0) + if arrival.source_sequence <= previous_arrival: + raise EvidenceContractError( + "C01 local callback arrival sequence must strictly increase per connection" + ) + self._callback_arrivals[arrival_key] = arrival.source_sequence + if policy.requires_provider_scope: + if not evidence.provider_session_id or not evidence.trading_day: + raise EvidenceContractError("provider evidence requires session_id and trading_day") + current_scope = (evidence.provider_session_id, evidence.trading_day) + if ( + self.scenario.case_id in {"M02", "M03"} + and evidence.event_kind == "store_reconnect_success" + ): + disconnects = [ + item for item in self._events if item.event_kind == "store_disconnected" + ] + if len(disconnects) != 1 or any( + item.event_kind == "store_reconnect_success" for item in self._events + ): + raise EvidenceContractError("restoration requires one prior disconnect") + disconnected = disconnects[0] + disconnected_at = datetime.fromisoformat( + disconnected.occurred_at_utc.replace("Z", "+00:00") + ) + if ( + evidence.provider_session_id == disconnected.provider_session_id + or evidence.trading_day != disconnected.trading_day + or evidence.fields.get("previous_session_id") + != disconnected.provider_session_id + or evidence.fields.get("new_session_id") != evidence.provider_session_id + or evidence.fields.get("gateway_key") != disconnected.fields.get("gateway_key") + or observed_at <= disconnected_at + ): + raise EvidenceContractError( + "restoration does not match the disconnected provider session" + ) + if self._provider_scope is not None and current_scope != self._provider_scope: + if not ( + self.scenario.case_id in {"M02", "M03"} + and evidence.event_kind == "store_reconnect_success" + ): + raise EvidenceContractError( + "one certification case cannot mix provider sessions" + ) + self._provider_scope = current_scope + if evidence.source_sequence < 1: + raise EvidenceContractError("provider evidence requires positive source_sequence") + previous = self._sequences.get(evidence.provider_session_id, 0) + if evidence.source_sequence <= previous: + raise EvidenceContractError("provider source_sequence must strictly increase") + self._sequences[evidence.provider_session_id] = evidence.source_sequence + if policy.requires_control_attestation: + if not evidence.actor_id_hash or not _SHA256_RE.fullmatch(evidence.signature_sha256): + raise EvidenceContractError( + "control-plane evidence requires actor hash and signed receipt digest" + ) + if evidence.event_kind == "order_validation_rejected": + if evidence.fields.get("dispatch_absent") is not True: + raise EvidenceContractError( + "local validation rejection requires evidence that provider dispatch was absent" + ) + if evidence.fields.get("validation_rule") != _EXPECTED_VALIDATION_RULE.get( + self.scenario.case_id + ): + raise EvidenceContractError("validation rejection does not match this case's rule") + if evidence.event_kind == "order_reject_remote": + try: + error_id = int(evidence.fields.get("ErrorID")) + except (TypeError, ValueError) as exc: + raise EvidenceContractError( + "provider rejection ErrorID must be an integer" + ) from exc + if error_id <= 0: + raise EvidenceContractError("provider rejection ErrorID must be positive") + expected_class = _EXPECTED_PROVIDER_REJECTION_CLASS.get(self.scenario.case_id) + if expected_class and evidence.fields.get("verified_rejection_class") != expected_class: + raise EvidenceContractError( + f"provider response is not verified as {expected_class}" + ) + if expected_class and _is_missing(evidence.fields.get("error_mapping_evidence_ref")): + raise EvidenceContractError( + "provider error classification needs its review evidence" + ) + if self.scenario.case_id == "E03": + if _is_missing(evidence.fields.get("market_state_evidence_ref")): + raise EvidenceContractError( + "E03 requires independent provider market-state evidence" + ) + if _is_missing(evidence.fields.get("market_state_source_event_id")): + raise EvidenceContractError( + "E03 market-state evidence must correlate to a source event" + ) + if evidence.event_kind == "store_connected": + if evidence.fields.get("market_connection") is not True: + raise EvidenceContractError("market connection must be provider-confirmed") + if evidence.fields.get("trade_connection") is not True: + raise EvidenceContractError("trade connection must be provider-confirmed") + if evidence.event_kind == "order_status_accepted": + if evidence.fields.get("provider_status") not in {"accepted", "working"}: + raise EvidenceContractError("provider order status is not accepted/working") + if evidence.event_kind == "order_status_canceled": + if evidence.fields.get("provider_status") not in {"canceled", "cancelled"}: + raise EvidenceContractError("provider order status is not canceled") + if evidence.event_kind == "store_auth_success": + if evidence.fields.get("authentication_succeeded") is not True: + raise EvidenceContractError("authentication callback does not report success") + if _integer_value(evidence.fields.get("error_id")) != 0: + raise EvidenceContractError("authentication ErrorID must be zero") + if evidence.event_kind == "store_login_success": + if evidence.fields.get("login_succeeded") is not True: + raise EvidenceContractError("login callback does not report success") + if _integer_value(evidence.fields.get("error_id")) != 0: + raise EvidenceContractError("login ErrorID must be zero") + if evidence.event_kind == "store_reconnect_success": + if evidence.fields.get("previous_session_id") == evidence.fields.get("new_session_id"): + raise EvidenceContractError( + "reconnect evidence must identify a new provider session" + ) + if ( + any( + type(evidence.fields.get(key)) is not int or evidence.fields.get(key) != 0 + for key in ("auth_error_id", "login_error_id", "subscription_error_id") + ) + or any( + key in evidence.fields + and (type(evidence.fields.get(key)) is not int or evidence.fields.get(key) != 0) + for key in ("ErrorID", "error_id") + ) + or any( + evidence.fields.get(key) is not True + for key in ( + "authentication_succeeded", + "login_succeeded", + "subscription_succeeded", + ) + ) + ): + raise EvidenceContractError( + "restored provider authentication, login, and subscription must succeed" + ) + if evidence.event_kind == "gateway_force_logout_requested": + if evidence.fields.get("gateway_released") is not True: + raise EvidenceContractError( + "force logout requires completed gateway release evidence" + ) + self._events.append(evidence) + self._event_ids.add(evidence.event_id) + + + def mark_external_unavailable(self, event_kind: str, reason: str, evidence_ref: str) -> None: + """Record a sourced external condition that prevents this plan from running.""" + + if event_kind not in self.required_events: + raise EvidenceContractError(f"unexpected unavailable event {event_kind!r}") + if not reason.strip() or not evidence_ref.strip(): + raise EvidenceContractError("external unavailability requires reason and evidence_ref") + self._unavailable[event_kind] = reason + + def record_provider_callback(self, evidence: "ProviderCallbackEvidence") -> None: + """Feed selected order scenarios into the stricter per-order model.""" + + if self._case_engine is None: + raise EvidenceContractError( + f"{self.scenario.case_id} has no specialized order callback model" + ) + self._case_engine.record_callback(evidence) + + def record_order_dependency( + self, + dependency_id: str, + state: DependencyState, + *, + source: str, + evidence_ref: str, + reason: str = "", + ) -> None: + if self._case_engine is None: + raise EvidenceContractError( + f"{self.scenario.case_id} has no specialized order callback model" + ) + self._case_engine.record_dependency( + dependency_id, state, source=source, evidence_ref=evidence_ref, reason=reason + ) + + def snapshot(self) -> dict[str, Any]: + event_kinds = {event.event_kind for event in self._events} + event_fields: dict[str, set[str]] = {} + for event in self._events: + event_fields.setdefault(event.event_kind, set()).update( + key for key, value in event.fields.items() if not _is_missing(value) + ) + present_fields = set().union(*event_fields.values()) if event_fields else set() + missing_events = [event for event in self.required_events if event not in event_kinds] + missing_fields = [ + name for name in self.scenario.evidence_fields if name not in present_fields + ] + missing_policy_events = [ + event for event in self.required_events if event not in EVENT_PROVENANCE_POLICIES + ] + missing_correlations = self._missing_order_correlations() + missing_correlations.extend(self._missing_c01_correlations()) + if self.scenario.case_id == "C01": + missing_correlations.append( + "C01 trusted issuer-side request ledger verifier is not wired into this tracker" + ) + order_snapshot = self._case_engine.snapshot() if self._case_engine is not None else None + if self._unavailable or ( + order_snapshot + and order_snapshot["state"] == EngineState.EXTERNAL_CONDITION_UNAVAILABLE.value + ): + state = CertificationState.EXTERNAL_CONDITION_UNAVAILABLE + elif not self._events and not ( + order_snapshot and order_snapshot["provider_callback_count"] + ): + state = CertificationState.BLOCKED + elif ( + missing_events + or missing_fields + or missing_policy_events + or missing_correlations + or ( + order_snapshot + and order_snapshot["state"] != EngineState.EVIDENCE_COMPLETE_REQUIRES_REVIEW.value + ) + ): + state = CertificationState.INCOMPLETE + else: + state = CertificationState.REVIEW_REQUIRED + return { + "case_id": self.scenario.case_id, + "scenario_id": self.scenario.scenario_id, + "state": state.value, + "certification_pass": False, + "required_events": list(self.required_events), + "observed_events": sorted(event_kinds), + "missing_events": missing_events, + "required_evidence_fields": list(self.scenario.evidence_fields), + "missing_evidence_fields": missing_fields, + "missing_provenance_policies": missing_policy_events, + "missing_event_correlations": missing_correlations, + "unavailable_conditions": dict(self._unavailable), + "evidence_sha256": [event.evidence_sha256.lower() for event in self._events], + "order_callback_model": order_snapshot, + "missing_order_callback_facts": ( + list(order_snapshot["missing_facts"]) if order_snapshot else [] + ), + } + + def _missing_c01_correlations(self) -> list[str]: + if self.scenario.case_id != "C01": + return [] + auth = [item for item in self._events if item.event_kind == "store_auth_success"] + login = [item for item in self._events if item.event_kind == "store_login_success"] + if len(auth) != 1 or len(login) != 1: + return [] + auth_row, login_row = auth[0], login[0] + auth_arrival, login_arrival = auth_row.callback_arrival, login_row.callback_arrival + if auth_arrival is None or login_arrival is None: + return ["C01 auth/login require local callback arrival and issued receipts"] + request_ids = (auth_row.fields.get("request_id"), login_row.fields.get("request_id")) + if ( + auth_arrival.client_instance_id != login_arrival.client_instance_id + or auth_arrival.arrival_generation != login_arrival.arrival_generation + or auth_arrival.request_generation == login_arrival.request_generation + ): + return ["C01 auth/login must share client generation and use distinct requests"] + if ( + type(request_ids[0]) is not int + or type(request_ids[1]) is not int + or request_ids[0] == request_ids[1] + ): + return ["C01 auth/login must use distinct native RequestIDs"] + auth_receipt_ok = validate_issued_request_receipt( + auth_arrival.issued_request_receipt, + request_kind="authenticate", + phase="", + request_id=request_ids[0], + request_generation=auth_arrival.request_generation, + client_instance_id=auth_arrival.client_instance_id, + arrival_generation=auth_arrival.arrival_generation, + arrived_at_utc=auth_arrival.arrived_at_utc, + arrived_monotonic=auth_arrival.arrived_monotonic, + ) + login_receipt_ok = validate_issued_request_receipt( + login_arrival.issued_request_receipt, + request_kind="login", + phase="", + request_id=request_ids[1], + request_generation=login_arrival.request_generation, + client_instance_id=login_arrival.client_instance_id, + arrival_generation=login_arrival.arrival_generation, + arrived_at_utc=login_arrival.arrived_at_utc, + arrived_monotonic=login_arrival.arrived_monotonic, + ) + if not auth_receipt_ok or not login_receipt_ok: + return ["C01 auth/login require matching typed issued-request receipts"] + ledger_gate = "C01 requires a trusted issuer-side request ledger verifier; none is wired here" + if ( + auth_arrival.issued_request_receipt.receipt_id + == login_arrival.issued_request_receipt.receipt_id + ): + return ["C01 auth/login issued-request receipts must be distinct"] + if ( + auth_arrival.source_sequence >= login_arrival.source_sequence + or _utc_timestamp(auth_arrival.arrived_at_utc) is None + or _utc_timestamp(login_arrival.arrived_at_utc) is None + or _utc_timestamp(auth_arrival.arrived_at_utc) + >= _utc_timestamp(login_arrival.arrived_at_utc) + ): + return ["C01 auth/login local callback arrival order is invalid"] + return [ledger_gate] + + def _missing_order_correlations(self) -> list[str]: + submitted = { + str(event.fields.get("order_ref")) + for event in self._events + if event.event_kind == "order_submit_request" + and not _is_missing(event.fields.get("order_ref")) + } + if not submitted: + return [] + required_refs = set(submitted) + if self._case_engine is not None: + required_refs.update(self._case_engine.accepted_order_refs) + provider_refs = { + str(event.fields.get("order_ref")) + for event in self._events + if event.event_kind + in { + "order_reject_remote", + "order_cancel_request", + "trade_execution", + } + and not _is_missing(event.fields.get("order_ref")) + } + provider_refs.update( + str(event.fields.get("blocked_order_ref")) + for event in self._events + if event.event_kind == "account_trading_disabled" + and not _is_missing(event.fields.get("blocked_order_ref")) + ) + missing = [f"order_ref:{ref}" for ref in sorted(provider_refs - submitted)] + if self.scenario.case_id in { + "T01", + "T02", + "T03", + "E01", + "E02", + "E03", + "EM01", + "L01", + }: + if self._case_engine is not None: + missing.extend( + f"provider_order_ref:{ref}" + for ref in sorted(self._case_engine.accepted_order_refs - submitted) + ) + if self.scenario.case_id in {"B01", "B02"}: + batch_refs = { + str(ref) + for event in self._events + if event.event_kind == "batch_cancel_requested" + for ref in (event.fields.get("order_refs") or []) + } + if required_refs - batch_refs: + missing.append("batch_cancel_refs_match_submitted_orders") + return missing + + +def _is_missing(value: Any) -> bool: + return value is None or value == "" or value == [] or value == () + + +def _integer_value(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return None + + +def unmapped_required_events() -> list[str]: + """Return canonical required events lacking an explicit source policy.""" + + required = { + event for scenario in all_certification_scenarios() for event in scenario.required_events + } + return sorted(required - EVENT_PROVENANCE_POLICIES.keys()) + + +@dataclass(frozen=True) +class CasePlan: + case_id: str + scenario_id: str + minimum_orders: int + dependencies: tuple[str, ...] + + +CASE_PLANS: dict[str, CasePlan] = { + "T01": CasePlan( + "T01", + "TRADE-OPEN-01", + 1, + ("reviewed_order_admission", "active_provider_session", "provider_acceptance"), + ), + "T02": CasePlan( + "T02", + "TRADE-CLOSE-01", + 1, + ( + "reviewed_order_admission", + "active_provider_session", + "closeable_position_confirmed", + "provider_acceptance", + ), + ), + "T03": CasePlan( + "T03", + "TRADE-CANCEL-01", + 1, + ("reviewed_order_admission", "active_provider_session", "cancel_window_available"), + ), + "B01": CasePlan( + "B01", + "BATCH-CANCEL-01", + 2, + ( + "reviewed_order_admission", + "active_provider_session", + "provider_partial_fill_opportunity", + "provider_batch_cancel_capability", + ), + ), + "B02": CasePlan( + "B02", + "BATCH-CANCEL-02", + 2, + ( + "reviewed_order_admission", + "active_provider_session", + "multiple_open_orders_available", + "provider_batch_cancel_capability", + ), + ), +} + +_CALLBACK_FOR_FACT = { + ProviderFact.ORDER_ACCEPTED: "OnRtnOrder", + ProviderFact.ORDER_PARTIAL: "OnRtnOrder", + ProviderFact.ORDER_CANCELED: "OnRtnOrder", + ProviderFact.ORDER_FILLED: "OnRtnOrder", + ProviderFact.ORDER_REJECTED: "OnRspOrderInsert", + ProviderFact.TRADE_EXECUTION: "OnRtnTrade", + ProviderFact.POSITION_SNAPSHOT: "OnRspQryInvestorPosition", + ProviderFact.ORDER_SNAPSHOT: "OnRspQryOrder", +} +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_ALLOWED_DEPENDENCY_SOURCES = { + "provider_callback", + "provider_query", + "reviewed_operator_evidence", +} + + +@dataclass(frozen=True) +class ProviderCallbackEvidence: + """Redacted normalized fact emitted by a real provider callback adapter. + + ``evidence_sha256`` must identify the retained, redacted callback artifact. + Raw account credentials or callback objects must not be stored here. + """ + + fact: ProviderFact + callback_name: str + source: str + event_id: str + session_id: str + trading_day: str + sequence: int + observed_at_utc: str + evidence_sha256: str + order_ref: str = "" + external_order_id: str = "" + instrument_id: str = "" + status: str = "" + traded_quantity: float = 0.0 + remaining_quantity: float = 0.0 + trade_id: str = "" + query_id: str = "" + query_complete: bool = False + position_phase: str = "" + closeable_quantity: float = 0.0 + open_order_refs: tuple[str, ...] = () + error_id: int = 0 + error_message: str = "" + + +@dataclass(frozen=True) +class DependencyEvidence: + dependency_id: str + state: DependencyState + source: str + evidence_ref: str + reason: str = "" + + +class CaseEvidenceEngine: + """Accumulate provider callback facts for T01-T03/B01-B02. + + The engine can report missing or externally unavailable conditions and + evidence ready for independent review. It intentionally has no PASS state. + """ + + def __init__(self, case_id: str): + try: + self.plan = CASE_PLANS[case_id] + except KeyError as exc: + raise ValueError(f"no callback state model for case {case_id!r}") from exc + self._events: list[ProviderCallbackEvidence] = [] + self._event_ids: set[str] = set() + self._last_sequence: dict[tuple[str, str], int] = {} + self._dependency_evidence: dict[str, DependencyEvidence] = {} + self._order_ids: dict[str, str] = {} + + def record_dependency( + self, + dependency_id: str, + state: DependencyState, + *, + source: str, + evidence_ref: str, + reason: str = "", + ) -> None: + """Attach external evidence; the engine cannot satisfy dependencies itself.""" + + if dependency_id not in self.plan.dependencies: + raise EvidenceContractError(f"unexpected external dependency {dependency_id!r}") + if state is DependencyState.UNKNOWN: + raise EvidenceContractError("UNKNOWN is implicit; provide SATISFIED or UNAVAILABLE") + if source not in _ALLOWED_DEPENDENCY_SOURCES: + raise EvidenceContractError(f"unsupported dependency evidence source {source!r}") + if not evidence_ref.strip(): + raise EvidenceContractError("external dependency evidence_ref is required") + if state is DependencyState.UNAVAILABLE and not reason.strip(): + raise EvidenceContractError("unavailable external conditions require a reason") + self._dependency_evidence[dependency_id] = DependencyEvidence( + dependency_id=dependency_id, + state=state, + source=source, + evidence_ref=evidence_ref, + reason=reason, + ) + + def record_callback(self, event: ProviderCallbackEvidence) -> None: + """Record one normalized provider fact from its named native callback.""" + + self._validate_callback(event) + session_scope = (event.session_id, event.trading_day) + if self._events: + first = self._events[0] + if session_scope != (first.session_id, first.trading_day): + raise EvidenceContractError( + "one case run cannot mix provider sessions or trading days" + ) + prior_sequence = self._last_sequence.get(session_scope, 0) + if event.sequence <= prior_sequence: + raise EvidenceContractError("provider callback sequence must strictly increase") + if event.event_id in self._event_ids: + raise EvidenceContractError("duplicate provider callback event_id") + if event.order_ref and event.external_order_id: + prior_external_id = self._order_ids.get(event.order_ref) + if prior_external_id and prior_external_id != event.external_order_id: + raise EvidenceContractError("provider order identity changed for an order_ref") + self._order_ids[event.order_ref] = event.external_order_id + elif event.fact in { + ProviderFact.ORDER_ACCEPTED, + ProviderFact.ORDER_PARTIAL, + ProviderFact.ORDER_CANCELED, + ProviderFact.ORDER_FILLED, + }: + raise EvidenceContractError( + "order status callback requires order_ref and external_order_id" + ) + self._last_sequence[session_scope] = event.sequence + self._event_ids.add(event.event_id) + self._events.append(event) + + def snapshot(self) -> dict[str, Any]: + """Return a machine-readable state without granting certification.""" + + unavailable = [ + item + for item in self._dependency_evidence.values() + if item.state is DependencyState.UNAVAILABLE + ] + unresolved = [ + dep + for dep in self.plan.dependencies + if self._dependency_evidence.get( + dep, DependencyEvidence(dep, DependencyState.UNKNOWN, "", "") + ).state + is DependencyState.UNKNOWN + ] + missing_facts = self._missing_facts() + state = self._derive_state(unavailable, unresolved, missing_facts) + return { + "case_id": self.plan.case_id, + "scenario_id": self.plan.scenario_id, + "state": state.value, + "certification_pass": False, + "accepted_order_count": len(self._accepted_orders()), + "provider_callback_count": len(self._events), + "missing_facts": missing_facts, + "unresolved_external_dependencies": unresolved, + "unavailable_external_dependencies": [ + {"dependency_id": item.dependency_id, "reason": item.reason} for item in unavailable + ], + "evidence_digests": [event.evidence_sha256.lower() for event in self._events], + } + + def _validate_callback(self, event: ProviderCallbackEvidence) -> None: + if event.source != "ctp_provider_callback": + raise EvidenceContractError("provider facts must originate from a CTP callback adapter") + if event.callback_name != _CALLBACK_FOR_FACT[event.fact]: + raise EvidenceContractError( + f"{event.fact.value} requires {_CALLBACK_FOR_FACT[event.fact]}" + ) + if not event.event_id or not event.session_id or not event.trading_day: + raise EvidenceContractError( + "callback event_id, session_id, and trading_day are required" + ) + if ( + not isinstance(event.sequence, int) + or isinstance(event.sequence, bool) + or event.sequence < 1 + ): + raise EvidenceContractError("callback sequence must be a positive integer") + if not _SHA256_RE.fullmatch(event.evidence_sha256): + raise EvidenceContractError("redacted provider evidence must include a SHA-256 digest") + try: + observed_at = datetime.fromisoformat(event.observed_at_utc.replace("Z", "+00:00")) + except ValueError as exc: + raise EvidenceContractError("observed_at_utc must be an ISO-8601 timestamp") from exc + if observed_at.tzinfo is None or observed_at.utcoffset() != timezone.utc.utcoffset( + observed_at + ): + raise EvidenceContractError("observed_at_utc must carry a UTC offset") + + order_facts = { + ProviderFact.ORDER_ACCEPTED, + ProviderFact.ORDER_PARTIAL, + ProviderFact.ORDER_CANCELED, + ProviderFact.ORDER_FILLED, + } + if event.fact in order_facts and ( + not event.order_ref or not event.external_order_id or not event.instrument_id + ): + raise EvidenceContractError( + "order callback must identify ref, provider id, and instrument" + ) + if event.fact is ProviderFact.ORDER_REJECTED and ( + not event.order_ref or not event.instrument_id + ): + raise EvidenceContractError("rejected insert callback must identify ref and instrument") + if event.fact in order_facts | {ProviderFact.ORDER_REJECTED}: + if not _nonnegative_finite(event.traded_quantity) or not _nonnegative_finite( + event.remaining_quantity + ): + raise EvidenceContractError( + "order callback quantities must be finite and non-negative" + ) + expected_status = { + ProviderFact.ORDER_ACCEPTED: {"accepted", "working"}, + ProviderFact.ORDER_PARTIAL: {"partial"}, + ProviderFact.ORDER_CANCELED: {"canceled"}, + ProviderFact.ORDER_FILLED: {"filled"}, + ProviderFact.ORDER_REJECTED: {"rejected"}, + } + if event.fact in expected_status and event.status not in expected_status[event.fact]: + raise EvidenceContractError(f"status does not match provider fact {event.fact.value}") + if event.fact is ProviderFact.ORDER_PARTIAL: + if not _positive_finite(event.traded_quantity) or not _positive_finite( + event.remaining_quantity + ): + raise EvidenceContractError( + "partial order callback requires traded and remaining quantity" + ) + if event.fact is ProviderFact.ORDER_ACCEPTED and not _positive_finite( + event.remaining_quantity + ): + raise EvidenceContractError( + "accepted working order must have positive remaining quantity" + ) + if event.fact is ProviderFact.ORDER_CANCELED and event.remaining_quantity != 0: + raise EvidenceContractError("canceled order callback must have zero remaining quantity") + if event.fact is ProviderFact.ORDER_FILLED and event.remaining_quantity != 0: + raise EvidenceContractError("filled order callback must have zero remaining quantity") + if event.fact is ProviderFact.ORDER_FILLED and not _positive_finite(event.traded_quantity): + raise EvidenceContractError( + "filled order callback must report positive traded quantity" + ) + if event.fact is ProviderFact.ORDER_REJECTED and ( + event.error_id <= 0 or not event.error_message + ): + raise EvidenceContractError("provider rejection requires ErrorID and ErrorMsg") + if event.fact is ProviderFact.TRADE_EXECUTION: + if ( + not event.order_ref + or not event.trade_id + or not _positive_finite(event.traded_quantity) + ): + raise EvidenceContractError( + "trade callback requires order_ref, trade_id, and positive quantity" + ) + if event.fact is ProviderFact.POSITION_SNAPSHOT: + if ( + not event.instrument_id + or not event.query_id + or not event.query_complete + or event.position_phase not in {"baseline", "final"} + or not _nonnegative_finite(event.closeable_quantity) + ): + raise EvidenceContractError( + "position evidence must be a complete tagged provider query" + ) + if event.fact is ProviderFact.ORDER_SNAPSHOT: + if not event.query_id or not event.query_complete: + raise EvidenceContractError( + "order evidence must be a complete provider order query" + ) + if any(not isinstance(ref, str) or not ref for ref in event.open_order_refs): + raise EvidenceContractError("open_order_refs must contain non-empty order refs") + + def _accepted_orders(self) -> set[str]: + return { + event.order_ref for event in self._events if event.fact is ProviderFact.ORDER_ACCEPTED + } + + @property + def accepted_order_refs(self) -> set[str]: + return self._accepted_orders() + + def _facts_by_order(self) -> dict[str, set[ProviderFact]]: + facts: dict[str, set[ProviderFact]] = {} + for event in self._events: + if event.order_ref: + facts.setdefault(event.order_ref, set()).add(event.fact) + return facts + + def _missing_facts(self) -> list[str]: + by_order = self._facts_by_order() + accepted = self._accepted_orders() + if any(event.fact is ProviderFact.ORDER_REJECTED for event in self._events): + return ["provider_rejected_request"] + final_order_snapshots = [ + event for event in self._events if event.fact is ProviderFact.ORDER_SNAPSHOT + ] + final_position_snapshots = [ + event + for event in self._events + if event.fact is ProviderFact.POSITION_SNAPSHOT and event.position_phase == "final" + ] + missing_reconciliation: list[str] = [] + if not final_order_snapshots: + missing_reconciliation.append("final_provider_order_query") + elif final_order_snapshots[-1].open_order_refs: + missing_reconciliation.append("provider_open_orders_remain") + provider_order_events = [ + event + for event in self._events + if event.fact + in { + ProviderFact.ORDER_ACCEPTED, + ProviderFact.ORDER_PARTIAL, + ProviderFact.ORDER_CANCELED, + ProviderFact.ORDER_FILLED, + ProviderFact.ORDER_REJECTED, + ProviderFact.TRADE_EXECUTION, + } + ] + if final_order_snapshots and provider_order_events: + if final_order_snapshots[-1].sequence <= max( + event.sequence for event in provider_order_events + ): + missing_reconciliation.append("final_order_query_must_follow_provider_events") + any_fill = any( + event.fact in {ProviderFact.TRADE_EXECUTION, ProviderFact.ORDER_FILLED} + for event in self._events + ) + if any_fill and not final_position_snapshots: + missing_reconciliation.append("final_provider_position_query") + if any_fill and final_position_snapshots and provider_order_events: + if final_position_snapshots[-1].sequence <= max( + event.sequence + for event in provider_order_events + if event.fact in {ProviderFact.TRADE_EXECUTION, ProviderFact.ORDER_FILLED} + ): + missing_reconciliation.append("final_position_query_must_follow_fills") + if self.plan.case_id in {"T01", "T03"}: + if len(accepted) > self.plan.minimum_orders: + return ["unexpected_order_count_requires_reconciliation", *missing_reconciliation] + if not accepted: + return ["order_accepted", *missing_reconciliation] + if self.plan.case_id == "T03" and any( + by_order.get(ref, set()) & {ProviderFact.ORDER_FILLED} for ref in accepted + ): + return ["cancel_window_unavailable", *missing_reconciliation] + terminal = {ProviderFact.ORDER_CANCELED, ProviderFact.ORDER_FILLED} + terminal_events = [ + event + for event in self._events + if event.order_ref in accepted and event.fact in terminal + ] + accepted_events = [ + event + for event in self._events + if event.order_ref in accepted and event.fact is ProviderFact.ORDER_ACCEPTED + ] + if not terminal_events: + return ["order_canceled_or_filled", *missing_reconciliation] + if min(event.sequence for event in accepted_events) >= min( + event.sequence for event in terminal_events + ): + return ["provider_order_transition_out_of_order", *missing_reconciliation] + if any(self._trade_quantity_mismatch(ref) for ref in accepted): + return ["trade_quantity_reconciliation", *missing_reconciliation] + return missing_reconciliation + if self.plan.case_id == "T02": + if len(accepted) > self.plan.minimum_orders: + return ["unexpected_order_count_requires_reconciliation", *missing_reconciliation] + position_events = [ + event for event in self._events if event.fact is ProviderFact.POSITION_SNAPSHOT + ] + phases = {event.position_phase for event in position_events} + required = [] + if "baseline" not in phases: + required.append("baseline_position_snapshot") + if "final" not in phases: + required.append("final_position_snapshot") + if not accepted: + required.append("order_accepted") + if accepted and not any( + by_order.get(ref, set()) & {ProviderFact.ORDER_CANCELED, ProviderFact.ORDER_FILLED} + for ref in accepted + ): + required.append("order_terminal") + baseline = next( + (event for event in position_events if event.position_phase == "baseline"), None + ) + if baseline and accepted: + first_acceptance = min( + event.sequence + for event in self._events + if event.fact is ProviderFact.ORDER_ACCEPTED + ) + if baseline.sequence >= first_acceptance: + required.append("baseline_position_snapshot_must_precede_order") + if any(self._trade_quantity_mismatch(ref) for ref in accepted): + required.append("trade_quantity_reconciliation") + if "baseline" in phases and not any( + event.position_phase == "baseline" and event.closeable_quantity > 0 + for event in position_events + ): + return ["closeable_position_unavailable"] + return [*required, *missing_reconciliation] + if self.plan.case_id == "B01": + qualified = { + ref + for ref in accepted + if { + ProviderFact.ORDER_PARTIAL, + ProviderFact.TRADE_EXECUTION, + ProviderFact.ORDER_CANCELED, + }.issubset(by_order.get(ref, set())) + } + required = ( + [] + if len(qualified) >= self.plan.minimum_orders and len(qualified) == len(accepted) + else ["every_test_order_must_be_partially_filled_then_canceled"] + ) + for ref in qualified: + sequence_by_fact = { + fact: min( + event.sequence + for event in self._events + if event.order_ref == ref and event.fact is fact + ) + for fact in ( + ProviderFact.ORDER_ACCEPTED, + ProviderFact.ORDER_PARTIAL, + ProviderFact.ORDER_CANCELED, + ) + } + if not ( + sequence_by_fact[ProviderFact.ORDER_ACCEPTED] + < sequence_by_fact[ProviderFact.ORDER_PARTIAL] + < sequence_by_fact[ProviderFact.ORDER_CANCELED] + ): + required.append(f"provider_order_transition_out_of_order:{ref}") + if self._trade_quantity_mismatch(ref): + required.append(f"trade_quantity_reconciliation:{ref}") + accepted_sequence = sequence_by_fact[ProviderFact.ORDER_ACCEPTED] + if any( + event.order_ref == ref + and event.fact is ProviderFact.TRADE_EXECUTION + and event.sequence <= accepted_sequence + for event in self._events + ): + required.append(f"provider_trade_precedes_acceptance:{ref}") + if any_fill and not final_position_snapshots: + required.append("final_provider_position_query") + return [*required, *missing_reconciliation] + if any( + ProviderFact.ORDER_PARTIAL in by_order.get(ref, set()) + and ProviderFact.TRADE_EXECUTION not in by_order.get(ref, set()) + for ref in accepted + ): + return ["partial_order_missing_trade_callback", *missing_reconciliation] + if any( + ProviderFact.ORDER_FILLED in by_order.get(ref, set()) + and ProviderFact.TRADE_EXECUTION not in by_order.get(ref, set()) + for ref in accepted + ): + return ["filled_order_missing_trade_callback", *missing_reconciliation] + for ref in accepted: + if self._trade_quantity_mismatch(ref): + return [f"trade_quantity_reconciliation:{ref}", *missing_reconciliation] + for ref in accepted: + acceptance = min( + event.sequence + for event in self._events + if event.order_ref == ref and event.fact is ProviderFact.ORDER_ACCEPTED + ) + terminal_sequences = [ + event.sequence + for event in self._events + if event.order_ref == ref + and event.fact in {ProviderFact.ORDER_CANCELED, ProviderFact.ORDER_FILLED} + ] + if terminal_sequences and acceptance >= min(terminal_sequences): + return [f"provider_order_transition_out_of_order:{ref}", *missing_reconciliation] + if any( + event.order_ref == ref + and event.fact is ProviderFact.TRADE_EXECUTION + and event.sequence <= acceptance + for event in self._events + ): + return [f"provider_trade_precedes_acceptance:{ref}", *missing_reconciliation] + qualified = { + ref + for ref in accepted + if by_order.get(ref, set()) & {ProviderFact.ORDER_CANCELED, ProviderFact.ORDER_FILLED} + } + required = ( + [] + if len(qualified) >= self.plan.minimum_orders and len(qualified) == len(accepted) + else ["every_test_order_must_reach_terminal_state"] + ) + return [*required, *missing_reconciliation] + + def _trade_quantity_mismatch(self, order_ref: str) -> bool: + trades = [ + event + for event in self._events + if event.order_ref == order_ref and event.fact is ProviderFact.TRADE_EXECUTION + ] + if not trades: + return False + terminal = [ + event + for event in self._events + if event.order_ref == order_ref + and event.fact in {ProviderFact.ORDER_CANCELED, ProviderFact.ORDER_FILLED} + ] + if not terminal: + return False + reported = max(terminal, key=lambda event: event.sequence) + return not math.isclose( + sum(event.traded_quantity for event in trades), + reported.traded_quantity, + rel_tol=0.0, + abs_tol=1e-9, + ) + + def _derive_state( + self, + unavailable: list[DependencyEvidence], + unresolved: list[str], + missing_facts: list[str], + ) -> EngineState: + if unavailable or "closeable_position_unavailable" in missing_facts: + return EngineState.EXTERNAL_CONDITION_UNAVAILABLE + if {"provider_rejected_request", "cancel_window_unavailable"} & set(missing_facts): + return EngineState.EXTERNAL_CONDITION_UNAVAILABLE + if "unexpected_fill_requires_reconciliation" in missing_facts: + return EngineState.STOP_AND_RECONCILE + if not missing_facts and not unresolved: + return EngineState.EVIDENCE_COMPLETE_REQUIRES_REVIEW + if self._events: + return EngineState.COLLECTING + return EngineState.WAITING_EXTERNAL + + +def sha256_redacted_payload(payload: bytes) -> str: + """Return a digest for a caller-supplied redacted callback artifact.""" + + if not isinstance(payload, bytes) or not payload: + raise ValueError("payload must be non-empty redacted bytes") + return hashlib.sha256(payload).hexdigest() + + +def _positive_finite(value: Any) -> bool: + return _finite_number(value) and float(value) > 0 + + +def _nonnegative_finite(value: Any) -> bool: + return _finite_number(value) and float(value) >= 0 + + +def _finite_number(value: Any) -> bool: + if isinstance(value, bool): + return False + try: + return math.isfinite(float(value)) + except (TypeError, ValueError, OverflowError): + return False + + +def validate_issued_request_receipt( + receipt: Any, + *, + request_kind: str, + phase: str, + request_id: int, + request_generation: int, + client_instance_id: str, + arrival_generation: int, + arrived_at_utc: str, + arrived_monotonic: float, +) -> bool: + """Check receipt shape and callback correlation, without authenticating it.""" + + if not isinstance(receipt, IssuedRequestReceipt): + return False + if (receipt.request_kind, receipt.phase) != (request_kind, phase): + return False + if ( + type(receipt.request_id) is not int + or receipt.request_id <= 0 + or receipt.request_id != request_id + or type(receipt.request_generation) is not int + or receipt.request_generation <= 0 + or receipt.request_generation != request_generation + or not isinstance(receipt.client_instance_id, str) + or not receipt.client_instance_id + or receipt.client_instance_id != client_instance_id + or type(receipt.arrival_generation) is not int + or receipt.arrival_generation <= 0 + or receipt.arrival_generation != arrival_generation + or type(receipt.send_return_code) is not int + or receipt.send_return_code != 0 + or receipt.ledger_origin != "local_sdk_request_ledger" + or receipt.issued_at_origin != "local_sdk_call_boundary" + or not isinstance(receipt.receipt_id, str) + or not receipt.receipt_id.strip() + or not _SHA256_RE.fullmatch(str(receipt.ledger_entry_sha256)) + ): + return False + issued_at = _utc_timestamp(receipt.issued_at_utc) + arrived_at = _utc_timestamp(arrived_at_utc) + if ( + issued_at is None + or arrived_at is None + or issued_at > arrived_at + or type(receipt.issued_monotonic) not in {int, float} + or isinstance(receipt.issued_monotonic, bool) + or receipt.issued_monotonic <= 0 + or type(arrived_monotonic) not in {int, float} + or isinstance(arrived_monotonic, bool) + # Windows monotonic clocks can return the same tick for an inline + # callback. Strict local callback-sequence checks establish event + # ordering separately; this check only rejects an earlier clock value. + or arrived_monotonic < receipt.issued_monotonic + ): + return False + return True + + +def _validate_c01_callback_evidence(evidence: CertificationEvidence) -> None: + """Validate native C01 fields and explicitly local callback-arrival data.""" + + arrival = evidence.callback_arrival + if not isinstance(arrival, LocalCallbackArrival): + raise EvidenceContractError("C01 callback requires local callback-arrival metadata") + if not isinstance(arrival.client_instance_id, str) or not arrival.client_instance_id.strip(): + raise EvidenceContractError("C01 callback requires local client_instance_id") + if type(arrival.request_generation) is not int or arrival.request_generation < 1: + raise EvidenceContractError("C01 callback requires positive local request_generation") + if type(arrival.arrival_generation) is not int or arrival.arrival_generation < 1: + raise EvidenceContractError("C01 callback requires positive local arrival_generation") + if type(arrival.source_sequence) is not int or arrival.source_sequence < 1: + raise EvidenceContractError("C01 callback requires positive local source_sequence") + if ( + type(arrival.arrived_monotonic) not in {int, float} + or isinstance(arrival.arrived_monotonic, bool) + or arrival.arrived_monotonic <= 0 + ): + raise EvidenceContractError("C01 callback requires positive local arrived_monotonic") + if arrival.sequence_origin != "local_sdk_callback_arrival": + raise EvidenceContractError("C01 callback sequence must be local SDK arrival metadata") + if arrival.timestamp_origin != "local_sdk_capture_clock": + raise EvidenceContractError("C01 callback timestamp must be local capture metadata") + if not _is_utc_timestamp(arrival.arrived_at_utc): + raise EvidenceContractError("C01 callback arrived_at_utc must carry UTC offset") + if not _is_utc_timestamp(evidence.occurred_at_utc) or ( + _utc_timestamp(evidence.occurred_at_utc) != _utc_timestamp(arrival.arrived_at_utc) + ): + raise EvidenceContractError( + "C01 occurred_at_utc must mirror local callback arrival, not provider event time" + ) + + fields = evidence.fields + request_id = fields.get("request_id") + if type(request_id) is not int or request_id <= 0: + raise EvidenceContractError("C01 native callback requires positive integer nRequestID") + request_kind = "authenticate" if evidence.event_kind == "store_auth_success" else "login" + if arrival.issued_request_receipt is not None and not validate_issued_request_receipt( + arrival.issued_request_receipt, + request_kind=request_kind, + phase="", + request_id=request_id, + request_generation=arrival.request_generation, + client_instance_id=arrival.client_instance_id, + arrival_generation=arrival.arrival_generation, + arrived_at_utc=arrival.arrived_at_utc, + arrived_monotonic=arrival.arrived_monotonic, + ): + raise EvidenceContractError("C01 callback issued-request receipt does not match callback") + if type(fields.get("is_last")) is not bool or fields.get("is_last") is not True: + raise EvidenceContractError("C01 native response callback must carry IsLast=True") + if type(fields.get("error_id")) is not int or fields.get("error_id") != 0: + raise EvidenceContractError("C01 native callback ErrorID must be integer zero") + if type(fields.get("request_generation")) is not int or ( + fields.get("request_generation") != arrival.request_generation + ): + raise EvidenceContractError( + "native callback request_id must bind to local request_generation" + ) + if type(fields.get("arrival_generation")) is not int or ( + fields.get("arrival_generation") != arrival.arrival_generation + ): + raise EvidenceContractError( + "native callback must bind to local arrival_generation" + ) + + if evidence.event_kind == "store_auth_success": + if evidence.provider_front_id is not None or evidence.provider_session_id or evidence.trading_day: + raise EvidenceContractError( + "OnRspAuthenticate has no provider session or TradingDay fields" + ) + invented_auth_fields = { + "FrontID", + "front_id", + "provider_front_id", + "SessionID", + "session_id", + "provider_session_id", + "TradingDay", + "trading_day", + "provider_timestamp_utc", + "provider_sequence", + } + if any(fields.get(key) not in (None, "") for key in invented_auth_fields): + raise EvidenceContractError( + "OnRspAuthenticate evidence cannot claim login-only or provider-time fields" + ) + if fields.get("authentication_succeeded") is not True: + raise EvidenceContractError("authentication callback does not report success") + if evidence.source_sequence != 0: + raise EvidenceContractError( + "C01 auth source_sequence is not provider-issued; use callback_arrival" + ) + return + + impossible_provider_metadata = ( + "provider_timestamp_utc", + "provider_sequence", + "provider_event_id", + "source_sequence", + "source_timestamp_utc", + ) + if any(fields.get(key) not in (None, "") for key in impossible_provider_metadata): + raise EvidenceContractError( + "C01 auth/login callback cannot claim provider timestamp, sequence, or event id" + ) + + front_id = fields.get("provider_front_id") + session_id = fields.get("provider_session_id") + trading_day = fields.get("trading_day") + if type(front_id) is not int or front_id < 1: + raise EvidenceContractError("OnRspUserLogin requires native positive FrontID") + if not isinstance(session_id, str) or not session_id.isdigit(): + raise EvidenceContractError("OnRspUserLogin requires native non-negative SessionID") + if not isinstance(trading_day, str) or not re.fullmatch(r"[0-9]{8}", trading_day): + raise EvidenceContractError("OnRspUserLogin requires native YYYYMMDD TradingDay") + if ( + evidence.provider_front_id != front_id + or evidence.provider_session_id != session_id + or evidence.trading_day != trading_day + ): + raise EvidenceContractError( + "login scope aliases must match native FrontID, SessionID, and TradingDay" + ) + if fields.get("login_succeeded") is not True: + raise EvidenceContractError("login callback does not report success") + if evidence.source_sequence != 0: + raise EvidenceContractError( + "C01 login source_sequence is not provider-issued; use callback_arrival" + ) + + +def _utc_timestamp(value: str) -> datetime | None: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed): + return None + return parsed + + +def _is_utc_timestamp(value: str) -> bool: + return _utc_timestamp(value) is not None diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/certification.py b/examples/007_ctp/live_certification/simnow_penetration/common/certification.py index 2cce697a..3f0c6a21 100644 --- a/examples/007_ctp/live_certification/simnow_penetration/common/certification.py +++ b/examples/007_ctp/live_certification/simnow_penetration/common/certification.py @@ -74,15 +74,15 @@ def to_dict(self) -> dict[str, Any]: "C01": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none"}, "T01": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "T02": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, - "T03": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, + "T03": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "M01": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none"}, "M02": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none"}, "M03": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none"}, "M04": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, - "M05": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, + "M05": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "O01": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "O02": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, - "O03": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, + "O03": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "TH01": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none"}, "TH02": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "TH03": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none"}, @@ -94,12 +94,12 @@ def to_dict(self) -> dict[str, Any]: "V03": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, "E01": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, "E02": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, - "E03": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, - "EM01": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, + "E03": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, + "EM01": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, "EM02": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, "EM03": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, - "B01": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, - "B02": {"order_activity": "required", "trade_activity": "none", "account_position_change": "none", "no_open_orders_after": True}, + "B01": {"order_activity": "required", "trade_activity": "required", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, + "B02": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "L01": {"order_activity": "required", "trade_activity": "required", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, "L02": {"order_activity": "none", "trade_activity": "none", "account_position_change": "none"}, "L03": {"order_activity": "required", "trade_activity": "allowed", "account_position_change": "allowed_if_trade", "no_open_orders_after": True}, diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/completion_invariants.py b/examples/007_ctp/live_certification/simnow_penetration/common/completion_invariants.py new file mode 100644 index 00000000..0ee07df8 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/common/completion_invariants.py @@ -0,0 +1,2117 @@ +"""Pure-data lifecycle and account reconciliation invariants for 007 cases. + +This checker is unregistered. It performs no provider I/O and trusts no local +result summary. Callers supply normalized managed-request receipts, native +order/trade callbacks, and complete before/after order/position/account query +snapshots. It validates shape and cross-source correlations only: source +authenticity, real-account ownership, provider semantics, and certification +still require independent review. Its strongest result is REVIEW_REQUIRED. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from enum import Enum +from fractions import Fraction +from typing import Any, Mapping + +from .case_engine import ( + CertificationEvidence, + CertificationEvidenceTracker, + EvidenceContractError, + IssuedRequestReceipt, + validate_issued_request_receipt, +) +from .certification import ( + RECONCILIATION_EXPECTATIONS, + SCENARIOS_BY_CASE_ID, +) + +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") + + +class CompletionEvidenceError(ValueError): + """Raised when a supplied evidence row is malformed or untyped.""" + + +class CompletionStatus(str, Enum): + INCOMPLETE = "INCOMPLETE" + EXTERNAL_CONDITION_UNAVAILABLE = "EXTERNAL_CONDITION_UNAVAILABLE" + REVIEW_REQUIRED = "REVIEW_REQUIRED" + + +class RequestAction(str, Enum): + SUBMIT = "submit" + CANCEL = "cancel" + BATCH_CANCEL = "batch_cancel" + + +class DispatchState(str, Enum): + DISPATCHED = "dispatched" + BLOCKED_PRE_DISPATCH = "blocked_pre_dispatch" + + +class NativeOrderFactKind(str, Enum): + ACCEPTED = "accepted" + PARTIAL = "partial" + CANCELED = "canceled" + FILLED = "filled" + REJECTED = "rejected" + + +class SnapshotPhase(str, Enum): + BASELINE = "baseline" + FINAL = "final" + + +@dataclass(frozen=True) +class CloseablePositionBucket: + """Provider position-query bucket keyed by instrument, held side, and close offset.""" + + instrument_id: str + position_side: str # long / short + offset: str # close_today / close_yesterday + quantity: Decimal | str | int | float + + +@dataclass(frozen=True) +class ManagedOrderRequest: + """Managed runtime receipt for a submit/cancel attempt, not a write grant.""" + + request_id: str + action: RequestAction + dispatch_state: DispatchState + order_refs: tuple[str, ...] + occurred_at_utc: str + sequence: int + evidence_sha256: str + source: str = "managed_runtime_receipt" + quantity: Decimal | str | int | float | None = None + instrument_id: str | None = None + direction: str | None = None + offset: str | None = None + account_id_masked: str | None = None + provider_session_id: str | None = None + request_generation: int | None = None + intent_key: str | None = None + trading_day: str | None = None + + +@dataclass(frozen=True) +class NativeOrderFact: + event_id: str + fact: NativeOrderFactKind + callback_name: str + source: str + order_ref: str + external_order_id: str + instrument_id: str + status: str + traded_quantity: Decimal | str | int | float + remaining_quantity: Decimal | str | int | float + session_id: str + trading_day: str + source_sequence: int + occurred_at_utc: str + evidence_sha256: str + error_id: int = 0 + direction: str | None = None + offset: str | None = None + account_id_masked: str | None = None + + +@dataclass(frozen=True) +class NativeTradeFact: + event_id: str + trade_id: str + order_ref: str + instrument_id: str + quantity: Decimal | str | int | float + direction: str + price: Decimal | str | int | float + callback_name: str + source: str + session_id: str + trading_day: str + source_sequence: int + occurred_at_utc: str + evidence_sha256: str + external_order_id: str = "" + offset: str | None = None + account_id_masked: str | None = None + + +@dataclass(frozen=True) +class AccountReconciliationSnapshot: + """Three completed native queries; positions are signed net by instrument. + + ``order_query_id``/``position_query_id``/``account_query_id`` and the + optional ``query_round_id`` are local query-coordinator correlations, not + provider-issued query IDs. The corresponding ``query_native_request_ids`` + map contains CTP's echoed callback ``nRequestID`` values. Query sequences + and ``occurred_at_utc`` are local SDK callback-arrival metadata; they are + not provider timestamps or provider sequence numbers. This checker cannot + independently prove that a future adapter supplies a truthful timeline. + """ + + phase: SnapshotPhase + session_id: str + trading_day: str + occurred_at_utc: str + order_query_id: str + position_query_id: str + account_query_id: str + order_query_sequence: int + position_query_sequence: int + account_query_sequence: int + open_order_refs: tuple[str, ...] + positions: Mapping[str, Decimal | str | int | float] + funds: Mapping[str, Decimal | str | int | float] + callback_names: tuple[str, ...] + source: str + evidence_sha256: str + query_error_ids: Mapping[str, int] = field(default_factory=dict) + query_is_last: Mapping[str, bool] = field(default_factory=dict) + closeable_quantities: Mapping[str, Decimal | str | int | float] = field(default_factory=dict) + closeable_position_buckets: tuple[CloseablePositionBucket, ...] = () + account_id_masked: str | None = None + client_instance_id: str = "" + arrival_generation: int = 0 + query_native_request_ids: Mapping[str, int] = field(default_factory=dict) + query_round_id: str = "" + query_id_origin: str = "" + query_round_id_origin: str = "" + sequence_origin: str = "" + timestamp_origin: str = "" + query_issued_request_receipts: Mapping[str, IssuedRequestReceipt] = field(default_factory=dict) + query_request_generations: Mapping[str, int] = field(default_factory=dict) + query_arrival_times_utc: Mapping[str, str] = field(default_factory=dict) + query_arrival_monotonic: Mapping[str, float] = field(default_factory=dict) + session_identity_origin: str = "" + + +@dataclass(frozen=True) +class ExternalDependency: + dependency_id: str + state: str + evidence_ref: str + reason: str = "" + source: str = "control_plane_receipt" + evidence_sha256: str = "" + fields: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class CompletionEvidence: + case_id: str + scenario_evidence: tuple[CertificationEvidence, ...] = () + managed_requests: tuple[ManagedOrderRequest, ...] = () + order_facts: tuple[NativeOrderFact, ...] = () + trade_facts: tuple[NativeTradeFact, ...] = () + snapshots: tuple[AccountReconciliationSnapshot, ...] = () + external_dependencies: tuple[ExternalDependency, ...] = () + + +@dataclass(frozen=True) +class CompletionReport: + case_id: str + scenario_id: str + status: CompletionStatus + certification_pass: bool + dispatch_permitted: bool + source_authenticity_verified: bool + missing_invariants: tuple[str, ...] + contradictions: tuple[str, ...] + unavailable_dependencies: tuple[str, ...] + review_reasons: tuple[str, ...] + + +_CALLBACK_BY_FACT = { + NativeOrderFactKind.ACCEPTED: "OnRtnOrder", + NativeOrderFactKind.PARTIAL: "OnRtnOrder", + NativeOrderFactKind.CANCELED: "OnRtnOrder", + NativeOrderFactKind.FILLED: "OnRtnOrder", + NativeOrderFactKind.REJECTED: "OnRspOrderInsert", +} +_STATUS_BY_FACT = { + NativeOrderFactKind.ACCEPTED: {"accepted", "working"}, + NativeOrderFactKind.PARTIAL: {"partial"}, + NativeOrderFactKind.CANCELED: {"canceled", "cancelled"}, + NativeOrderFactKind.FILLED: {"filled"}, + NativeOrderFactKind.REJECTED: {"rejected"}, +} +_TERMINAL_FACTS = { + NativeOrderFactKind.CANCELED, + NativeOrderFactKind.FILLED, + NativeOrderFactKind.REJECTED, +} +_REQUIRED_QUERY_CALLBACKS = { + "OnRspQryOrder", + "OnRspQryInvestorPosition", + "OnRspQryTradingAccount", +} +_REQUIRED_FUND_FIELDS = {"cash", "available_funds", "equity"} +_EXPECTED_REMOTE_REJECTION_CASES = frozenset({"E01", "E02", "E03", "EM01"}) +_REQUIRED_DEPENDENCIES = { + "M02": {"external_disconnect"}, + "M03": {"external_reconnect"}, + "E01": {"insufficient_funds"}, + "E02": {"insufficient_position"}, + "E03": {"market_state"}, + "EM01": {"account_permission_disabled", "account_permission_restored"}, + "EM02": {"strategy_pause_authorized"}, + "EM03": {"gateway_force_logout_ack"}, +} + + +def evaluate_case_completion(evidence: CompletionEvidence) -> CompletionReport: + """Check order terminality, trade conservation, account delta, and cleanup. + + The result is a reconciliation status only. Even a complete result remains + REVIEW_REQUIRED with PASS and dispatch authority hard-coded false. + """ + + case_id = evidence.case_id + scenario = SCENARIOS_BY_CASE_ID.get(case_id) + expectation = RECONCILIATION_EXPECTATIONS.get(case_id) + if scenario is None or expectation is None: + raise CompletionEvidenceError(f"unknown certification case {case_id!r}") + _validate_rows(evidence) + unavailable = tuple( + f"{item.dependency_id}:{item.reason}" + for item in evidence.external_dependencies + if item.state == "unavailable" + ) + missing, contradictions = _derive_invariants(evidence, expectation) + scenario_missing, scenario_contradictions = _scenario_invariants(evidence) + missing.extend(scenario_missing) + contradictions.extend(scenario_contradictions) + review_reasons = [ + "provider source authenticity and account ownership require independent verification" + ] + if evidence.trade_facts: + review_reasons.append( + "cash/equity deltas, fees, and margin effects require provider-specific independent accounting review" + ) + if unavailable: + status = CompletionStatus.EXTERNAL_CONDITION_UNAVAILABLE + elif missing or contradictions: + status = CompletionStatus.INCOMPLETE + else: + status = CompletionStatus.REVIEW_REQUIRED + return CompletionReport( + case_id=case_id, + scenario_id=scenario.scenario_id, + status=status, + certification_pass=False, + dispatch_permitted=False, + source_authenticity_verified=False, + missing_invariants=tuple(dict.fromkeys(missing)), + contradictions=tuple(dict.fromkeys(contradictions)), + unavailable_dependencies=unavailable, + review_reasons=tuple(review_reasons), + ) + + +def _scenario_invariants(evidence: CompletionEvidence) -> tuple[list[str], list[str]]: + """Require the canonical scenario evidence contract for every case.""" + + tracker = CertificationEvidenceTracker(evidence.case_id) + try: + for row in evidence.scenario_evidence: + tracker.record(row) + except EvidenceContractError as exc: + raise CompletionEvidenceError(str(exc)) from exc + snapshot = tracker.snapshot() + missing = [ + *(f"scenario_event:{item}" for item in snapshot["missing_events"]), + *(f"scenario_field:{item}" for item in snapshot["missing_evidence_fields"]), + *(f"scenario_provenance_policy:{item}" for item in snapshot["missing_provenance_policies"]), + *(f"scenario_correlation:{item}" for item in snapshot["missing_event_correlations"]), + ] + contradictions: list[str] = [] + _check_scenario_correlations(evidence, contradictions) + return missing, contradictions + + +def validation_failure_conditions( + case_id: str, fields: Mapping[str, Any], occurred_at_utc: str +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Check pure factual conditions for the V01/V02/V03 local-rejection cases. + + This validates supplied data only. In particular, the authoritative lookup + flags and metadata digests are not authenticated here, and a complete result + is still only eligible for REVIEW_REQUIRED. + """ + + if case_id not in {"V01", "V02", "V03"}: + return (), () + + missing: list[str] = [] + contradictions: list[str] = [] + if fields.get("dispatch_absent") is not True: + missing.append("validation_rejection_requires_proven_absent_dispatch") + reference_digest = fields.get("reference_data_digest") + if not isinstance(reference_digest, str) or not _SHA256_RE.fullmatch(reference_digest): + missing.append("validation_requires_reference_data_sha256") + + if case_id == "V01": + instrument = fields.get("instrument_id") + if fields.get("instrument") != instrument: + contradictions.append("validation_instrument_alias_mismatch") + lookup_result = fields.get("authoritative_lookup_result") + lookup_instrument = fields.get("authoritative_lookup_instrument") + lookup_at = _parse_utc(str(fields.get("authoritative_lookup_at_utc", ""))) + required_lookup_fields = ( + fields.get("authoritative_lookup_source"), + fields.get("authoritative_lookup_id"), + ) + if not isinstance(instrument, str) or not instrument.strip(): + missing.append("unknown_contract_requires_instrument_id") + if fields.get("authoritative_lookup_complete") is not True: + missing.append("unknown_contract_requires_complete_authoritative_lookup") + if fields.get("authoritative_lookup_authoritative") is not True: + missing.append("unknown_contract_requires_authoritative_reference_source") + if any(not isinstance(value, str) or not value.strip() for value in required_lookup_fields): + missing.append("unknown_contract_requires_lookup_source_and_query_id") + if lookup_at is None: + missing.append("unknown_contract_requires_utc_lookup_time") + if lookup_result != "not_found": + contradictions.append("authoritative_contract_lookup_did_not_report_not_found") + if instrument and lookup_instrument != instrument: + contradictions.append("authoritative_contract_lookup_instrument_mismatch") + rejection_at = _parse_utc(occurred_at_utc) + if lookup_at is not None and rejection_at is not None and lookup_at > rejection_at: + contradictions.append("contract_lookup_must_precede_local_validation_rejection") + elif case_id == "V02": + price = _decimal(fields.get("proposed_price")) + if _decimal(fields.get("price")) != price: + contradictions.append("validation_price_alias_mismatch") + price_tick = _decimal(fields.get("price_tick")) + if price is None or price <= 0: + missing.append("tick_validation_requires_positive_finite_price") + if price_tick is None or price_tick <= 0: + missing.append("tick_validation_requires_positive_finite_tick") + if price is not None and price_tick is not None and price > 0 and price_tick > 0: + if not _bounded_contract_decimal(price) or not _bounded_contract_decimal(price_tick): + missing.append("tick_validation_requires_bounded_contract_decimals") + elif Fraction(price) % Fraction(price_tick) == 0: + contradictions.append("reported_price_is_aligned_to_contract_tick") + else: # V03 + size = _decimal(fields.get("requested_size")) + if _decimal(fields.get("size")) != size: + contradictions.append("validation_size_alias_mismatch") + maximum = _decimal(fields.get("max_order_size")) + if size is None or size <= 0: + missing.append("maximum_size_validation_requires_positive_finite_size") + elif not _bounded_contract_decimal(size): + missing.append("maximum_size_validation_requires_bounded_size") + elif size != size.to_integral_value(): + missing.append("maximum_size_validation_requires_integer_order_size") + if maximum is None or maximum <= 0: + missing.append("maximum_size_validation_requires_positive_finite_limit") + elif not _bounded_contract_decimal(maximum): + missing.append("maximum_size_validation_requires_bounded_limit") + elif maximum != maximum.to_integral_value(): + missing.append("maximum_size_validation_requires_integer_limit") + if ( + size is not None + and maximum is not None + and _bounded_contract_decimal(size) + and _bounded_contract_decimal(maximum) + and size <= maximum + ): + contradictions.append("reported_size_does_not_exceed_contract_maximum") + + return tuple(missing), tuple(contradictions) + + +def _check_scenario_correlations(evidence: CompletionEvidence, contradictions: list[str]) -> None: + rows_by_kind: dict[str, list[CertificationEvidence]] = {} + for row in evidence.scenario_evidence: + rows_by_kind.setdefault(row.event_kind, []).append(row) + managed_by_action = { + action: [request for request in evidence.managed_requests if request.action is action] + for action in RequestAction + } + state_values = { + DispatchState.DISPATCHED: "dispatched", + DispatchState.BLOCKED_PRE_DISPATCH: "blocked_pre_dispatch", + } + for event_kind, action in ( + ("order_submit_request", RequestAction.SUBMIT), + ("order_cancel_request", RequestAction.CANCEL), + ): + rows = rows_by_kind.get(event_kind, []) + if not rows: + continue + requests = managed_by_action[action] + row_keys = sorted( + (str(row.fields.get("order_ref")), str(row.fields.get("dispatch_state"))) + for row in rows + ) + request_keys = sorted( + (ref, state_values[request.dispatch_state]) + for request in requests + for ref in request.order_refs + ) + if row_keys != request_keys: + contradictions.append(f"{event_kind}_does_not_match_managed_request_receipts") + batch_rows = rows_by_kind.get("batch_cancel_requested", []) + batch_requests = managed_by_action[RequestAction.BATCH_CANCEL] + if batch_rows and ( + len(batch_rows) != len(batch_requests) + or any( + set(row.fields.get("order_refs", ())) != set(request.order_refs) + or row.fields.get("dispatch_state") != state_values[request.dispatch_state] + for row, request in zip(batch_rows, batch_requests) + ) + ): + contradictions.append("batch_cancel_event_does_not_match_managed_request_receipt") + for event_kind, fact_kind in ( + ("order_status_accepted", NativeOrderFactKind.ACCEPTED), + ("order_status_canceled", NativeOrderFactKind.CANCELED), + ("order_reject_remote", NativeOrderFactKind.REJECTED), + ): + rows = rows_by_kind.get(event_kind, []) + facts = [fact for fact in evidence.order_facts if fact.fact is fact_kind] + for row in rows: + matching = [ + fact + for fact in facts + if row.fields.get("order_ref") == fact.order_ref + and row.fields.get("external_order_id", fact.external_order_id) + == fact.external_order_id + ] + if not matching: + contradictions.append(f"{event_kind}_has_no_matching_native_order_fact") + elif event_kind == "order_reject_remote" and any( + row.fields.get("ErrorID") != fact.error_id for fact in matching + ): + contradictions.append("remote_rejection_event_error_id_mismatch") + trade_rows = rows_by_kind.get("trade_execution", []) + if trade_rows: + fact_map = {fact.trade_id: fact for fact in evidence.trade_facts} + for row in trade_rows: + fact = fact_map.get(str(row.fields.get("trade_id"))) + if ( + fact is None + or row.fields.get("order_ref") != fact.order_ref + or row.fields.get("external_order_id") != fact.external_order_id + ): + contradictions.append("trade_event_does_not_match_native_trade_fact") + continue + if evidence.case_id == "L01" and any( + key not in row.fields for key in ("instrument_id", "quantity", "direction", "price") + ): + contradictions.append("trade_log_requires_complete_native_trade_fields") + if ( + ( + "instrument_id" in row.fields + and row.fields["instrument_id"] != fact.instrument_id + ) + or ("direction" in row.fields and row.fields["direction"] != fact.direction) + or ( + "quantity" in row.fields + and _decimal(row.fields["quantity"]) != _decimal(fact.quantity) + ) + or ("price" in row.fields and _decimal(row.fields["price"]) != _decimal(fact.price)) + ): + contradictions.append("trade_event_fields_do_not_match_native_trade_fact") + + +def _validate_rows(evidence: CompletionEvidence) -> None: + c01_native_login_present = evidence.case_id == "C01" and any( + row.event_kind == "store_login_success" for row in evidence.scenario_evidence + ) + if len({request.request_id for request in evidence.managed_requests}) != len( + evidence.managed_requests + ): + raise CompletionEvidenceError("duplicate managed request_id") + if len({fact.event_id for fact in evidence.order_facts}) != len(evidence.order_facts): + raise CompletionEvidenceError("duplicate native order event_id") + if len({fact.event_id for fact in evidence.trade_facts}) != len(evidence.trade_facts): + raise CompletionEvidenceError("duplicate native trade event_id") + if len({fact.trade_id for fact in evidence.trade_facts}) != len(evidence.trade_facts): + raise CompletionEvidenceError("duplicate native trade_id") + + for request in evidence.managed_requests: + if request.source != "managed_runtime_receipt": + raise CompletionEvidenceError("managed intent must use managed runtime receipt source") + if not request.request_id or not request.order_refs: + raise CompletionEvidenceError("managed request requires id and order refs") + if any(not isinstance(ref, str) or not ref for ref in request.order_refs): + raise CompletionEvidenceError("managed request order refs must be non-empty strings") + if request.action is RequestAction.SUBMIT and len(request.order_refs) != 1: + raise CompletionEvidenceError("one submit request must identify exactly one order ref") + if request.action is RequestAction.BATCH_CANCEL and len(set(request.order_refs)) < 2: + raise CompletionEvidenceError("batch cancel must identify at least two distinct refs") + if request.action is RequestAction.SUBMIT: + quantity = _decimal(request.quantity) + if quantity is None or quantity <= 0: + raise CompletionEvidenceError("submit receipt requires positive requested quantity") + elif request.quantity is not None: + raise CompletionEvidenceError("only submit receipts may carry requested quantity") + if len(set(request.order_refs)) != len(request.order_refs): + raise CompletionEvidenceError("request contains duplicate order refs") + if not _SHA256_RE.fullmatch(request.evidence_sha256): + raise CompletionEvidenceError("managed request requires SHA-256 evidence digest") + if request.sequence < 1 or _parse_utc(request.occurred_at_utc) is None: + raise CompletionEvidenceError("managed request requires positive sequence and UTC time") + if request.instrument_id is not None and not request.instrument_id.strip(): + raise CompletionEvidenceError("managed request instrument id must be non-empty") + if request.direction not in {None, "buy", "sell"}: + raise CompletionEvidenceError("managed request direction must be buy or sell") + if request.offset not in {None, "open", "close", "close_today", "close_yesterday"}: + raise CompletionEvidenceError("managed request offset is unsupported") + if request.account_id_masked is not None and not request.account_id_masked.strip(): + raise CompletionEvidenceError("managed account identity must be non-empty when supplied") + if request.provider_session_id is not None and not request.provider_session_id.strip(): + raise CompletionEvidenceError("managed provider session must be non-empty when supplied") + if request.trading_day is not None and not request.trading_day.strip(): + raise CompletionEvidenceError("managed trading day must be non-empty when supplied") + if request.request_generation is not None and ( + not isinstance(request.request_generation, int) + or isinstance(request.request_generation, bool) + or request.request_generation < 1 + ): + raise CompletionEvidenceError("managed request generation must be a positive integer") + if request.intent_key is not None and not request.intent_key.strip(): + raise CompletionEvidenceError("managed intent key must be non-empty when supplied") + + provider_scope: tuple[str, str] | None = None + all_native_sequences: list[int] = [] + all_native_times: list[datetime] = [] + for fact in evidence.order_facts: + _validate_native_identity( + event_id=fact.event_id, + source=fact.source, + session_id=fact.session_id, + trading_day=fact.trading_day, + sequence=fact.source_sequence, + occurred_at_utc=fact.occurred_at_utc, + evidence_sha256=fact.evidence_sha256, + ) + if fact.callback_name != _CALLBACK_BY_FACT[fact.fact]: + raise CompletionEvidenceError( + f"{fact.fact.value} requires {_CALLBACK_BY_FACT[fact.fact]}" + ) + if fact.status not in _STATUS_BY_FACT[fact.fact]: + raise CompletionEvidenceError("native order status conflicts with typed fact") + if not fact.order_ref or not fact.instrument_id: + raise CompletionEvidenceError("native order fact requires ref and instrument") + if fact.direction not in {None, "buy", "sell"}: + raise CompletionEvidenceError("native order direction must be buy or sell") + if fact.account_id_masked is not None and not fact.account_id_masked.strip(): + raise CompletionEvidenceError("native order account identity must be non-empty when supplied") + if fact.offset not in {None, "open", "close", "close_today", "close_yesterday"}: + raise CompletionEvidenceError("native order offset is unsupported") + if fact.fact is not NativeOrderFactKind.REJECTED and not fact.external_order_id: + raise CompletionEvidenceError("native order status requires external order id") + traded, remaining = _decimal(fact.traded_quantity), _decimal(fact.remaining_quantity) + if traded is None or remaining is None or traded < 0 or remaining < 0: + raise CompletionEvidenceError("native order quantities must be finite and non-negative") + if fact.fact is NativeOrderFactKind.ACCEPTED and remaining <= 0: + raise CompletionEvidenceError("accepted order must remain open") + if fact.fact is NativeOrderFactKind.PARTIAL and (traded <= 0 or remaining <= 0): + raise CompletionEvidenceError("partial order requires traded and remaining quantity") + if ( + fact.fact in {NativeOrderFactKind.CANCELED, NativeOrderFactKind.FILLED} + and remaining != 0 + ): + raise CompletionEvidenceError("terminal order must have zero remaining quantity") + if fact.fact is NativeOrderFactKind.REJECTED and fact.error_id <= 0: + raise CompletionEvidenceError("provider rejection requires positive ErrorID") + provider_scope = _same_scope(provider_scope, (fact.session_id, fact.trading_day)) + all_native_sequences.append(fact.source_sequence) + all_native_times.append(_parse_utc(fact.occurred_at_utc)) + + for fact in evidence.trade_facts: + _validate_native_identity( + event_id=fact.event_id, + source=fact.source, + session_id=fact.session_id, + trading_day=fact.trading_day, + sequence=fact.source_sequence, + occurred_at_utc=fact.occurred_at_utc, + evidence_sha256=fact.evidence_sha256, + ) + if fact.callback_name != "OnRtnTrade": + raise CompletionEvidenceError("trade facts require OnRtnTrade") + if fact.account_id_masked is not None and not fact.account_id_masked.strip(): + raise CompletionEvidenceError("native trade account identity must be non-empty when supplied") + if ( + not fact.trade_id + or not fact.order_ref + or not fact.instrument_id + or not fact.external_order_id + or _decimal(fact.quantity) is None + or fact.direction not in {"buy", "sell"} + or fact.offset not in {None, "open", "close", "close_today", "close_yesterday"} + or _decimal(fact.price) is None + ): + raise CompletionEvidenceError( + "trade fact requires id, order/instrument, quantity, direction, and price" + ) + if _decimal(fact.quantity) <= 0: + raise CompletionEvidenceError("trade quantity must be positive") + if _decimal(fact.price) <= 0: + raise CompletionEvidenceError("trade price must be positive") + provider_scope = _same_scope(provider_scope, (fact.session_id, fact.trading_day)) + all_native_sequences.append(fact.source_sequence) + all_native_times.append(_parse_utc(fact.occurred_at_utc)) + + if len(all_native_sequences) != len(set(all_native_sequences)): + raise CompletionEvidenceError("native source sequence must be unique per case") + native_event_ids = [fact.event_id for fact in (*evidence.order_facts, *evidence.trade_facts)] + if len(native_event_ids) != len(set(native_event_ids)): + raise CompletionEvidenceError( + "native event_id must be unique across order and trade events" + ) + events_by_sequence = sorted( + ( + (fact.source_sequence, _parse_utc(fact.occurred_at_utc)) + for fact in (*evidence.order_facts, *evidence.trade_facts) + ), + key=lambda item: item[0], + ) + if any( + later_time < earlier_time + for (_, earlier_time), (_, later_time) in zip(events_by_sequence, events_by_sequence[1:]) + ): + raise CompletionEvidenceError("native event timestamps must not move backwards") + + for dependency in evidence.external_dependencies: + if dependency.state not in {"satisfied", "unavailable"}: + raise CompletionEvidenceError("external dependency state must be satisfied/unavailable") + if not dependency.dependency_id or not dependency.evidence_ref: + raise CompletionEvidenceError("external dependency requires id and evidence reference") + if dependency.source != "control_plane_receipt": + raise CompletionEvidenceError("external dependency requires control-plane source") + if not _SHA256_RE.fullmatch(dependency.evidence_sha256): + raise CompletionEvidenceError("external dependency requires SHA-256 evidence digest") + if dependency.state == "unavailable" and not dependency.reason.strip(): + raise CompletionEvidenceError("unavailable dependency requires reason") + + phases = [snapshot.phase for snapshot in evidence.snapshots] + if sorted(phase.value for phase in phases) != ["baseline", "final"]: + raise CompletionEvidenceError("exactly one baseline and one final snapshot are required") + baseline, final = _snapshots_by_phase(evidence.snapshots) + for snapshot in (baseline, final): + if not snapshot.session_id or not snapshot.trading_day: + if not ( + evidence.case_id == "C01" + and not c01_native_login_present + and snapshot.session_id == "" + and snapshot.trading_day == "" + ): + raise CompletionEvidenceError( + "account snapshot requires provider session and trading day" + ) + if snapshot.account_id_masked is not None and not snapshot.account_id_masked.strip(): + raise CompletionEvidenceError("snapshot masked account identity must be non-empty when supplied") + if provider_scope and (snapshot.session_id, snapshot.trading_day) != provider_scope: + raise CompletionEvidenceError("snapshot provider scope does not match native callbacks") + if _parse_utc(snapshot.occurred_at_utc) is None: + raise CompletionEvidenceError("snapshot timestamp must carry UTC offset") + if snapshot.phase is SnapshotPhase.FINAL: + if any( + _parse_utc(snapshot.occurred_at_utc) < _parse_utc(fact.occurred_at_utc) + for fact in (*evidence.order_facts, *evidence.trade_facts) + ): + raise CompletionEvidenceError("final account snapshot predates a native event") + elif snapshot.phase is SnapshotPhase.BASELINE: + native_times = [ + _parse_utc(fact.occurred_at_utc) + for fact in (*evidence.order_facts, *evidence.trade_facts) + ] + if native_times and _parse_utc(snapshot.occurred_at_utc) >= min(native_times): + raise CompletionEvidenceError( + "baseline account snapshot must predate native activity" + ) + if not all( + (snapshot.order_query_id, snapshot.position_query_id, snapshot.account_query_id) + ): + raise CompletionEvidenceError("snapshot requires three native query ids") + if not all( + isinstance(sequence, int) and not isinstance(sequence, bool) and sequence > 0 + for sequence in ( + snapshot.order_query_sequence, + snapshot.position_query_sequence, + snapshot.account_query_sequence, + ) + ): + raise CompletionEvidenceError("snapshot query sequences must be positive integers") + if ( + len( + { + snapshot.order_query_sequence, + snapshot.position_query_sequence, + snapshot.account_query_sequence, + } + ) + != 3 + ): + raise CompletionEvidenceError("three query families require distinct source sequences") + if ( + len( + { + snapshot.order_query_id, + snapshot.position_query_id, + snapshot.account_query_id, + } + ) + != 3 + ): + raise CompletionEvidenceError("three query families require distinct query ids") + if snapshot.source != "ctp_provider_callback": + raise CompletionEvidenceError("account snapshot must originate from provider callbacks") + if evidence.case_id == "C01": + if not snapshot.client_instance_id: + raise CompletionEvidenceError("C01 query projection requires local client identity") + if type(snapshot.arrival_generation) is not int or snapshot.arrival_generation < 1: + raise CompletionEvidenceError("C01 query projection requires local arrival generation") + if snapshot.query_id_origin != "local_query_coordinator": + raise CompletionEvidenceError("C01 query IDs must be attributed to local coordinator") + if not snapshot.query_round_id or snapshot.query_round_id_origin != "local_query_coordinator": + raise CompletionEvidenceError("C01 query round ID must be locally attributed") + if snapshot.sequence_origin != "local_sdk_callback_arrival": + raise CompletionEvidenceError("C01 query sequences must be local callback arrivals") + if snapshot.timestamp_origin != "local_sdk_capture_clock": + raise CompletionEvidenceError("C01 query time must be local callback capture time") + expected_scope_origin = ( + "derived_from_same_client_generation_native_login" + if c01_native_login_present + else "" + ) + if snapshot.session_identity_origin != expected_scope_origin: + raise CompletionEvidenceError( + "C01 query scope must derive from a native login when one is present" + ) + if set(snapshot.query_native_request_ids) != _REQUIRED_QUERY_CALLBACKS or any( + type(value) is not int or value <= 0 + for value in snapshot.query_native_request_ids.values() + ): + raise CompletionEvidenceError( + "C01 queries require positive native callback RequestIDs by query family" + ) + if any( + type(value) is not int or value != 0 + for value in snapshot.query_error_ids.values() + ): + raise CompletionEvidenceError("C01 native query ErrorIDs must be integer zero") + if not _REQUIRED_QUERY_CALLBACKS.issubset(snapshot.callback_names): + raise CompletionEvidenceError( + "snapshot lacks complete native order/position/account query callbacks" + ) + if set(snapshot.query_error_ids) != _REQUIRED_QUERY_CALLBACKS or any( + isinstance(error_id, bool) or error_id != 0 + for error_id in snapshot.query_error_ids.values() + ): + raise CompletionEvidenceError("every native query callback must report ErrorID=0") + if set(snapshot.query_is_last) != _REQUIRED_QUERY_CALLBACKS or any( + value is not True for value in snapshot.query_is_last.values() + ): + raise CompletionEvidenceError( + "each native query family must include its final callback marker" + ) + if not _SHA256_RE.fullmatch(snapshot.evidence_sha256): + raise CompletionEvidenceError("snapshot requires SHA-256 evidence digest") + if any(not isinstance(ref, str) or not ref for ref in snapshot.open_order_refs): + raise CompletionEvidenceError("snapshot open refs must be non-empty strings") + if len(set(snapshot.open_order_refs)) != len(snapshot.open_order_refs): + raise CompletionEvidenceError("snapshot contains duplicate open order refs") + if set(snapshot.funds) != _REQUIRED_FUND_FIELDS: + raise CompletionEvidenceError( + "funds snapshot requires cash, available_funds, and equity" + ) + if any(_decimal(value) is None for value in snapshot.funds.values()): + raise CompletionEvidenceError("funds snapshot values must be finite decimals") + if any( + not instrument or _decimal(value) is None + for instrument, value in snapshot.positions.items() + ): + raise CompletionEvidenceError( + "positions snapshot requires instrument ids and finite quantities" + ) + if any( + not instrument or _decimal(value) is None or _decimal(value) < 0 + for instrument, value in snapshot.closeable_quantities.items() + ): + raise CompletionEvidenceError( + "closeable quantities require instrument ids and finite non-negative values" + ) + bucket_keys: set[tuple[str, str, str]] = set() + for bucket in snapshot.closeable_position_buckets: + if not isinstance(bucket, CloseablePositionBucket): + raise CompletionEvidenceError("closeable position evidence must use typed buckets") + if ( + not bucket.instrument_id.strip() + or bucket.position_side not in {"long", "short"} + or bucket.offset not in {"close_today", "close_yesterday"} + or _decimal(bucket.quantity) is None + or _decimal(bucket.quantity) < 0 + ): + raise CompletionEvidenceError("closeable position bucket has invalid scope or quantity") + bucket_key = (bucket.instrument_id, bucket.position_side, bucket.offset) + if bucket_key in bucket_keys: + raise CompletionEvidenceError("duplicate closeable position bucket") + bucket_keys.add(bucket_key) + if provider_scope and (baseline.session_id, baseline.trading_day) != provider_scope: + raise CompletionEvidenceError("baseline snapshot session/day differs from callbacks") + same_session = (baseline.session_id, baseline.trading_day) == ( + final.session_id, + final.trading_day, + ) + if evidence.case_id in {"M02", "M03"}: + if baseline.trading_day != final.trading_day: + raise CompletionEvidenceError("reconnect snapshots must use one trading day") + elif not same_session: + raise CompletionEvidenceError( + "baseline and final snapshots must use one provider session/day" + ) + if _parse_utc(final.occurred_at_utc) <= _parse_utc(baseline.occurred_at_utc): + raise CompletionEvidenceError("final account snapshot must be later than baseline") + if same_session: + if not ( + baseline.order_query_sequence < final.order_query_sequence + and baseline.position_query_sequence < final.position_query_sequence + and baseline.account_query_sequence < final.account_query_sequence + ): + raise CompletionEvidenceError("final snapshots must be fresh queries after baseline") + if { + baseline.order_query_id, + baseline.position_query_id, + baseline.account_query_id, + } & { + final.order_query_id, + final.position_query_id, + final.account_query_id, + }: + raise CompletionEvidenceError("baseline and final query identifiers must be distinct") + + +def _check_reconnect_scope( + evidence: CompletionEvidence, + baseline: AccountReconciliationSnapshot, + final: AccountReconciliationSnapshot, + missing: list[str], + contradictions: list[str], +) -> None: + """Correlate an M03 session transition across callbacks and both queries. + + Query request IDs and source sequences can restart with a native session, + so their identity is scoped to the session, not compared across it. + """ + + if baseline.session_id == final.session_id: + missing.append("reconnect_snapshots_require_distinct_provider_sessions") + rows = [ + row for row in evidence.scenario_evidence if row.event_kind == "store_reconnect_success" + ] + disconnects = [ + row for row in evidence.scenario_evidence if row.event_kind == "store_disconnected" + ] + if len(rows) != 1 or len(disconnects) != 1: + if rows or disconnects: + contradictions.append("reconnect_requires_one_disconnect_and_restore_transition") + return + row, disconnected = rows[0], disconnects[0] + if ( + disconnected.provider_session_id != baseline.session_id + or disconnected.trading_day != baseline.trading_day + or disconnected.fields.get("gateway_key") != row.fields.get("gateway_key") + or row.fields.get("previous_session_id") != baseline.session_id + or row.fields.get("new_session_id") != final.session_id + or row.provider_session_id != final.session_id + or row.trading_day != final.trading_day + ): + contradictions.append("reconnect_event_does_not_match_snapshot_sessions") + old_generation = disconnected.fields.get("connection_generation") + new_generation = row.fields.get("connection_generation") + if ( + type(old_generation) is not int + or old_generation < 1 + or type(new_generation) is not int + or new_generation <= old_generation + or row.fields.get("previous_connection_generation") != old_generation + or row.fields.get("new_connection_generation") != new_generation + ): + contradictions.append("reconnect_requires_new_connection_generation") + baseline_time = _parse_utc(baseline.occurred_at_utc) + disconnect_time = _parse_utc(disconnected.occurred_at_utc) + reconnect_time = _parse_utc(row.occurred_at_utc) + final_time = _parse_utc(final.occurred_at_utc) + if not baseline_time < disconnect_time < reconnect_time < final_time: + contradictions.append("reconnect_event_must_separate_account_snapshots") + if any( + sequence >= disconnected.source_sequence + for sequence in ( + baseline.order_query_sequence, + baseline.position_query_sequence, + baseline.account_query_sequence, + ) + ): + contradictions.append("baseline_account_queries_must_precede_disconnect_callback") + if any( + sequence <= row.source_sequence + for sequence in ( + final.order_query_sequence, + final.position_query_sequence, + final.account_query_sequence, + ) + ): + missing.append("final_account_queries_must_follow_reconnect_callback") + dependency = next( + ( + item + for item in evidence.external_dependencies + if item.dependency_id == "external_reconnect" + ), + None, + ) + if ( + dependency is not None + and dependency.state == "satisfied" + and ( + dependency.fields.get("previous_session_id") != baseline.session_id + or dependency.fields.get("new_session_id") != final.session_id + or dependency.fields.get("gateway_key") != row.fields.get("gateway_key") + or dependency.fields.get("disconnect_event_ref") != disconnected.event_id + or dependency.fields.get("reconnect_event_ref") != row.event_id + or dependency.fields.get("previous_connection_generation") != old_generation + or dependency.fields.get("new_connection_generation") != new_generation + ) + ): + contradictions.append("reconnect_control_receipt_does_not_match_snapshot_sessions") + + +def _check_disconnect_restoration( + evidence: CompletionEvidence, + baseline: AccountReconciliationSnapshot, + final: AccountReconciliationSnapshot, + missing: list[str], + contradictions: list[str], +) -> None: + """Require M02's final account queries to follow a sourced restoration.""" + + if baseline.session_id == final.session_id: + missing.append("disconnect_restoration_requires_new_provider_session") + disconnects = [ + row for row in evidence.scenario_evidence if row.event_kind == "store_disconnected" + ] + reconnects = [ + row for row in evidence.scenario_evidence if row.event_kind == "store_reconnect_success" + ] + if len(disconnects) != 1 or len(reconnects) != 1: + if disconnects or reconnects: + contradictions.append("disconnect_restoration_requires_one_callback_transition") + return + disconnected, reconnected = disconnects[0], reconnects[0] + if ( + disconnected.provider_session_id != baseline.session_id + or disconnected.trading_day != baseline.trading_day + or reconnected.provider_session_id != final.session_id + or reconnected.trading_day != final.trading_day + or reconnected.fields.get("previous_session_id") != baseline.session_id + or reconnected.fields.get("new_session_id") != final.session_id + or disconnected.fields.get("gateway_key") != reconnected.fields.get("gateway_key") + ): + contradictions.append("disconnect_restore_callbacks_do_not_match_snapshot_sessions") + old_generation = disconnected.fields.get("connection_generation") + new_generation = reconnected.fields.get("connection_generation") + if ( + type(old_generation) is not int + or old_generation < 1 + or type(new_generation) is not int + or new_generation <= old_generation + or reconnected.fields.get("previous_connection_generation") != old_generation + or reconnected.fields.get("new_connection_generation") != new_generation + ): + contradictions.append("disconnect_restoration_requires_new_connection_generation") + if not ( + _parse_utc(baseline.occurred_at_utc) + < _parse_utc(disconnected.occurred_at_utc) + < _parse_utc(reconnected.occurred_at_utc) + < _parse_utc(final.occurred_at_utc) + ): + contradictions.append("disconnect_restore_callbacks_must_separate_account_snapshots") + if any( + sequence >= disconnected.source_sequence + for sequence in ( + baseline.order_query_sequence, + baseline.position_query_sequence, + baseline.account_query_sequence, + ) + ): + contradictions.append("baseline_account_queries_must_precede_disconnect_callback") + if any( + sequence <= reconnected.source_sequence + for sequence in ( + final.order_query_sequence, + final.position_query_sequence, + final.account_query_sequence, + ) + ): + missing.append("final_account_queries_must_follow_restored_session") + dependency = next( + ( + item + for item in evidence.external_dependencies + if item.dependency_id == "external_disconnect" + ), + None, + ) + if ( + dependency is not None + and dependency.state == "satisfied" + and ( + dependency.fields.get("session_id") != baseline.session_id + or dependency.fields.get("gateway_key") != disconnected.fields.get("gateway_key") + or dependency.fields.get("provider_event_ref") != disconnected.event_id + or dependency.fields.get("connection_generation") != old_generation + ) + ): + contradictions.append("disconnect_control_receipt_does_not_match_provider_event") + + +def _derive_invariants( + evidence: CompletionEvidence, expectation: Mapping[str, Any] +) -> tuple[list[str], list[str]]: + missing: list[str] = [] + contradictions: list[str] = [] + case_id = evidence.case_id + if case_id in {"V01", "V02", "V03"}: + validation_rows = [ + row + for row in evidence.scenario_evidence + if row.event_kind == "order_validation_rejected" + ] + if len(validation_rows) != 1: + missing.append("validation_case_requires_exactly_one_local_rejection_record") + for row in validation_rows: + row_missing, row_contradictions = validation_failure_conditions( + case_id, row.fields, row.occurred_at_utc + ) + missing.extend(row_missing) + contradictions.extend(row_contradictions) + baseline, final = _snapshots_by_phase(evidence.snapshots) + _check_o02_close_semantics(evidence, baseline, final, missing, contradictions) + baseline_time = _parse_utc(baseline.occurred_at_utc) + final_time = _parse_utc(final.occurred_at_utc) + if case_id == "C01": + auth_rows = [ + row for row in evidence.scenario_evidence if row.event_kind == "store_auth_success" + ] + login_rows = [ + row for row in evidence.scenario_evidence if row.event_kind == "store_login_success" + ] + if len(auth_rows) != 1 or len(login_rows) != 1: + missing.append("exactly_one_provider_auth_and_login_callback_required") + else: + auth, login = auth_rows[0], login_rows[0] + auth_arrival = auth.callback_arrival + login_arrival = login.callback_arrival + if auth_arrival is None or login_arrival is None: + missing.append("auth_and_login_require_local_callback_arrival_metadata") + else: + if ( + auth_arrival.client_instance_id != login_arrival.client_instance_id + or auth_arrival.arrival_generation != login_arrival.arrival_generation + ): + contradictions.append( + "auth_and_login_must_share_client_and_arrival_generation" + ) + auth_request_id = auth.fields.get("request_id") + login_request_id = login.fields.get("request_id") + if ( + type(auth_request_id) is not int + or type(login_request_id) is not int + or auth_request_id == login_request_id + ): + contradictions.append("auth_login_native_request_ids_must_be_distinct") + for row, arrival, request_kind, request_id in ( + (auth, auth_arrival, "authenticate", auth_request_id), + (login, login_arrival, "login", login_request_id), + ): + if not validate_issued_request_receipt( + arrival.issued_request_receipt, + request_kind=request_kind, + phase="", + request_id=request_id, + request_generation=arrival.request_generation, + client_instance_id=arrival.client_instance_id, + arrival_generation=arrival.arrival_generation, + arrived_at_utc=arrival.arrived_at_utc, + arrived_monotonic=arrival.arrived_monotonic, + ): + missing.append(f"{request_kind}_requires_matching_typed_issued_request_receipt") + if ( + auth_arrival.source_sequence >= login_arrival.source_sequence + or auth_arrival.request_generation == login_arrival.request_generation + or _parse_utc(auth_arrival.arrived_at_utc) + >= _parse_utc(login_arrival.arrived_at_utc) + ): + contradictions.append( + "authentication_arrival_must_precede_distinct_login_request" + ) + all_query_ids = [] + for snapshot in (baseline, final): + if ( + snapshot.client_instance_id != login_arrival.client_instance_id + or snapshot.arrival_generation != login_arrival.arrival_generation + ): + contradictions.append( + f"{snapshot.phase.value}_queries_must_share_login_client_generation" + ) + expected_families = { + "orders": "OnRspQryOrder", + "positions": "OnRspQryInvestorPosition", + "funds": "OnRspQryTradingAccount", + } + all_query_ids.extend( + snapshot.query_native_request_ids.get(callback) + for callback in expected_families.values() + ) + if set(snapshot.query_issued_request_receipts) != set(expected_families.values()): + missing.append( + f"{snapshot.phase.value}_queries_require_typed_issued_request_receipts" + ) + for family, callback in expected_families.items(): + receipt = snapshot.query_issued_request_receipts.get(callback) + request_generation = snapshot.query_request_generations.get(callback) + arrived_at = snapshot.query_arrival_times_utc.get(callback) + arrived_monotonic = snapshot.query_arrival_monotonic.get(callback) + if not validate_issued_request_receipt( + receipt, + request_kind=family, + phase=snapshot.phase.value, + request_id=snapshot.query_native_request_ids.get(callback), + request_generation=request_generation, + client_instance_id=snapshot.client_instance_id, + arrival_generation=snapshot.arrival_generation, + arrived_at_utc=arrived_at, + arrived_monotonic=arrived_monotonic, + ): + missing.append( + f"{snapshot.phase.value}_{family}_query_receipt_does_not_match_callback" + ) + if ( + len(all_query_ids) != 6 + or any(type(item) is not int or item <= 0 for item in all_query_ids) + or len(set(all_query_ids)) != 6 + ): + contradictions.append("C01 baseline/final queries require six distinct native RequestIDs") + if ( + login.provider_session_id != baseline.session_id + or login.trading_day != baseline.trading_day + or login.provider_session_id != final.session_id + or login.trading_day != final.trading_day + ): + contradictions.append("login_native_scope_must_match_account_queries") + if ( + _parse_utc(login_arrival.arrived_at_utc) >= baseline_time + or login_arrival.source_sequence + >= min( + baseline.order_query_sequence, + baseline.position_query_sequence, + baseline.account_query_sequence, + ) + ): + contradictions.append("login_must_precede_baseline_query_arrivals") + for request in evidence.managed_requests: + request_time = _parse_utc(request.occurred_at_utc) + if not baseline_time < request_time < final_time: + contradictions.append( + f"managed_request_outside_account_snapshot_window:{request.request_id}" + ) + if request.dispatch_state is DispatchState.DISPATCHED: + if request.action is RequestAction.SUBMIT: + corresponding = [ + fact for fact in evidence.order_facts if fact.order_ref == request.order_refs[0] + ] + if corresponding and request_time > min( + _parse_utc(fact.occurred_at_utc) for fact in corresponding + ): + contradictions.append( + f"managed_submit_after_native_order_callback:{request.request_id}" + ) + else: + canceled = [ + fact + for fact in evidence.order_facts + if fact.fact is NativeOrderFactKind.CANCELED + and fact.order_ref in request.order_refs + ] + if canceled and request_time > min( + _parse_utc(fact.occurred_at_utc) for fact in canceled + ): + contradictions.append( + f"managed_cancel_after_native_cancel_callback:{request.request_id}" + ) + if case_id == "M02": + _check_disconnect_restoration(evidence, baseline, final, missing, contradictions) + if case_id == "M03": + _check_reconnect_scope(evidence, baseline, final, missing, contradictions) + dispatched_submits = [ + request + for request in evidence.managed_requests + if request.action is RequestAction.SUBMIT + and request.dispatch_state is DispatchState.DISPATCHED + ] + submit_refs = [request.order_refs[0] for request in dispatched_submits] + submit_ref_set = set(submit_refs) + all_provider_refs = {fact.order_ref for fact in evidence.order_facts} + all_trade_refs = {fact.order_ref for fact in evidence.trade_facts} + if len(submit_refs) != len(submit_ref_set): + contradictions.append("one_order_ref_has_multiple_dispatched_submit_receipts") + if all_provider_refs - submit_ref_set: + contradictions.append("provider_order_fact_without_managed_submit_receipt") + if all_trade_refs - submit_ref_set: + contradictions.append("trade_without_managed_submit_receipt") + + for dependency in evidence.external_dependencies: + order_ref = dependency.fields.get("order_ref") + if order_ref and order_ref not in submit_ref_set: + contradictions.append( + f"external_condition_order_ref_without_dispatched_submit:{dependency.dependency_id}" + ) + + order_expectation = str(expectation.get("order_activity", "")) + trade_expectation = str(expectation.get("trade_activity", "")) + _check_external_dependencies(evidence, missing, contradictions) + minimum_refs = 2 if case_id in {"B01", "B02"} else 1 + if order_expectation == "none": + if dispatched_submits or evidence.order_facts or evidence.trade_facts: + contradictions.append("provider_order_activity_present_for_no_order_case") + elif order_expectation == "required": + if len(submit_ref_set) < minimum_refs: + missing.append(f"minimum_dispatched_order_refs:{minimum_refs}") + if not dispatched_submits: + missing.append("managed_dispatched_submit_receipt") + if not evidence.order_facts: + missing.append("native_provider_order_activity") + + if trade_expectation == "required" and not evidence.trade_facts: + missing.append("required_native_trade_activity") + if trade_expectation == "none" and evidence.trade_facts: + contradictions.append("native_trade_activity_for_no_trade_case") + + requests_for_cancel = [ + request + for request in evidence.managed_requests + if request.action in {RequestAction.CANCEL, RequestAction.BATCH_CANCEL} + and request.dispatch_state is DispatchState.DISPATCHED + ] + cancel_refs = {ref for request in requests_for_cancel for ref in request.order_refs} + blocked_cancel_requests = [ + request + for request in evidence.managed_requests + if request.action is RequestAction.CANCEL + and request.dispatch_state is DispatchState.BLOCKED_PRE_DISPATCH + ] + if case_id in {"O01", "O02"}: + repeat_attempts = [ + request + for request in evidence.managed_requests + if request.action is RequestAction.SUBMIT + ] + blocked_repeats = [ + request + for request in repeat_attempts + if request.dispatch_state is DispatchState.BLOCKED_PRE_DISPATCH + ] + if len(repeat_attempts) < 2 or not blocked_repeats: + missing.append("repeat_scenario_requires_second_submit_blocked_pre_dispatch") + elif not any( + request.dispatch_state is DispatchState.DISPATCHED + and request.order_refs == blocked_repeats[0].order_refs + for request in repeat_attempts + ): + contradictions.append("repeat_submit_must_target_the_same_intent_key") + if case_id == "O03": + repeat_cancel_attempts = [ + request + for request in evidence.managed_requests + if request.action is RequestAction.CANCEL + ] + if len(repeat_cancel_attempts) < 2 or not blocked_cancel_requests: + missing.append("repeat_cancel_requires_second_attempt_blocked_pre_dispatch") + if not any( + request.action is RequestAction.CANCEL + and request.dispatch_state is DispatchState.DISPATCHED + for request in evidence.managed_requests + ): + missing.append("repeat_cancel_requires_first_dispatched_cancel") + dispatched_cancel = next( + ( + request + for request in evidence.managed_requests + if request.action is RequestAction.CANCEL + and request.dispatch_state is DispatchState.DISPATCHED + ), + None, + ) + blocked_cancel = next(iter(blocked_cancel_requests), None) + if ( + dispatched_cancel + and blocked_cancel + and dispatched_cancel.order_refs != blocked_cancel.order_refs + ): + contradictions.append("repeat_cancel_must_target_the_same_order_ref") + if case_id in {"M05", "T03", "TH04"} and not any( + request.action is RequestAction.CANCEL + and request.dispatch_state is DispatchState.DISPATCHED + for request in evidence.managed_requests + ): + missing.append("managed_dispatched_cancel_receipt") + + if case_id in {"B01", "B02"}: + batch_requests = [ + request + for request in evidence.managed_requests + if request.action is RequestAction.BATCH_CANCEL + and request.dispatch_state is DispatchState.DISPATCHED + ] + if not batch_requests: + missing.append("native_batch_cancel_request_receipt") + else: + batch_refs = {ref for request in batch_requests for ref in request.order_refs} + if batch_refs != submit_ref_set: + contradictions.append("batch_cancel_refs_must_exactly_match_submitted_orders") + if len(batch_requests) != 1: + contradictions.append("exactly_one_dispatched_batch_cancel_receipt_required") + for fact in evidence.order_facts: + if fact.fact is NativeOrderFactKind.CANCELED and fact.order_ref not in cancel_refs: + contradictions.append( + f"canceled_order_without_dispatched_cancel_request:{fact.order_ref}" + ) + for request in requests_for_cancel: + if not set(request.order_refs).issubset(submit_ref_set): + contradictions.append("cancel_request_references_unsubmitted_order") + + by_order: dict[str, list[NativeOrderFact]] = {} + for fact in evidence.order_facts: + by_order.setdefault(fact.order_ref, []).append(fact) + trades_by_order: dict[str, list[NativeTradeFact]] = {} + for trade in evidence.trade_facts: + trades_by_order.setdefault(trade.order_ref, []).append(trade) + + for order_ref in sorted(submit_ref_set): + facts = sorted(by_order.get(order_ref, []), key=lambda item: item.source_sequence) + terminal = [fact for fact in facts if fact.fact in _TERMINAL_FACTS] + if not facts or not terminal: + missing.append(f"provider_terminal_order_state:{order_ref}") + continue + submit = next( + request for request in dispatched_submits if request.order_refs[0] == order_ref + ) + requested_quantity = _decimal(submit.quantity) + if any( + (_decimal(fact.traded_quantity) or Decimal(0)) > requested_quantity for fact in facts + ): + contradictions.append(f"provider_traded_quantity_exceeds_request:{order_ref}") + for fact in facts: + traded = _decimal(fact.traded_quantity) or Decimal(0) + remaining = _decimal(fact.remaining_quantity) or Decimal(0) + if fact.fact in {NativeOrderFactKind.ACCEPTED, NativeOrderFactKind.PARTIAL}: + if traded + remaining != requested_quantity: + contradictions.append( + f"provider_open_quantity_conservation_mismatch:{order_ref}" + ) + elif fact.fact is NativeOrderFactKind.FILLED and traded != requested_quantity: + contradictions.append( + f"provider_filled_quantity_does_not_match_request:{order_ref}" + ) + if any( + fact.instrument_id != facts[0].instrument_id + or fact.external_order_id != facts[0].external_order_id + for fact in facts + ): + contradictions.append(f"provider_order_identity_changed:{order_ref}") + if any( + (_decimal(current.traded_quantity) or Decimal(0)) + < (_decimal(previous.traded_quantity) or Decimal(0)) + for previous, current in zip(facts, facts[1:]) + ): + contradictions.append(f"provider_cumulative_trade_quantity_decreased:{order_ref}") + latest = facts[-1] + if latest.fact not in _TERMINAL_FACTS: + missing.append(f"latest_provider_order_fact_not_terminal:{order_ref}") + continue + if ( + latest.fact is NativeOrderFactKind.REJECTED + and expectation.get("order_activity") == "required" + and case_id not in _EXPECTED_REMOTE_REJECTION_CASES + ): + contradictions.append(f"unexpected_provider_rejection_for_order_scenario:{order_ref}") + if case_id == "T03" and latest.fact is not NativeOrderFactKind.CANCELED: + contradictions.append(f"cancel_scenario_did_not_end_canceled:{order_ref}") + if case_id == "B01" and latest.fact is not NativeOrderFactKind.CANCELED: + contradictions.append(f"partial_batch_case_did_not_end_canceled:{order_ref}") + if case_id == "B01": + if not any(fact.fact is NativeOrderFactKind.PARTIAL for fact in facts): + missing.append(f"partial_fill_required:{order_ref}") + if ( + case_id in _EXPECTED_REMOTE_REJECTION_CASES + and latest.fact is not NativeOrderFactKind.REJECTED + ): + contradictions.append(f"external_rejection_case_not_rejected:{order_ref}") + if latest.fact in { + NativeOrderFactKind.CANCELED, + NativeOrderFactKind.FILLED, + NativeOrderFactKind.PARTIAL, + }: + traded = _decimal(latest.traded_quantity) + trade_sum = sum( + (_decimal(item.quantity) or Decimal(0)) + for item in trades_by_order.get(order_ref, []) + ) + if traded is None or traded != trade_sum: + contradictions.append(f"terminal_trade_quantity_mismatch:{order_ref}") + if requested_quantity is not None and trade_sum > requested_quantity: + contradictions.append(f"native_trade_quantity_exceeds_request:{order_ref}") + if any( + item.instrument_id != latest.instrument_id + for item in trades_by_order.get(order_ref, []) + ): + contradictions.append(f"trade_instrument_mismatch:{order_ref}") + if any( + item.external_order_id != latest.external_order_id + for item in trades_by_order.get(order_ref, []) + ): + contradictions.append(f"trade_external_order_id_mismatch:{order_ref}") + if latest.fact in { + NativeOrderFactKind.PARTIAL, + NativeOrderFactKind.FILLED, + } and not trades_by_order.get(order_ref): + missing.append(f"provider_trade_callback_required:{order_ref}") + accepted = [fact for fact in facts if fact.fact is NativeOrderFactKind.ACCEPTED] + if latest.fact is not NativeOrderFactKind.REJECTED and not accepted: + missing.append(f"provider_acceptance_callback:{order_ref}") + if accepted and any( + trade.source_sequence <= min(fact.source_sequence for fact in accepted) + for trade in trades_by_order.get(order_ref, []) + ): + contradictions.append(f"trade_precedes_provider_acceptance:{order_ref}") + + final_order_events = [ + *evidence.order_facts, + *evidence.trade_facts, + ] + latest_provider_sequence = max((item.source_sequence for item in final_order_events), default=0) + if expectation.get("no_open_orders_after") is True and final.open_order_refs: + contradictions.append("provider_open_orders_remain_after_cleanup") + if order_expectation == "none" and set(baseline.open_order_refs) != set(final.open_order_refs): + contradictions.append("open_order_refs_changed_without_case_order_activity") + if final.order_query_sequence <= latest_provider_sequence: + missing.append("final_order_query_must_follow_all_native_order_and_trade_events") + if final.position_query_sequence <= latest_provider_sequence: + missing.append("final_position_query_must_follow_all_native_order_and_trade_events") + if final.account_query_sequence <= latest_provider_sequence: + missing.append("final_account_query_must_follow_all_native_order_and_trade_events") + first_provider_sequence = min( + (item.source_sequence for item in final_order_events), default=None + ) + if first_provider_sequence is not None and ( + baseline.order_query_sequence >= first_provider_sequence + or baseline.position_query_sequence >= first_provider_sequence + or baseline.account_query_sequence >= first_provider_sequence + ): + missing.append("baseline_order_position_account_queries_must_precede_native_activity") + + has_trades = bool(evidence.trade_facts) + position_policy = expectation.get("account_position_change") + baseline_positions = _decimal_mapping(baseline.positions) + final_positions = _decimal_mapping(final.positions) + unchanged_positions = baseline_positions == final_positions + unchanged_funds = _decimal_mapping(baseline.funds) == _decimal_mapping(final.funds) + if position_policy == "none" and (not unchanged_positions or not unchanged_funds): + contradictions.append("positions_or_funds_changed_for_no_change_case") + elif position_policy == "allowed_if_trade" and not has_trades: + if not unchanged_positions or not unchanged_funds: + contradictions.append("positions_or_funds_changed_without_trade_callback") + elif position_policy == "allowed_if_trade" and baseline_positions is not None: + expected_positions = dict(baseline_positions) + for trade in evidence.trade_facts: + quantity = _decimal(trade.quantity) or Decimal(0) + signed_quantity = quantity if trade.direction == "buy" else -quantity + expected_positions[trade.instrument_id] = ( + expected_positions.get(trade.instrument_id, Decimal(0)) + signed_quantity + ) + expected_positions = { + instrument: quantity + for instrument, quantity in expected_positions.items() + if quantity != 0 + } + actual_positions = { + instrument: quantity + for instrument, quantity in (final_positions or {}).items() + if quantity != 0 + } + if final_positions is None or actual_positions != expected_positions: + contradictions.append("final_net_positions_do_not_match_provider_trade_delta") + if trade_expectation == "none" and (not unchanged_positions or not unchanged_funds): + contradictions.append("positions_or_funds_changed_without_permitted_trade") + + return missing, contradictions + + +def _check_o02_close_semantics( + evidence: CompletionEvidence, + baseline: AccountReconciliationSnapshot, + final: AccountReconciliationSnapshot, + missing: list[str], + contradictions: list[str], +) -> None: + """Require one scoped O02 close and a blocked repeat backed by a CTP bucket. + + Scalar net position and closeable-by-instrument maps cannot prove CTP + long/short or close-today/close-yesterday availability. O02 therefore + supports only explicitly bucketed ``close_today`` and ``close_yesterday`` + requests; generic ``close`` remains incomplete until an adapter can supply + a typed bucket for its actual CTP semantics. + """ + + if evidence.case_id != "O02": + return + attempts = [ + request + for request in evidence.managed_requests + if request.action is RequestAction.SUBMIT + ] + if not attempts: + return # The general order invariants report the missing submit. + + dispatched = [ + request + for request in attempts + if request.dispatch_state is DispatchState.DISPATCHED + ] + blocked = [ + request + for request in attempts + if request.dispatch_state is DispatchState.BLOCKED_PRE_DISPATCH + ] + if len(attempts) != 2 or len(dispatched) != 1 or len(blocked) != 1: + contradictions.append("o02_requires_exactly_one_dispatched_and_one_blocked_submit") + return + + first, repeat = dispatched[0], blocked[0] + if len(first.order_refs) != 1 or first.order_refs != repeat.order_refs: + contradictions.append("o02_blocked_repeat_must_match_dispatched_order_ref") + + scoped_fields = ( + ("account_id_masked", first.account_id_masked, repeat.account_id_masked), + ("provider_session_id", first.provider_session_id, repeat.provider_session_id), + ("trading_day", first.trading_day, repeat.trading_day), + ("intent_key", first.intent_key, repeat.intent_key), + ("instrument_id", first.instrument_id, repeat.instrument_id), + ("direction", first.direction, repeat.direction), + ("offset", first.offset, repeat.offset), + ) + for field_name, left, right in scoped_fields: + if not left or left != right: + contradictions.append(f"o02_repeat_{field_name}_must_match_and_be_present") + + first_quantity = _decimal(first.quantity) + repeat_quantity = _decimal(repeat.quantity) + if first_quantity is None or repeat_quantity is None or first_quantity != repeat_quantity: + contradictions.append("o02_repeat_quantity_must_match") + if ( + first.request_generation is None + or repeat.request_generation is None + or repeat.request_generation <= first.request_generation + or repeat.sequence <= first.sequence + or _parse_utc(repeat.occurred_at_utc) <= _parse_utc(first.occurred_at_utc) + ): + contradictions.append("o02_blocked_repeat_must_follow_first_request_generation") + + repeat_rows = [ + row + for row in evidence.scenario_evidence + if row.event_kind == "risk_repeat_order_detected" + ] + if len(repeat_rows) != 1: + missing.append("o02_requires_one_repeat_monitor_event") + elif ( + repeat_rows[0].fields.get("repeat_key") != first.intent_key + or repeat_rows[0].fields.get("repeat_count") != 2 + or repeat_rows[0].fields.get("account_id_masked") != first.account_id_masked + or repeat_rows[0].fields.get("provider_session_id") != first.provider_session_id + or repeat_rows[0].fields.get("trading_day") != first.trading_day + ): + contradictions.append("o02_repeat_monitor_must_match_submit_scope_and_intent") + + if first.instrument_id is None or first.direction is None or first.offset is None: + missing.append("o02_submit_requires_instrument_direction_and_offset") + return + if first.offset not in {"close_today", "close_yesterday"}: + missing.append("o02_requires_typed_close_today_or_close_yesterday_bucket") + expected_side = "long" if first.direction == "sell" else "short" + expected_direction = "sell" if expected_side == "long" else "buy" + if first.direction != expected_direction: + contradictions.append("o02_close_direction_must_match_held_position_side") + + if not baseline.account_id_masked or not final.account_id_masked: + missing.append("o02_requires_account_identity_on_baseline_and_final_snapshots") + elif baseline.account_id_masked != final.account_id_masked: + contradictions.append("o02_account_identity_changed_between_snapshots") + if ( + not baseline.account_id_masked + or first.account_id_masked != baseline.account_id_masked + or repeat.account_id_masked != baseline.account_id_masked + ): + contradictions.append("o02_request_account_must_match_account_snapshots") + if ( + not first.provider_session_id + or first.provider_session_id != baseline.session_id + or first.provider_session_id != final.session_id + ): + contradictions.append("o02_request_provider_session_must_match_account_snapshots") + if ( + not first.trading_day + or first.trading_day != baseline.trading_day + or first.trading_day != final.trading_day + ): + contradictions.append("o02_request_trading_day_must_match_account_snapshots") + + instrument_offset_buckets = [ + bucket + for bucket in baseline.closeable_position_buckets + if bucket.instrument_id == first.instrument_id and bucket.offset == first.offset + ] + matching_buckets = [ + bucket for bucket in instrument_offset_buckets if bucket.position_side == expected_side + ] + if len(matching_buckets) != 1: + if instrument_offset_buckets: + contradictions.append("o02_close_direction_does_not_match_available_position_side") + else: + missing.append("o02_requires_matching_baseline_side_and_offset_closeable_bucket") + available = None + else: + available = _decimal(matching_buckets[0].quantity) + if available is None or available <= 0: + contradictions.append("o02_requires_positive_baseline_closeable_bucket") + available = None + + if first_quantity is not None and available is not None and first_quantity > available: + contradictions.append("o02_requested_close_quantity_exceeds_baseline_bucket") + + order_ref = first.order_refs[0] if len(first.order_refs) == 1 else None + for fact in evidence.order_facts: + if fact.order_ref != order_ref: + continue + if (fact.session_id, fact.trading_day) != (baseline.session_id, baseline.trading_day) or ( + fact.session_id, fact.trading_day + ) != (final.session_id, final.trading_day): + contradictions.append("o02_native_order_provider_scope_mismatch") + if ( + not fact.account_id_masked + or fact.account_id_masked != baseline.account_id_masked + or fact.account_id_masked != final.account_id_masked + ): + contradictions.append("o02_native_order_account_must_match_account_snapshots") + if fact.instrument_id != first.instrument_id: + contradictions.append("o02_native_order_instrument_mismatch") + if fact.direction is None or fact.offset is None: + missing.append("o02_native_order_requires_direction_and_offset") + continue + if fact.direction != first.direction or fact.offset != first.offset: + contradictions.append("o02_native_order_side_or_offset_mismatch") + + filled_quantity = Decimal(0) + for trade in evidence.trade_facts: + if trade.order_ref != order_ref: + continue + if (trade.session_id, trade.trading_day) != (baseline.session_id, baseline.trading_day) or ( + trade.session_id, trade.trading_day + ) != (final.session_id, final.trading_day): + contradictions.append("o02_native_trade_provider_scope_mismatch") + if ( + not trade.account_id_masked + or trade.account_id_masked != baseline.account_id_masked + or trade.account_id_masked != final.account_id_masked + ): + contradictions.append("o02_native_trade_account_must_match_account_snapshots") + if trade.instrument_id != first.instrument_id: + contradictions.append("o02_trade_instrument_mismatch") + if trade.direction != first.direction: + contradictions.append("o02_trade_direction_mismatch") + if trade.offset is None: + missing.append("o02_trade_requires_close_offset") + elif trade.offset != first.offset: + contradictions.append("o02_trade_offset_must_match_submit") + quantity = _decimal(trade.quantity) + if quantity is not None: + filled_quantity += quantity + if available is not None and filled_quantity > available: + contradictions.append("o02_filled_close_quantity_exceeds_baseline_bucket") + +def _check_external_dependencies( + evidence: CompletionEvidence, + missing: list[str], + contradictions: list[str], +) -> None: + required = _REQUIRED_DEPENDENCIES.get(evidence.case_id, set()) + by_id = {dependency.dependency_id: dependency for dependency in evidence.external_dependencies} + if len(by_id) != len(evidence.external_dependencies): + contradictions.append("duplicate_external_dependency_receipt") + if set(by_id) - required: + contradictions.append("unexpected_external_dependency_receipt") + for dependency_id in sorted(required): + dependency = by_id.get(dependency_id) + if dependency is None: + missing.append(f"external_dependency:{dependency_id}") + continue + if dependency.state == "unavailable": + continue + fields = dependency.fields + if dependency_id == "external_disconnect": + if not all( + fields.get(key) for key in ("gateway_key", "session_id", "provider_event_ref") + ): + missing.append("external_disconnect_requires_provider_session_event") + elif dependency_id == "external_reconnect": + previous = fields.get("previous_session_id") + current = fields.get("new_session_id") + if not fields.get("gateway_key") or not previous or not current: + missing.append("external_reconnect_requires_old_and_new_session_ids") + elif previous == current: + contradictions.append("reconnect_must_create_a_new_provider_session") + elif dependency_id == "insufficient_funds": + available = _decimal(fields.get("available_funds")) + required_margin = _decimal(fields.get("required_margin")) + if not fields.get("instrument_id") or not fields.get("order_ref"): + missing.append("insufficient_funds_requires_instrument_and_order_ref") + if available is None or required_margin is None: + missing.append("insufficient_funds_requires_numeric_funds_and_margin") + elif available >= required_margin: + contradictions.append("insufficient_funds_condition_not_physically_satisfied") + elif dependency_id == "insufficient_position": + available = _decimal(fields.get("available_closeable_quantity")) + requested = _decimal(fields.get("requested_close_quantity")) + if not fields.get("instrument_id") or not fields.get("order_ref"): + missing.append("insufficient_position_requires_instrument_and_order_ref") + if available is None or requested is None: + missing.append("insufficient_position_requires_numeric_quantities") + elif requested <= available: + contradictions.append("insufficient_position_condition_not_physically_satisfied") + elif dependency_id == "market_state": + if not fields.get("instrument_id") or not fields.get("order_ref"): + missing.append("market_state_requires_instrument_and_order_ref") + if fields.get("tradable") is not False or not fields.get("provider_market_event_ref"): + missing.append("market_state_requires_nontrading_provider_observation") + elif dependency_id == "account_permission_disabled": + if fields.get("state") != "disabled" or not all( + fields.get(key) + for key in ( + "account_id_masked", + "change_id", + "provider_audit_ref", + "occurred_at_utc", + ) + ): + missing.append("permission_disable_requires_admin_audit_and_masked_account") + if _parse_utc(str(fields.get("occurred_at_utc", ""))) is None: + missing.append("permission_disable_requires_utc_timestamp") + elif dependency_id == "account_permission_restored": + if fields.get("state") != "restored" or not all( + fields.get(key) + for key in ( + "account_id_masked", + "change_id", + "provider_audit_ref", + "occurred_at_utc", + ) + ): + missing.append("permission_restore_requires_admin_audit_and_masked_account") + if _parse_utc(str(fields.get("occurred_at_utc", ""))) is None: + missing.append("permission_restore_requires_utc_timestamp") + elif dependency_id == "strategy_pause_authorized": + if ( + fields.get("state") != "paused" + or not fields.get("strategy_id") + or not fields.get("pause_event_ref") + or fields.get("pending_orders_reconciled") is not True + ): + missing.append("strategy_pause_requires_authorized_pause_and_order_reconciliation") + elif dependency_id == "gateway_force_logout_ack": + if ( + fields.get("acknowledged") is not True + or not fields.get("gateway_key") + or not fields.get("session_id") + or not fields.get("operator_audit_ref") + ): + missing.append("force_logout_requires_external_operator_ack_and_session_identity") + if evidence.case_id == "EM01": + disabled = by_id.get("account_permission_disabled") + restored = by_id.get("account_permission_restored") + rows = [ + row + for row in evidence.scenario_evidence + if row.event_kind == "account_trading_disabled" + ] + if disabled and disabled.state == "satisfied" and len(rows) == 1: + row = rows[0] + if ( + disabled.fields.get("account_id_masked") != row.fields.get("account_id_masked") + or disabled.fields.get("source_event_ref") != row.event_id + ): + contradictions.append("permission_disable_receipt_does_not_match_control_event") + if disabled and restored and disabled.state == restored.state == "satisfied": + left, right = disabled.fields, restored.fields + if left.get("account_id_masked") != right.get("account_id_masked"): + contradictions.append("permission_change_account_identity_mismatch") + if left.get("change_id") != right.get("change_id"): + contradictions.append("permission_change_id_mismatch") + disabled_time = _parse_utc(str(left.get("occurred_at_utc", ""))) + restored_time = _parse_utc(str(right.get("occurred_at_utc", ""))) + if disabled_time is not None and restored_time is not None: + if restored_time <= disabled_time: + contradictions.append("permission_restore_did_not_follow_disable") + else: + if len(rows) == 1 and _parse_utc(rows[0].occurred_at_utc) != disabled_time: + contradictions.append("permission_disable_event_time_mismatch") + relevant_rows = [ + row + for row in evidence.scenario_evidence + if row.event_kind == "order_reject_remote" + and row.fields.get("verified_rejection_class") + == "account_permission_denied" + ] + if len(relevant_rows) != 1: + missing.append("permission_rejection_requires_one_correlated_provider_event") + else: + reject_row = relevant_rows[0] + order_ref = reject_row.fields.get("order_ref") + requests = [ + request + for request in evidence.managed_requests + if request.action is RequestAction.SUBMIT + and request.dispatch_state is DispatchState.DISPATCHED + and request.order_refs == (order_ref,) + ] + matching_facts = [ + fact + for fact in evidence.order_facts + if fact.fact is NativeOrderFactKind.REJECTED + and fact.order_ref == order_ref + and fact.error_id == reject_row.fields.get("ErrorID") + and _parse_utc(fact.occurred_at_utc) + == _parse_utc(reject_row.occurred_at_utc) + ] + if len(requests) != 1 or len(matching_facts) != 1: + missing.append("permission_rejection_requires_one_managed_submit_and_native_fact") + else: + baseline, final = _snapshots_by_phase(evidence.snapshots) + request, fact = requests[0], matching_facts[0] + submit_rows = [ + row + for row in evidence.scenario_evidence + if row.event_kind == "order_submit_request" + and row.fields.get("order_ref") == order_ref + ] + if len(submit_rows) != 1: + missing.append("permission_rejection_requires_one_submit_scenario_row") + else: + submit_row = submit_rows[0] + if ( + submit_row.fields.get("request_id") != request.request_id + or submit_row.fields.get("request_generation") + != request.request_generation + or not isinstance(request.request_generation, int) + or isinstance(request.request_generation, bool) + or request.request_generation < 1 + or submit_row.fields.get("dispatch_state") != "dispatched" + or submit_row.fields.get("account_id_masked") + != request.account_id_masked + or submit_row.fields.get("provider_session_id") + != request.provider_session_id + or submit_row.fields.get("trading_day") != request.trading_day + or submit_row.source_sequence != request.sequence + or _parse_utc(submit_row.occurred_at_utc) + != _parse_utc(request.occurred_at_utc) + or submit_row.evidence_sha256 != request.evidence_sha256 + ): + contradictions.append( + "permission_submit_scenario_row_does_not_match_managed_receipt" + ) + disabled_row_error = rows[0].fields.get("ErrorID") if len(rows) == 1 else None + rejection_error = reject_row.fields.get("ErrorID") + if rows and ( + rows[0].fields.get("blocked_order_ref") != order_ref + or not isinstance(disabled_row_error, int) + or isinstance(disabled_row_error, bool) + or not isinstance(rejection_error, int) + or isinstance(rejection_error, bool) + or disabled_row_error != rejection_error + or rejection_error != fact.error_id + ): + contradictions.append( + "permission_control_error_and_blocked_ref_must_match_native_rejection" + ) + request_time = _parse_utc(request.occurred_at_utc) + reject_row_time = _parse_utc(reject_row.occurred_at_utc) + fact_time = _parse_utc(fact.occurred_at_utc) + account_id = left.get("account_id_masked") + if not baseline.account_id_masked or not final.account_id_masked: + missing.append("permission_rejection_requires_account_identity_on_snapshots") + elif baseline.account_id_masked != final.account_id_masked: + contradictions.append("permission_account_identity_changed_between_snapshots") + if ( + not request.account_id_masked + or request.account_id_masked != account_id + or request.account_id_masked != baseline.account_id_masked + or request.account_id_masked != final.account_id_masked + ): + contradictions.append("permission_submit_account_identity_mismatch") + if ( + not fact.account_id_masked + or fact.account_id_masked != request.account_id_masked + or fact.account_id_masked != baseline.account_id_masked + or fact.account_id_masked != final.account_id_masked + ): + contradictions.append("permission_native_rejection_account_mismatch") + if ( + not request.provider_session_id + or request.provider_session_id != fact.session_id + or fact.session_id != baseline.session_id + or fact.session_id != final.session_id + or reject_row.provider_session_id != fact.session_id + or rows[0].provider_session_id != baseline.session_id + ): + contradictions.append("permission_submit_provider_session_mismatch") + if ( + not request.trading_day + or request.trading_day != fact.trading_day + or fact.trading_day != baseline.trading_day + or fact.trading_day != final.trading_day + or reject_row.trading_day != fact.trading_day + or rows[0].trading_day != baseline.trading_day + ): + contradictions.append("permission_submit_trading_day_mismatch") + for dependency in (disabled, restored): + if dependency is not None and dependency.state == "satisfied": + if ( + dependency.fields.get("account_id_masked") + != baseline.account_id_masked + or dependency.fields.get("provider_session_id") + != baseline.session_id + or dependency.fields.get("trading_day") + != baseline.trading_day + ): + contradictions.append( + "permission_control_receipt_account_session_day_mismatch" + ) + if request_time is None or reject_row_time is None or fact_time is None: + missing.append("permission_rejection_requires_utc_event_timeline") + else: + if not disabled_time < request_time <= reject_row_time < restored_time: + contradictions.append("permission_submit_and_rejection_must_occur_while_disabled") + if reject_row_time != fact_time: + contradictions.append("permission_rejection_event_time_mismatch") + if evidence.case_id == "EM02": + pause = by_id.get("strategy_pause_authorized") + rows = [ + row for row in evidence.scenario_evidence if row.event_kind == "strategy_trading_paused" + ] + if pause and pause.state == "satisfied" and len(rows) == 1: + row = rows[0] + if ( + pause.fields.get("strategy_id") != row.fields.get("strategy_id") + or pause.fields.get("pause_event_ref") != row.event_id + ): + contradictions.append("strategy_pause_receipt_does_not_match_pause_event") + if evidence.case_id == "EM03": + logout = by_id.get("gateway_force_logout_ack") + rows = [ + row + for row in evidence.scenario_evidence + if row.event_kind == "gateway_force_logout_requested" + ] + if logout and logout.state == "satisfied" and len(rows) == 1: + row = rows[0] + if ( + logout.fields.get("gateway_key") != row.fields.get("gateway_key") + or logout.fields.get("session_id") != row.provider_session_id + or logout.fields.get("source_event_ref") != row.event_id + ): + contradictions.append("force_logout_receipt_does_not_match_disconnect_event") + + +def _validate_native_identity( + *, + event_id: str, + source: str, + session_id: str, + trading_day: str, + sequence: int, + occurred_at_utc: str, + evidence_sha256: str, +) -> None: + if source != "ctp_provider_callback": + raise CompletionEvidenceError("native facts must originate from provider callback adapter") + if not event_id or not session_id or not trading_day: + raise CompletionEvidenceError("native fact requires event/session/trading-day identity") + if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 1: + raise CompletionEvidenceError("native fact requires positive source sequence") + if _parse_utc(occurred_at_utc) is None: + raise CompletionEvidenceError("native fact timestamp must carry UTC offset") + if not _SHA256_RE.fullmatch(evidence_sha256): + raise CompletionEvidenceError("native fact requires SHA-256 evidence digest") + + +def _same_scope(current: tuple[str, str] | None, observed: tuple[str, str]) -> tuple[str, str]: + if current is not None and current != observed: + raise CompletionEvidenceError( + "one case completion bundle cannot mix provider sessions/days" + ) + return observed + + +def _snapshots_by_phase( + snapshots: tuple[AccountReconciliationSnapshot, ...], +) -> tuple[AccountReconciliationSnapshot, AccountReconciliationSnapshot]: + by_phase = {snapshot.phase: snapshot for snapshot in snapshots} + return by_phase[SnapshotPhase.BASELINE], by_phase[SnapshotPhase.FINAL] + + +def _decimal(value: Any) -> Decimal | None: + if isinstance(value, bool): + return None + try: + number = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + return number if number.is_finite() else None + + +def _bounded_contract_decimal(value: Decimal) -> bool: + """Keep exact validation arithmetic within a finite CTP field-sized range.""" + parts = value.as_tuple() + return len(parts.digits) <= 32 and -12 <= parts.exponent <= 12 + + +def _decimal_mapping(values: Mapping[str, Any]) -> dict[str, Decimal] | None: + result: dict[str, Decimal] = {} + for key, value in values.items(): + parsed = _decimal(value) + if parsed is None: + return None + result[str(key)] = parsed + return result + + +def _parse_utc(value: str) -> datetime | None: + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if result.tzinfo is None or result.utcoffset() != timedelta(0): + return None + return result.astimezone(timezone.utc) diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/decision_engine.py b/examples/007_ctp/live_certification/simnow_penetration/common/decision_engine.py new file mode 100644 index 00000000..abdd9626 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/common/decision_engine.py @@ -0,0 +1,2363 @@ +"""Typed review-only decisions for the 007 SimNow certification cases. + +This module is unregistered and has no provider, SDK, network, config, Store, +Broker, or write dependency. It does not execute or interpret descriptive plan +text. A case-specific rule can produce only a typed candidate for independent +review. Candidates permanently have dispatch_permitted=False. + +ObservationAuthenticator is an integration port, not a production verifier. +Without an injected trusted verifier, observations are rejected and decisions +remain BLOCKED. Test verifiers establish interface behavior only; they do not +represent real provider evidence or certification PASS. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from enum import Enum +from typing import Any, Mapping, Protocol + +from .case_engine import ( + DescriptiveCasePlan, + IssuedRequestReceipt, + validate_issued_request_receipt, +) +from .certification import SCENARIOS_BY_CASE_ID + +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") + + +class DecisionError(ValueError): + """Raised when an observation or scope violates the decision contract.""" + + +class DecisionStatus(str, Enum): + BLOCKED = "BLOCKED" + INCOMPLETE = "INCOMPLETE" + EXTERNAL_CONDITION_UNAVAILABLE = "EXTERNAL_CONDITION_UNAVAILABLE" + REVIEW_REQUIRED = "REVIEW_REQUIRED" + + +class ObservationKind(str, Enum): + AUTH_SUCCESS = "auth_success" + LOGIN_SUCCESS = "login_success" + FRONT_CONNECTED = "front_connected" + FRONT_DISCONNECTED = "front_disconnected" + MARKET_SUBSCRIPTION_ACK = "market_subscription_ack" + MARKET_TICK = "market_tick" + ORDER_ACCEPTED = "order_accepted" + ORDER_PARTIAL = "order_partial" + ORDER_CANCELED = "order_canceled" + ORDER_FILLED = "order_filled" + ORDER_REJECTED = "order_rejected" + TRADE_EXECUTION = "trade_execution" + ORDER_QUERY = "order_query" + POSITION_QUERY = "position_query" + ORDER_ADMISSION = "order_admission" + ORDER_SUBMIT_RECEIPT = "order_submit_receipt" + ORDER_CANCEL_RECEIPT = "order_cancel_receipt" + MONITOR_CONFIGURATION = "monitor_configuration" + MONITOR_TRIGGER = "monitor_trigger" + REPEAT_GUARD = "repeat_guard" + VALIDATION_REJECTION = "validation_rejection" + DISPATCH_ABSENCE = "dispatch_absence" + EXTERNAL_CONDITION = "external_condition" + ACCOUNT_PERMISSION_DISABLED = "account_permission_disabled" + ACCOUNT_PERMISSION_RESTORED = "account_permission_restored" + STRATEGY_PAUSED = "strategy_paused" + GATEWAY_LOGOUT_AUTHORIZED = "gateway_logout_authorized" + POST_DISCONNECT_WRITE_BLOCKED = "post_disconnect_write_blocked" + SYSTEM_LOG = "system_log" + MONITOR_LOG = "monitor_log" + + +_PAYLOADLESS_CALLBACK_FIELD_ALLOWLIST = { + ObservationKind.FRONT_CONNECTED: frozenset( + {"gatewaykey", "connectiongeneration", "arrivalgeneration"} + ), + ObservationKind.FRONT_DISCONNECTED: frozenset( + {"gatewaykey", "reason", "nreason", "connectiongeneration", "arrivalgeneration"} + ), + ObservationKind.AUTH_SUCCESS: frozenset( + { + "requestid", + "requestgeneration", + "arrivalgeneration", + "connectiongeneration", + "errorid", + "islast", + "success", + "brokerid", + "userid", + "userproductinfo", + "appid", + "apptype", + } + ), +} +_PAYLOADLESS_CALLBACK_LOCAL_FIELD_ALLOWLIST = { + kind: frozenset( + { + "localaccountidentitysha256", + "localclientid", + "clientid", + "localconnectiongeneration", + } + ) + for kind in _PAYLOADLESS_CALLBACK_FIELD_ALLOWLIST +} + + +def validate_payloadless_callback_fields( + event: "NativeObservation", *, scope_account_identity_sha256: str +) -> None: + """Reject fields outside the native callback shape and named local metadata. + + The small local-field allowlist covers only the adapter's scope fingerprint, + client binding, and connection generation. Those values are checked against + the already-bound scope and callback metadata; they never supply provider + login identity. + """ + if event.kind not in _PAYLOADLESS_CALLBACK_FIELD_ALLOWLIST: + return + fields = event.fields + if not isinstance(fields, Mapping): + raise DecisionError("payloadless front/auth callback fields must be a mapping") + allowed_fields = _PAYLOADLESS_CALLBACK_FIELD_ALLOWLIST[event.kind] + allowed_local_fields = _PAYLOADLESS_CALLBACK_LOCAL_FIELD_ALLOWLIST[event.kind] + for key, value in fields.items(): + if not isinstance(key, str): + raise DecisionError("payloadless front/auth callback field names must be strings") + normalized = re.sub(r"[^a-z0-9]", "", key.casefold()) + if normalized in allowed_fields: + if normalized in {"connectiongeneration", "arrivalgeneration"} and ( + type(value) is not int or value != event.arrival_generation + ): + raise DecisionError("local callback generation field does not match arrival metadata") + continue + if normalized not in allowed_local_fields: + raise DecisionError( + f"{event.callback_name} cannot carry login identity alias {key!r}" + ) + if normalized == "localaccountidentitysha256": + if ( + not isinstance(value, str) + or not _SHA256_RE.fullmatch(value) + or not isinstance(scope_account_identity_sha256, str) + or value.casefold() != scope_account_identity_sha256.casefold() + ): + raise DecisionError( + "local account scope fingerprint must exactly match the bound decision scope" + ) + elif normalized in {"localclientid", "clientid"}: + if ( + not isinstance(value, str) + or not value + or not isinstance(event.client_instance_id, str) + or value != event.client_instance_id + ): + raise DecisionError("local client field must match callback client metadata") + elif normalized == "localconnectiongeneration": + if type(value) is not int or value != event.arrival_generation: + raise DecisionError( + "local connection generation field does not match arrival metadata" + ) + + +class IntentKind(str, Enum): + AUTH_SESSION_AUDIT = "auth_session_audit" + OPEN_ORDER_CANDIDATE = "open_order_candidate" + CLOSE_ORDER_CANDIDATE = "close_order_candidate" + CANCEL_ORDER_CANDIDATE = "cancel_order_candidate" + CONNECTION_AUDIT = "connection_audit" + EXTERNAL_DISCONNECT_AUDIT = "external_disconnect_audit" + RECONNECT_AUDIT = "reconnect_audit" + SUBMIT_COUNT_PROBE = "submit_count_probe" + CANCEL_COUNT_PROBE = "cancel_count_probe" + REPEAT_OPEN_GUARD_PROBE = "repeat_open_guard_probe" + REPEAT_CLOSE_GUARD_PROBE = "repeat_close_guard_probe" + REPEAT_CANCEL_GUARD_PROBE = "repeat_cancel_guard_probe" + SET_ORDER_THRESHOLD = "set_order_threshold" + TRIGGER_ORDER_THRESHOLD = "trigger_order_threshold" + SET_CANCEL_THRESHOLD = "set_cancel_threshold" + TRIGGER_CANCEL_THRESHOLD = "trigger_cancel_threshold" + SET_REPEAT_THRESHOLD = "set_repeat_threshold" + TRIGGER_REPEAT_THRESHOLD = "trigger_repeat_threshold" + INVALID_INSTRUMENT_VALIDATION = "invalid_instrument_validation" + INVALID_TICK_VALIDATION = "invalid_tick_validation" + OVERSIZE_VALIDATION = "oversize_validation" + REMOTE_REJECTION_REVIEW = "remote_rejection_review" + ACCOUNT_PERMISSION_REVIEW = "account_permission_review" + STRATEGY_PAUSE_REVIEW = "strategy_pause_review" + FORCE_LOGOUT_REVIEW = "force_logout_review" + BATCH_CANCEL_PARTIALS_CANDIDATE = "batch_cancel_partials_candidate" + BATCH_CANCEL_OPEN_ORDERS_CANDIDATE = "batch_cancel_open_orders_candidate" + TRADE_AUDIT = "trade_audit" + SYSTEM_AUDIT = "system_audit" + MONITOR_AUDIT = "monitor_audit" + VALIDATION_ERROR_AUDIT = "validation_error_audit" + + +class EvidenceTrustDomain(str, Enum): + CTP_CALLBACK = "ctp_provider_callback" + MANAGED_RUNTIME = "managed_runtime_receipt" + MONITOR = "runtime_monitor_receipt" + LOCAL_VALIDATOR = "local_validator_receipt" + CONTROL_PLANE = "control_plane_receipt" + + +@dataclass(frozen=True) +class DecisionScope: + """Binding supplied by a future managed scope adapter, not created here. + + ``account_identity_sha256`` is optional for source compatibility only. An + order-case review requires the managed scope producer and authenticator to + provide and bind it; a raw account identifier or observation field is not + an authority source. No current production adapter supplies this binding. + """ + + case_id: str + scenario_id: str + plan_source_sha256: str + scope_sha256: str + account_identity_sha256: str = "" + + @classmethod + def for_plan( + cls, + plan: DescriptiveCasePlan, + scope_sha256: str, + account_identity_sha256: str = "", + ) -> "DecisionScope": + scenario = SCENARIOS_BY_CASE_ID.get(plan.case_id) + if scenario is None: + raise DecisionError(f"unknown certification case {plan.case_id!r}") + return cls( + plan.case_id, + scenario.scenario_id, + plan.source_sha256, + scope_sha256, + account_identity_sha256, + ) + + def validate(self, plan: DescriptiveCasePlan) -> None: + scenario = SCENARIOS_BY_CASE_ID.get(self.case_id) + if scenario is None or self.case_id != plan.case_id: + raise DecisionError("decision scope case does not match the static plan") + if self.scenario_id != scenario.scenario_id: + raise DecisionError("decision scope scenario does not match canonical mapping") + if self.plan_source_sha256 != plan.source_sha256: + raise DecisionError("decision scope is not bound to this strategy source digest") + if not _SHA256_RE.fullmatch(self.plan_source_sha256): + raise DecisionError("strategy source digest must be SHA-256") + if not _SHA256_RE.fullmatch(self.scope_sha256): + raise DecisionError("sealed scope digest must be SHA-256") + if self.account_identity_sha256 != "" and ( + not isinstance(self.account_identity_sha256, str) + or not _SHA256_RE.fullmatch(self.account_identity_sha256) + ): + raise DecisionError("account identity binding must be SHA-256 when present") + + +@dataclass(frozen=True) +class NativeObservation: + """Normalized fact from a source event; it contains no operation command. + + For native CTP callbacks, ``event_id`` is local. C01 authenticate/login + ``sequence`` and ``occurred_at_utc`` are local SDK arrival metadata, as + identified by the origin fields; CTP does not issue those values. + """ + + kind: ObservationKind + source_domain: EvidenceTrustDomain + event_id: str + evidence_sha256: str + occurred_at_utc: str + sequence: int + stream_id: str + callback_name: str = "" + provider_session_id: str = "" + trading_day: str = "" + fields: Mapping[str, Any] = field(default_factory=dict) + provider_front_id: int | None = None + client_instance_id: str = "" + request_generation: int = 0 + request_id_origin: str = "" + request_generation_origin: str = "" + arrival_generation: int = 0 + session_identity_origin: str = "" + event_id_origin: str = "" + provider_issued_event_id: bool = False + arrived_at_utc: str = "" + arrived_monotonic: float = 0.0 + sequence_origin: str = "" + timestamp_origin: str = "" + connection_generation_origin: str = "" + issued_request_receipt: IssuedRequestReceipt | None = None + + +@dataclass(frozen=True) +class AuthenticationReceipt: + event_id: str + evidence_sha256: str + scope_sha256: str + trust_domain: EvidenceTrustDomain + verification_ref: str + account_identity_sha256: str = "" + + +class ObservationAuthenticator(Protocol): + """Trusted verification port; this package supplies no production adapter.""" + + def authenticate( + self, observation: NativeObservation, scope: DecisionScope + ) -> AuthenticationReceipt | None: + """Verify source signature/provenance and bind evidence to this scope.""" + + +@dataclass(frozen=True) +class IntentSpec: + case_id: str + intent_kind: IntentKind + required_kinds: tuple[ObservationKind, ...] + rule: str + admission_kind: str = "" + + +@dataclass(frozen=True) +class IntentCandidate: + case_id: str + scenario_id: str + intent_kind: IntentKind + evidence_event_ids: tuple[str, ...] + correlation_refs: tuple[str, ...] + dispatch_permitted: bool = field(default=False, init=False) + + +@dataclass(frozen=True) +class DecisionSnapshot: + case_id: str + scenario_id: str + status: DecisionStatus + certification_pass: bool + dispatch_permitted: bool + intent_candidate: IntentCandidate | None + missing_conditions: tuple[str, ...] + rejected_observations: tuple[str, ...] + external_unavailability: tuple[str, ...] + + +def _spec( + case: str, + intent: IntentKind, + kinds: tuple[ObservationKind, ...], + rule: str, + admission: str = "", +) -> IntentSpec: + return IntentSpec(case, intent, kinds, rule, admission) + + +# One explicit, typed intent and fact rule for every case. Descriptive strings +# in *_strategy.py do not select or alter these definitions. +CASE_INTENT_SPECS: dict[str, IntentSpec] = { + "C01": _spec( + "C01", + IntentKind.AUTH_SESSION_AUDIT, + (ObservationKind.AUTH_SUCCESS, ObservationKind.LOGIN_SUCCESS), + "auth_login", + ), + "T01": _spec( + "T01", + IntentKind.OPEN_ORDER_CANDIDATE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ), + "open_order", + "open", + ), + "T02": _spec( + "T02", + IntentKind.CLOSE_ORDER_CANDIDATE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.POSITION_QUERY, + ObservationKind.ORDER_ADMISSION, + ), + "close_order", + "close", + ), + "T03": _spec( + "T03", + IntentKind.CANCEL_ORDER_CANDIDATE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_QUERY, + ObservationKind.ORDER_ADMISSION, + ), + "cancel_order", + "cancel", + ), + "M01": _spec( + "M01", + IntentKind.CONNECTION_AUDIT, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ), + "connected", + ), + "M02": _spec( + "M02", + IntentKind.EXTERNAL_DISCONNECT_AUDIT, + (ObservationKind.EXTERNAL_CONDITION, ObservationKind.FRONT_DISCONNECTED), + "disconnected", + ), + "M03": _spec( + "M03", + IntentKind.RECONNECT_AUDIT, + ( + ObservationKind.FRONT_DISCONNECTED, + ObservationKind.EXTERNAL_CONDITION, + ObservationKind.FRONT_CONNECTED, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ), + "reconnected", + ), + "M04": _spec( + "M04", + IntentKind.SUBMIT_COUNT_PROBE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ), + "submit_count", + "open", + ), + "M05": _spec( + "M05", + IntentKind.CANCEL_COUNT_PROBE, + ( + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_QUERY, + ObservationKind.ORDER_ADMISSION, + ), + "cancel_count", + "cancel", + ), + "O01": _spec( + "O01", + IntentKind.REPEAT_OPEN_GUARD_PROBE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.REPEAT_GUARD, + ), + "repeat_open", + "open", + ), + "O02": _spec( + "O02", + IntentKind.REPEAT_CLOSE_GUARD_PROBE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.POSITION_QUERY, + ObservationKind.ORDER_ADMISSION, + ObservationKind.REPEAT_GUARD, + ), + "repeat_close", + "close", + ), + "O03": _spec( + "O03", + IntentKind.REPEAT_CANCEL_GUARD_PROBE, + ( + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_QUERY, + ObservationKind.ORDER_ADMISSION, + ObservationKind.REPEAT_GUARD, + ), + "repeat_cancel", + "cancel", + ), + "TH01": _spec( + "TH01", + IntentKind.SET_ORDER_THRESHOLD, + (ObservationKind.MONITOR_CONFIGURATION,), + "configure_order", + ), + "TH02": _spec( + "TH02", + IntentKind.TRIGGER_ORDER_THRESHOLD, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.MONITOR_CONFIGURATION, + ObservationKind.MONITOR_TRIGGER, + ), + "trigger_order", + ), + "TH03": _spec( + "TH03", + IntentKind.SET_CANCEL_THRESHOLD, + (ObservationKind.MONITOR_CONFIGURATION,), + "configure_cancel", + ), + "TH04": _spec( + "TH04", + IntentKind.TRIGGER_CANCEL_THRESHOLD, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_CANCEL_RECEIPT, + ObservationKind.MONITOR_CONFIGURATION, + ObservationKind.MONITOR_TRIGGER, + ), + "trigger_cancel", + ), + "TH05": _spec( + "TH05", + IntentKind.SET_REPEAT_THRESHOLD, + (ObservationKind.MONITOR_CONFIGURATION,), + "configure_repeat", + ), + "TH06": _spec( + "TH06", + IntentKind.TRIGGER_REPEAT_THRESHOLD, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MONITOR_CONFIGURATION, + ObservationKind.REPEAT_GUARD, + ObservationKind.MONITOR_TRIGGER, + ), + "trigger_repeat", + ), + "V01": _spec( + "V01", + IntentKind.INVALID_INSTRUMENT_VALIDATION, + (ObservationKind.VALIDATION_REJECTION, ObservationKind.DISPATCH_ABSENCE), + "validate_instrument", + ), + "V02": _spec( + "V02", + IntentKind.INVALID_TICK_VALIDATION, + ( + ObservationKind.MARKET_TICK, + ObservationKind.VALIDATION_REJECTION, + ObservationKind.DISPATCH_ABSENCE, + ), + "validate_tick", + ), + "V03": _spec( + "V03", + IntentKind.OVERSIZE_VALIDATION, + ( + ObservationKind.ORDER_ADMISSION, + ObservationKind.VALIDATION_REJECTION, + ObservationKind.DISPATCH_ABSENCE, + ), + "validate_size", + "validation", + ), + "E01": _spec( + "E01", + IntentKind.REMOTE_REJECTION_REVIEW, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.EXTERNAL_CONDITION, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_REJECTED, + ), + "reject_funds", + "remote_reject", + ), + "E02": _spec( + "E02", + IntentKind.REMOTE_REJECTION_REVIEW, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.EXTERNAL_CONDITION, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_REJECTED, + ), + "reject_position", + "remote_reject", + ), + "E03": _spec( + "E03", + IntentKind.REMOTE_REJECTION_REVIEW, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.EXTERNAL_CONDITION, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_REJECTED, + ), + "reject_market", + "remote_reject", + ), + "EM01": _spec( + "EM01", + IntentKind.ACCOUNT_PERMISSION_REVIEW, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.ACCOUNT_PERMISSION_DISABLED, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_REJECTED, + ObservationKind.ACCOUNT_PERMISSION_RESTORED, + ObservationKind.ORDER_QUERY, + ), + "account_permission", + "remote_reject", + ), + "EM02": _spec( + "EM02", + IntentKind.STRATEGY_PAUSE_REVIEW, + (ObservationKind.STRATEGY_PAUSED, ObservationKind.ORDER_QUERY), + "strategy_paused", + ), + "EM03": _spec( + "EM03", + IntentKind.FORCE_LOGOUT_REVIEW, + ( + ObservationKind.GATEWAY_LOGOUT_AUTHORIZED, + ObservationKind.FRONT_DISCONNECTED, + ObservationKind.POST_DISCONNECT_WRITE_BLOCKED, + ), + "force_logout", + ), + "B01": _spec( + "B01", + IntentKind.BATCH_CANCEL_PARTIALS_CANDIDATE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.TRADE_EXECUTION, + ObservationKind.ORDER_QUERY, + ), + "batch_partials", + "batch_cancel", + ), + "B02": _spec( + "B02", + IntentKind.BATCH_CANCEL_OPEN_ORDERS_CANDIDATE, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_QUERY, + ), + "batch_open", + "batch_cancel", + ), + "L01": _spec( + "L01", + IntentKind.TRADE_AUDIT, + (ObservationKind.ORDER_ACCEPTED, ObservationKind.TRADE_EXECUTION), + "trade_log", + ), + "L02": _spec( + "L02", + IntentKind.SYSTEM_AUDIT, + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.SYSTEM_LOG, + ), + "system_log", + ), + "L03": _spec( + "L03", + IntentKind.MONITOR_AUDIT, + (ObservationKind.ORDER_ACCEPTED, ObservationKind.MONITOR_LOG), + "monitor_log", + ), + "L04": _spec( + "L04", + IntentKind.VALIDATION_ERROR_AUDIT, + (ObservationKind.VALIDATION_REJECTION, ObservationKind.DISPATCH_ABSENCE), + "validation_log", + ), +} + + +_SESSION_REQUIRED_RULES = frozenset( + { + "open_order", + "close_order", + "cancel_order", + "submit_count", + "cancel_count", + "repeat_open", + "repeat_close", + "repeat_cancel", + "connected", + "reconnected", + "system_log", + "reject_funds", + "reject_position", + "reject_market", + "account_permission", + "batch_partials", + "batch_open", + "trigger_order", + } +) +_TICK_REQUIRED_RULES = frozenset( + { + "open_order", + "close_order", + "cancel_order", + "submit_count", + "repeat_open", + "repeat_close", + "reject_funds", + "reject_position", + "reject_market", + "account_permission", + "batch_partials", + "batch_open", + "trigger_order", + } +) + +_POLICY: dict[ObservationKind, tuple[EvidenceTrustDomain, str, tuple[str, ...]]] = { + ObservationKind.AUTH_SUCCESS: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRspAuthenticate", + ( + "request_id", + "request_generation", + "arrival_generation", + "is_last", + "error_id", + "success", + ), + ), + ObservationKind.LOGIN_SUCCESS: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRspUserLogin", + ( + "request_id", + "request_generation", + "arrival_generation", + "is_last", + "error_id", + "provider_front_id", + "provider_session_id", + "trading_day", + "success", + ), + ), + ObservationKind.FRONT_CONNECTED: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnFrontConnected", + ("gateway_key",), + ), + ObservationKind.FRONT_DISCONNECTED: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnFrontDisconnected", + ("gateway_key", "reason"), + ), + ObservationKind.MARKET_SUBSCRIPTION_ACK: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRspSubMarketData", + ("instrument_id", "success"), + ), + ObservationKind.MARKET_TICK: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRtnDepthMarketData", + ("instrument_id", "bid", "ask", "last", "price_tick"), + ), + ObservationKind.ORDER_ACCEPTED: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRtnOrder", + ("order_ref", "external_order_id", "instrument_id", "status", "remaining_quantity"), + ), + ObservationKind.ORDER_PARTIAL: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRtnOrder", + ( + "order_ref", + "external_order_id", + "instrument_id", + "status", + "traded_quantity", + "remaining_quantity", + ), + ), + ObservationKind.ORDER_CANCELED: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRtnOrder", + ("order_ref", "external_order_id", "instrument_id", "status", "remaining_quantity"), + ), + ObservationKind.ORDER_FILLED: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRtnOrder", + ( + "order_ref", + "external_order_id", + "instrument_id", + "status", + "traded_quantity", + "remaining_quantity", + ), + ), + ObservationKind.ORDER_REJECTED: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRspOrderInsert", + ("order_ref", "instrument_id", "error_id", "error_message", "rejection_class"), + ), + ObservationKind.TRADE_EXECUTION: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRtnTrade", + ("order_ref", "trade_id", "quantity"), + ), + ObservationKind.ORDER_QUERY: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRspQryOrder", + ("query_id", "complete", "open_order_refs"), + ), + ObservationKind.POSITION_QUERY: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRspQryInvestorPosition", + ("query_id", "complete", "instrument_id", "phase", "closeable_quantity"), + ), + ObservationKind.ORDER_ADMISSION: ( + EvidenceTrustDomain.MANAGED_RUNTIME, + "", + ("case_id", "intent_kind", "approval_ref", "maximum_quantity"), + ), + ObservationKind.ORDER_SUBMIT_RECEIPT: ( + EvidenceTrustDomain.MANAGED_RUNTIME, + "", + ("order_ref", "trace_id", "dispatch_state"), + ), + ObservationKind.ORDER_CANCEL_RECEIPT: ( + EvidenceTrustDomain.MANAGED_RUNTIME, + "", + ("order_ref", "trace_id", "dispatch_state", "request_id", "action"), + ), + ObservationKind.MONITOR_CONFIGURATION: ( + EvidenceTrustDomain.MONITOR, + "", + ("metric", "threshold", "configuration_digest", "monitor_digest"), + ), + ObservationKind.MONITOR_TRIGGER: ( + EvidenceTrustDomain.MONITOR, + "", + ("metric", "threshold", "observed_value", "monitor_digest"), + ), + ObservationKind.REPEAT_GUARD: ( + EvidenceTrustDomain.MONITOR, + "", + ("action_kind", "repeat_key", "repeat_count", "monitor_digest"), + ), + ObservationKind.VALIDATION_REJECTION: ( + EvidenceTrustDomain.LOCAL_VALIDATOR, + "", + ("order_ref", "rule", "error_message", "validator_digest", "reference_data_digest"), + ), + ObservationKind.DISPATCH_ABSENCE: ( + EvidenceTrustDomain.MANAGED_RUNTIME, + "", + ("order_ref", "dispatch_count", "audit_digest"), + ), + ObservationKind.EXTERNAL_CONDITION: ( + EvidenceTrustDomain.CONTROL_PLANE, + "", + ("condition_id", "state", "evidence_ref"), + ), + ObservationKind.ACCOUNT_PERMISSION_DISABLED: ( + EvidenceTrustDomain.CONTROL_PLANE, + "", + ("account_id_masked", "authorization_ref", "independent_evidence_ref"), + ), + ObservationKind.ACCOUNT_PERMISSION_RESTORED: ( + EvidenceTrustDomain.CONTROL_PLANE, + "", + ("account_id_masked", "restoration_ref"), + ), + ObservationKind.STRATEGY_PAUSED: ( + EvidenceTrustDomain.CONTROL_PLANE, + "", + ("strategy_id", "authorization_ref", "reason"), + ), + ObservationKind.GATEWAY_LOGOUT_AUTHORIZED: ( + EvidenceTrustDomain.CONTROL_PLANE, + "", + ("gateway_key", "authorization_ref", "operator_evidence_ref"), + ), + ObservationKind.POST_DISCONNECT_WRITE_BLOCKED: ( + EvidenceTrustDomain.MANAGED_RUNTIME, + "", + ("gateway_key", "guard_ref", "blocked_attempt_count"), + ), + ObservationKind.SYSTEM_LOG: ( + EvidenceTrustDomain.MANAGED_RUNTIME, + "", + ("trace_id", "gateway_key", "log_digest"), + ), + ObservationKind.MONITOR_LOG: ( + EvidenceTrustDomain.MONITOR, + "", + ("trace_id", "metric", "monitor_digest"), + ), +} + + +class CaseIntentDecisionEngine: + """Evaluate source-verified observations into review-only candidates.""" + + def __init__( + self, + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: ObservationAuthenticator | None = None, + ) -> None: + scope.validate(plan) + self.plan = plan + self.scope = scope + self.spec = CASE_INTENT_SPECS[plan.case_id] + self._authenticator = authenticator + self._events: list[NativeObservation] = [] + self._event_ids: set[str] = set() + self._sequences: dict[tuple[EvidenceTrustDomain, str, str, str], int] = {} + self._provider_session: tuple[str, str] | None = None + self._rejected: list[str] = [] + + def record(self, event: NativeObservation) -> bool: + """Record one authenticated observation and poison the review on rejection.""" + try: + return self._record_observation(event) + except Exception as exc: + event_id = event.event_id if type(event) is NativeObservation else "invalid-event" + self._rejected.append(f"{event_id}:record_rejected:{type(exc).__name__}") + raise + + def _record_observation(self, event: NativeObservation) -> bool: + """Internal recorder shared by typed case-specific evidence policies.""" + self._validate_event(event) + if event.event_id in self._event_ids: + raise DecisionError("duplicate observation event_id") + if self._authenticator is None: + self._rejected.append(f"{event.event_id}:authenticator_unavailable") + return False + receipt = self._authenticator.authenticate(event, self.scope) + if receipt is None: + self._rejected.append(f"{event.event_id}:verification_denied") + return False + if ( + receipt.event_id != event.event_id + or receipt.evidence_sha256.lower() != event.evidence_sha256.lower() + or receipt.scope_sha256.lower() != self.scope.scope_sha256.lower() + or not isinstance(receipt.account_identity_sha256, str) + or receipt.account_identity_sha256.lower() != self.scope.account_identity_sha256.lower() + or receipt.trust_domain is not event.source_domain + or not receipt.verification_ref.strip() + ): + self._rejected.append(f"{event.event_id}:verification_receipt_mismatch") + return False + key = ( + (event.source_domain, event.client_instance_id, event.stream_id, "") + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK + and self.plan.case_id in {"M02", "M03"} + else ( + event.source_domain, + event.stream_id, + event.provider_session_id, + event.trading_day, + ) + ) + if event.sequence <= self._sequences.get(key, 0): + raise DecisionError("observation sequence must increase within a source stream") + + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK: + provider_events = [ + item for item in self._events if item.source_domain is EvidenceTrustDomain.CTP_CALLBACK + ] + if self.plan.case_id in {"M02", "M03"}: + previous_logins = [ + item for item in provider_events if item.kind is ObservationKind.LOGIN_SUCCESS + ] + previous_login = previous_logins[-1] if previous_logins else None + generation = event.fields.get("connection_generation") + if event.kind is ObservationKind.LOGIN_SUCCESS: + if previous_login is not None: + old_generation = previous_login.arrival_generation + new_scope = (event.provider_session_id, event.trading_day) + old_scope = (previous_login.provider_session_id, previous_login.trading_day) + if event.client_instance_id != previous_login.client_instance_id: + raise DecisionError("native login cannot splice provider clients") + if type(generation) is not int or generation < old_generation: + raise DecisionError("native login generation cannot move backwards") + if generation == old_generation and new_scope != old_scope: + raise DecisionError("same-generation native login cannot change provider identity") + if generation > old_generation and not ( + any( + item.kind is ObservationKind.FRONT_DISCONNECTED + and item.client_instance_id == event.client_instance_id + and item.arrival_generation == old_generation + and previous_login.sequence < item.sequence < event.sequence + for item in provider_events + ) + and any( + item.kind is ObservationKind.FRONT_CONNECTED + and item.client_instance_id == event.client_instance_id + and item.arrival_generation == generation + and item.sequence < event.sequence + for item in provider_events + ) + ): + raise DecisionError( + "provider identity may change only on a later native login after reconnect" + ) + self._provider_session = (event.provider_session_id, event.trading_day) + elif event.kind not in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + }: + if previous_login is None: + raise DecisionError("session-scoped callback requires a prior native user login") + if ( + event.client_instance_id != previous_login.client_instance_id + or (event.provider_session_id, event.trading_day) + != (previous_login.provider_session_id, previous_login.trading_day) + or ( + type(generation) is int + and generation != previous_login.arrival_generation + ) + ): + raise DecisionError( + "provider callback scope must match the latest native login for this client" + ) + if event.session_identity_origin != "derived_from_same_client_generation_native_login": + raise DecisionError( + "provider callback identity must be locally derived from native login" + ) + if event.sequence <= previous_login.sequence: + raise DecisionError("provider callback must follow its native login") + elif event.kind not in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + }: + provider_scope = (event.provider_session_id, event.trading_day) + if self._provider_session is None: + self._provider_session = provider_scope + elif provider_scope != self._provider_session and self.plan.case_id != "M03": + raise DecisionError( + "one case decision cannot mix provider sessions or trading days" + ) + self._sequences[key] = event.sequence + self._event_ids.add(event.event_id) + self._events.append(event) + return True + + def evaluate(self, *, now_utc: datetime | None = None) -> DecisionSnapshot: + scenario_id = SCENARIOS_BY_CASE_ID[self.plan.case_id].scenario_id + if self._authenticator is None: + return self._snapshot( + DecisionStatus.BLOCKED, ("trusted_authenticator_not_configured",), None + ) + if not self._events: + return self._snapshot(DecisionStatus.BLOCKED, ("no_authenticated_observations",), None) + unavailable = tuple( + f"{item.fields.get('condition_id')}:{item.fields.get('reason', 'unavailable')}" + for item in self._all(ObservationKind.EXTERNAL_CONDITION) + if item.fields.get("state") == "unavailable" + ) + if unavailable: + return self._snapshot( + DecisionStatus.EXTERNAL_CONDITION_UNAVAILABLE, (), None, unavailable + ) + evaluation_time = now_utc or datetime.now(timezone.utc) + if self._rejected: + missing = self._missing(evaluation_time) + poisoned = tuple( + dict.fromkeys(("rejected_observation_poisoned_review", *missing)) + ) + return self._snapshot(DecisionStatus.INCOMPLETE, poisoned, None) + missing = self._missing(evaluation_time) + if missing: + return self._snapshot(DecisionStatus.INCOMPLETE, missing, None) + candidate = IntentCandidate( + self.plan.case_id, + scenario_id, + self.spec.intent_kind, + tuple(item.event_id for item in self._events), + self._correlation_refs(), + ) + return self._snapshot(DecisionStatus.REVIEW_REQUIRED, (), candidate) + + def _snapshot( + self, + status: DecisionStatus, + missing: tuple[str, ...], + candidate: IntentCandidate | None, + unavailable: tuple[str, ...] = (), + ) -> DecisionSnapshot: + return DecisionSnapshot( + self.plan.case_id, + SCENARIOS_BY_CASE_ID[self.plan.case_id].scenario_id, + status, + False, + False, + candidate, + missing, + tuple(self._rejected), + unavailable, + ) + + def _validate_event(self, event: NativeObservation) -> None: + policy = _POLICY[event.kind] + domain, callback, required_fields = policy + if event.source_domain is not domain or event.callback_name != callback: + raise DecisionError(f"{event.kind.value} has incorrect source domain/callback") + if ( + domain is EvidenceTrustDomain.CTP_CALLBACK + and event.kind + in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + } + ): + self._validate_no_login_identity_aliases(event) + if event.kind not in self.spec.required_kinds and not ( + self.spec.rule in _SESSION_REQUIRED_RULES + and event.kind + in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + } + ): + raise DecisionError(f"{event.kind.value} is not required by case {self.plan.case_id}") + if not event.event_id or not _SHA256_RE.fullmatch(event.evidence_sha256): + raise DecisionError("event id and SHA-256 evidence digest are required") + if not event.stream_id: + raise DecisionError("source stream_id is required") + if ( + not isinstance(event.sequence, int) + or isinstance(event.sequence, bool) + or event.sequence < 1 + ): + raise DecisionError("source sequence must be a positive integer") + timestamp = _parse_utc(event.occurred_at_utc) + if timestamp is None: + raise DecisionError("event timestamp must carry UTC offset") + is_auth_callback = event.kind is ObservationKind.AUTH_SUCCESS + is_payloadless_front = event.kind in { + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + } + if is_auth_callback or is_payloadless_front: + if ( + event.provider_front_id is not None + or event.provider_session_id + or event.trading_day + or any( + event.fields.get(name) not in (None, "") + for name in ("provider_front_id", "provider_session_id", "trading_day") + ) + ): + callback = "OnRspAuthenticate" if is_auth_callback else event.callback_name + raise DecisionError( + f"{callback} cannot claim provider FrontID, SessionID, or TradingDay" + ) + elif domain is EvidenceTrustDomain.CTP_CALLBACK and ( + not event.provider_session_id or not event.trading_day + ): + raise DecisionError("native provider events require session and trading day") + if event.kind is ObservationKind.FRONT_CONNECTED and ( + not event.client_instance_id + or type(event.arrival_generation) is not int + or event.arrival_generation < 1 + or event.connection_generation_origin != "local_connection_generation" + ): + raise DecisionError( + "OnFrontConnected requires local client and connection-generation metadata" + ) + missing = [name for name in required_fields if _missing(event.fields.get(name))] + if self.plan.case_id == "TH04": + extra_fields = { + ObservationKind.ORDER_SUBMIT_RECEIPT: ("request_id", "action"), + ObservationKind.ORDER_CANCEL_RECEIPT: ("request_id", "action"), + ObservationKind.MONITOR_TRIGGER: ( + "configuration_digest", + "source_request_ids", + "source_native_event_ids", + ), + }.get(event.kind, ()) + missing.extend(name for name in extra_fields if _missing(event.fields.get(name))) + if missing: + raise DecisionError(f"{event.kind.value} missing fields: {', '.join(missing)}") + if event.kind in {ObservationKind.AUTH_SUCCESS, ObservationKind.LOGIN_SUCCESS}: + self._validate_c01_auth_login_callback(event) + if self.plan.case_id == "TH04" and event.kind in { + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_CANCEL_RECEIPT, + }: + expected_action = ( + "submit" if event.kind is ObservationKind.ORDER_SUBMIT_RECEIPT else "cancel" + ) + if ( + event.fields.get("action") != expected_action + or not isinstance(event.fields.get("request_id"), str) + or not event.fields["request_id"].strip() + or event.fields.get("dispatch_state") not in {"dispatched", "blocked_pre_dispatch"} + ): + raise DecisionError("TH04 managed receipt action/id/dispatch state is invalid") + _validate_values(event) + + def _validate_no_login_identity_aliases(self, event: NativeObservation) -> None: + validate_payloadless_callback_fields( + event, + scope_account_identity_sha256=self.scope.account_identity_sha256, + ) + + def _validate_c01_auth_login_callback(self, event: NativeObservation) -> None: + fields = event.fields + is_auth = event.kind is ObservationKind.AUTH_SUCCESS + if type(fields.get("request_id")) is not int or fields.get("request_id") <= 0: + raise DecisionError("native auth/login callback requires positive echoed request_id") + if type(fields.get("request_generation")) is not int or ( + fields.get("request_generation") != event.request_generation + or event.request_generation < 1 + or event.request_generation_origin != "local_request_generation_binding" + ): + raise DecisionError("native request_id requires matching local request_generation") + if event.request_id_origin != "native_callback_argument": + raise DecisionError("request_id origin must be the native CTP callback argument") + if type(fields.get("arrival_generation")) is not int or ( + fields.get("arrival_generation") != event.arrival_generation + or event.arrival_generation < 1 + ): + raise DecisionError("native callback requires matching local arrival_generation") + if not isinstance(event.client_instance_id, str) or not event.client_instance_id.strip(): + raise DecisionError("C01 callback requires local client_instance_id") + if type(fields.get("is_last")) is not bool or fields.get("is_last") is not True: + raise DecisionError("auth/login native response requires exact is_last=True") + if type(fields.get("error_id")) is not int or fields.get("error_id") != 0: + raise DecisionError("auth/login native ErrorID must be integer zero") + if event.sequence_origin != "local_sdk_callback_arrival": + raise DecisionError("auth/login source sequence must be local callback arrival") + if event.timestamp_origin != "local_sdk_capture_clock": + raise DecisionError("auth/login timestamp must be local SDK capture time") + if _parse_utc(event.arrived_at_utc) is None or _parse_utc(event.occurred_at_utc) != _parse_utc( + event.arrived_at_utc + ): + raise DecisionError("auth/login occurred_at_utc must mirror local arrived_at_utc") + if ( + type(event.arrived_monotonic) not in {int, float} + or isinstance(event.arrived_monotonic, bool) + or event.arrived_monotonic <= 0 + ): + raise DecisionError("auth/login requires positive local arrived_monotonic") + if fields.get("success") is not True: + raise DecisionError("auth/login provider callback must report success") + if ( + event.event_id_origin != "local_sdk_callback_arrival" + or event.provider_issued_event_id is not False + ): + raise DecisionError("auth/login event id must be explicitly local") + impossible_provider_metadata = ( + "provider_timestamp_utc", + "provider_sequence", + "provider_event_id", + "source_sequence", + "source_timestamp_utc", + ) + if any(fields.get(name) not in (None, "") for name in impossible_provider_metadata): + raise DecisionError("auth/login callback cannot claim provider time, sequence, or event ID") + if is_auth: + if event.provider_front_id is not None or event.provider_session_id or event.trading_day: + raise DecisionError( + "OnRspAuthenticate cannot claim login-only provider identity fields" + ) + if any( + fields.get(name) not in (None, "") + for name in ( + "provider_front_id", + "provider_session_id", + "trading_day", + "provider_timestamp_utc", + "provider_sequence", + "provider_event_id", + "source_sequence", + "source_timestamp_utc", + ) + ): + raise DecisionError("OnRspAuthenticate evidence contains impossible provider fields") + if ( + event.session_identity_origin + != "unavailable_on_native_authentication_response" + ): + raise DecisionError("authentication identity origin must remain unavailable") + return + front_id = fields.get("provider_front_id") + session_id = fields.get("provider_session_id") + trading_day = fields.get("trading_day") + if type(front_id) is not int or front_id < 1: + raise DecisionError("OnRspUserLogin requires native positive provider_front_id") + if not isinstance(session_id, str) or not session_id.isdigit(): + raise DecisionError("OnRspUserLogin requires native provider_session_id") + if not isinstance(trading_day, str) or not re.fullmatch(r"[0-9]{8}", trading_day): + raise DecisionError("OnRspUserLogin requires native YYYYMMDD trading_day") + if ( + event.provider_front_id != front_id + or event.provider_session_id != session_id + or event.trading_day != trading_day + or event.session_identity_origin != "native_login_response_fields" + ): + raise DecisionError("OnRspUserLogin scope must match native provider identity fields") + + def _all(self, kind: ObservationKind) -> list[NativeObservation]: + return [item for item in self._events if item.kind is kind] + + def _last(self, kind: ObservationKind) -> NativeObservation | None: + events = self._all(kind) + return events[-1] if events else None + + def _missing(self, now: datetime) -> tuple[str, ...]: + missing = [kind.value for kind in self.spec.required_kinds if not self._all(kind)] + rule = self.spec.rule + + def require(condition: bool, label: str) -> None: + if not condition: + missing.append(label) + + if rule in _SESSION_REQUIRED_RULES: + require(self._session_ready(), "provider_session_not_ready") + if rule in _TICK_REQUIRED_RULES: + require(self._fresh_tick(now), "fresh_valid_market_tick_required") + + admission = self._last(ObservationKind.ORDER_ADMISSION) + if self.spec.admission_kind: + require( + bool( + admission + and admission.fields.get("case_id") == self.plan.case_id + and admission.fields.get("intent_kind") == self.spec.admission_kind + and admission.fields.get("approval_state") == "REVIEW_ONLY" + and admission.fields.get("dispatch_permitted") is False + and _positive(admission.fields.get("maximum_quantity")) + and str(admission.fields.get("approval_ref", "")).strip() + ), + "case_bound_review_only_admission_required", + ) + + if rule == "auth_login": + missing.append("trusted_c01_issued_request_ledger_verifier_required") + missing.append("c01_baseline_final_query_receipt_path_not_wired") + require(self._success(ObservationKind.AUTH_SUCCESS), "authentication_success_required") + require(self._success(ObservationKind.LOGIN_SUCCESS), "login_success_required") + auth_events = self._all(ObservationKind.AUTH_SUCCESS) + login_events = self._all(ObservationKind.LOGIN_SUCCESS) + if len(auth_events) != 1 or len(login_events) != 1: + missing.append("one_auth_and_one_login_callback_required") + else: + auth, login = auth_events[0], login_events[0] + if ( + auth.stream_id != login.stream_id + or auth.client_instance_id != login.client_instance_id + or auth.arrival_generation != login.arrival_generation + or auth.request_generation == login.request_generation + ): + missing.append("auth_login_same_client_generation_and_distinct_requests_required") + auth_request_id = auth.fields.get("request_id") + login_request_id = login.fields.get("request_id") + if ( + type(auth_request_id) is not int + or type(login_request_id) is not int + or auth_request_id == login_request_id + ): + missing.append("auth_login_native_request_ids_must_be_distinct") + if ( + auth.sequence >= login.sequence + or _parse_utc(auth.arrived_at_utc) >= _parse_utc(login.arrived_at_utc) + ): + missing.append("authentication_must_arrive_before_login_on_same_client") + for event, request_kind in ((auth, "authenticate"), (login, "login")): + if not validate_issued_request_receipt( + event.issued_request_receipt, + request_kind=request_kind, + phase="", + request_id=event.fields.get("request_id"), + request_generation=event.request_generation, + client_instance_id=event.client_instance_id, + arrival_generation=event.arrival_generation, + arrived_at_utc=event.arrived_at_utc, + arrived_monotonic=event.arrived_monotonic, + ): + missing.append(f"{request_kind}_issued_request_receipt_required") + elif rule in {"connected", "system_log"}: + require(self._session_ready(), "provider_connection_not_fully_ready") + if rule == "system_log": + require(bool(self._all(ObservationKind.SYSTEM_LOG)), "managed_system_log_required") + elif rule == "disconnected": + require( + self._disconnected(), + "correlated_native_disconnect_and_control_receipt_required", + ) + elif rule == "reconnected": + require(self._reconnected(), "different_authenticated_provider_session_required") + require( + self._external_satisfied("external_reconnect"), + "external_reconnect_condition_not_confirmed", + ) + elif rule == "close_order": + require(self._positive_position(), "provider_closeable_position_required") + require(self._tick_matches_position(), "position_and_tick_instrument_mismatch") + elif rule in {"cancel_order", "cancel_count"}: + require(self._target_open_orders(1), "provider_confirmed_open_order_required") + if rule == "cancel_order": + require( + bool(admission and admission.fields.get("cancel_window_open") is True), + "provider_cancel_window_not_confirmed", + ) + require(self._tick_matches_open_order(), "order_and_tick_instrument_mismatch") + elif rule.startswith("repeat_"): + expected = {"repeat_open": "open", "repeat_close": "close", "repeat_cancel": "cancel"}[ + rule + ] + repeat = self._last(ObservationKind.REPEAT_GUARD) + require( + bool( + repeat + and repeat.fields.get("action_kind") == expected + and _integer(repeat.fields.get("repeat_count"), minimum=2) + and str(repeat.fields.get("repeat_key", "")).strip() + ), + "matching_repeat_monitor_count_at_least_two_required", + ) + if rule == "repeat_close": + require(self._positive_position(), "provider_closeable_position_required") + if rule == "repeat_cancel": + require(self._target_open_orders(1), "provider_confirmed_open_order_required") + elif rule.startswith("configure_"): + metric = { + "configure_order": "submitted_order_count", + "configure_cancel": "cancel_order_count", + "configure_repeat": "repeat_order_count", + }[rule] + config = self._last(ObservationKind.MONITOR_CONFIGURATION) + require( + bool( + config + and config.fields.get("metric") == metric + and _integer(config.fields.get("threshold"), minimum=1) + ), + "case_specific_monitor_configuration_required", + ) + if rule == "configure_repeat": + require( + bool(config and _positive(config.fields.get("window_seconds"))), + "repeat_window_must_be_positive", + ) + elif rule.startswith("trigger_"): + require( + self._trigger_correlated(rule), + "threshold_trigger_not_correlated_to_native_activity", + ) + elif rule.startswith("validate_") or rule == "validation_log": + require( + self._validation_proof(rule), + "matching_validator_rejection_and_zero_dispatch_required", + ) + elif rule.startswith("reject_"): + require( + self._remote_rejection_proof(rule), + "external_condition_and_correlated_native_rejection_required", + ) + elif rule == "account_permission": + require( + self._account_permission_proof(), + "permission_disable_reject_restore_and_empty_query_required", + ) + elif rule == "strategy_paused": + query = self._last(ObservationKind.ORDER_QUERY) + require( + bool( + query + and query.fields.get("complete") is True + and not query.fields.get("open_order_refs") + ), + "final_query_must_show_no_open_orders", + ) + elif rule == "force_logout": + require(self._force_logout_proof(), "operator_ack_disconnect_and_write_guard_required") + elif rule == "batch_partials": + require( + self._batch_partials_ready(), + "two_partial_orders_trade_reconciliation_and_batch_capability_required", + ) + elif rule == "batch_open": + require( + self._batch_open_ready(), + "two_current_native_open_orders_and_batch_capability_required", + ) + elif rule == "trade_log": + require(self._trade_correlated(), "trade_must_correlate_to_provider_order") + elif rule == "monitor_log": + require( + bool(self._all(ObservationKind.ORDER_ACCEPTED)), "native_order_activity_required" + ) + return tuple(dict.fromkeys(missing)) + + def _success(self, kind: ObservationKind) -> bool: + event = self._last(kind) + return bool( + event + and event.fields.get("success") is True + and _integer(event.fields.get("error_id")) == 0 + ) + + def _session_ready(self) -> bool: + return ( + self._success(ObservationKind.AUTH_SUCCESS) + and self._success(ObservationKind.LOGIN_SUCCESS) + and bool( + self._all(ObservationKind.FRONT_CONNECTED) + and self._success(ObservationKind.MARKET_SUBSCRIPTION_ACK) + ) + ) + + def _fresh_tick(self, now: datetime) -> bool: + tick = self._last(ObservationKind.MARKET_TICK) + if tick is None or now.tzinfo is None or now.utcoffset() != timedelta(0): + return False + occurred = _parse_utc(tick.occurred_at_utc) + if occurred is None or not timedelta(0) <= now - occurred <= timedelta(seconds=2): + return False + bid, ask, last, price_tick = ( + _decimal(tick.fields.get(key)) for key in ("bid", "ask", "last", "price_tick") + ) + return bool( + bid + and ask + and last + and price_tick + and bid > 0 + and ask >= bid + and last > 0 + and price_tick > 0 + ) + + def _positive_position(self) -> bool: + snapshots = [ + event + for event in self._all(ObservationKind.POSITION_QUERY) + if event.fields.get("complete") is True and event.fields.get("phase") == "baseline" + ] + quantity = _decimal(snapshots[-1].fields.get("closeable_quantity")) if snapshots else None + return quantity is not None and quantity > 0 + + def _tick_matches_position(self) -> bool: + tick, positions = ( + self._last(ObservationKind.MARKET_TICK), + [ + event + for event in self._all(ObservationKind.POSITION_QUERY) + if event.fields.get("phase") == "baseline" + ], + ) + return bool( + tick + and positions + and tick.fields.get("instrument_id") == positions[-1].fields.get("instrument_id") + ) + + def _target_open_orders(self, minimum: int) -> bool: + query = self._last(ObservationKind.ORDER_QUERY) + accepted = { + str(event.fields.get("order_ref")) + for event in self._all(ObservationKind.ORDER_ACCEPTED) + } + open_refs = set(query.fields.get("open_order_refs", ())) if query else set() + return bool( + query and query.fields.get("complete") is True and len(accepted & open_refs) >= minimum + ) + + def _tick_matches_open_order(self) -> bool: + tick, query = ( + self._last(ObservationKind.MARKET_TICK), + self._last(ObservationKind.ORDER_QUERY), + ) + if not tick or not query: + return False + open_refs = set(query.fields.get("open_order_refs", ())) + return any( + event.fields.get("order_ref") in open_refs + and event.fields.get("instrument_id") == tick.fields.get("instrument_id") + for event in self._all(ObservationKind.ORDER_ACCEPTED) + ) + + def _disconnected(self) -> bool: + for disconnected in self._all(ObservationKind.FRONT_DISCONNECTED): + old_generation = _integer(disconnected.fields.get("connection_generation"), minimum=1) + if ( + old_generation is None + or not disconnected.client_instance_id + or disconnected.provider_session_id + or disconnected.trading_day + or disconnected.provider_front_id is not None + or disconnected.arrival_generation != old_generation + or disconnected.connection_generation_origin != "local_connection_generation" + ): + continue + disconnected_at = _parse_utc(disconnected.occurred_at_utc) + old_logins = [ + item + for item in self._all(ObservationKind.LOGIN_SUCCESS) + if item.client_instance_id == disconnected.client_instance_id + and item.stream_id == disconnected.stream_id + and item.arrival_generation == old_generation + and item.fields.get("connection_generation") == old_generation + and item.session_identity_origin == "native_login_response_fields" + and item.sequence < disconnected.sequence + and _parse_utc(item.occurred_at_utc) < disconnected_at + ] + if len(old_logins) != 1: + continue + old_login = old_logins[0] + if any( + item.fields.get("condition_id") == "external_disconnect" + and item.fields.get("state") == "satisfied" + and item.fields.get("gateway_key") == disconnected.fields.get("gateway_key") + and item.fields.get("session_id") == old_login.provider_session_id + and item.fields.get("connection_generation") == old_generation + and item.fields.get("provider_event_ref") == disconnected.event_id + and _parse_utc(item.occurred_at_utc) >= disconnected_at + for item in self._all(ObservationKind.EXTERNAL_CONDITION) + ): + return True + return False + + def _reconnected(self) -> bool: + disconnects = self._all(ObservationKind.FRONT_DISCONNECTED) + if not disconnects: + return False + disconnected = disconnects[-1] + disconnected_at = _parse_utc(disconnected.occurred_at_utc) + old_generation = _integer(disconnected.fields.get("connection_generation"), minimum=1) + if ( + old_generation is None + or not disconnected.client_instance_id + or not disconnected.stream_id + or disconnected.provider_session_id + or disconnected.trading_day + or disconnected.provider_front_id is not None + or disconnected.arrival_generation != old_generation + or disconnected.connection_generation_origin != "local_connection_generation" + ): + return False + old_logins = [ + item + for item in self._all(ObservationKind.LOGIN_SUCCESS) + if item.client_instance_id == disconnected.client_instance_id + and item.stream_id == disconnected.stream_id + and item.arrival_generation == old_generation + and item.fields.get("connection_generation") == old_generation + and item.session_identity_origin == "native_login_response_fields" + and item.sequence < disconnected.sequence + and _parse_utc(item.occurred_at_utc) < disconnected_at + ] + if len(old_logins) != 1: + return False + old_login = old_logins[0] + old_scope = (old_login.provider_session_id, old_login.trading_day) + for connected in self._all(ObservationKind.FRONT_CONNECTED): + connected_at = _parse_utc(connected.occurred_at_utc) + new_generation = _integer(connected.fields.get("connection_generation"), minimum=1) + if ( + connected.provider_session_id + or connected.trading_day + or connected.provider_front_id is not None + or connected.client_instance_id != disconnected.client_instance_id + or connected.stream_id != disconnected.stream_id + or connected.fields.get("gateway_key") != disconnected.fields.get("gateway_key") + or connected_at <= disconnected_at + or connected.sequence <= disconnected.sequence + or new_generation is None + or new_generation <= old_generation + or connected.arrival_generation != new_generation + or connected.connection_generation_origin != "local_connection_generation" + ): + continue + login_bindings = [ + item + for item in self._all(ObservationKind.LOGIN_SUCCESS) + if item.client_instance_id == connected.client_instance_id + and item.arrival_generation == new_generation + and item.stream_id == connected.stream_id + and item.sequence > connected.sequence + and item.fields.get("connection_generation") == new_generation + and item.session_identity_origin == "native_login_response_fields" + and item.provider_session_id + and item.trading_day + and _parse_utc(item.occurred_at_utc) >= connected_at + ] + if len(login_bindings) != 1: + continue + login_binding = login_bindings[0] + new_scope = (login_binding.provider_session_id, login_binding.trading_day) + if new_scope[0] == old_scope[0] or new_scope[1] != old_scope[1]: + continue + + auth_rows = [ + item + for item in self._all(ObservationKind.AUTH_SUCCESS) + if item.stream_id == connected.stream_id + and item.client_instance_id == connected.client_instance_id + and item.arrival_generation == new_generation + and item.sequence > connected.sequence + and item.sequence < login_binding.sequence + and item.fields.get("connection_generation") == new_generation + and item.session_identity_origin + == "unavailable_on_native_authentication_response" + and item.fields.get("success") is True + and _integer(item.fields.get("error_id")) == 0 + and (arrived_at := _parse_utc(item.occurred_at_utc)) is not None + and connected_at <= arrived_at <= _parse_utc(login_binding.occurred_at_utc) + ] + market_rows = [ + item + for item in self._all(ObservationKind.MARKET_SUBSCRIPTION_ACK) + if item.stream_id == connected.stream_id + and item.client_instance_id == connected.client_instance_id + and item.arrival_generation == new_generation + and item.sequence > login_binding.sequence + and item.fields.get("connection_generation") == new_generation + and (item.provider_session_id, item.trading_day) == new_scope + and item.session_identity_origin + == "derived_from_same_client_generation_native_login" + and item.fields.get("success") is True + and _integer(item.fields.get("error_id")) == 0 + and _parse_utc(item.occurred_at_utc) + >= _parse_utc(login_binding.occurred_at_utc) + ] + if not auth_rows or not market_rows: + continue + authentication = auth_rows[0] + subscribed = market_rows[0] + if not ( + connected.sequence < authentication.sequence + < login_binding.sequence < subscribed.sequence + and _parse_utc(connected.occurred_at_utc) + <= _parse_utc(authentication.occurred_at_utc) + <= _parse_utc(login_binding.occurred_at_utc) + <= _parse_utc(subscribed.occurred_at_utc) + ): + continue + if any( + item.fields.get("condition_id") == "external_reconnect" + and item.fields.get("state") == "satisfied" + and item.fields.get("previous_session_id") == old_scope[0] + and item.fields.get("new_session_id") == new_scope[0] + and item.fields.get("gateway_key") == connected.fields.get("gateway_key") + and item.fields.get("previous_connection_generation") == old_generation + and item.fields.get("new_connection_generation") == new_generation + and item.fields.get("disconnect_event_ref") == disconnected.event_id + and item.fields.get("reconnect_event_ref") == connected.event_id + and _parse_utc(item.occurred_at_utc) >= _parse_utc(subscribed.occurred_at_utc) + for item in self._all(ObservationKind.EXTERNAL_CONDITION) + ): + return True + return False + + def _trigger_correlated(self, rule: str) -> bool: + if rule == "trigger_cancel" and self.plan.case_id == "TH04": + return self._th04_combined_request_trigger_correlated() + trigger = self._last(ObservationKind.MONITOR_TRIGGER) + if not trigger: + return False + metric = { + "trigger_order": "submitted_order_count", + "trigger_cancel": "cancel_order_count", + "trigger_repeat": "repeat_order_count", + }[rule] + threshold = _integer(trigger.fields.get("threshold"), minimum=1) + observed = _integer(trigger.fields.get("observed_value"), minimum=1) + if ( + trigger.fields.get("metric") != metric + or threshold is None + or observed is None + or observed < threshold + ): + return False + config = self._last(ObservationKind.MONITOR_CONFIGURATION) + if ( + not config + or config.fields.get("metric") != metric + or _integer(config.fields.get("threshold"), minimum=1) != threshold + ): + return False + if rule == "trigger_order": + count = len( + { + event.fields.get("order_ref") + for event in self._all(ObservationKind.ORDER_ACCEPTED) + } + ) + return count >= threshold and observed <= count + if rule == "trigger_cancel": + count = len( + { + event.fields.get("order_ref") + for event in self._all(ObservationKind.ORDER_CANCELED) + } + ) + return count >= threshold and observed <= count + guard = self._last(ObservationKind.REPEAT_GUARD) + return bool(guard and _integer(guard.fields.get("repeat_count")) == observed) + + def _th04_combined_request_trigger_correlated(self) -> bool: + """Bind TH04's combined count to managed requests, not callback refs.""" + config = self._last(ObservationKind.MONITOR_CONFIGURATION) + trigger = self._last(ObservationKind.MONITOR_TRIGGER) + if config is None or trigger is None: + return False + config_fields, trigger_fields = config.fields, trigger.fields + threshold = _integer(config_fields.get("threshold"), minimum=1) + observed = _integer(trigger_fields.get("observed_value"), minimum=1) + if ( + config_fields.get("metric") != "combined_order_cancel_count" + or trigger_fields.get("metric") != "combined_order_cancel_count" + or threshold != 3 + or trigger_fields.get("threshold") != threshold + or observed is None + or observed < threshold + or not _SHA256_RE.fullmatch(str(config_fields.get("configuration_digest", ""))) + or trigger_fields.get("configuration_digest") + != config_fields.get("configuration_digest") + or not _SHA256_RE.fullmatch(str(config_fields.get("monitor_digest", ""))) + or trigger_fields.get("monitor_digest") != config_fields.get("monitor_digest") + ): + return False + + receipts = [ + *self._all(ObservationKind.ORDER_SUBMIT_RECEIPT), + *self._all(ObservationKind.ORDER_CANCEL_RECEIPT), + ] + request_ids = [event.fields.get("request_id") for event in receipts] + if any( + not isinstance(request_id, str) or not request_id.strip() for request_id in request_ids + ) or len(set(request_ids)) != len(request_ids): + return False + dispatched = [ + event for event in receipts if event.fields.get("dispatch_state") == "dispatched" + ] + if ( + not any(event.kind is ObservationKind.ORDER_SUBMIT_RECEIPT for event in dispatched) + or not any(event.kind is ObservationKind.ORDER_CANCEL_RECEIPT for event in dispatched) + or observed != len(dispatched) + ): + return False + expected_request_ids = tuple( + sorted(str(event.fields["request_id"]) for event in dispatched) + ) + supplied_request_ids = trigger_fields.get("source_request_ids") + if ( + not isinstance(supplied_request_ids, (tuple, list)) + or tuple(supplied_request_ids) != expected_request_ids + ): + return False + + submit_refs = { + str(event.fields.get("order_ref")) + for event in dispatched + if event.kind is ObservationKind.ORDER_SUBMIT_RECEIPT + } + dispatched_submits = sum( + event.kind is ObservationKind.ORDER_SUBMIT_RECEIPT for event in dispatched + ) + cancel_refs = { + str(event.fields.get("order_ref")) + for event in dispatched + if event.kind is ObservationKind.ORDER_CANCEL_RECEIPT + } + accepted = { + str(event.fields.get("order_ref")) + for event in self._all(ObservationKind.ORDER_ACCEPTED) + } + canceled_events = self._all(ObservationKind.ORDER_CANCELED) + canceled_refs = {str(event.fields.get("order_ref")) for event in canceled_events} + if ( + not submit_refs + or len(submit_refs) != dispatched_submits + or submit_refs != accepted + or not cancel_refs + or not cancel_refs.issubset(accepted) + or not canceled_refs + or not canceled_refs.issubset(cancel_refs) + ): + return False + native_event_ids = tuple( + sorted( + event.event_id + for event in (*self._all(ObservationKind.ORDER_ACCEPTED), *canceled_events) + ) + ) + supplied_native_ids = trigger_fields.get("source_native_event_ids") + return bool( + isinstance(supplied_native_ids, (tuple, list)) + and tuple(supplied_native_ids) == native_event_ids + ) + + def _validation_proof(self, rule: str) -> bool: + expected = { + "validate_instrument": "instrument", + "validate_tick": "price_tick", + "validate_size": "max_order_size", + "validation_log": None, + }[rule] + audits = { + event.fields.get("order_ref"): event + for event in self._all(ObservationKind.DISPATCH_ABSENCE) + } + for event in self._all(ObservationKind.VALIDATION_REJECTION): + ref = event.fields.get("order_ref") + if expected is not None and event.fields.get("rule") != expected: + continue + audit = audits.get(ref) + if not audit or _integer(audit.fields.get("dispatch_count")) != 0: + continue + if any( + item.fields.get("order_ref") == ref + for item in self._events + if item.kind + in { + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.ORDER_REJECTED, + } + ): + continue + if rule == "validate_tick" and not self._all(ObservationKind.MARKET_TICK): + continue + if rule == "validate_instrument": + if ( + not str(event.fields.get("instrument_id", "")).strip() + or event.fields.get("instrument_lookup_found") is not False + ): + continue + if rule == "validate_tick": + tick = self._last(ObservationKind.MARKET_TICK) + proposed = _decimal(event.fields.get("proposed_price")) + price_tick = _decimal(tick.fields.get("price_tick")) if tick else None + if proposed is None or price_tick is None or price_tick <= 0: + continue + if proposed % price_tick == 0: + continue + if rule == "validate_size": + admission = self._last(ObservationKind.ORDER_ADMISSION) + requested = _decimal(event.fields.get("requested_size")) + maximum = _decimal(event.fields.get("maximum_order_size")) + admitted_maximum = ( + _decimal(admission.fields.get("maximum_order_size")) if admission else None + ) + if ( + requested is None + or maximum is None + or requested <= maximum + or admitted_maximum != maximum + ): + continue + return True + return False + + def _remote_rejection_proof(self, rule: str) -> bool: + expected = { + "reject_funds": "insufficient_funds", + "reject_position": "insufficient_position", + "reject_market": "market_state", + }[rule] + condition = next( + ( + event + for event in reversed(self._all(ObservationKind.EXTERNAL_CONDITION)) + if event.fields.get("condition_id") == expected + and event.fields.get("state") == "satisfied" + ), + None, + ) + submits = { + event.fields.get("order_ref") + for event in self._all(ObservationKind.ORDER_SUBMIT_RECEIPT) + } + rejected = [ + event + for event in self._all(ObservationKind.ORDER_REJECTED) + if event.fields.get("rejection_class") == expected + and _integer(event.fields.get("error_id"), minimum=1) + ] + if not condition or not any(event.fields.get("order_ref") in submits for event in rejected): + return False + if rule == "reject_funds": + available = _decimal(condition.fields.get("available_funds")) + required = _decimal(condition.fields.get("required_margin")) + if available is None or required is None or available >= required: + return False + if rule == "reject_position": + closeable = _decimal(condition.fields.get("available_closeable_quantity")) + requested = _decimal(condition.fields.get("requested_close_quantity")) + if ( + closeable is None + or requested is None + or closeable >= requested + or not str(condition.fields.get("instrument_id", "")).strip() + ): + return False + if rule == "reject_market": + tick = self._last(ObservationKind.MARKET_TICK) + return bool( + tick + and tick.fields.get("market_state") in {"closed", "halted", "non_trading"} + and condition.fields.get("source_event_id") + and condition.fields.get("market_state") == tick.fields.get("market_state") + ) + return True + + def _account_permission_proof(self) -> bool: + disabled, restored = ( + self._last(ObservationKind.ACCOUNT_PERMISSION_DISABLED), + self._last(ObservationKind.ACCOUNT_PERMISSION_RESTORED), + ) + if ( + not disabled + or not restored + or disabled.fields.get("account_id_masked") != restored.fields.get("account_id_masked") + ): + return False + submits = { + event.fields.get("order_ref") + for event in self._all(ObservationKind.ORDER_SUBMIT_RECEIPT) + } + rejected = any( + event.fields.get("order_ref") in submits + and event.fields.get("rejection_class") == "account_permission_denied" + and _integer(event.fields.get("error_id"), minimum=1) + for event in self._all(ObservationKind.ORDER_REJECTED) + ) + query = self._last(ObservationKind.ORDER_QUERY) + return bool( + rejected + and query + and query.fields.get("complete") is True + and not query.fields.get("open_order_refs") + ) + + def _force_logout_proof(self) -> bool: + auth, disconnect, blocked = ( + self._last(kind) + for kind in ( + ObservationKind.GATEWAY_LOGOUT_AUTHORIZED, + ObservationKind.FRONT_DISCONNECTED, + ObservationKind.POST_DISCONNECT_WRITE_BLOCKED, + ) + ) + return bool( + auth + and disconnect + and blocked + and auth.fields.get("gateway_key") + == disconnect.fields.get("gateway_key") + == blocked.fields.get("gateway_key") + and _integer(blocked.fields.get("blocked_attempt_count"), minimum=1) + ) + + def _batch_partials_ready(self) -> bool: + admission, query = ( + self._last(ObservationKind.ORDER_ADMISSION), + self._last(ObservationKind.ORDER_QUERY), + ) + if ( + not admission + or admission.fields.get("batch_cancel_supported") is not True + or not query + or query.fields.get("complete") is not True + ): + return False + partials = { + event.fields.get("order_ref"): event + for event in self._all(ObservationKind.ORDER_PARTIAL) + } + if len(partials) < 2: + return False + accepted_by_ref = { + event.fields.get("order_ref"): event + for event in self._all(ObservationKind.ORDER_ACCEPTED) + } + if any( + ref not in accepted_by_ref or accepted_by_ref[ref].sequence >= partial.sequence + for ref, partial in partials.items() + ): + return False + traded: dict[str, Decimal] = {} + for event in self._all(ObservationKind.TRADE_EXECUTION): + quantity = _decimal(event.fields.get("quantity")) + if quantity and quantity > 0: + ref = str(event.fields.get("order_ref")) + traded[ref] = traded.get(ref, Decimal(0)) + quantity + if any( + (_decimal(event.fields.get("traded_quantity")) or Decimal(0)) + > traded.get(str(ref), Decimal(0)) + for ref, event in partials.items() + ): + return False + provider_activity = ( + *self._all(ObservationKind.ORDER_PARTIAL), + *self._all(ObservationKind.TRADE_EXECUTION), + ) + if not provider_activity or query.sequence <= max( + event.sequence for event in provider_activity + ): + return False + return len(set(partials) & set(query.fields.get("open_order_refs", ()))) >= 2 + + def _batch_open_ready(self) -> bool: + admission, query = ( + self._last(ObservationKind.ORDER_ADMISSION), + self._last(ObservationKind.ORDER_QUERY), + ) + if ( + not admission + or admission.fields.get("batch_cancel_supported") is not True + or not query + or query.fields.get("complete") is not True + ): + return False + accepted_events = self._all(ObservationKind.ORDER_ACCEPTED) + if not accepted_events or query.sequence <= max( + event.sequence for event in accepted_events + ): + return False + accepted = {event.fields.get("order_ref") for event in accepted_events} + # A fill/cancel race is allowed; the latest complete native query is + # the source for which accepted orders are still open. + return len(accepted & set(query.fields.get("open_order_refs", ()))) >= 2 + + def _trade_correlated(self) -> bool: + accepted = { + event.fields.get("order_ref") for event in self._all(ObservationKind.ORDER_ACCEPTED) + } + return any( + event.fields.get("order_ref") in accepted + for event in self._all(ObservationKind.TRADE_EXECUTION) + ) + + def _external_satisfied(self, condition_id: str) -> bool: + return any( + event.fields.get("condition_id") == condition_id + and event.fields.get("state") == "satisfied" + for event in self._all(ObservationKind.EXTERNAL_CONDITION) + ) + + def _correlation_refs(self) -> tuple[str, ...]: + if self.plan.case_id == "TH04": + return tuple( + sorted( + { + str(event.fields.get("order_ref")) + for event in ( + *self._all(ObservationKind.ORDER_ACCEPTED), + *self._all(ObservationKind.ORDER_CANCELED), + ) + } + ) + ) + if self.spec.rule == "batch_partials": + return tuple( + sorted( + { + str(event.fields.get("order_ref")) + for event in self._all(ObservationKind.ORDER_PARTIAL) + } + ) + ) + if self.spec.rule == "batch_open": + query = self._last(ObservationKind.ORDER_QUERY) + accepted = { + str(event.fields.get("order_ref")) + for event in self._all(ObservationKind.ORDER_ACCEPTED) + } + return tuple( + sorted(accepted & set(query.fields.get("open_order_refs", ())) if query else ()) + ) + if self.spec.rule in {"cancel_order", "cancel_count", "repeat_cancel"}: + query = self._last(ObservationKind.ORDER_QUERY) + return tuple(sorted(query.fields.get("open_order_refs", ())) if query else ()) + if self.spec.rule.startswith("reject_"): + return tuple( + str(event.fields.get("order_ref")) + for event in self._all(ObservationKind.ORDER_REJECTED) + ) + return () + + +def _validate_values(event: NativeObservation) -> None: + fields = event.fields + if event.kind in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + }: + if fields.get("success") is not True or _integer(fields.get("error_id")) != 0: + raise DecisionError("provider acknowledgement is not successful") + if event.kind is ObservationKind.MARKET_TICK: + if any(_decimal(fields.get(key)) is None for key in ("bid", "ask", "last", "price_tick")): + raise DecisionError("tick numeric fields must be finite decimals") + if event.kind in { + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_FILLED, + }: + allowed_statuses = { + ObservationKind.ORDER_ACCEPTED: {"accepted", "working"}, + ObservationKind.ORDER_PARTIAL: {"partial"}, + ObservationKind.ORDER_CANCELED: {"canceled", "cancelled"}, + ObservationKind.ORDER_FILLED: {"filled"}, + } + if fields.get("status") not in allowed_statuses[event.kind]: + raise DecisionError("provider order status does not match typed observation kind") + remaining, traded = ( + _decimal(fields.get("remaining_quantity")), + _decimal(fields.get("traded_quantity", 0)), + ) + if remaining is None or traded is None or remaining < 0 or traded < 0: + raise DecisionError("provider order quantities must be finite and non-negative") + if event.kind is ObservationKind.ORDER_ACCEPTED and remaining <= 0: + raise DecisionError("accepted order must remain working") + if event.kind is ObservationKind.ORDER_PARTIAL and (remaining <= 0 or traded <= 0): + raise DecisionError("partial order needs positive traded and remaining quantity") + if ( + event.kind in {ObservationKind.ORDER_CANCELED, ObservationKind.ORDER_FILLED} + and remaining != 0 + ): + raise DecisionError("terminal order must have zero remaining quantity") + if event.kind is ObservationKind.TRADE_EXECUTION and not _positive(fields.get("quantity")): + raise DecisionError("trade quantity must be positive") + if ( + event.kind is ObservationKind.ORDER_REJECTED + and _integer(fields.get("error_id"), minimum=1) is None + ): + raise DecisionError("native rejection requires positive provider error id") + if event.kind is ObservationKind.ORDER_QUERY: + refs = fields.get("open_order_refs") + if ( + fields.get("complete") is not True + or not isinstance(refs, (list, tuple, set)) + or any(not isinstance(ref, str) or not ref for ref in refs) + ): + raise DecisionError("order query must be complete and contain valid open refs") + if event.kind is ObservationKind.POSITION_QUERY: + quantity = _decimal(fields.get("closeable_quantity")) + if ( + fields.get("complete") is not True + or fields.get("phase") not in {"baseline", "final"} + or quantity is None + or quantity < 0 + ): + raise DecisionError("position query must be complete, phase-tagged, and non-negative") + if event.kind is ObservationKind.EXTERNAL_CONDITION: + if fields.get("state") not in {"satisfied", "unavailable"}: + raise DecisionError("external condition state must be satisfied/unavailable") + if fields.get("state") == "unavailable" and not str(fields.get("reason", "")).strip(): + raise DecisionError("unavailable condition requires sourced reason") + if event.kind is ObservationKind.ORDER_ADMISSION: + if ( + fields.get("approval_state") != "REVIEW_ONLY" + or fields.get("dispatch_permitted") is not False + ): + raise DecisionError("order admission must be review-only with dispatch disabled") + if ( + event.kind is ObservationKind.DISPATCH_ABSENCE + and _integer(fields.get("dispatch_count")) != 0 + ): + raise DecisionError("dispatch absence must prove zero dispatches") + + +def _missing(value: Any) -> bool: + return value is None or value == "" or value == [] or value == () + + +def _integer(value: Any, *, minimum: int | None = None) -> int | None: + if isinstance(value, bool): + return None + try: + result = int(value) + except (TypeError, ValueError, OverflowError): + return None + if isinstance(value, float) and not value.is_integer(): + return None + if minimum is not None and result < minimum: + return None + return result + + +def _decimal(value: Any) -> Decimal | None: + try: + result = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + return result if result.is_finite() else None + + +def _positive(value: Any) -> bool: + result = _decimal(value) + return result is not None and result > 0 + + +def _parse_utc(value: str) -> datetime | None: + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if result.tzinfo is None or result.utcoffset() != timedelta(0): + return None + return result.astimezone(timezone.utc) diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/evidence.py b/examples/007_ctp/live_certification/simnow_penetration/common/evidence.py index 1eac1a70..24fc2656 100644 --- a/examples/007_ctp/live_certification/simnow_penetration/common/evidence.py +++ b/examples/007_ctp/live_certification/simnow_penetration/common/evidence.py @@ -2,7 +2,6 @@ from __future__ import annotations import json -import re from dataclasses import asdict, is_dataclass from datetime import datetime from pathlib import Path @@ -12,7 +11,6 @@ get_certification_scenario, get_reconciliation_expectation, ) -from common.result import _collect_evidence_field_names, _collect_observed_events SNAPSHOT_FILE = "state_snapshots.json" @@ -75,7 +73,7 @@ def capture_store_snapshot( positions, positions_error = _safe_call("positions", store.get_positions) open_orders, open_orders_error = _safe_call( "open_orders", - getattr(store, "get_open_orders", lambda: []), + getattr(store, "get_open_orders", list), ) snapshot = { "case_id": case_id, @@ -126,292 +124,18 @@ def _collect_log_events(report_dir: Path) -> list[dict[str, Any]]: return events -def _field_names(value: Any) -> set[str]: - fields: set[str] = set() - if isinstance(value, dict): - for key, item in value.items(): - fields.add(str(key)) - fields.update(_field_names(item)) - elif isinstance(value, (list, tuple, set)): - for item in value: - fields.update(_field_names(item)) - return fields - - -def _put_value(values: dict[str, Any], key: str, value: Any) -> None: - if key in values: - return - if value in (None, ""): - return - values[key] = _jsonable(value) - - -def _first_value(*values: Any) -> Any: - for value in values: - if value not in (None, ""): - return value - return None - - -def _event_details(event: dict[str, Any]) -> dict[str, Any]: - details = event.get("details") - return dict(details) if isinstance(details, dict) else {} - - -def _is_remote_counter_order_reject(event: dict[str, Any]) -> bool: - """Return whether an order rejection carries counter-side CTP evidence.""" - - event_type = str(event.get("event_type") or "") - if event_type == "order_reject_remote": - return True - if event_type != "order_rejected": - return False - - details = _event_details(event) - error_code = str( - _first_value(event.get("error_code"), details.get("ErrorID"), details.get("ErrorId")) - or "" - ).strip() - error_msg = str( - _first_value(event.get("error_msg"), details.get("ErrorMsg"), details.get("StatusMsg")) - or "" - ) - provider = str(event.get("provider") or details.get("provider") or "").lower() - - if error_code.isdigit(): - return True - return provider == "ctp" and ("CTP:" in error_msg or "CTP" in error_msg) - - -def _derive_order_status_event(event: dict[str, Any]) -> str: - status = str(event.get("status") or "").strip().lower() - if status in {"accepted"}: - return "order_status_accepted" - if status in {"canceled", "cancelled"}: - return "order_status_canceled" - if status in {"partial", "partialfilled", "partial_filled"}: - return "order_status_partial" - if status in {"completed", "filled"}: - return "order_status_completed" - return "" - - -def _event_aliases(event: dict[str, Any]) -> set[str]: - event_type = str(event.get("event_type") or "") - aliases = set() - if event_type == "session_stopped": - aliases.add("store_disconnected") - if _is_remote_counter_order_reject(event): - aliases.add("order_reject_remote") - if event_type == "order_submit_accepted": - aliases.add("order_status_accepted") - order_log_row = not event_type and any( - key in event for key in ("ref", "order_type", "external_order_id") - ) - if event_type.startswith("order_") or order_log_row: - status_event = _derive_order_status_event(event) - if status_event: - aliases.add(status_event) - if not event_type and ("trade_id" in event or "tradeid" in event) and event.get("status") in { - "Completed", - "completed", - "Filled", - "filled", - }: - aliases.add("trade_execution") - return aliases - - -def _parse_numeric(pattern: str, text: str) -> float | None: - match = re.search(pattern, text) - if not match: - return None - try: - value = float(match.group(1)) - except ValueError: - return None - return int(value) if value.is_integer() else value - - -def _derive_threshold_values(details: dict[str, Any], values: dict[str, Any]) -> None: - thresholds = details.get("thresholds") - if isinstance(thresholds, dict): - if "submit_count" in thresholds: - _put_value(values, "order_threshold", thresholds.get("submit_count")) - if "cancel_count" in thresholds: - _put_value(values, "cancel_threshold", thresholds.get("cancel_count")) - if "submit_cancel_total" in thresholds: - _put_value(values, "cancel_threshold", thresholds.get("submit_cancel_total")) - if "duplicate_order" in thresholds: - _put_value(values, "repeat_threshold", thresholds.get("duplicate_order")) - - counter = str(details.get("counter") or "") - threshold = details.get("threshold") - value = details.get("value") - if counter == "submit_count": - _put_value(values, "order_threshold", threshold) - _put_value(values, "submitted_order_count", value) - elif counter in {"cancel_count", "submit_cancel_total"}: - _put_value(values, "cancel_threshold", threshold) - _put_value(values, "cancel_order_count", value) - elif counter == "duplicate_order": - _put_value(values, "repeat_threshold", threshold) - _put_value(values, "repeat_count", value) - - _put_value(values, "repeat_window_sec", details.get("repeat_window_sec")) - - def _derive_runtime_evidence( result: Any, events: list[dict[str, Any]], snapshots: list[dict[str, Any]], ) -> dict[str, Any]: - observed_events = set(getattr(result, "observed_events", []) or []) - observed_events.update(_collect_observed_events(getattr(result, "details", {}) or {})) - field_names = set(_collect_evidence_field_names(getattr(result, "details", {}) or {})) - values: dict[str, Any] = {"trace_id": getattr(result, "trace_id", "")} - order_refs: list[Any] = [] - cancel_refs: list[Any] = [] - submit_count = 0 - cancel_count = 0 - - for snapshot in snapshots: - field_names.update(_field_names(snapshot)) - _put_value(values, "account_id_masked", snapshot.get("account_id_masked")) - _put_value(values, "gateway_key", snapshot.get("env")) - - for event in events: - event_type = str(event.get("event_type") or "") - details = _event_details(event) - if event_type: - observed_events.add(event_type) - observed_events.update(_event_aliases(event)) - field_names.update(_field_names(event)) - - timestamp = _first_value(event.get("event_time"), event.get("log_time"), event.get("timestamp")) - _put_value(values, "timestamp", timestamp) - _put_value(values, "gateway_key", _first_value(event.get("gateway_key"), event.get("provider"))) - _put_value(values, "account_id_masked", event.get("account_id_masked")) - _put_value(values, "strategy_id", event.get("strategy_name")) - _put_value(values, "reason", details.get("reason")) - _put_value(values, "metric", details.get("metric")) - - order_ref = _first_value( - event.get("order_ref"), - event.get("ref"), - details.get("order_ref"), - details.get("bt_order_ref"), - details.get("OrderRef"), - ) - if order_ref not in (None, ""): - order_refs.append(order_ref) - if event_type.startswith("order_cancel"): - cancel_refs.append(order_ref) - _put_value(values, "order_ref", order_ref) - _put_value( - values, - "external_order_id", - _first_value( - event.get("external_order_id"), - details.get("external_order_id"), - details.get("OrderSysID"), - ), - ) - _put_value(values, "trade_id", _first_value(event.get("trade_id"), details.get("trade_id"))) - _put_value( - values, - "instrument", - _first_value( - event.get("data_name"), - event.get("symbol"), - details.get("data_name"), - details.get("symbol"), - details.get("InstrumentID"), - ), - ) - _put_value(values, "price", _first_value(event.get("price"), details.get("price"))) - _put_value(values, "size", _first_value(event.get("size"), details.get("size"))) - error_id = _first_value( - details.get("ErrorID"), - details.get("ErrorId"), - event.get("error_code"), - ) - error_msg_value = _first_value( - details.get("ErrorMsg"), - event.get("error_msg"), - ) - status_msg = _first_value( - details.get("StatusMsg"), - event.get("status_msg"), - event.get("status_message"), - error_msg_value if _is_remote_counter_order_reject(event) else None, - ) - _put_value(values, "error_msg", error_msg_value) - _put_value(values, "error_code", error_id) - _put_value(values, "ErrorID", error_id) - _put_value(values, "ErrorMsg", error_msg_value) - _put_value(values, "StatusMsg", status_msg) - - if event_type == "order_submit_request": - submit_count += 1 - elif event_type.startswith("order_cancel"): - cancel_count += 1 - if event_type == "market_data_subscribe_request": - _put_value(values, "market_connection", True) - if event_type in {"store_login_success", "store_ready", "store_connected"}: - _put_value(values, "trade_connection", True) - - if event_type == "monitoring_summary": - _put_value(values, "submitted_order_count", details.get("submit_count")) - _put_value(values, "cancel_order_count", details.get("cancel_count")) - _put_value(values, "open_order_count", details.get("open_order_count")) - if event_type in {"risk_threshold_configured", "risk_threshold_triggered"}: - _derive_threshold_values(details, values) - if event_type in {"risk_repeat_order_detected", "risk_repeat_cancel_detected"}: - _put_value(values, "repeat_key", details.get("repeat_key")) - _put_value(values, "repeat_count", details.get("repeat_count")) - if event_type == "batch_cancel_requested": - requested = details.get("orders") - if isinstance(requested, list): - refs = [ - item.get("external_order_id") or item.get("order_ref") - for item in requested - if isinstance(item, dict) - ] - refs = [ref for ref in refs if ref not in (None, "")] - _put_value(values, "order_refs", refs) - _put_value(values, "open_order_count", len(refs)) - - error_msg = str(_first_value(event.get("error_msg"), details.get("error_msg")) or "") - if "tick size" in error_msg: - _put_value(values, "price_tick", _parse_numeric(r"tick size ([0-9.]+)", error_msg)) - if "max allowed size" in error_msg: - _put_value(values, "max_order_size", _parse_numeric(r"max allowed size ([0-9.]+)", error_msg)) - - if "market_connection" not in values and any( - event in observed_events for event in ("data_status", "tick") - ): - values["market_connection"] = True - if "trade_connection" not in values and any( - event in observed_events for event in ("store_login_success", "store_ready") - ): - values["trade_connection"] = True - _put_value(values, "submitted_order_count", submit_count) - _put_value(values, "cancel_order_count", cancel_count) - if "order_refs" not in values and order_refs: - values["order_refs"] = [_jsonable(ref) for ref in order_refs] - if "open_order_count" not in values and order_refs: - values["open_order_count"] = len(order_refs) - if "partial_count" not in values: - partial_count = sum(1 for event in observed_events if event == "order_status_partial") - values["partial_count"] = partial_count - - field_names.update(values.keys()) - return { - "observed_events": sorted(event for event in observed_events if event), - "field_names": field_names, - "values": values, - } + """Return no certification claims from the unauthenticated legacy path. + + JSONL rows and result details are caller-forgeable. Reconciliation still + reads logs for diagnostics, but no provider/runtime/validator/control-plane + adapter is wired into this path to authenticate required events or fields. + """ + return {"observed_events": [], "field_names": set(), "values": {}} def _missing_evidence_reason(missing_events: list[str], missing_fields: list[str]) -> str: @@ -559,8 +283,9 @@ def build_reconciliation(result: Any, report_dir: str | Path) -> dict[str, Any]: events = _collect_log_events(report_dir) event_types = [str(event.get("event_type") or "") for event in events] details = getattr(result, "details", {}) or {} - observed_events = list(getattr(result, "observed_events", []) or []) - all_event_types = event_types + observed_events + # Reconciliation activity must come from persisted event rows, never the + # result's locally assembled observed-event list. + all_event_types = event_types order_events = [ event for event in all_event_types if _is_real_order_activity_event(event) @@ -580,8 +305,6 @@ def build_reconciliation(result: Any, report_dir: str | Path) -> dict[str, Any]: expectation = get_reconciliation_expectation(case_id) order_seen = bool(order_events) trade_seen = bool(trade_events) - account_or_position_changed = bool(balance_changed) or bool(positions_changed) - checks = { "required_events": { "expected": list(getattr(result, "required_events", []) or []), @@ -593,7 +316,16 @@ def build_reconciliation(result: Any, report_dir: str | Path) -> dict[str, Any]: "post_action_open_orders": { "expected": "none" if expectation.get("no_open_orders_after") else "not_specified", "observed_count": len(post_open_orders or []), - "passed": (len(post_open_orders or []) == 0) if expectation.get("no_open_orders_after") else True, + "passed": ( + len(snapshots) >= 2 + and isinstance(after, dict) + and isinstance(after.get("open_orders"), list) + and not any( + str(error).startswith("open_orders:") + for error in (after.get("errors") or []) + ) + and len(post_open_orders or []) == 0 + ) if expectation.get("no_open_orders_after") else True, }, } if expectation.get("account_position_change") == "none": @@ -604,12 +336,15 @@ def build_reconciliation(result: Any, report_dir: str | Path) -> dict[str, Any]: "passed": balance_changed is False and positions_changed is False, } elif expectation.get("account_position_change") == "allowed_if_trade": + unchanged_without_trade = ( + balance_changed is False and positions_changed is False + ) checks["account_position_change"] = { "expected": "allowed_if_trade", "balance_changed": balance_changed, "positions_changed": positions_changed, "trade_events": len(trade_events), - "passed": True, + "passed": trade_seen or unchanged_without_trade, } strict_pass = all(item.get("passed") is True for item in checks.values()) diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/read_only_case_strategy.py b/examples/007_ctp/live_certification/simnow_penetration/common/read_only_case_strategy.py new file mode 100644 index 00000000..a9c8e9a6 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/common/read_only_case_strategy.py @@ -0,0 +1,2369 @@ +"""Read-only typed strategies for a first set of 007 certification cases. + +The strategies consume normalized ``NativeObservation`` envelopes only. They +perform no provider I/O, configuration reads, file writes, order dispatch, or +session shutdown. A future managed adapter must supply an accepted +``ObservationAuthenticator``. Even complete evidence returns REVIEW_REQUIRED; +the source-authentication port and test authenticators do not establish real +provider acceptance or grant runtime authority. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from enum import Enum +from hashlib import sha256 +import json +from types import MappingProxyType +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from .case_engine import ( + CertificationEvidence, + DescriptiveCasePlan, + EvidenceSource, + validate_issued_request_receipt, +) +from .certification import SCENARIOS_BY_CASE_ID +from .completion_invariants import ( + AccountReconciliationSnapshot, + CompletionEvidence, + CompletionEvidenceError, + DispatchState, + ManagedOrderRequest, + NativeOrderFact, + NativeOrderFactKind, + NativeTradeFact, + RequestAction, + SnapshotPhase, + evaluate_case_completion, +) +from .decision_engine import ( + AuthenticationReceipt, + DecisionError, + DecisionScope, + EvidenceTrustDomain, + NativeObservation, + ObservationAuthenticator, + ObservationKind, + validate_payloadless_callback_fields, +) + +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_MAX_CANDIDATE_EVIDENCE_AGE = timedelta(minutes=5) +_MAX_CANDIDATE_FUTURE_SKEW = timedelta(seconds=30) + + +class ReadOnlyStrategyError(ValueError): + """Raised when an envelope does not match a selected case contract.""" + + +class ReadOnlyState(str, Enum): + BLOCKED = "BLOCKED" + INCOMPLETE = "INCOMPLETE" + EXTERNAL_CONDITION_UNAVAILABLE = "EXTERNAL_CONDITION_UNAVAILABLE" + REVIEW_REQUIRED = "REVIEW_REQUIRED" + + +@dataclass(frozen=True) +class ReadOnlyCaseSpec: + case_id: str + required_kinds: Tuple[ObservationKind, ...] + requires_store_connected: bool = False + requires_store_ready: bool = False + requires_start_log: bool = False + requires_process_identity: bool = False + expected_metric: str = "" + expected_threshold: int = 0 + requires_repeat_window: bool = False + order_profile: str = "" + + +@dataclass(frozen=True) +class ReadOnlyCaseResult: + case_id: str + scenario_id: str + state: ReadOnlyState + certification_pass: bool + dispatch_permitted: bool + source_authenticity_verified: bool + evidence_event_ids: Tuple[str, ...] + missing_conditions: Tuple[str, ...] + rejected_events: Tuple[str, ...] + unavailable_conditions: Tuple[str, ...] = () + + +_ORDER_FLOW_KINDS = ( + ObservationKind.FRONT_CONNECTED, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ObservationKind.ORDER_SUBMIT_RECEIPT, + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_FILLED, + ObservationKind.ORDER_REJECTED, + ObservationKind.TRADE_EXECUTION, + ObservationKind.ORDER_QUERY, + ObservationKind.POSITION_QUERY, + ObservationKind.EXTERNAL_CONDITION, + ObservationKind.SYSTEM_LOG, +) + + +READ_ONLY_CASE_SPECS: Dict[str, ReadOnlyCaseSpec] = { + "C01": ReadOnlyCaseSpec( + "C01", + ( + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.SYSTEM_LOG, + ), + ), + "M01": ReadOnlyCaseSpec( + "M01", + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.SYSTEM_LOG, + ), + requires_store_connected=True, + requires_store_ready=True, + ), + "L02": ReadOnlyCaseSpec( + "L02", + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.SYSTEM_LOG, + ), + requires_store_connected=True, + requires_store_ready=True, + requires_start_log=True, + requires_process_identity=True, + ), + "TH01": ReadOnlyCaseSpec( + "TH01", + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.MONITOR_CONFIGURATION, + ObservationKind.MONITOR_LOG, + ObservationKind.SYSTEM_LOG, + ), + requires_store_connected=True, + requires_store_ready=True, + requires_start_log=True, + expected_metric="submit_count", + expected_threshold=5, + ), + "TH03": ReadOnlyCaseSpec( + "TH03", + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.MONITOR_CONFIGURATION, + ObservationKind.MONITOR_LOG, + ObservationKind.SYSTEM_LOG, + ), + requires_store_connected=True, + requires_store_ready=True, + requires_start_log=True, + expected_metric="submit_cancel_total", + expected_threshold=10, + ), + "TH05": ReadOnlyCaseSpec( + "TH05", + ( + ObservationKind.LOGIN_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.MONITOR_CONFIGURATION, + ObservationKind.MONITOR_LOG, + ObservationKind.SYSTEM_LOG, + ), + requires_store_connected=True, + requires_store_ready=True, + requires_start_log=True, + expected_metric="duplicate_order", + expected_threshold=3, + requires_repeat_window=True, + ), + "T01": ReadOnlyCaseSpec("T01", _ORDER_FLOW_KINDS, order_profile="open_cancel"), + "T02": ReadOnlyCaseSpec("T02", _ORDER_FLOW_KINDS, order_profile="close_position"), + "T03": ReadOnlyCaseSpec("T03", _ORDER_FLOW_KINDS, order_profile="cancel"), + "B01": ReadOnlyCaseSpec("B01", _ORDER_FLOW_KINDS, order_profile="batch_partials"), + "B02": ReadOnlyCaseSpec("B02", _ORDER_FLOW_KINDS, order_profile="batch_open"), + "L01": ReadOnlyCaseSpec("L01", _ORDER_FLOW_KINDS, order_profile="trade_log"), +} + +_SOURCE_POLICY = { + ObservationKind.AUTH_SUCCESS: (EvidenceTrustDomain.CTP_CALLBACK, "OnRspAuthenticate"), + ObservationKind.LOGIN_SUCCESS: (EvidenceTrustDomain.CTP_CALLBACK, "OnRspUserLogin"), + ObservationKind.FRONT_CONNECTED: (EvidenceTrustDomain.CTP_CALLBACK, "OnFrontConnected"), + ObservationKind.MARKET_SUBSCRIPTION_ACK: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRspSubMarketData", + ), + ObservationKind.MARKET_TICK: ( + EvidenceTrustDomain.CTP_CALLBACK, + "OnRtnDepthMarketData", + ), + ObservationKind.ORDER_ADMISSION: (EvidenceTrustDomain.MANAGED_RUNTIME, ""), + ObservationKind.ORDER_SUBMIT_RECEIPT: (EvidenceTrustDomain.MANAGED_RUNTIME, ""), + ObservationKind.ORDER_ACCEPTED: (EvidenceTrustDomain.CTP_CALLBACK, "OnRtnOrder"), + ObservationKind.ORDER_PARTIAL: (EvidenceTrustDomain.CTP_CALLBACK, "OnRtnOrder"), + ObservationKind.ORDER_CANCELED: (EvidenceTrustDomain.CTP_CALLBACK, "OnRtnOrder"), + ObservationKind.ORDER_FILLED: (EvidenceTrustDomain.CTP_CALLBACK, "OnRtnOrder"), + ObservationKind.ORDER_REJECTED: (EvidenceTrustDomain.CTP_CALLBACK, "OnRspOrderInsert"), + ObservationKind.TRADE_EXECUTION: (EvidenceTrustDomain.CTP_CALLBACK, "OnRtnTrade"), + ObservationKind.ORDER_QUERY: (EvidenceTrustDomain.CTP_CALLBACK, "OnRspQryOrder"), + ObservationKind.POSITION_QUERY: (EvidenceTrustDomain.CTP_CALLBACK, ""), + ObservationKind.EXTERNAL_CONDITION: (EvidenceTrustDomain.CONTROL_PLANE, ""), + ObservationKind.SYSTEM_LOG: (EvidenceTrustDomain.MANAGED_RUNTIME, ""), + ObservationKind.MONITOR_CONFIGURATION: (EvidenceTrustDomain.MONITOR, ""), + ObservationKind.MONITOR_LOG: (EvidenceTrustDomain.MONITOR, ""), +} + + +class ReadOnlyCaseStrategy: + """Collect source-verified evidence and evaluate one bounded read-only case.""" + + case_id = "" + + def __init__( + self, + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: Optional[ObservationAuthenticator], + ) -> None: + spec = READ_ONLY_CASE_SPECS.get(self.case_id) + if spec is None or plan.case_id != self.case_id: + raise ReadOnlyStrategyError("strategy case id does not match its static plan") + scope.validate(plan) + self.plan = plan + self.scope = scope + self.spec = spec + self._authenticator = authenticator + # C01 cannot be promoted by a caller-provided verifier. No code-owned + # issuer-ledger verifier is installed in this candidate. + self._c01_unverified_receipt = self.case_id == "C01" + self._c01_receipt_ids = set() + self._c01_request_ids = set() + self._events: List[NativeObservation] = [] + self._event_ids = set() + self._stream_state: Dict[ + Tuple[EvidenceTrustDomain, str, str, str], Tuple[int, datetime] + ] = {} + self._provider_scope: Optional[Tuple[str, str]] = None + self._rejected: List[str] = [] + self._unavailable_conditions: List[str] = [] + + def on_envelope(self, event: NativeObservation) -> bool: + """Consume one normalized source envelope; never execute its contents.""" + + if not isinstance(event, NativeObservation): + raise ReadOnlyStrategyError("future adapter must provide a NativeObservation envelope") + if not isinstance(event.fields, Mapping): + raise ReadOnlyStrategyError("observation fields must be a mapping") + field_snapshot = dict(event.fields) + if any( + not isinstance(name, str) + or not isinstance(value, (str, bool, int, float, Decimal, type(None))) + for name, value in field_snapshot.items() + ): + raise ReadOnlyStrategyError("observation fields must contain only scalar facts") + event = NativeObservation( + kind=event.kind, + source_domain=event.source_domain, + event_id=event.event_id, + evidence_sha256=event.evidence_sha256, + occurred_at_utc=event.occurred_at_utc, + sequence=event.sequence, + stream_id=event.stream_id, + callback_name=event.callback_name, + provider_session_id=event.provider_session_id, + trading_day=event.trading_day, + fields=MappingProxyType(field_snapshot), + provider_front_id=event.provider_front_id, + client_instance_id=event.client_instance_id, + request_generation=event.request_generation, + request_id_origin=event.request_id_origin, + request_generation_origin=event.request_generation_origin, + arrival_generation=event.arrival_generation, + session_identity_origin=event.session_identity_origin, + arrived_at_utc=event.arrived_at_utc, + arrived_monotonic=event.arrived_monotonic, + sequence_origin=event.sequence_origin, + timestamp_origin=event.timestamp_origin, + event_id_origin=event.event_id_origin, + provider_issued_event_id=event.provider_issued_event_id, + connection_generation_origin=event.connection_generation_origin, + issued_request_receipt=event.issued_request_receipt, + ) + self._validate_event(event) + if event.event_id in self._event_ids: + raise ReadOnlyStrategyError("duplicate source event id") + if self._authenticator is None: + self._rejected.append(f"{event.event_id}:trusted_authenticator_unavailable") + return False + receipt = self._authenticator.authenticate(event, self.scope) + if not self._receipt_matches(receipt, event, self.scope): + self._rejected.append(f"{event.event_id}:source_verification_denied") + return False + if self.case_id == "C01" and self._is_c01_request_callback(event): + issued = event.issued_request_receipt + if issued is not None: + if issued.receipt_id in self._c01_receipt_ids: + raise ReadOnlyStrategyError("C01 request receipt cannot be replayed") + native_id = event.fields.get("request_id") + if native_id in self._c01_request_ids: + raise ReadOnlyStrategyError("C01 native RequestID cannot be reused") + self._c01_receipt_ids.add(issued.receipt_id) + self._c01_request_ids.add(native_id) + + occurred_at = _parse_utc(event.occurred_at_utc) + key = ( + event.source_domain, + event.stream_id, + event.provider_session_id, + event.trading_day, + ) + previous = self._stream_state.get(key) + if previous and (event.sequence <= previous[0] or occurred_at < previous[1]): + raise ReadOnlyStrategyError("source sequence and time must increase per stream") + sessionless_callback = event.kind in { + ObservationKind.AUTH_SUCCESS, ObservationKind.FRONT_CONNECTED + } + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK and not sessionless_callback: + observed_scope = (event.provider_session_id, event.trading_day) + if self._provider_scope is not None and observed_scope != self._provider_scope: + raise ReadOnlyStrategyError( + "case evidence cannot mix provider sessions or trading days" + ) + self._provider_scope = observed_scope + self._stream_state[key] = (event.sequence, occurred_at) + self._event_ids.add(event.event_id) + self._events.append(event) + return True + + def evaluate(self, *, now_utc: Optional[datetime] = None) -> ReadOnlyCaseResult: + """Return a review state against an explicitly injectable UTC clock. + + The five-minute order-evidence window is a conservative candidate + policy, not an acceptance time source. It compares evidence to + ``now_utc`` supplied by the managed caller (or the system clock when + omitted); event timestamps never define the current time. + """ + + evaluation_time = now_utc or datetime.now(timezone.utc) + if ( + not isinstance(evaluation_time, datetime) + or evaluation_time.tzinfo is None + or evaluation_time.utcoffset() != timedelta(0) + ): + raise ReadOnlyStrategyError("evaluation clock must be an aware UTC datetime") + evaluation_time = evaluation_time.astimezone(timezone.utc) + scenario = SCENARIOS_BY_CASE_ID[self.case_id] + missing = self._missing_conditions(now_utc=evaluation_time) + if self._authenticator is None or not self._events: + state = ReadOnlyState.BLOCKED + elif self._rejected or missing: + state = ReadOnlyState.INCOMPLETE + elif self._unavailable_conditions: + state = ReadOnlyState.EXTERNAL_CONDITION_UNAVAILABLE + else: + state = ReadOnlyState.REVIEW_REQUIRED + return ReadOnlyCaseResult( + case_id=self.case_id, + scenario_id=scenario.scenario_id, + state=state, + certification_pass=False, + dispatch_permitted=False, + source_authenticity_verified=False, + evidence_event_ids=tuple(event.event_id for event in self._events), + missing_conditions=tuple(missing), + rejected_events=tuple(self._rejected), + unavailable_conditions=tuple(self._unavailable_conditions), + ) + + def _validate_event(self, event: NativeObservation) -> None: + if not isinstance(event.kind, ObservationKind): + raise ReadOnlyStrategyError("observation kind must use the typed enum") + if event.kind in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.FRONT_CONNECTED, + ObservationKind.FRONT_DISCONNECTED, + }: + try: + validate_payloadless_callback_fields( + event, + scope_account_identity_sha256=self.scope.account_identity_sha256, + ) + except DecisionError as exc: + raise ReadOnlyStrategyError(str(exc)) from exc + if event.kind is ObservationKind.AUTH_SUCCESS and ( + event.provider_front_id is not None + or event.provider_session_id + or event.trading_day + or any( + event.fields.get(name) not in (None, "") + for name in ( + "provider_front_id", "provider_session_id", "trading_day", + "provider_timestamp_utc", "provider_sequence", "provider_event_id", + "source_sequence", "source_timestamp_utc" + ) + ) + ): + raise ReadOnlyStrategyError( + "OnRspAuthenticate cannot claim provider front/session/day/time/sequence" + ) + c01_account_query = self.case_id == "C01" and event.kind in { + ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY + } + if event.kind not in self.spec.required_kinds and not c01_account_query: + raise ReadOnlyStrategyError( + f"{event.kind.value} is outside this case's read-only contract" + ) + expected_domain, expected_callback = _SOURCE_POLICY[event.kind] + if event.kind is ObservationKind.POSITION_QUERY: + query_family = event.fields.get("query_family") + expected_callback = { + "positions": "OnRspQryInvestorPosition", + "funds": "OnRspQryTradingAccount", + }.get(query_family, "") + if event.source_domain is not expected_domain or event.callback_name != expected_callback: + raise ReadOnlyStrategyError( + "source domain or native callback does not match event kind" + ) + if ( + not isinstance(event.event_id, str) + or not event.event_id + or not isinstance(event.stream_id, str) + or not event.stream_id + or not isinstance(event.evidence_sha256, str) + or not _SHA256_RE.fullmatch(event.evidence_sha256) + ): + raise ReadOnlyStrategyError("event id, stream id, and SHA-256 digest are required") + if ( + not isinstance(event.sequence, int) + or isinstance(event.sequence, bool) + or event.sequence < 1 + ): + raise ReadOnlyStrategyError("source sequence must be a positive integer") + if _parse_utc(event.occurred_at_utc) is None: + raise ReadOnlyStrategyError("event timestamp must be UTC") + is_auth_callback = event.kind is ObservationKind.AUTH_SUCCESS + is_payloadless_front = event.kind is ObservationKind.FRONT_CONNECTED + if expected_domain is EvidenceTrustDomain.CTP_CALLBACK and not ( + is_auth_callback or is_payloadless_front + ): + if ( + not isinstance(event.provider_session_id, str) + or not event.provider_session_id + or not isinstance(event.trading_day, str) + or not event.trading_day + ): + raise ReadOnlyStrategyError( + "native callback requires provider session and trading day" + ) + elif expected_domain is not EvidenceTrustDomain.CTP_CALLBACK and event.callback_name: + raise ReadOnlyStrategyError("managed/monitor events must not claim a native callback") + if not isinstance(event.provider_session_id, str) or not isinstance(event.trading_day, str): + raise ReadOnlyStrategyError("session and trading-day identifiers must be strings") + + fields = event.fields + provider_identity_names = ("provider_front_id", "provider_session_id", "trading_day") + if event.kind is ObservationKind.AUTH_SUCCESS: + if ( + event.provider_front_id is not None + or event.provider_session_id + or event.trading_day + or any(fields.get(name) not in (None, "") for name in provider_identity_names) + or any( + fields.get(name) not in (None, "") + for name in ( + "provider_timestamp_utc", "provider_sequence", "provider_event_id", + "source_sequence", "source_timestamp_utc" + ) + ) + ): + raise ReadOnlyStrategyError( + "OnRspAuthenticate cannot claim provider front/session/day/time/sequence" + ) + if event.kind is ObservationKind.FRONT_CONNECTED and ( + event.provider_front_id is not None + or event.provider_session_id + or event.trading_day + or any(fields.get(name) not in (None, "") for name in provider_identity_names) + ): + raise ReadOnlyStrategyError( + "OnFrontConnected has no native provider FrontID, SessionID, or TradingDay" + ) + if event.kind is ObservationKind.FRONT_CONNECTED and ( + not event.client_instance_id + or type(event.arrival_generation) is not int + or event.arrival_generation < 1 + or fields.get("connection_generation") != event.arrival_generation + or event.connection_generation_origin != "local_connection_generation" + ): + raise ReadOnlyStrategyError( + "OnFrontConnected requires local client and connection-generation metadata" + ) + if event.kind in {ObservationKind.AUTH_SUCCESS, ObservationKind.LOGIN_SUCCESS}: + if fields.get("success") is not True or _integer(fields.get("error_id")) != 0: + raise ReadOnlyStrategyError("authentication/login callback is not successful") + self._validate_c01_auth_login_callback(event) + elif event.kind is ObservationKind.FRONT_CONNECTED: + if not fields.get("gateway_key"): + raise ReadOnlyStrategyError("front connection requires gateway identity") + elif event.kind is ObservationKind.MARKET_SUBSCRIPTION_ACK: + if fields.get("success") is not True or not fields.get("instrument_id"): + raise ReadOnlyStrategyError("market subscription is not provider-acknowledged") + elif event.kind is ObservationKind.MARKET_TICK: + tick_values = ( + _decimal(fields.get("bid")), + _decimal(fields.get("ask")), + _decimal(fields.get("last")), + _decimal(fields.get("price_tick")), + ) + if not fields.get("instrument_id") or any(value is None for value in tick_values): + raise ReadOnlyStrategyError("market tick lacks typed price and instrument evidence") + if tick_values[-1] <= 0: + raise ReadOnlyStrategyError("market tick price increment must be positive") + elif event.kind is ObservationKind.ORDER_ADMISSION: + maximum = _decimal(fields.get("maximum_quantity")) + expected_intent = { + "open_cancel": "open", + "close_position": "close", + "cancel": "open", + "batch_partials": "open", + "batch_open": "open", + "trade_log": "open", + }[self.spec.order_profile] + if ( + fields.get("case_id") != self.case_id + or fields.get("intent_kind") != expected_intent + or not fields.get("approval_ref") + or maximum is None + or maximum <= 0 + ): + raise ReadOnlyStrategyError("order admission is not a bounded case-specific review") + elif event.kind is ObservationKind.ORDER_SUBMIT_RECEIPT: + required = ( + "request_id", + "order_ref", + "dispatch_state", + "quantity", + "instrument_id", + "direction", + "offset", + "trace_id", + "invocation_id", + ) + quantity = _decimal(fields.get("quantity")) + if any(not fields.get(name) for name in required) or quantity is None or quantity <= 0: + raise ReadOnlyStrategyError("managed submit receipt lacks bounded order identity") + if fields.get("action") != "submit" or fields.get("dispatch_state") not in { + "dispatched", + "blocked_pre_dispatch", + }: + raise ReadOnlyStrategyError("managed submit receipt has an unknown dispatch state") + if fields.get("direction") not in {"buy", "sell"} or fields.get("offset") not in { + "open", + "close", + }: + raise ReadOnlyStrategyError("managed submit receipt has an unknown side/offset") + elif event.kind in { + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_FILLED, + ObservationKind.ORDER_REJECTED, + }: + self._validate_order_status(event) + elif event.kind is ObservationKind.TRADE_EXECUTION: + trade = event.fields + if ( + not trade.get("trade_id") + or not trade.get("order_ref") + or not trade.get("external_order_id") + or not trade.get("instrument_id") + or trade.get("direction") not in {"buy", "sell"} + or _decimal(trade.get("quantity")) is None + or _decimal(trade.get("quantity")) <= 0 + or _decimal(trade.get("price")) is None + or _decimal(trade.get("price")) <= 0 + ): + raise ReadOnlyStrategyError("native trade callback lacks typed execution facts") + elif event.kind is ObservationKind.ORDER_QUERY: + if fields.get("query_family") != "orders": + raise ReadOnlyStrategyError("order callback query family must be orders") + self._validate_query_event(event, "orders") + self._validate_c01_query_receipt(event) + elif event.kind is ObservationKind.POSITION_QUERY: + self._validate_query_event(event, str(fields.get("query_family", ""))) + self._validate_c01_query_receipt(event) + elif event.kind is ObservationKind.EXTERNAL_CONDITION: + required = ("condition_id", "state", "evidence_ref") + if any(not fields.get(name) for name in required): + raise ReadOnlyStrategyError("external condition receipt lacks identity") + if fields.get("state") not in {"satisfied", "unavailable"}: + raise ReadOnlyStrategyError( + "external condition state must be satisfied/unavailable" + ) + if fields.get("state") == "unavailable" and not fields.get("reason"): + raise ReadOnlyStrategyError("unavailable external condition requires a reason") + elif event.kind is ObservationKind.SYSTEM_LOG: + required = ("trace_id", "gateway_key", "log_digest", "event_name", "session_id") + if any(not fields.get(name) for name in required): + raise ReadOnlyStrategyError( + "system lifecycle log lacks required identity/evidence fields" + ) + if not _SHA256_RE.fullmatch(str(fields.get("log_digest"))): + raise ReadOnlyStrategyError("system log digest must be SHA-256") + if ( + self.spec.requires_process_identity + and fields.get("event_name") + in { + "session_started", + "session_stopped", + } + and not fields.get("process_id") + ): + raise ReadOnlyStrategyError("system lifecycle log lacks process identity") + if self.case_id == "C01" and fields.get("event_name") in { + "store_auth_success", + "store_login_success", + }: + local_fields = ( + "callback_event_id", + "callback_received_at_utc", + "client_instance_id", + "arrival_generation", + ) + if any(not fields.get(name) for name in local_fields): + raise ReadOnlyStrategyError( + "authentication system log lacks local callback correlation" + ) + if _parse_utc(str(fields.get("callback_received_at_utc"))) is None: + raise ReadOnlyStrategyError("local callback receive timestamp must be UTC") + if fields.get("provider_issued_event_id") is not False: + raise ReadOnlyStrategyError("callback event id must be marked local") + if fields.get("event_name") == "store_auth_success" and any( + fields.get(name) not in (None, "") + for name in ( + "provider_session_id", + "trading_day", + "provider_timestamp_utc", + "provider_front_id", + ) + ): + raise ReadOnlyStrategyError( + "authentication log claims unavailable native provider identity" + ) + if self.spec.order_profile and fields.get("event_name") == "order_cancel_request": + required_cancel = ( + "request_id", + "invocation_id", + "order_ref", + "dispatch_state", + ) + if any(not fields.get(name) for name in required_cancel): + raise ReadOnlyStrategyError("managed cancel receipt lacks request correlation") + if fields.get("dispatch_state") not in {"dispatched", "blocked_pre_dispatch"}: + raise ReadOnlyStrategyError( + "managed cancel receipt has an unknown dispatch state" + ) + if self.spec.order_profile and fields.get("event_name") == "batch_cancel_requested": + required_batch = ( + "request_id", + "invocation_id", + "order_refs_json", + "dispatch_state", + ) + if any(not fields.get(name) for name in required_batch): + raise ReadOnlyStrategyError("managed batch cancel receipt lacks correlation") + refs = _json_load(fields.get("order_refs_json"), "batch cancel order refs") + if not isinstance(refs, list) or any( + not isinstance(ref, str) or not ref for ref in refs + ): + raise ReadOnlyStrategyError("batch cancel receipt requires a typed ref list") + if fields.get("dispatch_state") not in {"dispatched", "blocked_pre_dispatch"}: + raise ReadOnlyStrategyError( + "managed batch cancel has an unknown dispatch state" + ) + elif event.kind is ObservationKind.MONITOR_CONFIGURATION: + required = ("metric", "threshold", "configuration_digest", "monitor_digest") + if any(fields.get(name) in (None, "") for name in required): + raise ReadOnlyStrategyError("monitor configuration lacks threshold provenance") + if not _SHA256_RE.fullmatch( + str(fields.get("configuration_digest")) + ) or not _SHA256_RE.fullmatch(str(fields.get("monitor_digest"))): + raise ReadOnlyStrategyError("monitor configuration digests must be SHA-256") + elif event.kind is ObservationKind.MONITOR_LOG: + required = ("trace_id", "metric", "monitor_digest", "event_name") + if any(not fields.get(name) for name in required): + raise ReadOnlyStrategyError("monitor log lacks event identity or digest") + if not _SHA256_RE.fullmatch(str(fields.get("monitor_digest"))): + raise ReadOnlyStrategyError("monitor log digest must be SHA-256") + + @staticmethod + def _validate_order_status(event: NativeObservation) -> None: + fields = event.fields + expected_status = { + ObservationKind.ORDER_ACCEPTED: {"accepted", "working"}, + ObservationKind.ORDER_PARTIAL: {"partial"}, + ObservationKind.ORDER_CANCELED: {"canceled", "cancelled"}, + ObservationKind.ORDER_FILLED: {"filled"}, + ObservationKind.ORDER_REJECTED: {"rejected"}, + }[event.kind] + if ( + not fields.get("order_ref") + or not fields.get("instrument_id") + or fields.get("status") not in expected_status + ): + raise ReadOnlyStrategyError( + "native order callback identity/status does not match its fact" + ) + if event.kind is not ObservationKind.ORDER_REJECTED and not fields.get("external_order_id"): + raise ReadOnlyStrategyError("native order callback requires exchange order identity") + traded = _decimal(fields.get("traded_quantity")) + remaining = _decimal(fields.get("remaining_quantity")) + if traded is None or remaining is None or traded < 0 or remaining < 0: + raise ReadOnlyStrategyError("native order quantities must be finite and non-negative") + if event.kind is ObservationKind.ORDER_ACCEPTED and remaining <= 0: + raise ReadOnlyStrategyError("accepted order must still have remaining quantity") + if event.kind is ObservationKind.ORDER_PARTIAL and (traded <= 0 or remaining <= 0): + raise ReadOnlyStrategyError("partial order must show traded and remaining quantity") + if ( + event.kind in {ObservationKind.ORDER_CANCELED, ObservationKind.ORDER_FILLED} + and remaining + ): + raise ReadOnlyStrategyError("terminal native order callback must have zero remainder") + if event.kind is ObservationKind.ORDER_REJECTED and _integer(fields.get("error_id")) in { + None, + 0, + }: + raise ReadOnlyStrategyError("provider rejection requires a nonzero ErrorID") + + def _validate_c01_auth_login_callback(self, event: NativeObservation) -> None: + """Validate native CTP callback facts separately from local arrival facts.""" + + if self.case_id != "C01": + return + fields = event.fields + is_auth = event.kind is ObservationKind.AUTH_SUCCESS + request_id = fields.get("request_id") + error_id = fields.get("error_id") + if type(request_id) is not int or request_id <= 0: + raise ReadOnlyStrategyError("CTP callback requires its native positive request_id") + if ( + type(fields.get("request_generation")) is not int + or fields.get("request_generation") != event.request_generation + or type(fields.get("arrival_generation")) is not int + or fields.get("arrival_generation") != event.arrival_generation + ): + raise ReadOnlyStrategyError("CTP callback local generation bindings do not match") + if type(error_id) is not int or error_id != 0 or fields.get("is_last") is not True: + raise ReadOnlyStrategyError("CTP callback ErrorID/IsLast facts are invalid") + if ( + not isinstance(event.client_instance_id, str) + or not event.client_instance_id + or type(event.request_generation) is not int + or event.request_generation < 1 + or event.request_id_origin != "native_callback_argument" + or event.request_generation_origin != "local_request_generation_binding" + or type(event.arrival_generation) is not int + or event.arrival_generation < 1 + or event.sequence_origin != "local_sdk_callback_arrival" + or event.timestamp_origin != "local_sdk_capture_clock" + or event.event_id_origin != "local_sdk_callback_arrival" + or event.provider_issued_event_id is not False + or type(event.sequence) is not int + or event.sequence < 1 + or _parse_utc(event.arrived_at_utc) is None + or _parse_utc(event.arrived_at_utc) != _parse_utc(event.occurred_at_utc) + or isinstance(event.arrived_monotonic, bool) + or not isinstance(event.arrived_monotonic, (int, float)) + or event.arrived_monotonic <= 0 + ): + raise ReadOnlyStrategyError("CTP callback lacks correctly attributed local arrival metadata") + receipt = event.issued_request_receipt + if receipt is not None and not validate_issued_request_receipt( + receipt, + request_kind="authenticate" if is_auth else "login", + phase="", + request_id=request_id, + request_generation=event.request_generation, + client_instance_id=event.client_instance_id, + arrival_generation=event.arrival_generation, + arrived_at_utc=event.arrived_at_utc, + arrived_monotonic=event.arrived_monotonic, + ): + raise ReadOnlyStrategyError("C01 auth/login receipt does not match native callback") + + provider_only_fields = ( + "provider_timestamp_utc", + "provider_sequence", + "provider_event_id", + "source_sequence", + "source_timestamp_utc", + ) + if any(fields.get(name) not in (None, "") for name in provider_only_fields): + raise ReadOnlyStrategyError( + "auth/login callback cannot claim provider time, sequence, or event ID" + ) + if is_auth: + if ( + event.provider_front_id is not None + or event.provider_session_id != "" + or event.trading_day != "" + or event.session_identity_origin + != "unavailable_on_native_authentication_response" + or any(fields.get(name) not in (None, "") for name in provider_only_fields) + or any( + fields.get(name) not in (None, "") + for name in ( + "provider_front_id", + "provider_session_id", + "trading_day", + ) + ) + ): + raise ReadOnlyStrategyError( + "OnRspAuthenticate cannot claim provider front/session/day/time/sequence" + ) + return + + front_id = fields.get("provider_front_id") + session_id = fields.get("provider_session_id") + trading_day = fields.get("trading_day") + if ( + event.session_identity_origin != "native_login_response_fields" + or type(front_id) is not int + or front_id <= 0 + or not isinstance(session_id, str) + or not session_id.isdigit() + or not isinstance(trading_day, str) + or not re.fullmatch(r"\d{8}", trading_day) + or event.provider_front_id != front_id + or event.provider_session_id != session_id + or event.trading_day != trading_day + or any(fields.get(name) not in (None, "") for name in provider_only_fields) + ): + raise ReadOnlyStrategyError( + "OnRspUserLogin requires native FrontID/SessionID/TradingDay fields" + ) + + @staticmethod + def _is_c01_request_callback(event: NativeObservation) -> bool: + return event.kind in { + ObservationKind.AUTH_SUCCESS, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.ORDER_QUERY, + ObservationKind.POSITION_QUERY, + } + + def _validate_c01_query_receipt(self, event: NativeObservation) -> None: + if self.case_id != "C01": + return + fields = event.fields + family = str(fields.get("query_family", "")) + if family not in {"orders", "positions", "funds"}: + raise ReadOnlyStrategyError("C01 query callback family is invalid") + if ( + not fields.get("query_round_id") + or fields.get("query_round_id_origin") != "local_query_coordinator" + or not fields.get("query_id") + or fields.get("query_id_origin") != "local_query_coordinator" + ): + raise ReadOnlyStrategyError("C01 query callback needs local query coordinator IDs") + if ( + type(fields.get("request_id")) is not int + or fields.get("request_id") <= 0 + or type(fields.get("error_id")) is not int + or fields.get("error_id") != 0 + or type(fields.get("is_last")) is not bool + or fields.get("is_last") is not True + or type(event.request_generation) is not int + or event.request_generation < 1 + or fields.get("request_generation") != event.request_generation + or event.request_generation_origin != "local_request_generation_binding" + or fields.get("arrival_generation") != event.arrival_generation + or event.request_id_origin != "native_callback_argument" + or type(event.arrival_generation) is not int + or event.arrival_generation < 1 + or event.sequence_origin != "local_sdk_callback_arrival" + or event.timestamp_origin != "local_sdk_capture_clock" + or event.event_id_origin != "local_sdk_callback_arrival" + or event.provider_issued_event_id is not False + or _parse_utc(event.arrived_at_utc) is None + or _parse_utc(event.arrived_at_utc) != _parse_utc(event.occurred_at_utc) + or isinstance(event.arrived_monotonic, bool) + or not isinstance(event.arrived_monotonic, (int, float)) + or event.arrived_monotonic <= 0 + ): + raise ReadOnlyStrategyError("C01 query callback native/local facts are malformed") + if ( + event.provider_front_id is not None + or any( + fields.get(name) not in (None, "") + for name in ( + "provider_front_id", "provider_session_id", "trading_day", + "provider_timestamp_utc", "provider_sequence", "provider_event_id", + "source_sequence", "source_timestamp_utc", "provider_query_id" + ) + ) + or event.session_identity_origin + != "derived_from_same_client_generation_native_login" + ): + raise ReadOnlyStrategyError( + "C01 query callback cannot claim provider query/time metadata; scope is login-derived" + ) + receipt = event.issued_request_receipt + if receipt is not None and not validate_issued_request_receipt( + receipt, + request_kind=family, + phase=str(fields.get("phase", "")), + request_id=fields.get("request_id"), + request_generation=event.request_generation, + client_instance_id=event.client_instance_id, + arrival_generation=event.arrival_generation, + arrived_at_utc=event.arrived_at_utc, + arrived_monotonic=event.arrived_monotonic, + ): + raise ReadOnlyStrategyError("C01 query receipt does not match native callback") + + @staticmethod + def _validate_query_event(event: NativeObservation, query_family: str) -> None: + fields = event.fields + if query_family not in {"orders", "positions", "funds"}: + raise ReadOnlyStrategyError("native query family is not recognized") + if ( + fields.get("phase") not in {"baseline", "final"} + or not fields.get("snapshot_id") + or not fields.get("query_id") + or fields.get("complete") is not True + or fields.get("is_last") is not True + or _integer(fields.get("error_id")) != 0 + ): + raise ReadOnlyStrategyError("native account query must be complete and error-free") + if query_family == "orders": + value = _json_load(fields.get("open_order_refs_json"), "open order refs") + if not isinstance(value, list) or any( + not isinstance(item, str) or not item for item in value + ): + raise ReadOnlyStrategyError("order query must contain a typed open-reference list") + elif query_family == "positions": + _json_decimal_map(fields.get("positions_json"), "position snapshot") + _json_decimal_map(fields.get("closeable_quantities_json"), "closeable snapshot") + else: + funds = _json_decimal_map(fields.get("funds_json"), "funds snapshot") + if set(funds) != {"cash", "available_funds", "equity"}: + raise ReadOnlyStrategyError( + "account query requires cash, available funds, and equity" + ) + + @staticmethod + def _receipt_matches( + receipt: Optional[AuthenticationReceipt], + event: NativeObservation, + scope: DecisionScope, + ) -> bool: + return bool( + isinstance(receipt, AuthenticationReceipt) + and isinstance(receipt.event_id, str) + and isinstance(receipt.evidence_sha256, str) + and isinstance(receipt.scope_sha256, str) + and isinstance(receipt.account_identity_sha256, str) + and isinstance(receipt.verification_ref, str) + and receipt.event_id == event.event_id + and receipt.evidence_sha256.lower() == event.evidence_sha256.lower() + and receipt.scope_sha256.lower() == scope.scope_sha256.lower() + and receipt.account_identity_sha256.lower() == scope.account_identity_sha256.lower() + and receipt.trust_domain is event.source_domain + and receipt.verification_ref.strip() + ) + + def _missing_conditions(self, *, now_utc: datetime) -> List[str]: + if self.spec.order_profile: + return self._missing_order_conditions(now_utc=now_utc) + by_kind = { + kind: [item for item in self._events if item.kind is kind] + for kind in self.spec.required_kinds + } + missing = [kind.value for kind, events in by_kind.items() if not events] + if missing: + return missing + for kind, events in by_kind.items(): + if kind not in { + ObservationKind.SYSTEM_LOG, ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY + } and len(events) != 1: + missing.append(f"exactly_one_observation_required:{kind.value}") + provider_events = [ + event + for event in self._events + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK + ] + provider_session = self._provider_scope[0] if self._provider_scope else "" + latest_provider_time = max(_parse_utc(event.occurred_at_utc) for event in provider_events) + + if self.case_id == "C01": + auth = self._one(ObservationKind.AUTH_SUCCESS) + login = self._one(ObservationKind.LOGIN_SUCCESS) + if auth.client_instance_id != login.client_instance_id: + missing.append("authentication_and_login_must_use_same_client_instance") + if auth.arrival_generation != login.arrival_generation: + missing.append("authentication_and_login_must_share_connection_generation") + if auth.request_generation == login.request_generation: + missing.append("authentication_and_login_request_generations_must_differ") + auth_request_id = auth.fields.get("request_id") + login_request_id = login.fields.get("request_id") + if ( + type(auth_request_id) is not int + or type(login_request_id) is not int + or auth_request_id == login_request_id + ): + missing.append("authentication_and_login_native_request_ids_must_differ") + if self._c01_unverified_receipt: + missing.append("trusted_c01_issued_request_ledger_verifier_required") + required_queries = { + (phase, family) + for phase in ("baseline", "final") + for family in ("orders", "positions", "funds") + } + query_rows = [ + event + for event in self._events + if event.kind in {ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY} + ] + observed_queries = { + (str(event.fields.get("phase")), str(event.fields.get("query_family"))) + for event in query_rows + } + if len(query_rows) != 6 or observed_queries != required_queries: + missing.append("c01_requires_baseline_and_final_native_order_position_account_queries") + for phase in ("baseline", "final"): + phase_rows = [event for event in query_rows if event.fields.get("phase") == phase] + if len(phase_rows) == 3 and ( + len({event.fields.get("query_round_id") for event in phase_rows}) != 1 + or len({event.fields.get("snapshot_id") for event in phase_rows}) != 1 + ): + missing.append(f"c01_{phase}_queries_must_share_local_round_and_snapshot") + query_ids = [event.fields.get("request_id") for event in query_rows] + if ( + len(query_ids) != 6 + or any(type(item) is not int or item <= 0 for item in query_ids) + or len(set(query_ids)) != 6 + ): + missing.append("c01_query_callbacks_require_six_distinct_native_request_ids") + if any( + event.issued_request_receipt is None + or not validate_issued_request_receipt( + event.issued_request_receipt, + request_kind=str(event.fields.get("query_family", "")), + phase=str(event.fields.get("phase", "")), + request_id=event.fields.get("request_id"), + request_generation=event.request_generation, + client_instance_id=event.client_instance_id, + arrival_generation=event.arrival_generation, + arrived_at_utc=event.arrived_at_utc, + arrived_monotonic=event.arrived_monotonic, + ) + for event in query_rows + ): + missing.append("c01_query_callbacks_require_matching_typed_issued_request_receipts") + for query in query_rows: + if ( + query.client_instance_id != login.client_instance_id + or query.arrival_generation != login.arrival_generation + or query.provider_session_id != login.provider_session_id + or query.trading_day != login.trading_day + or query.sequence <= login.sequence + ): + missing.append("c01_query_callbacks_must_follow_native_login_same_client_generation") + break + if ( + auth.sequence >= login.sequence + or _parse_utc(auth.arrived_at_utc) >= _parse_utc(login.arrived_at_utc) + or _parse_utc(auth.occurred_at_utc) >= _parse_utc(login.occurred_at_utc) + ): + missing.append("authentication_must_precede_login") + else: + front = self._one(ObservationKind.FRONT_CONNECTED) + login = self._one(ObservationKind.LOGIN_SUCCESS) + subscribed = self._one(ObservationKind.MARKET_SUBSCRIPTION_ACK) + if not ( + _parse_utc(front.occurred_at_utc) + <= _parse_utc(login.occurred_at_utc) + <= _parse_utc(subscribed.occurred_at_utc) + ): + missing.append("front_login_market_ack_order_invalid") + if self.spec.expected_metric: + tick = self._one(ObservationKind.MARKET_TICK) + if _parse_utc(tick.occurred_at_utc) < _parse_utc( + subscribed.occurred_at_utc + ) or tick.fields.get("instrument_id") != subscribed.fields.get("instrument_id"): + missing.append("threshold_case_requires_subscribed_instrument_market_tick") + + system_rows = by_kind.get(ObservationKind.SYSTEM_LOG, []) + for row in system_rows: + if self.case_id != "C01" and row.fields.get("session_id") != provider_session: + missing.append("system_log_provider_session_mismatch") + system_gateway_keys = {str(row.fields.get("gateway_key", "")) for row in system_rows} + provider_gateway_keys = { + str(event.fields.get("gateway_key", "")) + for event in provider_events + if event.kind is ObservationKind.FRONT_CONNECTED + } + if len(system_gateway_keys) > 1 or ( + provider_gateway_keys and system_gateway_keys != provider_gateway_keys + ): + missing.append("system_logs_must_match_provider_gateway_identity") + system_by_name: Dict[str, NativeObservation] = {} + for row in system_rows: + event_name = str(row.fields.get("event_name", "")) + if event_name in system_by_name: + missing.append(f"duplicate_lifecycle_event:{event_name}") + system_by_name[event_name] = row + required_system_events = ["session_stopped", "write_activity_summary"] + if self.case_id == "C01": + required_system_events.extend(("store_auth_success", "store_login_success")) + if self.spec.requires_start_log: + required_system_events.insert(0, "session_started") + if self.spec.requires_store_ready: + required_system_events.append("store_ready") + if self.spec.requires_store_connected: + required_system_events.append("store_connected") + for event_name in required_system_events: + if event_name not in system_by_name: + missing.append(f"system_log:{event_name}") + + if self.case_id == "C01": + auth = self._one(ObservationKind.AUTH_SUCCESS) + login = self._one(ObservationKind.LOGIN_SUCCESS) + auth_log = system_by_name.get("store_auth_success") + login_log = system_by_name.get("store_login_success") + for log, callback_event, missing_key in ( + (auth_log, auth, "authentication"), + (login_log, login, "login"), + ): + if log is None: + continue + if log.fields.get("callback_event_id") != callback_event.event_id: + missing.append(f"{missing_key}_log_must_reference_native_callback") + if ( + _parse_utc(str(log.fields.get("callback_received_at_utc", ""))) + != _parse_utc(callback_event.arrived_at_utc) + or log.fields.get("client_instance_id") + != callback_event.client_instance_id + or log.fields.get("arrival_generation") + != callback_event.arrival_generation + or log.fields.get("provider_issued_event_id") is not False + ): + missing.append("authentication_logs_must_bind_local_callback_arrival") + if ( + login_log + and ( + login_log.fields.get("provider_front_id") != login.provider_front_id + or login_log.fields.get("provider_session_id") + != login.provider_session_id + or login_log.fields.get("trading_day") != login.trading_day + ) + ): + missing.append("login_log_must_match_native_login_identity") + if auth_log and any( + auth_log.fields.get(name) not in (None, "") + for name in ( + "provider_front_id", + "provider_session_id", + "trading_day", + "provider_timestamp_utc", + "provider_sequence", + ) + ): + missing.append("authentication_log_must_not_claim_unavailable_provider_identity") + if ( + auth_log + and login_log + and _parse_utc(auth_log.occurred_at_utc) >= _parse_utc(login_log.occurred_at_utc) + ): + missing.append("authentication_log_must_precede_login_log") + stopped = system_by_name.get("session_stopped") + if stopped: + fields = stopped.fields + if ( + fields.get("clean_shutdown") is not True + or fields.get("gateway_released") is not True + or _integer(fields.get("exit_code")) != 0 + ): + missing.append("clean_gateway_release_and_zero_exit_required") + if _parse_utc(stopped.occurred_at_utc) < latest_provider_time: + missing.append("session_shutdown_must_follow_provider_evidence") + + write_summary = system_by_name.get("write_activity_summary") + if write_summary: + fields = write_summary.fields + counts = ("order_submit_count", "order_cancel_count", "broker_write_count") + if ( + fields.get("snapshot_complete") is not True + or not _SHA256_RE.fullmatch(str(fields.get("snapshot_digest", ""))) + or any(_integer(fields.get(name)) != 0 for name in counts) + ): + missing.append("complete_zero_order_cancel_and_broker_write_summary_required") + if self.case_id != "C01" and fields.get("session_id") != provider_session: + missing.append("write_activity_session_mismatch") + if stopped and _parse_utc(write_summary.occurred_at_utc) < _parse_utc( + stopped.occurred_at_utc + ): + missing.append("write_activity_summary_must_follow_session_shutdown") + + if self.spec.requires_start_log: + started = system_by_name.get("session_started") + if started and _parse_utc(started.occurred_at_utc) > min( + _parse_utc(event.occurred_at_utc) for event in provider_events + ): + missing.append("session_start_log_must_precede_provider_session") + if self.spec.requires_store_ready: + ready = system_by_name.get("store_ready") + if ready: + if ( + ready.fields.get("market_connection") is not True + or ready.fields.get("trade_connection") is not True + or ready.fields.get("session_id") != provider_session + ): + missing.append("store_ready_log_must_confirm_both_provider_fronts") + if _parse_utc(ready.occurred_at_utc) < max( + _parse_utc(event.occurred_at_utc) + for event in provider_events + if event.kind is not ObservationKind.AUTH_SUCCESS + ): + missing.append("store_ready_log_must_follow_provider_readiness") + connected = system_by_name.get("store_connected") + if connected and _parse_utc(ready.occurred_at_utc) < _parse_utc( + connected.occurred_at_utc + ): + missing.append("store_ready_log_must_follow_store_connected") + if self.spec.requires_store_connected: + connected = system_by_name.get("store_connected") + if connected: + if ( + connected.fields.get("market_connection") is not True + or connected.fields.get("trade_connection") is not True + or connected.fields.get("session_id") != provider_session + ): + missing.append("store_connected_log_must_confirm_both_provider_fronts") + ready_events = [ + event + for event in provider_events + if event.kind + in { + ObservationKind.FRONT_CONNECTED, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + } + ] + if ready_events and _parse_utc(connected.occurred_at_utc) < max( + _parse_utc(event.occurred_at_utc) for event in ready_events + ): + missing.append("store_connected_log_must_follow_provider_readiness") + + if self.spec.expected_metric: + self._check_threshold(by_kind, missing) + started = system_by_name.get("session_started") + stopped = system_by_name.get("session_stopped") + if started and stopped: + for kind in ( + ObservationKind.MONITOR_CONFIGURATION, + ObservationKind.MONITOR_LOG, + ): + for event in by_kind.get(kind, []): + if not ( + _parse_utc(started.occurred_at_utc) + <= _parse_utc(event.occurred_at_utc) + <= _parse_utc(stopped.occurred_at_utc) + ): + missing.append("monitor_evidence_must_be_within_runtime_session") + if self.spec.requires_process_identity: + started = system_by_name.get("session_started") + stopped = system_by_name.get("session_stopped") + if ( + started + and stopped + and started.fields.get("process_id") != stopped.fields.get("process_id") + ): + missing.append("session_start_and_stop_must_match_process_identity") + return list(dict.fromkeys(missing)) + + def _missing_order_conditions(self, *, now_utc: datetime) -> List[str]: + self._unavailable_conditions = [] + event_times = [_parse_utc(event.occurred_at_utc) for event in self._events] + if any( + occurred_at is None + or now_utc - occurred_at > _MAX_CANDIDATE_EVIDENCE_AGE + or occurred_at - now_utc > _MAX_CANDIDATE_FUTURE_SKEW + for occurred_at in event_times + ): + return ["evidence_outside_candidate_freshness_window"] + + unavailable = [ + event + for event in self._events + if event.kind is ObservationKind.EXTERNAL_CONDITION + and event.fields.get("state") == "unavailable" + ] + submitted_events = [ + event for event in self._events if event.kind is ObservationKind.ORDER_SUBMIT_RECEIPT + ] + allowed_unavailable = { + "close_position": {"closeable_position_confirmed"}, + "cancel": {"cancel_window_available"}, + "batch_partials": { + "provider_partial_fill_opportunity", + "provider_batch_cancel_capability", + }, + "batch_open": {"multiple_open_orders_available", "provider_batch_cancel_capability"}, + }.get(self.spec.order_profile, set()) + if unavailable and not submitted_events: + for event in unavailable: + condition_id = str(event.fields.get("condition_id", "")) + if condition_id not in allowed_unavailable: + return [f"unexpected_unavailable_external_condition:{condition_id}"] + self._unavailable_conditions.extend( + f"{event.fields['condition_id']}:{event.fields['reason']}" for event in unavailable + ) + return [] + + missing: List[str] = [] + if unavailable: + missing.append("external_condition_unavailable_after_order_activity") + events_by_kind = { + kind: [event for event in self._events if event.kind is kind] + for kind in self.spec.required_kinds + } + required_singletons = ( + ObservationKind.FRONT_CONNECTED, + ObservationKind.LOGIN_SUCCESS, + ObservationKind.MARKET_SUBSCRIPTION_ACK, + ObservationKind.MARKET_TICK, + ObservationKind.ORDER_ADMISSION, + ) + for kind in required_singletons: + if len(events_by_kind[kind]) != 1: + missing.append(f"exactly_one_{kind.value}_required") + if not submitted_events: + missing.append("managed_submit_receipt_required") + + account_fingerprint = self.scope.account_identity_sha256 + if not _SHA256_RE.fullmatch(account_fingerprint): + missing.append("sealed_account_identity_fingerprint_required") + account_bound_events = list(submitted_events) + account_bound_events.extend( + event + for event in self._events + if event.kind in {ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY} + ) + for event in account_bound_events: + # Raw account IDs are deliberately not an identity authority. + # Only a SHA-256 fingerprint bound into DecisionScope/auth receipts + # can correlate managed submits and provider account queries. + event_fingerprint = event.fields.get("account_identity_sha256") + if ( + not isinstance(event_fingerprint, str) + or not account_fingerprint + or event_fingerprint.lower() != account_fingerprint.lower() + ): + missing.append("account_identity_scope_mismatch") + break + + minimum_orders = 2 if self.spec.order_profile in {"batch_partials", "batch_open"} else 1 + if len(submitted_events) < minimum_orders: + missing.append(f"minimum_managed_order_receipts:{minimum_orders}") + if ( + self.spec.order_profile in {"open_cancel", "close_position", "cancel", "trade_log"} + and len(submitted_events) != 1 + ): + missing.append("single_order_scenario_requires_exactly_one_submit") + + provider_events = [ + event + for event in self._events + if event.source_domain is EvidenceTrustDomain.CTP_CALLBACK + ] + if provider_events: + if len({event.stream_id for event in provider_events}) != 1: + missing.append("native_order_workflow_requires_one_case_event_stream") + sequences = [event.sequence for event in provider_events] + if len(sequences) != len(set(sequences)): + missing.append("native_order_workflow_requires_unique_source_sequences") + ordered_provider_events = sorted(provider_events, key=lambda item: item.sequence) + if any( + _parse_utc(later.occurred_at_utc) < _parse_utc(earlier.occurred_at_utc) + for earlier, later in zip(ordered_provider_events, ordered_provider_events[1:]) + ): + missing.append("native_order_workflow_source_time_moved_backwards") + + front = ( + self._one(ObservationKind.FRONT_CONNECTED) + if events_by_kind[ObservationKind.FRONT_CONNECTED] + else None + ) + login = ( + self._one(ObservationKind.LOGIN_SUCCESS) + if events_by_kind[ObservationKind.LOGIN_SUCCESS] + else None + ) + subscription = ( + self._one(ObservationKind.MARKET_SUBSCRIPTION_ACK) + if events_by_kind[ObservationKind.MARKET_SUBSCRIPTION_ACK] + else None + ) + tick = ( + self._one(ObservationKind.MARKET_TICK) + if events_by_kind[ObservationKind.MARKET_TICK] + else None + ) + if front and login and subscription and tick: + times = [ + _parse_utc(event.occurred_at_utc) for event in (front, login, subscription, tick) + ] + if times != sorted(times): + missing.append("provider_front_login_subscription_tick_order_invalid") + if tick.fields.get("instrument_id") != subscription.fields.get("instrument_id"): + missing.append("market_tick_must_match_subscribed_instrument") + + submit_by_ref = { + str(event.fields.get("order_ref", "")): event for event in submitted_events + } + subscribed_instrument = ( + str(subscription.fields.get("instrument_id", "")) if subscription is not None else "" + ) + market_instrument = str(tick.fields.get("instrument_id", "")) if tick is not None else "" + for submit in submitted_events: + instrument = str(submit.fields.get("instrument_id", "")) + if subscribed_instrument and instrument != subscribed_instrument: + missing.append("managed_submit_instrument_must_match_market_subscription") + if market_instrument and instrument != market_instrument: + missing.append("managed_submit_instrument_must_match_market_tick") + native_order_kinds = { + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_FILLED, + ObservationKind.ORDER_REJECTED, + ObservationKind.TRADE_EXECUTION, + } + for event in self._events: + if event.kind not in native_order_kinds: + continue + order_ref = str(event.fields.get("order_ref", "")) + submit = submit_by_ref.get(order_ref) + if submit is None: + missing.append("native_order_fact_must_match_managed_submit") + elif event.fields.get("instrument_id") != submit.fields.get("instrument_id"): + missing.append("native_order_fact_instrument_must_match_managed_submit") + + admission = ( + self._one(ObservationKind.ORDER_ADMISSION) + if events_by_kind[ObservationKind.ORDER_ADMISSION] + else None + ) + if admission: + admission_maximum = _decimal(admission.fields.get("maximum_quantity")) + if admission_maximum is not None: + for event in submitted_events: + quantity = _decimal(event.fields.get("quantity")) + if quantity is not None and quantity > admission_maximum: + missing.append("submit_quantity_exceeds_case_admission_limit") + expected_offset = "close" if self.spec.order_profile == "close_position" else "open" + if any(event.fields.get("offset") != expected_offset for event in submitted_events): + missing.append("submit_offset_does_not_match_case_plan") + order_refs = [str(event.fields.get("order_ref", "")) for event in submitted_events] + if len(order_refs) != len(set(order_refs)): + missing.append("each_managed_submit_requires_a_distinct_order_ref") + + lifecycle = { + str(event.fields.get("event_name", "")): event + for event in self._events + if event.kind is ObservationKind.SYSTEM_LOG + } + for name in ( + "session_started", + "store_connected", + "store_ready", + "session_stopped", + "write_activity_summary", + ): + if name not in lifecycle: + missing.append(f"system_log:{name}") + system_events = [ + event for event in self._events if event.kind is ObservationKind.SYSTEM_LOG + ] + if len(lifecycle) != len(system_events): + missing.append("duplicate_managed_lifecycle_event_name") + provider_session = self._provider_scope[0] if self._provider_scope else "" + gateway = front.fields.get("gateway_key") if front else None + for event in system_events: + if event.fields.get("session_id") != provider_session or ( + gateway and event.fields.get("gateway_key") != gateway + ): + missing.append("managed_lifecycle_must_match_provider_session_and_gateway") + started = lifecycle.get("session_started") + connected = lifecycle.get("store_connected") + ready = lifecycle.get("store_ready") + stopped = lifecycle.get("session_stopped") + summary = lifecycle.get("write_activity_summary") + if ( + started + and provider_events + and _parse_utc(started.occurred_at_utc) + > min(_parse_utc(event.occurred_at_utc) for event in provider_events) + ): + missing.append("managed_process_start_must_precede_provider_callbacks") + if connected and ready: + if _parse_utc(ready.occurred_at_utc) < _parse_utc(connected.occurred_at_utc): + missing.append("store_ready_must_follow_store_connected") + for row in (connected, ready): + if ( + row.fields.get("market_connection") is not True + or row.fields.get("trade_connection") is not True + ): + missing.append("both_provider_fronts_must_be_confirmed_connected") + if ( + ready + and subscription + and _parse_utc(ready.occurred_at_utc) < _parse_utc(subscription.occurred_at_utc) + ): + missing.append("store_ready_must_follow_provider_market_subscription") + if ( + connected + and subscription + and _parse_utc(connected.occurred_at_utc) < _parse_utc(subscription.occurred_at_utc) + ): + missing.append("store_connected_must_follow_provider_market_subscription") + if ( + ready + and submitted_events + and any( + _parse_utc(event.occurred_at_utc) <= _parse_utc(ready.occurred_at_utc) + for event in submitted_events + ) + ): + missing.append("managed_submit_must_follow_store_ready") + if stopped: + if ( + stopped.fields.get("clean_shutdown") is not True + or stopped.fields.get("gateway_released") is not True + or _integer(stopped.fields.get("exit_code")) != 0 + ): + missing.append("clean_gateway_release_and_zero_exit_required") + if ( + stopped + and summary + and _parse_utc(summary.occurred_at_utc) < _parse_utc(stopped.occurred_at_utc) + ): + missing.append("write_summary_must_follow_session_stop") + + cancel_rows = [ + event + for event in self._events + if event.kind is ObservationKind.SYSTEM_LOG + and event.fields.get("event_name") in {"order_cancel_request", "batch_cancel_requested"} + ] + required_cancel_name = ( + "batch_cancel_requested" + if self.spec.order_profile in {"batch_partials", "batch_open"} + else "order_cancel_request" + ) + if self.spec.order_profile in { + "open_cancel", + "cancel", + "batch_partials", + "batch_open", + } and not any( + event.fields.get("event_name") == required_cancel_name for event in cancel_rows + ): + missing.append(f"managed_{required_cancel_name}_receipt_required") + if ( + self.spec.order_profile in {"batch_partials", "batch_open"} + and len( + [ + event + for event in cancel_rows + if event.fields.get("event_name") == required_cancel_name + ] + ) + != 1 + ): + missing.append("exactly_one_managed_batch_cancel_receipt_required") + if self.spec.order_profile not in {"batch_partials", "batch_open", "trade_log"}: + single_cancels = [ + event + for event in cancel_rows + if event.fields.get("event_name") == "order_cancel_request" + ] + if len(single_cancels) > 1: + missing.append("exactly_one_managed_order_cancel_receipt_allowed") + if ( + self.spec.order_profile in {"open_cancel", "cancel"} + and len( + [ + event + for event in cancel_rows + if event.fields.get("event_name") == "order_cancel_request" + ] + ) + != 1 + ): + missing.append("single_order_cancel_case_requires_one_cancel_receipt") + + phases = { + str(event.fields.get("phase", "")) + for event in self._events + if event.kind in {ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY} + } + if phases != {"baseline", "final"}: + missing.append("complete_baseline_and_final_native_account_queries_required") + for phase in ("baseline", "final"): + query_rows = [ + event + for event in self._events + if event.kind is ObservationKind.ORDER_QUERY and event.fields.get("phase") == phase + ] + position_rows = [ + event + for event in self._events + if event.kind is ObservationKind.POSITION_QUERY + and event.fields.get("phase") == phase + ] + if ( + len(query_rows) != 1 + or len(position_rows) != 2 + or {event.fields.get("query_family") for event in position_rows} + != {"positions", "funds"} + ): + missing.append(f"{phase}_requires_one_order_position_and_funds_query") + baseline_query_events = [ + event + for event in self._events + if event.kind in {ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY} + and event.fields.get("phase") == "baseline" + ] + final_query_events = [ + event + for event in self._events + if event.kind in {ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY} + and event.fields.get("phase") == "final" + ] + if ( + len(baseline_query_events) == 3 + and submitted_events + and max(_parse_utc(event.occurred_at_utc) for event in baseline_query_events) + >= min(_parse_utc(event.occurred_at_utc) for event in submitted_events) + ): + missing.append("baseline_order_position_and_funds_queries_must_precede_submit") + if ( + len(final_query_events) == 3 + and stopped + and max(_parse_utc(event.occurred_at_utc) for event in final_query_events) + >= _parse_utc(stopped.occurred_at_utc) + ): + missing.append("final_order_position_and_funds_queries_must_precede_shutdown") + + status_kinds = { + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_FILLED, + ObservationKind.ORDER_REJECTED, + } + statuses_by_ref: Dict[str, List[NativeObservation]] = {} + for event in self._events: + if event.kind in status_kinds: + statuses_by_ref.setdefault(str(event.fields["order_ref"]), []).append(event) + cancel_by_ref: Dict[str, NativeObservation] = {} + batch_cancel = next( + ( + event + for event in cancel_rows + if event.fields.get("event_name") == "batch_cancel_requested" + ), + None, + ) + for event in cancel_rows: + if event.fields.get("event_name") == "order_cancel_request": + cancel_by_ref[str(event.fields.get("order_ref", ""))] = event + for order_ref in order_refs: + facts = sorted(statuses_by_ref.get(order_ref, []), key=lambda item: item.sequence) + if not any(item.kind is ObservationKind.ORDER_ACCEPTED for item in facts): + missing.append(f"provider_acceptance_callback:{order_ref}") + terminal_indexes = [ + index + for index, item in enumerate(facts) + if item.kind + in { + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_FILLED, + ObservationKind.ORDER_REJECTED, + } + ] + if len(terminal_indexes) > 1 or ( + terminal_indexes and terminal_indexes[0] != len(facts) - 1 + ): + missing.append(f"provider_terminal_state_must_be_unique_and_latest:{order_ref}") + if ( + self.spec.order_profile == "open_cancel" + and facts + and facts[-1].kind is not ObservationKind.ORDER_CANCELED + ): + missing.append(f"open_order_case_requires_cancel_terminal:{order_ref}") + if self.spec.order_profile in {"open_cancel", "cancel"} and facts: + cancel = cancel_by_ref.get(order_ref) + accepted = next( + (item for item in facts if item.kind is ObservationKind.ORDER_ACCEPTED), None + ) + if ( + cancel + and accepted + and not ( + _parse_utc(accepted.occurred_at_utc) + < _parse_utc(cancel.occurred_at_utc) + < _parse_utc(facts[-1].occurred_at_utc) + ) + ): + missing.append( + f"cancel_request_must_follow_acceptance_and_precede_terminal:{order_ref}" + ) + if self.spec.order_profile == "close_position" and facts: + cancel = cancel_by_ref.get(order_ref) + if facts[-1].kind is ObservationKind.ORDER_CANCELED and cancel is None: + missing.append( + f"canceled_close_order_requires_managed_cancel_receipt:{order_ref}" + ) + if cancel and _parse_utc(cancel.occurred_at_utc) >= _parse_utc( + facts[-1].occurred_at_utc + ): + missing.append(f"close_cancel_receipt_must_precede_terminal:{order_ref}") + if self.spec.order_profile in {"batch_partials", "batch_open"} and batch_cancel: + batch_refs = _json_load(batch_cancel.fields.get("order_refs_json"), "batch cancel refs") + if set(batch_refs) != set(order_refs): + missing.append("batch_cancel_must_target_each_managed_submit_exactly_once") + if self.spec.order_profile == "batch_partials": + partial_refs = { + str(event.fields["order_ref"]) + for event in self._events + if event.kind is ObservationKind.ORDER_PARTIAL + } + if partial_refs != set(order_refs): + missing.append("every_batch_partial_order_requires_partial_callback") + if _integer(batch_cancel.fields.get("partial_count")) != len(partial_refs): + missing.append("batch_partial_count_must_match_native_partial_orders") + if any( + not any( + event.kind is ObservationKind.ORDER_PARTIAL + and event.fields.get("order_ref") == order_ref + and _parse_utc(event.occurred_at_utc) + < _parse_utc(batch_cancel.occurred_at_utc) + for event in self._events + ) + for order_ref in order_refs + ): + missing.append("batch_partial_cancel_must_follow_each_partial_callback") + else: + accepted_before_batch = { + str(event.fields["order_ref"]) + for event in self._events + if event.kind is ObservationKind.ORDER_ACCEPTED + and _parse_utc(event.occurred_at_utc) < _parse_utc(batch_cancel.occurred_at_utc) + } + if accepted_before_batch != set(order_refs): + missing.append("batch_cancel_requires_each_order_provider_accepted_first") + if _integer(batch_cancel.fields.get("open_order_count")) != len(order_refs): + missing.append("batch_open_order_count_must_match_provider_acceptances") + if self.spec.order_profile == "close_position" and submitted_events: + baseline_position = next( + ( + event + for event in self._events + if event.kind is ObservationKind.POSITION_QUERY + and event.fields.get("phase") == "baseline" + and event.fields.get("query_family") == "positions" + ), + None, + ) + if baseline_position: + positions = _json_decimal_map( + baseline_position.fields.get("positions_json"), "baseline positions" + ) + closeable = _json_decimal_map( + baseline_position.fields.get("closeable_quantities_json"), + "baseline closeable quantities", + ) + request = submitted_events[0] + instrument = str(request.fields["instrument_id"]) + net = positions.get(instrument, Decimal(0)) + available = closeable.get(instrument, Decimal(0)) + quantity = _decimal(request.fields.get("quantity")) or Decimal(0) + expected_direction = "sell" if net > 0 else "buy" if net < 0 else "" + if ( + net == 0 + or available <= 0 + or quantity > available + or request.fields.get("direction") != expected_direction + ): + missing.append("close_submit_must_be_within_opposite_side_closeable_position") + trades = [ + event + for event in self._events + if event.kind is ObservationKind.TRADE_EXECUTION + and event.fields.get("order_ref") == request.fields.get("order_ref") + ] + if any( + event.fields.get("direction") != expected_direction for event in trades + ) or sum( + (_decimal(event.fields.get("quantity")) or Decimal(0)) for event in trades + ) > min(abs(net), available): + missing.append("close_trade_must_reduce_position_without_reversal") + if any(event.kind is ObservationKind.ORDER_REJECTED for event in self._events): + missing.append("normal_order_scenario_provider_rejection_is_incomplete") + + if missing: + return list(dict.fromkeys(missing)) + try: + completion = self._build_completion_evidence() + report = evaluate_case_completion(completion) + except (CompletionEvidenceError, ValueError, TypeError, KeyError) as exc: + return [f"completion_invariant_input_invalid:{exc}"] + missing.extend(report.missing_invariants) + missing.extend(report.contradictions) + + if summary: + dispatched_submits = sum( + event.fields.get("dispatch_state") == "dispatched" for event in submitted_events + ) + dispatched_cancels = sum( + event.fields.get("dispatch_state") == "dispatched" for event in cancel_rows + ) + summary_fields = summary.fields + if ( + summary_fields.get("snapshot_complete") is not True + or not _SHA256_RE.fullmatch(str(summary_fields.get("snapshot_digest", ""))) + or _integer(summary_fields.get("order_submit_count")) != dispatched_submits + or _integer(summary_fields.get("order_cancel_count")) != dispatched_cancels + or _integer(summary_fields.get("broker_write_count")) + != dispatched_submits + dispatched_cancels + ): + missing.append("write_summary_counts_must_match_managed_order_receipts") + if stopped and provider_events: + last_provider_time = max(_parse_utc(event.occurred_at_utc) for event in provider_events) + if _parse_utc(stopped.occurred_at_utc) < last_provider_time: + missing.append("managed_session_stop_must_follow_all_native_queries_and_callbacks") + return list(dict.fromkeys(missing)) + + def _build_completion_evidence(self) -> CompletionEvidence: + requests: List[ManagedOrderRequest] = [] + order_facts: List[NativeOrderFact] = [] + trade_facts: List[NativeTradeFact] = [] + scenario_evidence: List[CertificationEvidence] = [] + for event in self._events: + fields = dict(event.fields) + if event.kind is ObservationKind.ORDER_SUBMIT_RECEIPT: + request = ManagedOrderRequest( + request_id=str(fields["request_id"]), + action=RequestAction.SUBMIT, + dispatch_state=_dispatch_state(fields["dispatch_state"]), + order_refs=(str(fields["order_ref"]),), + occurred_at_utc=event.occurred_at_utc, + sequence=event.sequence, + evidence_sha256=event.evidence_sha256, + quantity=str(fields["quantity"]), + ) + requests.append(request) + scenario_evidence.append( + _certification_row( + event, + "order_submit_request", + EvidenceSource.MANAGED_RUNTIME, + { + "trace_id": fields["trace_id"], + "invocation_id": fields["invocation_id"], + "order_ref": fields["order_ref"], + "dispatch_state": fields["dispatch_state"], + }, + ) + ) + elif event.kind in { + ObservationKind.ORDER_ACCEPTED, + ObservationKind.ORDER_PARTIAL, + ObservationKind.ORDER_CANCELED, + ObservationKind.ORDER_FILLED, + ObservationKind.ORDER_REJECTED, + }: + fact_kind = { + ObservationKind.ORDER_ACCEPTED: NativeOrderFactKind.ACCEPTED, + ObservationKind.ORDER_PARTIAL: NativeOrderFactKind.PARTIAL, + ObservationKind.ORDER_CANCELED: NativeOrderFactKind.CANCELED, + ObservationKind.ORDER_FILLED: NativeOrderFactKind.FILLED, + ObservationKind.ORDER_REJECTED: NativeOrderFactKind.REJECTED, + }[event.kind] + order_facts.append( + NativeOrderFact( + event_id=event.event_id, + fact=fact_kind, + callback_name=event.callback_name, + source="ctp_provider_callback", + order_ref=str(fields["order_ref"]), + external_order_id=str(fields.get("external_order_id", "")), + instrument_id=str(fields["instrument_id"]), + status=str(fields["status"]), + traded_quantity=str(fields["traded_quantity"]), + remaining_quantity=str(fields["remaining_quantity"]), + session_id=event.provider_session_id, + trading_day=event.trading_day, + source_sequence=event.sequence, + occurred_at_utc=event.occurred_at_utc, + evidence_sha256=event.evidence_sha256, + error_id=_integer(fields.get("error_id")) or 0, + ) + ) + canonical_event_kind = { + ObservationKind.ORDER_ACCEPTED: "order_status_accepted", + ObservationKind.ORDER_CANCELED: "order_status_canceled", + }.get(event.kind) + if canonical_event_kind in SCENARIOS_BY_CASE_ID[self.case_id].required_events: + scenario_evidence.append( + _certification_row( + event, + canonical_event_kind, + EvidenceSource.PROVIDER_CALLBACK, + { + "order_ref": fields["order_ref"], + "external_order_id": fields.get("external_order_id", ""), + "provider_status": fields["status"], + }, + ) + ) + elif event.kind is ObservationKind.TRADE_EXECUTION: + trade_facts.append( + NativeTradeFact( + event_id=event.event_id, + trade_id=str(fields["trade_id"]), + order_ref=str(fields["order_ref"]), + instrument_id=str(fields["instrument_id"]), + quantity=str(fields["quantity"]), + direction=str(fields["direction"]), + price=str(fields["price"]), + callback_name=event.callback_name, + source="ctp_provider_callback", + session_id=event.provider_session_id, + trading_day=event.trading_day, + source_sequence=event.sequence, + occurred_at_utc=event.occurred_at_utc, + evidence_sha256=event.evidence_sha256, + external_order_id=str(fields["external_order_id"]), + ) + ) + if "trade_execution" in SCENARIOS_BY_CASE_ID[self.case_id].required_events: + scenario_evidence.append( + _certification_row( + event, + "trade_execution", + EvidenceSource.PROVIDER_CALLBACK, + { + "order_ref": fields["order_ref"], + "trade_id": fields["trade_id"], + "external_order_id": fields["external_order_id"], + "instrument_id": fields["instrument_id"], + "quantity": fields["quantity"], + "direction": fields["direction"], + "price": fields["price"], + }, + ) + ) + elif event.kind is ObservationKind.SYSTEM_LOG: + event_name = fields.get("event_name") + if event_name == "order_cancel_request": + requests.append( + ManagedOrderRequest( + request_id=str(fields["request_id"]), + action=RequestAction.CANCEL, + dispatch_state=_dispatch_state(fields["dispatch_state"]), + order_refs=(str(fields["order_ref"]),), + occurred_at_utc=event.occurred_at_utc, + sequence=event.sequence, + evidence_sha256=event.evidence_sha256, + ) + ) + if "order_cancel_request" in SCENARIOS_BY_CASE_ID[self.case_id].required_events: + scenario_evidence.append( + _certification_row( + event, + "order_cancel_request", + EvidenceSource.MANAGED_RUNTIME, + { + "trace_id": fields["trace_id"], + "invocation_id": fields["invocation_id"], + "order_ref": fields["order_ref"], + "dispatch_state": fields["dispatch_state"], + }, + ) + ) + elif event_name == "batch_cancel_requested": + refs = _json_load(fields.get("order_refs_json"), "batch cancel order refs") + if not isinstance(refs, list) or any(not isinstance(ref, str) for ref in refs): + raise CompletionEvidenceError( + "batch cancel refs must be a JSON string list" + ) + requests.append( + ManagedOrderRequest( + request_id=str(fields["request_id"]), + action=RequestAction.BATCH_CANCEL, + dispatch_state=_dispatch_state(fields["dispatch_state"]), + order_refs=tuple(refs), + occurred_at_utc=event.occurred_at_utc, + sequence=event.sequence, + evidence_sha256=event.evidence_sha256, + ) + ) + batch_fields = { + "trace_id": fields["trace_id"], + "invocation_id": fields["invocation_id"], + "order_refs": refs, + "dispatch_state": fields["dispatch_state"], + } + count_name = "partial_count" if self.case_id == "B01" else "open_order_count" + if count_name in fields: + batch_fields[count_name] = fields[count_name] + scenario_evidence.append( + _certification_row( + event, + "batch_cancel_requested", + EvidenceSource.MANAGED_RUNTIME, + batch_fields, + ) + ) + + snapshots = self._build_query_snapshots() + return CompletionEvidence( + case_id=self.case_id, + scenario_evidence=tuple(scenario_evidence), + managed_requests=tuple(requests), + order_facts=tuple(order_facts), + trade_facts=tuple(trade_facts), + snapshots=tuple(snapshots), + ) + + def _build_query_snapshots(self) -> List[AccountReconciliationSnapshot]: + snapshots: List[AccountReconciliationSnapshot] = [] + callback_by_family = { + "orders": "OnRspQryOrder", + "positions": "OnRspQryInvestorPosition", + "funds": "OnRspQryTradingAccount", + } + for phase_text in ("baseline", "final"): + rows = { + str(event.fields.get("query_family")): event + for event in self._events + if event.kind in {ObservationKind.ORDER_QUERY, ObservationKind.POSITION_QUERY} + and event.fields.get("phase") == phase_text + } + if set(rows) != {"orders", "positions", "funds"}: + raise CompletionEvidenceError( + f"{phase_text} native snapshot has incomplete query families" + ) + events = tuple(rows[family] for family in ("orders", "positions", "funds")) + if len({event.fields.get("snapshot_id") for event in events}) != 1: + raise CompletionEvidenceError( + f"{phase_text} query families do not share snapshot id" + ) + order_event = rows["orders"] + position_event = rows["positions"] + funds_event = rows["funds"] + open_refs = _json_load(order_event.fields["open_order_refs_json"], "open order refs") + positions = _json_decimal_map( + position_event.fields["positions_json"], "position snapshot" + ) + funds = _json_decimal_map(funds_event.fields["funds_json"], "funds snapshot") + callbacks = tuple( + callback_by_family[family] for family in ("orders", "positions", "funds") + ) + digest_material = "".join(event.evidence_sha256 for event in events).encode("ascii") + snapshots.append( + AccountReconciliationSnapshot( + phase=SnapshotPhase(phase_text), + session_id=order_event.provider_session_id, + trading_day=order_event.trading_day, + occurred_at_utc=max( + (event.occurred_at_utc for event in events), + key=_parse_utc, + ), + order_query_id=str(order_event.fields["query_id"]), + position_query_id=str(position_event.fields["query_id"]), + account_query_id=str(funds_event.fields["query_id"]), + order_query_sequence=order_event.sequence, + position_query_sequence=position_event.sequence, + account_query_sequence=funds_event.sequence, + open_order_refs=tuple(open_refs), + positions=positions, + funds=funds, + callback_names=callbacks, + source="ctp_provider_callback", + evidence_sha256=sha256(digest_material).hexdigest(), + query_error_ids={ + callback_by_family[family]: _integer(rows[family].fields["error_id"]) + for family in ("orders", "positions", "funds") + }, + query_is_last={ + callback_by_family[family]: rows[family].fields["is_last"] + for family in ("orders", "positions", "funds") + }, + client_instance_id=(order_event.client_instance_id if self.case_id == "C01" else ""), + arrival_generation=(order_event.arrival_generation if self.case_id == "C01" else 0), + query_native_request_ids=( + {callback_by_family[family]: rows[family].fields["request_id"] for family in ("orders", "positions", "funds")} + if self.case_id == "C01" else {} + ), + query_round_id=(str(order_event.fields["query_round_id"]) if self.case_id == "C01" else ""), + query_id_origin=("local_query_coordinator" if self.case_id == "C01" else ""), + query_round_id_origin=("local_query_coordinator" if self.case_id == "C01" else ""), + sequence_origin=("local_sdk_callback_arrival" if self.case_id == "C01" else ""), + timestamp_origin=("local_sdk_capture_clock" if self.case_id == "C01" else ""), + session_identity_origin=( + "derived_from_same_client_generation_native_login" if self.case_id == "C01" else "" + ), + query_issued_request_receipts=( + {callback_by_family[family]: rows[family].issued_request_receipt for family in ("orders", "positions", "funds")} + if self.case_id == "C01" else {} + ), + query_request_generations=( + {callback_by_family[family]: rows[family].request_generation for family in ("orders", "positions", "funds")} + if self.case_id == "C01" else {} + ), + query_arrival_times_utc=( + {callback_by_family[family]: rows[family].arrived_at_utc for family in ("orders", "positions", "funds")} + if self.case_id == "C01" else {} + ), + query_arrival_monotonic=( + {callback_by_family[family]: rows[family].arrived_monotonic for family in ("orders", "positions", "funds")} + if self.case_id == "C01" else {} + ), + ) + ) + return snapshots + + def _check_threshold( + self, + by_kind: Mapping[ObservationKind, List[NativeObservation]], + missing: List[str], + ) -> None: + configurations = by_kind.get(ObservationKind.MONITOR_CONFIGURATION, []) + monitor_logs = by_kind.get(ObservationKind.MONITOR_LOG, []) + if len(configurations) != 1: + missing.append("exactly_one_runtime_threshold_configuration_required") + return + if len(monitor_logs) != 1: + missing.append("exactly_one_runtime_threshold_log_required") + config = configurations[0] + fields = config.fields + if ( + fields.get("metric") != self.spec.expected_metric + or _integer(fields.get("threshold")) != self.spec.expected_threshold + ): + missing.append("runtime_threshold_does_not_match_case_plan") + window = ( + _decimal(fields.get("window_seconds")) if self.spec.requires_repeat_window else None + ) + if self.spec.requires_repeat_window and (window is None or window <= 0): + missing.append("positive_repeat_window_required") + matching_logs = [ + event + for event in monitor_logs + if event.fields.get("event_name") == "risk_threshold_configured" + and event.fields.get("metric") == self.spec.expected_metric + and _integer(event.fields.get("threshold")) == self.spec.expected_threshold + and event.fields.get("configuration_digest") == fields.get("configuration_digest") + and event.fields.get("monitor_digest") == fields.get("monitor_digest") + and ( + not self.spec.requires_repeat_window + or _decimal(event.fields.get("window_seconds")) == window + ) + and _parse_utc(event.occurred_at_utc) >= _parse_utc(config.occurred_at_utc) + ] + if not matching_logs: + missing.append("matching_timestamped_monitor_log_required") + + def _one(self, kind: ObservationKind) -> NativeObservation: + events = [item for item in self._events if item.kind is kind] + # A missing event is detected before this helper is used. + return events[-1] + + +class C01Strategy(ReadOnlyCaseStrategy): + case_id = "C01" + + +class M01Strategy(ReadOnlyCaseStrategy): + case_id = "M01" + + +class L02Strategy(ReadOnlyCaseStrategy): + case_id = "L02" + + +class TH01Strategy(ReadOnlyCaseStrategy): + case_id = "TH01" + + +class TH03Strategy(ReadOnlyCaseStrategy): + case_id = "TH03" + + +class TH05Strategy(ReadOnlyCaseStrategy): + case_id = "TH05" + + +class T01Strategy(ReadOnlyCaseStrategy): + case_id = "T01" + + +class T02Strategy(ReadOnlyCaseStrategy): + case_id = "T02" + + +class T03Strategy(ReadOnlyCaseStrategy): + case_id = "T03" + + +class B01Strategy(ReadOnlyCaseStrategy): + case_id = "B01" + + +class B02Strategy(ReadOnlyCaseStrategy): + case_id = "B02" + + +class L01Strategy(ReadOnlyCaseStrategy): + case_id = "L01" + + +def create_read_only_strategy( + case_id: str, + plan: DescriptiveCasePlan, + scope: DecisionScope, + authenticator: Optional[ObservationAuthenticator], +) -> ReadOnlyCaseStrategy: + strategy_types = { + "C01": C01Strategy, + "M01": M01Strategy, + "L02": L02Strategy, + "TH01": TH01Strategy, + "TH03": TH03Strategy, + "TH05": TH05Strategy, + "T01": T01Strategy, + "T02": T02Strategy, + "T03": T03Strategy, + "B01": B01Strategy, + "B02": B02Strategy, + "L01": L01Strategy, + } + strategy_type = strategy_types.get(case_id) + if strategy_type is None: + raise ReadOnlyStrategyError(f"unsupported read-only strategy case {case_id!r}") + return strategy_type(plan, scope, authenticator) + + +def _integer(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + result = int(value) + except (TypeError, ValueError, OverflowError): + return None + if isinstance(value, float) and not value.is_integer(): + return None + return result + + +def _decimal(value: Any) -> Optional[Decimal]: + if isinstance(value, bool): + return None + try: + parsed = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + return parsed if parsed.is_finite() else None + + +def _dispatch_state(value: Any) -> DispatchState: + try: + return { + "dispatched": DispatchState.DISPATCHED, + "blocked_pre_dispatch": DispatchState.BLOCKED_PRE_DISPATCH, + }[value] + except (KeyError, TypeError) as exc: + raise CompletionEvidenceError("managed request has invalid dispatch state") from exc + + +def _certification_row( + event: NativeObservation, + event_kind: str, + source: EvidenceSource, + fields: Mapping[str, Any], +) -> CertificationEvidence: + provider = event.source_domain is EvidenceTrustDomain.CTP_CALLBACK + return CertificationEvidence( + event_kind=event_kind, + source=source, + event_id=event.event_id, + evidence_sha256=event.evidence_sha256, + occurred_at_utc=event.occurred_at_utc, + fields=dict(fields), + callback_names=(event.callback_name,) if provider else (), + provider_session_id=event.provider_session_id if provider else "", + trading_day=event.trading_day if provider else "", + source_sequence=event.sequence if provider else 0, + ) + + +def _json_load(value: Any, description: str) -> Any: + if not isinstance(value, str): + raise CompletionEvidenceError(f"{description} must be JSON text") + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError) as exc: + raise CompletionEvidenceError(f"{description} is not valid JSON") from exc + + +def _json_decimal_map(value: Any, description: str) -> Dict[str, Decimal]: + parsed = _json_load(value, description) + if not isinstance(parsed, dict) or any(not isinstance(key, str) or not key for key in parsed): + raise CompletionEvidenceError(f"{description} must be a JSON object keyed by identifiers") + result = {} + for key, raw_value in parsed.items(): + decimal_value = _decimal(raw_value) + if decimal_value is None: + raise CompletionEvidenceError(f"{description} values must be finite decimals") + result[key] = decimal_value + return result + + +def _parse_utc(value: str) -> Optional[datetime]: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0): + return None + return parsed.astimezone(timezone.utc) diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/result.py b/examples/007_ctp/live_certification/simnow_penetration/common/result.py index 42cd1284..566d3d53 100644 --- a/examples/007_ctp/live_certification/simnow_penetration/common/result.py +++ b/examples/007_ctp/live_certification/simnow_penetration/common/result.py @@ -3,7 +3,6 @@ import datetime as _dt import json -import os import uuid from dataclasses import asdict, dataclass, field from pathlib import Path @@ -16,6 +15,9 @@ EXIT_BLOCKED = 2 VALID_STATUSES = ("PASS", "FAIL", "BLOCKED") +PASS_UNAVAILABLE_REASON = ( + "Certification PASS unavailable: trusted post-reconciliation evidence adapter is not configured" +) def _collect_observed_events(value: Any, parent_key: str = "") -> set[str]: @@ -81,8 +83,9 @@ def to_dict(self) -> dict: """Convert case result to dictionary. Returns: - Dictionary representation of the case result. + Fail-closed dictionary representation of the case result. """ + _fail_unverified_pass(self) return asdict(self) def exit_code(self) -> int: @@ -91,6 +94,7 @@ def exit_code(self) -> int: Returns: Exit code: 0 for PASS, 1 for FAIL, 2 for BLOCKED. """ + _fail_unverified_pass(self) return {"PASS": EXIT_PASS, "FAIL": EXIT_FAIL, "BLOCKED": EXIT_BLOCKED}.get( self.status, EXIT_FAIL ) @@ -98,6 +102,7 @@ def exit_code(self) -> int: def save_result(result: CaseResult, report_dir: str | Path) -> Path: """Persist *result* as ``report_dir/result.json``.""" + _fail_unverified_pass(result) report_dir = Path(report_dir) report_dir.mkdir(parents=True, exist_ok=True) if result.audit_events: @@ -143,17 +148,20 @@ def __enter__(self): def __exit__(self, *exc): """Exit the context manager.""" - pass def pass_result(self, evidence=None, details=None) -> CaseResult: - """Create a PASS result. + """Request a PASS result. + + The legacy SimNow path has no trusted post-reconciliation evidence + adapter, so this request is returned as FAIL. Caller-provided + details are retained for diagnostics and cannot authorize PASS. Args: evidence: Evidence files or data. details: Additional details dictionary. Returns: - CaseResult with PASS status. + CaseResult with FAIL status until a trusted adapter is wired. """ return self._build("PASS", evidence=evidence, details=details) @@ -204,29 +212,26 @@ def _build( elapsed = ( round((now - self._start).total_seconds(), 2) if self._start else 0.0 ) - details = details or {} + details = dict(details or {}) scenario = get_certification_scenario(self.case_id) - observed_events = sorted(_collect_observed_events(details)) - evidence_field_names = _collect_evidence_field_names(details) + pass_requested = status == "PASS" + if pass_requested: + # Legacy details are caller-controlled and cannot establish + # provider/runtime/validator evidence. + details.pop("certification_evidence", None) + observed_events = [] + evidence_field_names = set() + else: + observed_events = sorted(_collect_observed_events(details)) + evidence_field_names = _collect_evidence_field_names(details) missing_required_events = [ event for event in scenario.required_events if event not in observed_events ] missing_evidence_fields = [ field for field in scenario.evidence_fields if field not in evidence_field_names ] - final_status = status - final_reason = reason - if status == "PASS" and (missing_required_events or missing_evidence_fields): - final_status = "FAIL" - missing_parts = [] - if missing_required_events: - missing_parts.append("events=" + ",".join(missing_required_events)) - if missing_evidence_fields: - missing_parts.append("fields=" + ",".join(missing_evidence_fields)) - final_reason = ( - reason - or "Missing required certification evidence: " + "; ".join(missing_parts) - ) + final_status = "FAIL" if pass_requested else status + final_reason = PASS_UNAVAILABLE_REASON if pass_requested else reason audit_event = { "event_type": "certification_case_result", "event_id": str(uuid.uuid4()), @@ -284,3 +289,35 @@ def _build( def _new_trace_id() -> str: timestamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") return f"ctp-cert-{timestamp}-{uuid.uuid4().hex[:8]}" + + +def _fail_unverified_pass(result: CaseResult) -> None: + """Prevent unauthenticated or hand-built PASS results from being persisted.""" + if result.status != "PASS": + return + + scenario = get_certification_scenario(result.case_id) + result.status = "FAIL" + result.failure_reason = PASS_UNAVAILABLE_REASON + result.observed_events = [] + result.missing_required_events = list(scenario.required_events) + result.required_events_present = False + result.missing_evidence_fields = list(scenario.evidence_fields) + result.evidence_fields_present = False + result.details = dict(result.details or {}) + result.details.pop("certification_evidence", None) + + for audit_event in result.audit_events: + audit_event.update( + { + "status": "FAIL", + "severity": "ERROR", + "message": PASS_UNAVAILABLE_REASON, + "observed_events": [], + "missing_required_events": list(scenario.required_events), + "missing_evidence_fields": list(scenario.evidence_fields), + "required_events_present": False, + "evidence_fields_present": False, + "details": result.details, + } + ) diff --git a/examples/007_ctp/live_certification/simnow_penetration/common/runtime.py b/examples/007_ctp/live_certification/simnow_penetration/common/runtime.py index ba8b52bf..4495486b 100644 --- a/examples/007_ctp/live_certification/simnow_penetration/common/runtime.py +++ b/examples/007_ctp/live_certification/simnow_penetration/common/runtime.py @@ -16,26 +16,39 @@ _SUITE_DIR = Path(__file__).resolve().parent.parent _REPO_ROOT = _SUITE_DIR.parents[3] + +def _is_direct_legacy_case_process() -> bool: + """Return true only for ``python cases/.py`` child invocations.""" + + try: + return Path(sys.argv[0]).resolve().parent == (_SUITE_DIR / "cases").resolve() + except (OSError, RuntimeError): + return False + + +if _is_direct_legacy_case_process(): + # This is deliberately before Backtrader, BtApiStore, and all + # provider configuration. A copied case reaches the same gate but is not + # trusted by the central registry, so it cannot become a hidden route. + if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + from backtrader_runtime.legacy import run_legacy_config_first_cli + + raise SystemExit(run_legacy_config_first_cli(_SUITE_DIR.parents[1] / "runtime")) + for _p in (_SUITE_DIR, _REPO_ROOT): _sp = str(_p) if _sp not in sys.path: sys.path.insert(0, _sp) -try: - from dotenv import load_dotenv - - load_dotenv() -except ImportError: - pass - -import backtrader as bt -from backtrader.brokers.btapibroker import BtApiBroker -from backtrader.feeds.btapifeed import BtApiFeed -from backtrader.stores.btapistore import BtApiStore +import backtrader as bt # noqa: E402 +from backtrader.brokers.btapibroker import BtApiBroker # noqa: E402 +from backtrader.feeds.btapifeed import BtApiFeed # noqa: E402 +from backtrader.stores.btapistore import BtApiStore # noqa: E402 -from common import config as cfg -from common.evidence import attach_reconciliation, capture_store_snapshot -from common.result import CaseTimer, save_result +from common import config as cfg # noqa: E402 +from common.evidence import attach_reconciliation, capture_store_snapshot # noqa: E402 +from common.result import CaseTimer, save_result # noqa: E402 def _mask_investor_id(investor_id): @@ -58,6 +71,13 @@ def _mask_investor_id(investor_id): @contextlib.contextmanager def started_store(env_key=None, stop_on_exit=True, case_id=None, report_dir=None): """Create a live BtApiStore in a subprocess-safe context.""" + # Keep the retained helper from becoming a programmatic bypass of the + # direct-case CLI fence. A future managed certification profile must use a + # separately reviewed runtime and may not toggle this historical helper. + from backtrader_runtime.legacy import legacy_direct_execution_error + + raise legacy_direct_execution_error("007_ctp/simnow_penetration/started_store") + env_key = env_key or cfg.get_env_key() simnow_config = cfg.create_config(env_key) env_info = cfg.SIMNOW_ENVIRONMENTS[env_key] @@ -123,9 +143,7 @@ def create_cerebro( cerebro.adddata(data) if with_trade_logger and log_dir: - cerebro.addobserver( - bt.observers.TradeLogger, log_dir=log_dir, log_format="json" - ) + cerebro.addobserver(bt.observers.TradeLogger, log_dir=log_dir, log_format="json") return cerebro @@ -245,7 +263,11 @@ def ensure_ctp_trading_admission(store, symbol, timeout=15.0): """ preflight = getattr(store, "get_ctp_preflight_snapshot", None) if not callable(preflight): - return CtpWriteAdmission(True) + return CtpWriteAdmission( + False, + "CTP typed preflight interface unavailable", + "Use a reviewed managed runtime with typed read-only preflight", + ) try: snapshot = preflight(symbol, timeout=timeout) except Exception as exc: @@ -267,7 +289,11 @@ def ensure_ctp_trading_admission(store, symbol, timeout=15.0): arm = getattr(store, "arm_registered_sim_execution", None) if not callable(arm): - return CtpWriteAdmission(True) + return CtpWriteAdmission( + False, + "CTP typed execution admission interface unavailable", + "Use a reviewed managed runtime with explicit execution admission", + ) identity = hashlib.sha256( f"simnow-penetration-certification|{symbol}".encode("utf-8") @@ -306,9 +332,7 @@ def case_main(run_fn, meta: dict): case_id = meta["case_id"] report_dir = ( - Path(args.report_dir) - if args.report_dir - else _SUITE_DIR / "reports" / "latest" / case_id + Path(args.report_dir) if args.report_dir else _SUITE_DIR / "reports" / "latest" / case_id ) report_dir.mkdir(parents=True, exist_ok=True) old_report_dir = os.environ.get("CERTIFICATION_REPORT_DIR") diff --git a/examples/007_ctp/live_certification/simnow_penetration/managed_case_entry.py b/examples/007_ctp/live_certification/simnow_penetration/managed_case_entry.py new file mode 100644 index 00000000..88f454dc --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/managed_case_entry.py @@ -0,0 +1,345 @@ +"""Fail-closed staging entrypoint for future managed SimNow case wrappers. + +This module validates a case-local schema-v4 configuration and then reports +that managed CTP certification is not registered. It is not an execution +route: every result is BLOCKED and the external action counters remain zero. +It deliberately does not import Backtrader, an SDK, or a provider. +""" + +from __future__ import annotations + +import importlib.util +import hashlib +import json +import os +from pathlib import Path +import re +import sys +from typing import Any, Optional + + +_SUITE_DIR = Path(__file__).resolve().parent +_CASES_ROOT = _SUITE_DIR / "cases" +_CERTIFICATION_SOURCE = _SUITE_DIR / "common" / "certification.py" +_CASE_ID_RE = re.compile(r"^[A-Z][A-Z0-9]{1,3}$") +_CURRENT_REQUIRED_CASE_IDS = frozenset( + ( + "B01", + "B02", + "C01", + "E01", + "E02", + "E03", + "EM01", + "EM02", + "EM03", + "L01", + "L02", + "L03", + "L04", + "M01", + "M02", + "M03", + "M04", + "M05", + "O01", + "O02", + "O03", + "T01", + "T02", + "T03", + "TH01", + "TH02", + "TH03", + "TH04", + "TH05", + "TH06", + "V01", + "V02", + "V03", + ) +) +_REQUIRED_TOP_LEVEL_KEYS = frozenset( + ("config_schema_version", "strategy", "runtime", "parameters") +) +_ALLOWED_TOP_LEVEL_KEYS = _REQUIRED_TOP_LEVEL_KEYS | frozenset(("secrets_ref",)) +_MAX_CONFIG_BYTES = 64 * 1024 + + +class _ScopeRejected(Exception): + """A redacted, fail-closed validation rejection.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +def _validate_current_required_case_ids(case_ids: set[str]) -> None: + """Fail closed unless the canonical registry contains every current case.""" + + if case_ids != _CURRENT_REQUIRED_CASE_IDS: + raise _ScopeRejected("managed_ctp_certification_registry_invalid") + + +def _load_code_owned_scenarios() -> dict[str, Any]: + """Load the local certification mapping without importing its package.""" + + try: + source = _CERTIFICATION_SOURCE.resolve(strict=True) + expected_source = (_SUITE_DIR / "common" / "certification.py").resolve( + strict=True + ) + except (OSError, RuntimeError, ValueError) as exc: + raise _ScopeRejected("managed_ctp_certification_registry_invalid") from exc + if source != expected_source or not source.is_file(): + raise _ScopeRejected("managed_ctp_certification_registry_invalid") + + module_name = "_iteration41_007_simnow_certification_mapping" + spec = importlib.util.spec_from_file_location(module_name, source) + if spec is None or spec.loader is None: + raise _ScopeRejected("managed_ctp_certification_registry_invalid") + + module = importlib.util.module_from_spec(spec) + previous_module = sys.modules.get(module_name) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + rows = module.all_certification_scenarios() + mapping = module.SCENARIOS_BY_CASE_ID + except Exception as exc: + raise _ScopeRejected("managed_ctp_certification_registry_invalid") from exc + finally: + if previous_module is None: + sys.modules.pop(module_name, None) + else: + sys.modules[module_name] = previous_module + + if type(rows) is not list or len(rows) != 33 or type(mapping) is not dict: + raise _ScopeRejected("managed_ctp_certification_registry_invalid") + + row_ids = [getattr(row, "case_id", None) for row in rows] + if ( + any(type(case_id) is not str or not _CASE_ID_RE.fullmatch(case_id) for case_id in row_ids) + or len(set(row_ids)) != 33 + or len(mapping) != 33 + or set(mapping) != set(row_ids) + ): + raise _ScopeRejected("managed_ctp_certification_registry_invalid") + + for row in rows: + if mapping.get(row.case_id) != row: + raise _ScopeRejected("managed_ctp_certification_registry_invalid") + + _validate_current_required_case_ids(set(row_ids)) + return mapping + + +def _expected_script(case_id: str) -> Path: + """Return the sole accepted child path for a code-owned case ID.""" + + return _CASES_ROOT / case_id / "run.py" + + +def _is_exact_direct_case_path(case_id: str, case_file: object) -> bool: + """Require the supplied ``__file__`` to be the exact case-local run.py.""" + + try: + expected = _expected_script(case_id) + supplied = Path(os.fspath(case_file)) + if not supplied.is_absolute(): + supplied = Path.cwd() / supplied + + cases_root = _CASES_ROOT.resolve(strict=True) + case_dir = expected.parent + if ( + case_dir.is_symlink() + or case_dir.resolve(strict=True).parent != cases_root + or expected.is_symlink() + or not expected.is_file() + or supplied.is_symlink() + ): + return False + + supplied_text = os.path.normcase(os.path.abspath(os.fspath(supplied))) + expected_text = os.path.normcase(os.path.abspath(os.fspath(expected))) + return supplied_text == expected_text and supplied.resolve(strict=True) == expected.resolve( + strict=True + ) + except (OSError, RuntimeError, TypeError, ValueError): + return False + + +def _strict_yaml_mapping(raw: bytes) -> Any: + """Parse one safe YAML document while rejecting duplicate/merged keys.""" + + try: + import yaml + except ImportError as exc: + raise _ScopeRejected("managed_ctp_certification_config_invalid") from exc + + class UniqueKeySafeLoader(yaml.SafeLoader): + pass + + def construct_unique_mapping(loader: Any, node: Any, deep: bool = False) -> dict: + if not isinstance(node, yaml.MappingNode): + raise yaml.constructor.ConstructorError( + None, None, "expected a mapping", node.start_mark + ) + + result: dict[str, Any] = {} + for key_node, value_node in node.value: + if key_node.tag == "tag:yaml.org,2002:merge": + raise yaml.constructor.ConstructorError( + None, None, "YAML merge keys are not allowed", key_node.start_mark + ) + key = loader.construct_object(key_node, deep=deep) + if type(key) is not str or key in result: + raise yaml.constructor.ConstructorError( + None, None, "mapping keys must be unique strings", key_node.start_mark + ) + result[key] = loader.construct_object(value_node, deep=deep) + return result + + UniqueKeySafeLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_unique_mapping + ) + try: + return yaml.load(raw, Loader=UniqueKeySafeLoader) + except Exception as exc: + raise _ScopeRejected("managed_ctp_certification_config_invalid") from exc + + +def _validated_case_config_digest(case_file: Path, case_id: str) -> Optional[str]: + """Validate one read of the non-secret case config and return its digest.""" + + config_path = case_file.parent / "config.yaml" + try: + if config_path.is_symlink() or not config_path.is_file(): + return None + resolved_config = config_path.resolve(strict=True) + if resolved_config != config_path.parent.resolve(strict=True) / "config.yaml": + return None + if config_path.stat().st_size > _MAX_CONFIG_BYTES: + return None + raw = config_path.read_bytes() + if len(raw) > _MAX_CONFIG_BYTES: + return None + except (OSError, RuntimeError, ValueError): + return None + + document = _strict_yaml_mapping(raw) + if ( + type(document) is not dict + or not _REQUIRED_TOP_LEVEL_KEYS.issubset(document) + or not frozenset(document).issubset(_ALLOWED_TOP_LEVEL_KEYS) + ): + return None + if "secrets_ref" in document and document["secrets_ref"] != "none": + return None + if type(document["config_schema_version"]) is not int or document[ + "config_schema_version" + ] != 4: + return None + + strategy = document["strategy"] + runtime = document["runtime"] + parameters = document["parameters"] + if ( + type(strategy) is not dict + or frozenset(strategy) != frozenset(("id",)) + or type(strategy["id"]) is not str + or strategy["id"] != f"example.007_ctp.simnow_penetration.{case_id}" + ): + return None + if ( + type(runtime) is not dict + or frozenset(runtime) != frozenset(("mode", "preset")) + or type(runtime["mode"]) is not str + or runtime["mode"] != "simulation" + or type(runtime["preset"]) is not str + or runtime["preset"] != "sandbox" + ): + return None + if not ( + type(parameters) is dict + and frozenset(parameters) == frozenset(("scenario",)) + and type(parameters["scenario"]) is str + and parameters["scenario"] == case_id + ): + return None + return hashlib.sha256(raw).hexdigest() + + +def _has_exact_case_config(case_file: Path, case_id: str) -> bool: + """Validate the exact non-secret case-local schema-v4 configuration.""" + + return _validated_case_config_digest(case_file, case_id) is not None + + +def _emit_blocked( + reason: str, + *, + case_id: Optional[str] = None, + scenario_id: Optional[str] = None, +) -> int: + """Print only code-owned identifiers and zero-I/O staging facts.""" + + result: dict[str, Any] = { + "status": "BLOCKED", + "reason": reason, + "evidence_boundary": "STAGING_ONLY_NOT_AUTHORITY", + "external_request_counts": {"network": 0, "order_write": 0}, + } + if case_id is not None: + result["case_id"] = case_id + if scenario_id is not None: + result["scenario_id"] = scenario_id + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 2 + + +def main(case_id: str, case_file: object) -> int: + """Validate a staged case and always return a redacted BLOCKED result. + + Each future ``cases//run.py`` wrapper should call + ``main(CASE_ID, __file__)``. A well-formed case-local config reaches the + explicit ``managed_ctp_certification_not_registered`` result; validation + failures remain blocked with generic redacted reasons. + """ + + try: + scenarios = _load_code_owned_scenarios() + except _ScopeRejected as exc: + return _emit_blocked(exc.reason) + + if type(case_id) is not str or case_id not in scenarios: + return _emit_blocked("managed_ctp_certification_case_id_invalid") + + scenario = scenarios[case_id] + if not _is_exact_direct_case_path(case_id, case_file): + return _emit_blocked( + "managed_ctp_certification_path_invalid", case_id=case_id + ) + + case_path = _expected_script(case_id) + try: + config_valid = _has_exact_case_config(case_path, case_id) + except _ScopeRejected as exc: + return _emit_blocked( + exc.reason, case_id=case_id, scenario_id=scenario.scenario_id + ) + if not config_valid: + return _emit_blocked( + "managed_ctp_certification_config_invalid", + case_id=case_id, + scenario_id=scenario.scenario_id, + ) + + return _emit_blocked( + "managed_ctp_certification_not_registered", + case_id=case_id, + scenario_id=scenario.scenario_id, + ) + + +__all__ = ["main"] diff --git a/examples/007_ctp/live_certification/simnow_penetration/managed_case_front_selection.py b/examples/007_ctp/live_certification/simnow_penetration/managed_case_front_selection.py new file mode 100644 index 00000000..08aca983 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/managed_case_front_selection.py @@ -0,0 +1,466 @@ +"""Offline, non-authorizing 007 front-selection receipts. + +A receipt binds one bounded local TCP front-check result to an issued case +scope and the freshly revalidated sealed 007 config. It carries only digests, +indexes, counts, and local origins; no endpoint or credential values. It does +not authenticate a provider, authorize execution, or certify a case. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import weakref +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, NoReturn + +from backtrader_runtime import ctp_configured_front_check +from backtrader_runtime.ctp_simnow_managed_operator import ( + ctp_simnow_front_pair_set_sha256, +) +from backtrader_runtime.ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding +from backtrader_runtime.errors import RuntimeConfigError +from backtrader_runtime.inventory import ITERATION41_007_CTP_PRIVATE_RUNTIME_ID +from backtrader_runtime.registry import ( + EffectiveRuntimeConfig, + RuntimeRegistry, + require_effective_runtime_config_seal, + validate_runtime_config, +) + +from . import managed_case_scope + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_RECEIPT_ORIGIN = "local_007_configured_tcp_front_check" +_CLOCK_ORIGIN = "local_system_utc_clock" +_ISSUED_RECEIPT_REFS: dict[ + int, weakref.ReferenceType[ManagedCaseFrontSelectionReceipt] +] = {} + + +class ManagedCaseFrontSelectionError(ValueError): + """Redacted rejection for the offline 007 front-selection receipt.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +def _reject(reason: str) -> NoReturn: + raise ManagedCaseFrontSelectionError(reason) + + +def _digest(value: Any, reason: str) -> str: + try: + raw = json.dumps( + value, + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + except (TypeError, ValueError): + _reject(reason) + return hashlib.sha256(raw).hexdigest() + + +def _current_007_config( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> tuple[EffectiveRuntimeConfig, str, int]: + if ( + type(effective) is not EffectiveRuntimeConfig + or type(registry) is not RuntimeRegistry + ): + _reject("front_selection_sealed_runtime_required") + try: + require_effective_runtime_config_seal(effective, registry) + current = validate_runtime_config(effective.registration.runtime_dir, registry) + require_effective_runtime_config_seal(current, registry) + if ( + current.registration is not effective.registration + or current.registration.runtime_id != ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + or current.config.config_digest != effective.config.config_digest + or current.effective_digest != effective.effective_digest + ): + _reject("front_selection_runtime_config_changed") + binding = registry.require_ctp_simnow_readonly_binding( + current.registration.runtime_id + ) + if ( + type(binding) is not CtpSimNowConfigReadOnlyBinding + or binding.runtime_id != ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + ): + _reject("front_selection_config_binding_invalid") + private, registration, front_pairs = binding._sealed_private_config( + current, registry + ) + if ( + registration is not current.registration + or type(front_pairs) is not tuple + or not 1 <= len(front_pairs) <= 8 + or any( + not isinstance(pair, Mapping) + or set(pair) != {"md_front", "td_front"} + or type(pair.get("md_front")) is not str + or type(pair.get("td_front")) is not str + for pair in front_pairs + ) + or type(getattr(private, "instrument_id", None)) is not str + ): + _reject("front_selection_config_invalid") + pair_set_sha256 = ctp_simnow_front_pair_set_sha256(front_pairs) + except ManagedCaseFrontSelectionError: + raise + except ( + RuntimeConfigError, + TypeError, + ValueError, + AttributeError, + KeyError, + OSError, + ): + _reject("front_selection_sealed_runtime_invalid") + return current, pair_set_sha256, len(front_pairs) + + +def _probe_payload(result: Any) -> dict[str, Any]: + """Project only the checker’s endpoint-free counts/status for hashing.""" + + if ( + type(result) is not ctp_configured_front_check.CtpConfiguredFrontCheckResult + or result.status != "selected" + or result.reason != "selected_configured_pair" + or type(result.configured_pair_count) is not int + or type(result.selected_config_index) is not int + or type(result.pairs) is not tuple + or len(result.pairs) != result.configured_pair_count + or not 1 <= result.configured_pair_count <= 8 + or not 0 <= result.selected_config_index < result.configured_pair_count + ): + _reject("front_selection_probe_not_selected") + rows = [] + for expected_index, pair in enumerate(result.pairs): + if ( + type(pair) is not ctp_configured_front_check.CtpConfiguredFrontPairCheck + or pair.config_index != expected_index + ): + _reject("front_selection_probe_result_invalid") + rows.append( + { + "config_index": pair.config_index, + "md_connected_count": pair.md_connected_count, + "md_sample_count": pair.md_sample_count, + "status": pair.status, + "td_connected_count": pair.td_connected_count, + "td_sample_count": pair.td_sample_count, + } + ) + if result.pairs[result.selected_config_index].status != "reachable": + _reject("front_selection_probe_result_invalid") + return { + "configured_pair_count": result.configured_pair_count, + "pairs": rows, + "reason": result.reason, + "selected_config_index": result.selected_config_index, + "status": result.status, + } + + +def _case_scope_sha256(case_scope: managed_case_scope.CertificationCaseScope) -> str: + return _digest( + { + "account_identity_scheme": case_scope.account_identity_scheme, + "account_identity_sha256": case_scope.account_identity_sha256, + "case_config_digest": case_scope.case_config_digest, + "case_id": case_scope.case_id, + "runner_digest": case_scope.runner_digest, + "runtime_id": case_scope.runtime_id, + "scenario_id": case_scope.scenario_id, + "strategy_plan_digest": case_scope.strategy_plan_digest, + "suite_config_digest": case_scope.suite_config_digest, + "suite_effective_digest": case_scope.suite_effective_digest, + }, + "front_selection_case_scope_digest_failed", + ) + + +def _receipt_payload(receipt: ManagedCaseFrontSelectionReceipt) -> dict[str, Any]: + return { + "case_id": receipt.case_id, + "case_scope_sha256": receipt.case_scope_sha256, + "checked_at_utc": receipt.checked_at_utc, + "clock_origin": receipt.clock_origin, + "config_digest": receipt.config_digest, + "effective_digest": receipt.effective_digest, + "ordered_front_pair_set_sha256": receipt.ordered_front_pair_set_sha256, + "probe_origin": receipt.probe_origin, + "probe_result_sha256": receipt.probe_result_sha256, + "receipt_version": receipt.receipt_version, + "runtime_id": receipt.runtime_id, + "scenario_id": receipt.scenario_id, + "selected_config_index": receipt.selected_config_index, + "configured_pair_count": receipt.configured_pair_count, + } + + +@dataclass(frozen=True) +class ManagedCaseFrontSelectionReceipt: + """Immutable local front-check receipt bound to one issued 007 case scope.""" + + receipt_version: int + case_id: str + case_scope_sha256: str + scenario_id: str + runtime_id: str + config_digest: str + effective_digest: str + ordered_front_pair_set_sha256: str + selected_config_index: int + configured_pair_count: int + probe_result_sha256: str + probe_origin: str + checked_at_utc: str + clock_origin: str + receipt_sha256: str + _case_scope: managed_case_scope.CertificationCaseScope = field( + repr=False, compare=False + ) + _probe_result: Any = field(repr=False, compare=False) + + def __post_init__(self) -> None: + if ( + type(self.receipt_version) is not int + or self.receipt_version != 1 + or type(self.case_id) is not str + or type(self.scenario_id) is not str + or self.runtime_id != ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + or any( + _SHA256_RE.fullmatch(value) is None + for value in ( + self.case_scope_sha256, + self.config_digest, + self.effective_digest, + self.ordered_front_pair_set_sha256, + self.probe_result_sha256, + self.receipt_sha256, + ) + ) + or type(self.selected_config_index) is not int + or type(self.configured_pair_count) is not int + or not 1 <= self.configured_pair_count <= 8 + or not 0 <= self.selected_config_index < self.configured_pair_count + or self.probe_origin != _RECEIPT_ORIGIN + or type(self.checked_at_utc) is not str + or not self.checked_at_utc.endswith("+00:00") + or self.clock_origin != _CLOCK_ORIGIN + or type(self._case_scope) is not managed_case_scope.CertificationCaseScope + ): + raise ValueError("front_selection_receipt_invalid") + + def as_redacted_dict(self) -> dict[str, Any]: + """Expose local provenance without endpoints, account IDs, or secrets.""" + + return { + "authorization_granted": False, + "case_id": self.case_id, + "case_scope_sha256": self.case_scope_sha256, + "checked_at_utc": self.checked_at_utc, + "clock_origin": self.clock_origin, + "config_digest": self.config_digest, + "configured_pair_count": self.configured_pair_count, + "effective_digest": self.effective_digest, + "ordered_front_pair_set_sha256": self.ordered_front_pair_set_sha256, + "probe_origin": self.probe_origin, + "probe_result_sha256": self.probe_result_sha256, + "provider_authenticated": False, + "receipt_sha256": self.receipt_sha256, + "runtime_id": self.runtime_id, + "scenario_id": self.scenario_id, + "selected_config_index": self.selected_config_index, + } + + +def _remember_receipt(receipt: ManagedCaseFrontSelectionReceipt) -> None: + identity = id(receipt) + + def discard( + reference: weakref.ReferenceType[ManagedCaseFrontSelectionReceipt], + ) -> None: + if _ISSUED_RECEIPT_REFS.get(identity) is reference: + _ISSUED_RECEIPT_REFS.pop(identity, None) + + _ISSUED_RECEIPT_REFS[identity] = weakref.ref(receipt, discard) + + +def check_case_front_selection( + case_scope: managed_case_scope.CertificationCaseScope, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, +) -> ManagedCaseFrontSelectionReceipt: + """Run the configured TCP check and issue a local, non-authorizing receipt.""" + + if ( + type(case_scope) is not managed_case_scope.CertificationCaseScope + or not managed_case_scope._is_issued_scope(case_scope) + or case_scope.runtime_id != ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + ): + _reject("front_selection_case_scope_invalid") + current, pair_set_sha256, pair_count = _current_007_config(effective, registry) + if ( + case_scope.suite_config_digest != current.config.config_digest + or case_scope.suite_effective_digest != current.effective_digest + ): + _reject("front_selection_case_config_mismatch") + + try: + result = ctp_configured_front_check.check_configured_ctp_fronts( + current, registry + ) + except (RuntimeConfigError, OSError, TypeError, ValueError): + _reject("front_selection_probe_rejected") + payload = _probe_payload(result) + if result.configured_pair_count != pair_count: + _reject("front_selection_pair_count_mismatch") + + # Re-read the sealed config after probing to catch config drift during the + # local socket sample window before binding the result to this identity. + after, after_pair_set_sha256, after_pair_count = _current_007_config( + effective, registry + ) + if ( + after.registration is not current.registration + or after.config.config_digest != current.config.config_digest + or after.effective_digest != current.effective_digest + or after_pair_set_sha256 != pair_set_sha256 + or after_pair_count != pair_count + ): + _reject("front_selection_runtime_config_changed_during_probe") + + now = datetime.now(timezone.utc).isoformat() + probe_digest = _digest(payload, "front_selection_probe_digest_failed") + provisional = { + "case_id": case_scope.case_id, + "case_scope_sha256": _case_scope_sha256(case_scope), + "checked_at_utc": now, + "clock_origin": _CLOCK_ORIGIN, + "config_digest": current.config.config_digest, + "effective_digest": current.effective_digest, + "ordered_front_pair_set_sha256": pair_set_sha256, + "probe_origin": _RECEIPT_ORIGIN, + "probe_result_sha256": probe_digest, + "receipt_version": 1, + "runtime_id": current.registration.runtime_id, + "scenario_id": case_scope.scenario_id, + "selected_config_index": result.selected_config_index, + "configured_pair_count": pair_count, + } + receipt_digest = _digest(provisional, "front_selection_receipt_digest_failed") + receipt = ManagedCaseFrontSelectionReceipt( + receipt_version=1, + case_id=case_scope.case_id, + case_scope_sha256=_case_scope_sha256(case_scope), + scenario_id=case_scope.scenario_id, + runtime_id=current.registration.runtime_id, + config_digest=current.config.config_digest, + effective_digest=current.effective_digest, + ordered_front_pair_set_sha256=pair_set_sha256, + selected_config_index=result.selected_config_index, + configured_pair_count=pair_count, + probe_result_sha256=probe_digest, + probe_origin=_RECEIPT_ORIGIN, + checked_at_utc=now, + clock_origin=_CLOCK_ORIGIN, + receipt_sha256=receipt_digest, + _case_scope=case_scope, + _probe_result=result, + ) + _remember_receipt(receipt) + return receipt + + +def validate_case_front_selection_receipt( + receipt: ManagedCaseFrontSelectionReceipt, + case_scope: managed_case_scope.CertificationCaseScope, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, +) -> None: + """Revalidate an issued receipt against its case scope and current config.""" + + reference = _ISSUED_RECEIPT_REFS.get(id(receipt)) + if ( + type(receipt) is not ManagedCaseFrontSelectionReceipt + or reference is None + or reference() is not receipt + or type(case_scope) is not managed_case_scope.CertificationCaseScope + or not managed_case_scope._is_issued_scope(case_scope) + or receipt._case_scope is not case_scope + or receipt.case_id != case_scope.case_id + or receipt.case_scope_sha256 != _case_scope_sha256(case_scope) + or receipt.scenario_id != case_scope.scenario_id + ): + _reject("front_selection_receipt_invalid") + current, pair_set_sha256, pair_count = _current_007_config(effective, registry) + if ( + receipt.runtime_id != current.registration.runtime_id + or receipt.config_digest != current.config.config_digest + or receipt.effective_digest != current.effective_digest + or receipt.config_digest != case_scope.suite_config_digest + or receipt.effective_digest != case_scope.suite_effective_digest + or receipt.ordered_front_pair_set_sha256 != pair_set_sha256 + or receipt.configured_pair_count != pair_count + or receipt.selected_config_index >= pair_count + ): + _reject("front_selection_receipt_config_mismatch") + try: + payload = _probe_payload(receipt._probe_result) + probe_digest = _digest(payload, "front_selection_probe_digest_failed") + expected_receipt_digest = _digest( + _receipt_payload(receipt), "front_selection_receipt_digest_failed" + ) + except ManagedCaseFrontSelectionError: + raise + except (AttributeError, TypeError, ValueError, KeyError): + _reject("front_selection_receipt_invalid") + if ( + receipt._probe_result.selected_config_index != receipt.selected_config_index + or receipt._probe_result.configured_pair_count != receipt.configured_pair_count + or probe_digest != receipt.probe_result_sha256 + or expected_receipt_digest != receipt.receipt_sha256 + ): + _reject("front_selection_receipt_digest_mismatch") + + +def validate_receipt_matches_selection( + receipt: ManagedCaseFrontSelectionReceipt, + case_scope: managed_case_scope.CertificationCaseScope, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + *, + config_digest: str, + effective_digest: str, + ordered_front_pair_set_sha256: str, + selected_config_index: int, +) -> None: + """Validate issuance/config freshness before matching invocation identity.""" + + validate_case_front_selection_receipt(receipt, case_scope, effective, registry) + if ( + config_digest != receipt.config_digest + or effective_digest != receipt.effective_digest + or ordered_front_pair_set_sha256 != receipt.ordered_front_pair_set_sha256 + or type(selected_config_index) is not int + or selected_config_index != receipt.selected_config_index + ): + _reject("front_selection_receipt_selection_mismatch") + + +__all__ = [ + "ManagedCaseFrontSelectionError", + "ManagedCaseFrontSelectionReceipt", + "check_case_front_selection", + "validate_case_front_selection_receipt", + "validate_receipt_matches_selection", +] diff --git a/examples/007_ctp/live_certification/simnow_penetration/managed_case_invocation.py b/examples/007_ctp/live_certification/simnow_penetration/managed_case_invocation.py new file mode 100644 index 00000000..b89e09d4 --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/managed_case_invocation.py @@ -0,0 +1,680 @@ +"""Bind a validated 007 case scope to one immutable selected run identity. + +This is offline provenance plumbing only. It does not authenticate a front +probe, own a lease, open a provider session, authorize dispatch, or certify a +case. Callers must supply the already sealed runtime/config objects they +validated for the invocation. Caller-supplied lease snapshots are rejected +until this offline binder has a trusted current lease-owner verifier; issued +bindings therefore carry no verified lease generation. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from statistics import median +import weakref +from dataclasses import dataclass, field +from typing import Any, NoReturn, Optional + +from backtrader_runtime.ctp_front_pair_probe import ( + CtpConfiguredFrontPair, + CtpFrontEndpointEvidence, + CtpFrontPairEvidence, + CtpFrontPairSelection, + CtpFrontProbeSample, +) +from backtrader_runtime.ctp_simnow_managed_md_bridge import ManagedCtpMdLeaseSnapshot +from backtrader_runtime.ctp_simnow_managed_operator import ( + CtpSimNowManagedScopeSelection, + _private_config, + ctp_simnow_front_pair_set_sha256, +) +from backtrader_runtime.ctp_simulation_execution import CtpSimulationExecutionRegistration +from backtrader_runtime.registry import ( + EffectiveRuntimeConfig, + RuntimeRegistry, + require_effective_runtime_config_seal, + validate_runtime_config, +) + +from . import managed_case_scope + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_INVOCATION_ID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +_ISSUED_BINDING_REFS: dict[int, weakref.ReferenceType["ManagedCaseInvocationBinding"]] = {} + + +class ManagedCaseInvocationError(ValueError): + """Redacted rejection from offline case invocation binding.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +def _reject(reason: str) -> NoReturn: + raise ManagedCaseInvocationError(reason) + + +def _require_digest(value: Any, reason: str) -> str: + if type(value) is not str or _SHA256_RE.fullmatch(value) is None: + _reject(reason) + return value + + +def _json_digest(value: Any, reason: str) -> str: + try: + payload = json.dumps( + value, + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + except Exception: + _reject(reason) + return hashlib.sha256(payload).hexdigest() + + +def _front_pair_data(pair: CtpConfiguredFrontPair) -> dict[str, str]: + if type(pair) is not CtpConfiguredFrontPair: + _reject("invocation_selection_invalid") + if type(pair.md_front) is not str or type(pair.td_front) is not str: + _reject("invocation_selection_invalid") + return {"md_front": pair.md_front, "td_front": pair.td_front} + + +def _number(value: Any, reason: str) -> Optional[float]: + if value is None: + return None + if ( + isinstance(value, bool) + or type(value) not in (int, float) + or not math.isfinite(float(value)) + or value < 0 + ): + _reject(reason) + return float(value) + + +def _endpoint_projection( + endpoint: CtpFrontEndpointEvidence, + expected_front: str, + repeated_samples: int, +) -> tuple[dict[str, Any], Optional[float]]: + if ( + type(endpoint) is not CtpFrontEndpointEvidence + or type(endpoint.front) is not str + or endpoint.front != expected_front + or type(endpoint.samples) is not tuple + or len(endpoint.samples) != repeated_samples + ): + _reject("invocation_selection_invalid") + + sample_rows = [] + successful_latencies = [] + for sample in endpoint.samples: + if type(sample) is not CtpFrontProbeSample or type(sample.connected) is not bool: + _reject("invocation_selection_invalid") + latency = _number(sample.latency_ms, "invocation_selection_invalid") + dns_latency = _number(sample.dns_resolution_ms, "invocation_selection_invalid") + tcp_latency = _number(sample.tcp_connect_ms, "invocation_selection_invalid") + if sample.failure is not None and ( + type(sample.failure) is not str or not sample.failure or len(sample.failure) > 64 + ): + _reject("invocation_selection_invalid") + if sample.connected: + if ( + sample.failure is not None + or latency is None + or dns_latency is None + or tcp_latency is None + # Match ctp_front_pair_probe's millisecond rounding tolerance. + or abs(latency - dns_latency - tcp_latency) > 0.00001 + ): + _reject("invocation_selection_invalid") + successful_latencies.append(latency) + elif ( + sample.failure is None + or latency is not None + or dns_latency is not None + or tcp_latency is not None + ): + _reject("invocation_selection_invalid") + sample_rows.append( + { + "connected": sample.connected, + "dns_resolution_ms": dns_latency, + "failure": sample.failure, + "latency_ms": latency, + "tcp_connect_ms": tcp_latency, + } + ) + + minimum_successes = repeated_samples // 2 + 1 + if len(successful_latencies) < minimum_successes: + endpoint_median = None + else: + endpoint_median = float(median(successful_latencies)) + return {"front": endpoint.front, "samples": sample_rows}, endpoint_median + + +def _selection_digest( + selection: CtpSimNowManagedScopeSelection, + configured_pairs: tuple[tuple[str, str], ...], +) -> str: + """Validate selection evidence consistency and hash its canonical projection. + + Probe samples remain unauthenticated transport observations. This check + only mirrors the selector's data-shape and ranking rules. + """ + try: + front_selection = selection.front_pair_selection + if ( + type(front_selection) is not CtpFrontPairSelection + or type(front_selection.pair) is not CtpConfiguredFrontPair + or type(front_selection.evidence) is not tuple + or type(front_selection.config_index) is not int + or type(front_selection.repeated_samples) is not int + or not 1 <= front_selection.repeated_samples <= 5 + or type(front_selection.timeout_seconds) not in (int, float) + or isinstance(front_selection.timeout_seconds, bool) + or not math.isfinite(float(front_selection.timeout_seconds)) + or not 0 < front_selection.timeout_seconds <= 10 + or type(front_selection.latency_score_ms) not in (int, float) + or isinstance(front_selection.latency_score_ms, bool) + or not math.isfinite(float(front_selection.latency_score_ms)) + or front_selection.latency_score_ms < 0 + or len(front_selection.evidence) != len(configured_pairs) + ): + _reject("invocation_selection_invalid") + + evidence_rows = [] + eligible = [] + for index, item in enumerate(front_selection.evidence): + if ( + type(item) is not CtpFrontPairEvidence + or type(item.config_index) is not int + or item.config_index != index + or type(item.reachable) is not bool + or (item.pair.md_front, item.pair.td_front) != configured_pairs[index] + ): + _reject("invocation_selection_invalid") + md_row, md_median = _endpoint_projection( + item.md, configured_pairs[index][0], front_selection.repeated_samples + ) + td_row, td_median = _endpoint_projection( + item.td, configured_pairs[index][1], front_selection.repeated_samples + ) + expected_reachable = md_median is not None and td_median is not None + expected_score = max(md_median, td_median) if expected_reachable else None + score = _number(item.latency_score_ms, "invocation_selection_invalid") + if item.reachable is not expected_reachable or score != expected_score: + _reject("invocation_selection_invalid") + if expected_reachable: + eligible.append((expected_score, index)) + evidence_rows.append( + { + "config_index": index, + "latency_score_ms": score, + "md": md_row, + "pair": _front_pair_data(item.pair), + "reachable": item.reachable, + "td": td_row, + } + ) + + if not eligible: + _reject("invocation_selection_invalid") + best_score, best_index = min(eligible, key=lambda item: (item[0], item[1])) + if ( + not 0 <= front_selection.config_index < len(configured_pairs) + or front_selection.config_index != best_index + or front_selection.pair != front_selection.evidence[best_index].pair + or float(front_selection.latency_score_ms) != best_score + ): + _reject("invocation_selection_invalid") + if type(selection.execution_registration) is not CtpSimulationExecutionRegistration: + _reject("invocation_selection_invalid") + registration_digest = selection.execution_registration.digest + _require_digest(registration_digest, "invocation_selection_invalid") + return _json_digest( + { + "config_digest": selection.config_digest, + "effective_digest": selection.effective_digest, + "evidence": evidence_rows, + "front_pair_set_sha256": selection.front_pair_set_sha256, + "profile_digest": selection.profile_digest, + "registration_digest": registration_digest, + "selected_config_index": front_selection.config_index, + "selected_pair": _front_pair_data(front_selection.pair), + }, + "invocation_selection_invalid", + ) + except ManagedCaseInvocationError: + raise + except Exception: + _reject("invocation_selection_invalid") + + +def _validate_lease_snapshot( + snapshot: Optional[ManagedCtpMdLeaseSnapshot], account_fingerprint: str +) -> Optional[int]: + """Accept no lease claim until a trusted owner can verify current state.""" + + if snapshot is None: + return None + if ( + type(snapshot) is not ManagedCtpMdLeaseSnapshot + or type(snapshot.account_fingerprint_sha256) is not str + or _SHA256_RE.fullmatch(snapshot.account_fingerprint_sha256) is None + or snapshot.account_fingerprint_sha256 != account_fingerprint + or type(snapshot.lease_generation) is not int + or snapshot.lease_generation <= 0 + or snapshot.active is not True + ): + _reject("invocation_lease_snapshot_invalid") + # This public frozen dataclass is caller-constructible and replayable. + # Field validation cannot prove owner issuance or lease freshness. + _reject("invocation_lease_verifier_unavailable") + + +def _current_sealed_config( + effective: EffectiveRuntimeConfig, registry: RuntimeRegistry +) -> tuple[EffectiveRuntimeConfig, Any]: + if type(effective) is not EffectiveRuntimeConfig or type(registry) is not RuntimeRegistry: + _reject("invocation_sealed_runtime_required") + try: + require_effective_runtime_config_seal(effective, registry) + current = validate_runtime_config(effective.registration.runtime_dir, registry) + require_effective_runtime_config_seal(current, registry) + except Exception: + _reject("invocation_sealed_runtime_invalid") + if ( + current.registration is not effective.registration + or current.config.config_digest != effective.config.config_digest + or current.effective_digest != effective.effective_digest + ): + _reject("invocation_runtime_config_changed") + try: + private = _private_config(current) + except Exception: + _reject("invocation_private_config_invalid") + return current, private + + +def _require_current_case_files( + case_id: str, scenario_id: str, case_scope: managed_case_scope.CertificationCaseScope +) -> None: + """Recheck the non-secret child config, strategy plan, and wrapper bytes.""" + + entry = managed_case_scope.managed_case_entry + try: + case_file = entry._expected_script(case_id) + if not entry._is_exact_direct_case_path(case_id, case_file): + _reject("invocation_case_files_invalid") + runner_digest = hashlib.sha256(case_file.read_bytes()).hexdigest() + case_config_digest = entry._validated_case_config_digest(case_file, case_id) + strategy_path = case_file.parent / f"{case_id}_strategy.py" + if strategy_path.is_symlink() or not strategy_path.is_file(): + _reject("invocation_case_files_invalid") + strategy_plan = managed_case_scope.load_descriptive_case_plan( + strategy_path, + expected_case_id=case_id, + expected_scenario_id=scenario_id, + ) + except ManagedCaseInvocationError: + raise + except Exception: + _reject("invocation_case_files_invalid") + if ( + case_config_digest is None + or runner_digest != case_scope.runner_digest + or case_config_digest != case_scope.case_config_digest + or strategy_plan.source_sha256 != case_scope.strategy_plan_digest + ): + _reject("invocation_case_files_changed") + + +def _validate_inputs( + case_scope: managed_case_scope.CertificationCaseScope, + selection: CtpSimNowManagedScopeSelection, + invocation_id: str, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + lease_snapshot: Optional[ManagedCtpMdLeaseSnapshot], +) -> dict[str, Any]: + if ( + type(case_scope) is not managed_case_scope.CertificationCaseScope + or not managed_case_scope._is_issued_scope(case_scope) + ): + _reject("invocation_case_scope_invalid") + if type(selection) is not CtpSimNowManagedScopeSelection: + _reject("invocation_selection_invalid") + if ( + type(invocation_id) is not str + or _INVOCATION_ID_RE.fullmatch(invocation_id) is None + ): + _reject("invocation_id_invalid") + + try: + scenario = managed_case_scope.managed_case_entry._load_code_owned_scenarios().get( + case_scope.case_id + ) + except Exception: + _reject("invocation_case_scope_invalid") + if ( + scenario is None + or case_scope.scenario_id != scenario.scenario_id + or case_scope.account_identity_scheme != managed_case_scope.ACCOUNT_IDENTITY_SCHEME + ): + _reject("invocation_case_scope_invalid") + _require_current_case_files(case_scope.case_id, scenario.scenario_id, case_scope) + for value in ( + case_scope.account_identity_sha256, + case_scope.suite_config_digest, + case_scope.suite_effective_digest, + case_scope.case_config_digest, + case_scope.strategy_plan_digest, + case_scope.runner_digest, + ): + _require_digest(value, "invocation_case_scope_invalid") + + current, private = _current_sealed_config(effective, registry) + if ( + type(selection.execution_registration) is not CtpSimulationExecutionRegistration + or type(selection.front_pair_selection) is not CtpFrontPairSelection + ): + _reject("invocation_selection_invalid") + registration = selection.execution_registration + front_selection = selection.front_pair_selection + if ( + case_scope.runtime_id != current.registration.runtime_id + or case_scope.suite_config_digest != current.config.config_digest + or case_scope.suite_effective_digest != current.effective_digest + or selection.config_digest != current.config.config_digest + or selection.effective_digest != current.effective_digest + or registration.runtime_registration is not current.registration + or registration.config_digest != current.config.config_digest + or registration.effective_digest != current.effective_digest + or registration.profile_digest + != (None if current.profile is None else current.profile.digest) + or selection.profile_digest != registration.profile_digest + ): + _reject("invocation_config_scope_mismatch") + + try: + case_account_digest = managed_case_scope._account_identity_sha256(current) + account_fingerprint = hashlib.sha256( + f"{private.broker_id}:{private.user_id}".encode("utf-8") + ).hexdigest()[:16] + execution_account_digest = hashlib.sha256( + ("acct_" + account_fingerprint).encode("ascii") + ).hexdigest() + configured_pairs = tuple( + (pair["md_front"], pair["td_front"]) for pair in private.front_pairs + ) + pair_set_digest = ctp_simnow_front_pair_set_sha256(private.front_pairs) + except Exception: + _reject("invocation_private_config_invalid") + selection_digest = _selection_digest(selection, configured_pairs) + selected_pair = (front_selection.pair.md_front, front_selection.pair.td_front) + if ( + case_scope.account_identity_scheme != managed_case_scope.ACCOUNT_IDENTITY_SCHEME + or case_scope.account_identity_sha256 != case_account_digest + or registration.account_fingerprint_sha256 != execution_account_digest + ): + _reject("invocation_account_scope_mismatch") + if ( + registration.instrument_id != private.instrument_id + or registration.exchange_id != private.exchange_id + or registration.hedge_flag != private.hedge_flag + ): + _reject("invocation_contract_scope_mismatch") + if ( + selection.front_pair_set_sha256 != pair_set_digest + or registration.front_pair_set_sha256 != pair_set_digest + or type(front_selection.config_index) is not int + or not 0 <= front_selection.config_index < len(configured_pairs) + or configured_pairs[front_selection.config_index] != selected_pair + or selected_pair != (registration.md_front, registration.td_front) + ): + _reject("invocation_pair_scope_mismatch") + + lease_generation = _validate_lease_snapshot( + lease_snapshot, registration.account_fingerprint_sha256 + ) + registration_digest = _require_digest( + registration.digest, "invocation_registration_invalid" + ) + return { + "account_identity_scheme": case_scope.account_identity_scheme, + "account_identity_sha256": case_account_digest, + "account_fingerprint_sha256": registration.account_fingerprint_sha256, + "case_id": case_scope.case_id, + "case_scope": case_scope, + "case_scope_sha256": _json_digest( + { + "account_identity_scheme": case_scope.account_identity_scheme, + "account_identity_sha256": case_scope.account_identity_sha256, + "case_config_digest": case_scope.case_config_digest, + "case_id": case_scope.case_id, + "runner_digest": case_scope.runner_digest, + "runtime_id": case_scope.runtime_id, + "scenario_id": case_scope.scenario_id, + "strategy_plan_digest": case_scope.strategy_plan_digest, + "suite_config_digest": case_scope.suite_config_digest, + "suite_effective_digest": case_scope.suite_effective_digest, + }, + "invocation_case_scope_invalid", + ), + "config_digest": current.config.config_digest, + "effective_digest": current.effective_digest, + "exchange_id": registration.exchange_id, + "instrument_id": registration.instrument_id, + "invocation_id": invocation_id, + "lease_generation": lease_generation, + "md_front": registration.md_front, + "pair_sha256": _json_digest( + [registration.md_front, registration.td_front], + "invocation_pair_scope_mismatch", + ), + "pair_set_sha256": pair_set_digest, + "registration_digest": registration_digest, + "runner_digest": case_scope.runner_digest, + "scenario_id": case_scope.scenario_id, + "selection": selection, + "selection_digest": selection_digest, + "strategy_plan_digest": case_scope.strategy_plan_digest, + "td_front": registration.td_front, + "lease_snapshot": lease_snapshot, + } + + +@dataclass(frozen=True) +class ManagedCaseInvocationBinding: + """Immutable, non-authorizing identities for one 007 case invocation.""" + + invocation_id: str + case_id: str + scenario_id: str + account_identity_scheme: str + account_identity_sha256: str + config_digest: str + effective_digest: str + pair_set_sha256: str + pair_sha256: str + registration_digest: str + selection_digest: str + instrument_id: str + exchange_id: str + strategy_plan_digest: str + runner_digest: str + # Always None until a trusted live lease-owner verifier is integrated. + lease_generation: Optional[int] + context_sha256: str + md_front: str = field(repr=False) + td_front: str = field(repr=False) + _case_scope: managed_case_scope.CertificationCaseScope = field(repr=False, compare=False) + _selection: CtpSimNowManagedScopeSelection = field(repr=False, compare=False) + _lease_snapshot: Optional[ManagedCtpMdLeaseSnapshot] = field(repr=False, compare=False) + + def as_redacted_dict(self) -> dict[str, Any]: + """Return a log-safe summary with no endpoint or credential values.""" + + return { + "account_identity_scheme": self.account_identity_scheme, + "account_identity_sha256": self.account_identity_sha256, + "case_id": self.case_id, + "config_digest": self.config_digest, + "context_sha256": self.context_sha256, + "effective_digest": self.effective_digest, + "exchange_id": self.exchange_id, + "instrument_id": self.instrument_id, + "invocation_id": self.invocation_id, + "lease_generation": self.lease_generation, + "pair_sha256": self.pair_sha256, + "pair_set_sha256": self.pair_set_sha256, + "registration_digest": self.registration_digest, + "runner_digest": self.runner_digest, + "scenario_id": self.scenario_id, + "selection_digest": self.selection_digest, + "strategy_plan_digest": self.strategy_plan_digest, + } + + +def _remember_binding(binding: ManagedCaseInvocationBinding) -> None: + identity = id(binding) + + def discard(reference: weakref.ReferenceType[ManagedCaseInvocationBinding]) -> None: + if _ISSUED_BINDING_REFS.get(identity) is reference: + _ISSUED_BINDING_REFS.pop(identity, None) + + _ISSUED_BINDING_REFS[identity] = weakref.ref(binding, discard) + + +def bind_case_invocation( + case_scope: managed_case_scope.CertificationCaseScope, + selection: CtpSimNowManagedScopeSelection, + invocation_id: str, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + *, + lease_snapshot: Optional[ManagedCtpMdLeaseSnapshot] = None, +) -> ManagedCaseInvocationBinding: + """Bind offline provenance; ``lease_snapshot`` must be None without a trusted owner verifier.""" + + values = _validate_inputs( + case_scope, selection, invocation_id, effective, registry, lease_snapshot + ) + payload = { + key: value + for key, value in values.items() + if key + not in { + "case_scope", + "lease_snapshot", + "md_front", + "selection", + "td_front", + } + } + context_sha256 = _json_digest(payload, "invocation_binding_invalid") + binding = ManagedCaseInvocationBinding( + invocation_id=invocation_id, + case_id=values["case_id"], + scenario_id=values["scenario_id"], + account_identity_scheme=values["account_identity_scheme"], + account_identity_sha256=values["account_identity_sha256"], + config_digest=values["config_digest"], + effective_digest=values["effective_digest"], + pair_set_sha256=values["pair_set_sha256"], + pair_sha256=values["pair_sha256"], + registration_digest=values["registration_digest"], + selection_digest=values["selection_digest"], + instrument_id=values["instrument_id"], + exchange_id=values["exchange_id"], + strategy_plan_digest=values["strategy_plan_digest"], + runner_digest=values["runner_digest"], + lease_generation=values["lease_generation"], + context_sha256=context_sha256, + md_front=values["md_front"], + td_front=values["td_front"], + _case_scope=case_scope, + _selection=selection, + _lease_snapshot=lease_snapshot, + ) + _remember_binding(binding) + return binding + + +def validate_case_invocation( + binding: ManagedCaseInvocationBinding, + case_scope: managed_case_scope.CertificationCaseScope, + selection: CtpSimNowManagedScopeSelection, + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + *, + lease_snapshot: Optional[ManagedCtpMdLeaseSnapshot] = None, +) -> None: + """Reject drift; caller lease claims remain unsupported without an owner verifier.""" + + reference = _ISSUED_BINDING_REFS.get(id(binding)) + if ( + type(binding) is not ManagedCaseInvocationBinding + or reference is None + or reference() is not binding + or binding._case_scope is not case_scope + or binding._selection is not selection + ): + _reject("invocation_binding_invalid") + values = _validate_inputs( + case_scope, + selection, + binding.invocation_id, + effective, + registry, + lease_snapshot, + ) + payload = { + key: value + for key, value in values.items() + if key + not in { + "case_scope", + "lease_snapshot", + "md_front", + "selection", + "td_front", + } + } + if ( + _json_digest(payload, "invocation_binding_invalid") != binding.context_sha256 + or values["lease_generation"] != binding.lease_generation + or values["selection_digest"] != binding.selection_digest + or values["registration_digest"] != binding.registration_digest + or values["pair_set_sha256"] != binding.pair_set_sha256 + or values["pair_sha256"] != binding.pair_sha256 + or values["account_identity_sha256"] != binding.account_identity_sha256 + or values["config_digest"] != binding.config_digest + or values["effective_digest"] != binding.effective_digest + or values["instrument_id"] != binding.instrument_id + or values["exchange_id"] != binding.exchange_id + or values["strategy_plan_digest"] != binding.strategy_plan_digest + or values["runner_digest"] != binding.runner_digest + or values["md_front"] != binding.md_front + or values["td_front"] != binding.td_front + ): + _reject("invocation_identity_drift") + + +__all__ = [ + "ManagedCaseInvocationBinding", + "ManagedCaseInvocationError", + "bind_case_invocation", + "validate_case_invocation", +] diff --git a/examples/007_ctp/live_certification/simnow_penetration/managed_case_scope.py b/examples/007_ctp/live_certification/simnow_penetration/managed_case_scope.py new file mode 100644 index 00000000..41419a0d --- /dev/null +++ b/examples/007_ctp/live_certification/simnow_penetration/managed_case_scope.py @@ -0,0 +1,281 @@ +"""Bind a staged certification case to the sealed 007 CTP read-only runtime. + +The returned scope is diagnostic provenance only. It does not authorize a +provider session, subscription, order, cancel, or certification PASS. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import weakref +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn + +from backtrader_runtime.ctp_sandbox_readonly_admission import ( + require_ctp_sandbox_profile_runtime, +) +from backtrader_runtime.inventory import ( + ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_007_CTP_PRIVATE_RUNTIME_ID, + ITERATION41_007_CTP_PRIVATE_STRATEGY_ID, +) +from backtrader_runtime.registry import EffectiveRuntimeConfig, RuntimeRegistry + +from . import managed_case_entry +from .common.case_engine import DescriptiveCasePlan, load_descriptive_case_plan +from .common.decision_engine import DecisionScope + + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +ACCOUNT_IDENTITY_SCHEME = "ctp.simnow.broker-user.sha256.v1" +_ACCOUNT_IDENTITY_DOMAIN = b"backtrader.iteration41.007.account-identity\x00" +_ISSUED_SCOPE_REFS: dict[int, weakref.ReferenceType["CertificationCaseScope"]] = {} + + +@dataclass(frozen=True) +class CertificationCaseScope: + """Non-authorizing identities for one real-case execution candidate.""" + + case_id: str + scenario_id: str + runtime_id: str + account_identity_scheme: str + account_identity_sha256: str + suite_config_digest: str + suite_effective_digest: str + case_config_digest: str + strategy_plan_digest: str + runner_digest: str + + +class CertificationCaseScopeError(ValueError): + """Redacted case/runtime binding failure.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +def _reject(reason: str) -> NoReturn: + raise CertificationCaseScopeError(reason) + + +def _account_identity_sha256(effective: EffectiveRuntimeConfig) -> str: + """Derive a pseudonymous account key from the already-sealed private config.""" + + try: + private = effective.config.ctp_simnow + broker_id = private.broker_id + user_id = private.user_id + if ( + type(broker_id) is not str + or not broker_id + or broker_id != broker_id.strip() + or "\x00" in broker_id + or type(user_id) is not str + or not user_id + or user_id != user_id.strip() + or "\x00" in user_id + ): + _reject("certification_account_identity_invalid") + canonical = json.dumps( + [ACCOUNT_IDENTITY_SCHEME, broker_id, user_id], + ensure_ascii=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(_ACCOUNT_IDENTITY_DOMAIN + canonical).hexdigest() + except CertificationCaseScopeError: + raise + except Exception: + _reject("certification_account_identity_invalid") + + +def _remember_issued_scope(scope: CertificationCaseScope) -> None: + """Track exact binder-issued objects to reject replaced/hand-built scopes.""" + + identity = id(scope) + + def discard(reference: weakref.ReferenceType[CertificationCaseScope]) -> None: + if _ISSUED_SCOPE_REFS.get(identity) is reference: + _ISSUED_SCOPE_REFS.pop(identity, None) + + _ISSUED_SCOPE_REFS[identity] = weakref.ref(scope, discard) + + +def _is_issued_scope(scope: CertificationCaseScope) -> bool: + reference = _ISSUED_SCOPE_REFS.get(id(scope)) + return reference is not None and reference() is scope + + +def _bind_case_scope( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + case_id: str, + case_file: object, + *, + expected_runtime_dir: Path, +) -> CertificationCaseScope: + """Shared binder; the public entry fixes the code-owned 007 directory.""" + + if type(effective) is not EffectiveRuntimeConfig or type(registry) is not RuntimeRegistry: + _reject("sealed_ctp_runtime_required") + try: + require_ctp_sandbox_profile_runtime(effective, registry) + registration = registry.require_runtime_dir(expected_runtime_dir) + except Exception: + _reject("sealed_ctp_runtime_required") + if ( + registration is not effective.registration + or registration.runtime_dir != expected_runtime_dir + or registration.runtime_id != ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + or registration.strategy_id != ITERATION41_007_CTP_PRIVATE_STRATEGY_ID + or effective.strategy_id != ITERATION41_007_CTP_PRIVATE_STRATEGY_ID + ): + _reject("certification_runtime_mismatch") + + try: + scenarios = managed_case_entry._load_code_owned_scenarios() + except Exception: + _reject("certification_scenarios_invalid") + if type(case_id) is not str or case_id not in scenarios: + _reject("certification_case_id_invalid") + if not managed_case_entry._is_exact_direct_case_path(case_id, case_file): + _reject("certification_case_path_invalid") + try: + runner_digest = hashlib.sha256( + managed_case_entry._expected_script(case_id).read_bytes() + ).hexdigest() + except (OSError, RuntimeError, ValueError): + _reject("certification_case_path_invalid") + try: + case_digest = managed_case_entry._validated_case_config_digest( + managed_case_entry._expected_script(case_id), case_id + ) + except Exception: + _reject("certification_case_config_invalid") + if case_digest is None: + _reject("certification_case_config_invalid") + + strategy_path = managed_case_entry._expected_script(case_id).parent / f"{case_id}_strategy.py" + try: + if strategy_path.is_symlink() or not strategy_path.is_file(): + _reject("certification_strategy_plan_invalid") + plan = load_descriptive_case_plan( + strategy_path, + expected_case_id=case_id, + expected_scenario_id=scenarios[case_id].scenario_id, + ) + except Exception: + _reject("certification_strategy_plan_invalid") + + account_identity_sha256 = _account_identity_sha256(effective) + scope = CertificationCaseScope( + case_id=case_id, + scenario_id=scenarios[case_id].scenario_id, + runtime_id=registration.runtime_id, + account_identity_scheme=ACCOUNT_IDENTITY_SCHEME, + account_identity_sha256=account_identity_sha256, + suite_config_digest=effective.config_digest, + suite_effective_digest=effective.effective_digest, + case_config_digest=case_digest, + strategy_plan_digest=plan.source_sha256, + runner_digest=runner_digest, + ) + _remember_issued_scope(scope) + return scope + + +def bind_case_scope( + effective: EffectiveRuntimeConfig, + registry: RuntimeRegistry, + case_id: str, + case_file: object, +) -> CertificationCaseScope: + """Bind one case to the exact 007 suite root; confer no I/O authority.""" + + return _bind_case_scope( + effective, + registry, + case_id, + case_file, + expected_runtime_dir=ITERATION41_007_CTP_PRIVATE_RUNTIME_DIR, + ) + + +def decision_scope_from_case_scope( + scope: CertificationCaseScope, plan: DescriptiveCasePlan +) -> DecisionScope: + """Bind the typed review engine to a previously sealed diagnostic scope. + + Only the exact case-scope object issued by the sealed binder in this + process is accepted. This object-identity check is not provider + authentication and grants no dispatch or certification authority. + """ + + if type(scope) is not CertificationCaseScope or type(plan) is not DescriptiveCasePlan: + _reject("certification_decision_scope_invalid") + if not _is_issued_scope(scope): + _reject("certification_decision_scope_invalid") + try: + scenario = managed_case_entry._load_code_owned_scenarios()[scope.case_id] + except (KeyError, ValueError): + _reject("certification_decision_scope_invalid") + if ( + scope.scenario_id != scenario.scenario_id + or scope.runtime_id != ITERATION41_007_CTP_PRIVATE_RUNTIME_ID + or scope.account_identity_scheme != ACCOUNT_IDENTITY_SCHEME + or type(scope.account_identity_sha256) is not str + or _SHA256_RE.fullmatch(scope.account_identity_sha256) is None + or plan.case_id != scope.case_id + or plan.source_sha256 != scope.strategy_plan_digest + or any( + type(value) is not str or _SHA256_RE.fullmatch(value) is None + for value in ( + scope.suite_config_digest, + scope.suite_effective_digest, + scope.case_config_digest, + scope.strategy_plan_digest, + scope.runner_digest, + ) + ) + ): + _reject("certification_decision_scope_invalid") + payload = { + "schema": "simnow.certification.case-scope.v2", + "case_id": scope.case_id, + "scenario_id": scope.scenario_id, + "runtime_id": scope.runtime_id, + "account_identity_scheme": scope.account_identity_scheme, + "account_identity_sha256": scope.account_identity_sha256, + "suite_config_digest": scope.suite_config_digest, + "suite_effective_digest": scope.suite_effective_digest, + "case_config_digest": scope.case_config_digest, + "strategy_plan_digest": scope.strategy_plan_digest, + "runner_digest": scope.runner_digest, + } + scope_sha256 = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode( + "ascii" + ) + ).hexdigest() + result = DecisionScope( + scope.case_id, + scope.scenario_id, + plan.source_sha256, + scope_sha256, + scope.account_identity_sha256, + ) + result.validate(plan) + return result + + +__all__ = [ + "ACCOUNT_IDENTITY_SCHEME", + "CertificationCaseScope", + "CertificationCaseScopeError", + "bind_case_scope", + "decision_scope_from_case_scope", +] diff --git a/examples/007_ctp/live_certification/simnow_penetration/run_all.py b/examples/007_ctp/live_certification/simnow_penetration/run_all.py index 8cc53746..437b0ee1 100644 --- a/examples/007_ctp/live_certification/simnow_penetration/run_all.py +++ b/examples/007_ctp/live_certification/simnow_penetration/run_all.py @@ -5,17 +5,46 @@ """ from __future__ import annotations -import sys -from pathlib import Path +# This fence must remain before every legacy framework, CTP, or provider import. +import sys as _iteration41_sys +from pathlib import Path as _Iteration41Path + +_ITERATION41_RUNTIME_DIR = _Iteration41Path(__file__).resolve().parents[2] / "runtime" +_ITERATION41_REPOSITORY_ROOT = _ITERATION41_RUNTIME_DIR.parents[2] +if str(_ITERATION41_REPOSITORY_ROOT) not in _iteration41_sys.path: + _iteration41_sys.path.insert(0, str(_ITERATION41_REPOSITORY_ROOT)) + +from backtrader_runtime.legacy import ( # noqa: E402 + legacy_direct_execution_error as _iteration41_legacy_direct_execution_error, + run_legacy_config_first_cli as _iteration41_run_legacy_config_first_cli, +) + + +def _run_config_first_cli(argv=None) -> int: + return _iteration41_run_legacy_config_first_cli(_ITERATION41_RUNTIME_DIR, argv) + + +def main(*args, **kwargs): + del args, kwargs + raise _iteration41_legacy_direct_execution_error("examples/007_ctp/live_certification/simnow_penetration/run_all.py") + + +if __name__ == "__main__": + raise SystemExit(_run_config_first_cli()) + +import sys # noqa: E402 +from pathlib import Path # noqa: E402 # Ensure suite dir is importable _SUITE_DIR = Path(__file__).resolve().parent if str(_SUITE_DIR) not in sys.path: sys.path.insert(0, str(_SUITE_DIR)) -from run_case import CASE_ORDER, main as _run_main +from run_case import CASE_ORDER, main as _run_main # noqa: E402, F401 + +# Iteration 41 retains this historical body for review only; direct execution is disabled. +if False: # pragma: no cover - retired direct entrypoint -if __name__ == "__main__": # Inject --all so run_case.main() executes the full suite sys.argv = [sys.argv[0], "--all"] + sys.argv[1:] _run_main() diff --git a/examples/007_ctp/live_certification/simnow_penetration/run_case.py b/examples/007_ctp/live_certification/simnow_penetration/run_case.py index 7226237d..ed3f0052 100644 --- a/examples/007_ctp/live_certification/simnow_penetration/run_case.py +++ b/examples/007_ctp/live_certification/simnow_penetration/run_case.py @@ -17,15 +17,42 @@ """ from __future__ import annotations -import argparse -import json -import os -import subprocess -import sys -from datetime import datetime -from pathlib import Path - -from common.certification import ( +# This fence must remain before every legacy framework, CTP, or provider import. +import sys as _iteration41_sys +from pathlib import Path as _Iteration41Path + +_ITERATION41_RUNTIME_DIR = _Iteration41Path(__file__).resolve().parents[2] / "runtime" +_ITERATION41_REPOSITORY_ROOT = _ITERATION41_RUNTIME_DIR.parents[2] +if str(_ITERATION41_REPOSITORY_ROOT) not in _iteration41_sys.path: + _iteration41_sys.path.insert(0, str(_ITERATION41_REPOSITORY_ROOT)) + +from backtrader_runtime.legacy import ( # noqa: E402 + legacy_direct_execution_error as _iteration41_legacy_direct_execution_error, + run_legacy_config_first_cli as _iteration41_run_legacy_config_first_cli, +) + + +def _run_config_first_cli(argv=None) -> int: + return _iteration41_run_legacy_config_first_cli(_ITERATION41_RUNTIME_DIR, argv) + + +def main(*args, **kwargs): + del args, kwargs + raise _iteration41_legacy_direct_execution_error("examples/007_ctp/live_certification/simnow_penetration/run_case.py") + + +if __name__ == "__main__": + raise SystemExit(_run_config_first_cli()) + +import argparse # noqa: E402 +import json # noqa: E402 +import os # noqa: E402 +import subprocess # noqa: E402 +import sys # noqa: E402 +from datetime import datetime # noqa: E402 +from pathlib import Path # noqa: E402 + +from common.certification import ( # noqa: E402 build_certification_coverage, enrich_result_payload, get_certification_scenario, @@ -76,6 +103,10 @@ def _discover_cases(): def run_case(case_id: str, report_root: Path, timeout: int = DEFAULT_TIMEOUT) -> dict: + raise _iteration41_legacy_direct_execution_error( + "examples/007_ctp/live_certification/simnow_penetration/run_case.py" + ) + """Run a single case in an isolated subprocess, return result dict.""" case_file = CASE_REGISTRY.get(case_id) if case_file is None: @@ -196,8 +227,12 @@ def print_summary(results: list[dict], report_root: Path): # --------------------------------------------------------------------------- -def main(): +def _legacy_main(): """Main entry point for running SimNow penetration certification cases.""" + raise _iteration41_legacy_direct_execution_error( + "examples/007_ctp/live_certification/simnow_penetration/run_case.py" + ) + parser = argparse.ArgumentParser( description="Run SimNow penetration certification cases", ) @@ -254,5 +289,7 @@ def main(): print_summary(results, report_root) -if __name__ == "__main__": +# Iteration 41 retains this historical body for review only; direct execution is disabled. +if False: # pragma: no cover - retired direct entrypoint + main() diff --git a/examples/012_1_midfreq_cross_exchange/strategy-candidate-manifest.json b/examples/012_1_midfreq_cross_exchange/strategy-candidate-manifest.json index 01b49966..557f5e67 100644 --- a/examples/012_1_midfreq_cross_exchange/strategy-candidate-manifest.json +++ b/examples/012_1_midfreq_cross_exchange/strategy-candidate-manifest.json @@ -68,10 +68,10 @@ "paper-live": "PROHIBITED_RESEARCH_REJECTED_NEW_CANDIDATE_REQUIRED", "demo": "OPERATOR_ACK_REQUIRED_DEMO_SIMULATION_ONLY" }, - "runner_sha256": "7edec6305367e4e5ca88086d30b06529f3770f66e7c20425401bc2b1c856171d", - "strategy_sha256": "a6c69a5f0a22b6d856d21805432cd57439f78bc33403529301f3363bce789ed0", - "config_sha256": "2e12477ba52411f0c9affa541117db1cfaf4d6248e12cfced3ac244a8ca28424", - "candidate_sha256": "561157117a4f26093961a1b969c24b8c4a998e9cff3ce866804d1f0a9ad394dd", + "runner_sha256": "fedd8e09246190bb6555bb6f3ed896567ff4291193a0bbbaf6f69b34489e8f72", + "strategy_sha256": "54f7c1aeb8831c79f97de40b9d77aeafd919611c2e1bd17ac8f5742b05c062ba", + "config_sha256": "efd7c84c0df9ec86b5f31bd41b1d3c2f9d41c7a046b72737b723feadd89812ca", + "candidate_sha256": "8eb7460e6389a9c43f21c345e770d7654c907e4e39f2cbc6e08e62a010125d77", "demo_approval": { "status": "NOT_APPROVED", "receipt_path": null, diff --git a/examples/012_2_event_driven_cross_exchange/strategy-candidate-manifest.json b/examples/012_2_event_driven_cross_exchange/strategy-candidate-manifest.json index 2f94aed3..b5a72e67 100644 --- a/examples/012_2_event_driven_cross_exchange/strategy-candidate-manifest.json +++ b/examples/012_2_event_driven_cross_exchange/strategy-candidate-manifest.json @@ -73,10 +73,10 @@ "paper-live": "PROHIBITED_RESEARCH_REJECTED_NEW_CANDIDATE_REQUIRED", "demo": "OPERATOR_ACK_REQUIRED_DEMO_SIMULATION_ONLY" }, - "runner_sha256": "5a2f1652ca4bba19b3d266eca11d8a6acb90efdff8ef8ed26c0fce85c934dca3", - "strategy_sha256": "edbdf1f2c3df64106b5a2acec5e1f300b3c6daec58115a87754ec88fb82ad0db", - "config_sha256": "08c89af2f0630d5a12da220e87f6b8b3f7df3177bc1e7f4547f219eecac63307", - "candidate_sha256": "f3bdce9a326904601d255974eeaad583a40365339b7a2b1dc3797ad0f10f077f", + "runner_sha256": "c767b606bf376e186f4cd86f7f499174a0d7f4b77b199dcf55965392f508fe11", + "strategy_sha256": "0b5f82b78dc8feb0cf161a28feeadb8e16cf5da303136a63fd19a6bb8b53901f", + "config_sha256": "9d3321b50a663ad9219465180dd4143351a6de544f022cdc359622357df7d2aa", + "candidate_sha256": "fd6aef1e36ebce5de6665c665988f5bac539f1f62a8e4c84e7b254dc3ce81a6f", "demo_approval": { "status": "NOT_APPROVED", "receipt_path": null, diff --git a/pyproject.toml b/pyproject.toml index 2bef7c85..fbe865b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,6 +185,12 @@ module = [ ] disable_error_code = ["attr-defined", "misc", "assignment", "arg-type", "union-attr"] +# The core mypy gate targets backtrader/. Iteration 41's separate runtime package +# supplies types to compatibility facades but its diagnostics need a separate gate. +[[tool.mypy.overrides]] +module = ["backtrader_runtime", "backtrader_runtime.*"] +follow_imports = "silent" + [tool.coverage.run] source = ["backtrader"] omit = [ diff --git a/requirements.txt b/requirements.txt index 45ff3480..b5a74967 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,13 +27,15 @@ tqdm>=4.50.0 types-pytz>=2021.1 types-python-dateutil>=2.8.0 types-PyMySQL>=1.0.0 -pytest>=6.0.0 +# pytest-asyncio 0.24+ fixes the pytest 8 package-collector incompatibility and +# requires pytest>=8.2; both packages support the project's Python 3.8-3.13 range. +pytest>=8.2,<9 pytest-benchmark>=3.4.0 pytest-sugar>=0.9.0 pytest-cov>=2.12.0 pytest-picked>=0.4.0 pytest-xdist>=2.3.0 -pytest-asyncio>=0.15.0 +pytest-asyncio>=0.24,<1 pytest-html>=3.1.0 pytest-ordering>=0.6.0 pytest-mock>=3.6.0 @@ -48,3 +50,5 @@ dash>=2.0.0 # backtrader # Don't install self from PyPI # bt_api_py # Optional external package statsmodels>=0.12.0 +# Required by the schema-v4 configuration-first runtime loader. +PyYAML>=5.4 diff --git a/scripts/ci/smoke_iteration41_registered_offline.py b/scripts/ci/smoke_iteration41_registered_offline.py new file mode 100644 index 00000000..a77d372d --- /dev/null +++ b/scripts/ci/smoke_iteration41_registered_offline.py @@ -0,0 +1,153 @@ +"""Run every registered Iteration 41 offline config through the public CLI. + +Only code-owned backtest/local_backtest and simulation/replay registrations are +eligible. The script filters registrations before reading configs, so it never +needs the private SimNow file and never runs shadow, private-read, or live +provider routes. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from backtrader_runtime.config import load_runtime_config +from backtrader_runtime.inventory import ( + ITERATION41_007_CTP_PRIVATE_READONLY_BINDING, + ITERATION41_007_CTP_PRIVATE_READONLY_REGISTRATION, + ITERATION41_010_OKX_SHADOW_REGISTRATION, + ITERATION41_013_3_CTP_PRIVATE_READONLY_BINDING, + ITERATION41_013_3_CTP_PRIVATE_READONLY_REGISTRATION, + iteration41_runtime_registry, +) +from backtrader_runtime.registry import RuntimeProfile + + +OFFLINE_PAIRS = frozenset({("backtest", "local_backtest"), ("simulation", "replay")}) +PRIVATE_READ_ONLY = ( + ( + ITERATION41_013_3_CTP_PRIVATE_READONLY_REGISTRATION, + ITERATION41_013_3_CTP_PRIVATE_READONLY_BINDING, + ), + ( + ITERATION41_007_CTP_PRIVATE_READONLY_REGISTRATION, + ITERATION41_007_CTP_PRIVATE_READONLY_BINDING, + ), +) + + +def _offline_smoke_registrations(registry): + """Filter by exact code-owned policy before opening any runtime config.""" + + selected = [] + skipped = [] + for registration in registry.registrations: + private_contract = next( + (item for item in PRIVATE_READ_ONLY if item[0].runtime_id == registration.runtime_id), + None, + ) + if private_contract is not None: + expected_registration, _binding = private_contract + profile = registration.profile_for("simulation", "sandbox") + unavailable = tuple( + (item.mode, item.preset, item.reason) + for item in registration.unavailable_mode_profiles + ) + if ( + registration is not expected_registration + or len(registration.profiles) != 1 + or type(profile) is not RuntimeProfile + or registration.profiles[0] is not profile + or registration.allowed_presets != () + or registration.allowed_secrets_refs != ("none",) + or profile.allowed_parameter_keys != () + or profile.allowed_secrets_refs != ("config_yaml",) + or profile.available_capabilities != () + or profile.approval_receipt_digest is not None + or profile.runner_module is not None + or profile.runner_entrypoint != "run_runtime" + or profile.capability_modules != () + or profile.offline_managed_execution is not False + or profile.sandbox_write_policy != "deny" + or unavailable + != (("live", "managed_live_direct", "managed_live_direct_profile_unavailable"),) + or registration.runner_module is not None + or registration.available_capabilities != () + or registration.capability_modules != () + or registration.bootstrap_parameters != () + or registration.sandbox_write_policy != "deny" + or registry.ctp_simnow_readonly_bindings + != tuple(binding for _, binding in PRIVATE_READ_ONLY) + ): + raise RuntimeError("private CTP runtime no longer matches its read-only contract") + skipped.append((registration, "SKIP_PRIVATE_READ_ONLY")) + continue + if registration.runtime_id == ITERATION41_010_OKX_SHADOW_REGISTRATION.runtime_id: + if ( + registration is not ITERATION41_010_OKX_SHADOW_REGISTRATION + or registration.allowed_presets != ("shadow",) + ): + raise RuntimeError("public shadow runtime no longer matches its reviewed contract") + skipped.append((registration, "SKIP_SHADOW")) + continue + if ( + registration.allowed_presets not in (("replay",), ("local_backtest",)) + or registration.runner_module is None + ): + raise RuntimeError("unreviewed runtime in offline smoke inventory") + selected.append(registration) + return tuple(selected), tuple(skipped) + + +def main() -> int: + registry = iteration41_runtime_registry() + selected, skipped = _offline_smoke_registrations(registry) + for registration, skip_reason in skipped: + print("{0}: {1}".format(registration.runtime_id, skip_reason), flush=True) + + for registration in selected: + config = load_runtime_config(registration.runtime_dir, registry=registry) + if (config.mode, config.preset) not in OFFLINE_PAIRS: + raise RuntimeError("offline smoke config does not match its code-owned preset") + if config.secrets_ref != "none": + raise RuntimeError("offline smoke config unexpectedly references credentials") + + failures = [] + for registration in selected: + try: + result = subprocess.run( + [ + sys.executable, + "-m", + "backtrader_runtime", + "run", + "--strategy-dir", + str(registration.runtime_dir), + ], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + status = "PASS" if result.returncode == 0 else "FAIL({0})".format(result.returncode) + if result.returncode: + failures.append(registration.runtime_id) + except subprocess.TimeoutExpired: + status = "TIMEOUT" + failures.append(registration.runtime_id) + print("{0}: {1}".format(registration.runtime_id, status), flush=True) + + print( + "offline smoke: {0} passed, {1} failed, {2} selected, {3} skipped".format( + len(selected) - len(failures), len(failures), len(selected), len(skipped) + ), + flush=True, + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/verify_release_wheel.py b/scripts/ci/verify_release_wheel.py index a970e15d..f83184d5 100644 --- a/scripts/ci/verify_release_wheel.py +++ b/scripts/ci/verify_release_wheel.py @@ -11,6 +11,8 @@ import pathlib import zipfile +SOURCE_PACKAGES = ("backtrader/", "backtrader_runtime/") + def verify(wheel, source_root, install_root, expected_version): """Check every packaged source and execute deterministic consumer trades.""" @@ -28,11 +30,13 @@ def verify(wheel, source_root, install_root, expected_version): unexpected = [ name for name in names - if not name.startswith(("backtrader/", "backtrader-" + expected_version + ".dist-info/")) + if not name.startswith( + (*SOURCE_PACKAGES, "backtrader-" + expected_version + ".dist-info/") + ) ] assert not unexpected, f"Non-library files in wheel: {unexpected[:10]}" for name in names: - if not name.startswith("backtrader/") or not name.endswith(".py"): + if not name.startswith(SOURCE_PACKAGES) or not name.endswith(".py"): continue expected = package.read(name) assert (source_root / name).read_bytes() == expected, name @@ -46,7 +50,8 @@ def verify(wheel, source_root, install_root, expected_version): assert expected_private.issubset(checked) expected_sources = { p.relative_to(source_root).as_posix() - for p in (source_root / "backtrader").rglob("*.py") + for package_root in SOURCE_PACKAGES + for p in (source_root / package_root.rstrip("/")).rglob("*.py") } assert set(checked) == expected_sources, "Wheel omits or adds package source files" assert not any("account_config.yaml" in name for name in names) diff --git a/tests/functional/strategies/time_based/test_0202_fnn_embedding.py b/tests/functional/strategies/time_based/test_0202_fnn_embedding.py index 264aa1a1..9e6d23e1 100644 --- a/tests/functional/strategies/time_based/test_0202_fnn_embedding.py +++ b/tests/functional/strategies/time_based/test_0202_fnn_embedding.py @@ -20,6 +20,7 @@ @pytest.fixture(scope="module") def result(): + pytest.importorskip("torch", reason="PyTorch is optional for FNN example tests") run_mod = importlib.import_module("examples.017_fnn_embedding.run") cfg = run_mod.load_yaml_config(REPO / "examples" / "017_fnn_embedding" / "config.yaml") cfg["data"]["fromdate"] = "2021-09-01" diff --git a/tests/test_windows_anchor.py b/tests/test_windows_anchor.py new file mode 100644 index 00000000..62901a68 --- /dev/null +++ b/tests/test_windows_anchor.py @@ -0,0 +1,719 @@ +"""Focused fake and Windows checks for the isolated anchor candidate.""" + +import ctypes +import json +import ntpath +import os +import struct +import tempfile +from types import SimpleNamespace + +import pytest + +from backtrader.notifications import _windows_anchor + +USER_SID = "S-1-5-21-11-22-33-1001" +FULL = 0x001F01FF + + +class FakeAnchorApi: + def __init__( + self, + *, + existing_reparse=False, + target_exists=False, + owner_sid=USER_SID, + initial_acl=None, + persistent_acls=True, + ): + self.token_sid_calls = 0 + self.descriptor = object() + self.directories = [] + self.existing_reparse = existing_reparse + self.target_exists = target_exists + self.owner_sid = owner_sid + self.initial_acl = initial_acl + self.persistent_acls = persistent_acls + self.link_count = 1 + self.reparse_opened = False + self.secured = False + self.events = [] + self.writes = [] + self.closed = [] + self.deleted = [] + self.kernel32 = SimpleNamespace(GetDriveTypeW=lambda _root: 3) + + def token_user_sid(self): + self.token_sid_calls += 1 + return USER_SID + + def security_descriptor(self, sid): + assert sid == USER_SID + return self.descriptor + + def free_security_descriptor(self, descriptor): + assert descriptor is self.descriptor + + def open_root(self, drive): + assert drive == "C:" + return "root" + + def volume_supports_persistent_acls(self, drive): + assert drive == "C:" + return self.persistent_acls + + def open_or_create_directory(self, parent, name, *, is_final, token_sid, descriptor): + assert token_sid == USER_SID + assert descriptor is self.descriptor + handle = "dir-" + name + self.directories.append((parent, name, is_final, handle)) + return handle, True + + def open_relative_update_file(self, parent, name): + if self.existing_reparse and not self.reparse_opened: + self.reparse_opened = True + return "reparse-target" + if not self.target_exists: + raise OSError(0xC0000034, "missing") + return "existing-target" + + def attributes(self, handle): + self.events.append(("attributes", handle)) + if handle == "reparse-target": + _windows_anchor._reject_reparse(_windows_anchor._FILE_ATTRIBUTE_REPARSE_POINT) + return 0 + + def identity(self, handle): + return (1, 2, 3, self.link_count) + + def acl(self, handle): + assert handle in ("new-file", "existing-target") + self.events.append(("acl", handle)) + if handle == "new-file" or self.secured: + self.secured = True + return USER_SID, True, ((0, 0, FULL, USER_SID),) + entries = self.initial_acl or ((0, 0, FULL, "S-1-1-0"),) + return self.owner_sid, False, entries + + def set_owner_only_acl(self, handle, descriptor): + assert descriptor is self.descriptor + assert handle in ("new-file", "existing-target") + self.events.append(("secure", handle)) + self.secured = True + + def create_relative_file(self, parent, name, descriptor): + assert parent == "dir-private" + assert descriptor is self.descriptor + if self.target_exists: + raise OSError(_windows_anchor._STATUS_COLLISION, "already exists") + self.target_exists = True + return "new-file" + + def truncate_and_write(self, handle, payload): + assert handle in ("new-file", "existing-target") + assert self.secured, "content write happened before owner-only ACL verification" + self.events.append(("write", handle)) + self.writes.append(payload) + + def mark_for_delete(self, handle): + self.deleted.append(handle) + + def close(self, handle): + self.closed.append(handle) + + +def test_owner_acl_policy_uses_exact_token_user_and_rejects_inheritance(): + valid = ((0, 0, FULL, USER_SID),) + _windows_anchor._validate_owner_only_acl(USER_SID, USER_SID, True, valid) + with pytest.raises(OSError): + _windows_anchor._validate_owner_only_acl("S-1-5-18", USER_SID, True, valid) + with pytest.raises(OSError): + _windows_anchor._validate_owner_only_acl(USER_SID, USER_SID, False, valid) + with pytest.raises(OSError): + _windows_anchor._validate_owner_only_acl( + USER_SID, USER_SID, True, valid + ((0, 0, FULL, "S-1-1-0"),) + ) + + +def test_directory_acl_accepts_owner_inheritance_flags_but_no_external_ace(): + inherited_owner = ((0, 0x1 | 0x2, FULL, USER_SID),) + _windows_anchor._validate_owner_only_directory_acl(USER_SID, USER_SID, True, inherited_owner) + with pytest.raises(OSError): + _windows_anchor._validate_owner_only_directory_acl( + USER_SID, USER_SID, False, inherited_owner + ) + with pytest.raises(OSError): + _windows_anchor._validate_owner_only_directory_acl( + USER_SID, USER_SID, True, inherited_owner + ((0, 0, FULL, "S-1-1-0"),) + ) + + +def test_existing_file_policy_rejects_external_aces_before_acl_mutation(): + _windows_anchor._validate_repairable_owner_acl( + USER_SID, USER_SID, ((0, 0x1 | 0x2, FULL, USER_SID),) + ) + with pytest.raises(OSError): + _windows_anchor._validate_repairable_owner_acl( + USER_SID, USER_SID, ((0, 0, 0x1, "S-1-1-0"),) + ) + + +def test_existing_ancestor_policy_rejects_untrusted_directory_mutation(): + _windows_anchor._validate_safe_ancestor_acl( + "S-1-5-18", USER_SID, ((0, 0, 0x001200A9, "S-1-1-0"),) + ) + with pytest.raises(OSError): + _windows_anchor._validate_safe_ancestor_acl( + USER_SID, USER_SID, ((0, 0, _windows_anchor._FILE_ADD_FILE, "S-1-1-0"),) + ) + with pytest.raises(OSError): + _windows_anchor._validate_safe_ancestor_acl(USER_SID, USER_SID, ((1, 0, 0, "S-1-1-0"),)) + with pytest.raises(OSError): + _windows_anchor._validate_safe_ancestor_acl( + USER_SID, + USER_SID, + ((0, 0, _windows_anchor._FILE_WRITE_ATTRIBUTES, "S-1-1-0"),), + ) + + +def test_existing_ancestor_owner_rights_maps_only_to_verified_owner(): + owner_rights = ((0, 0x1 | 0x2, FULL, _windows_anchor._OWNER_RIGHTS_SID),) + _windows_anchor._validate_safe_ancestor_acl(USER_SID, USER_SID, owner_rights) + with pytest.raises(OSError): + _windows_anchor._validate_safe_ancestor_acl("S-1-1-0", USER_SID, owner_rights) + with pytest.raises(OSError): + _windows_anchor._validate_safe_ancestor_acl( + USER_SID, USER_SID, ((0, 0x1 | 0x2, FULL, "S-1-3-0"),) + ) + + +def test_fake_windows_rejects_fixed_volume_without_persistent_acls(): + fake = FakeAnchorApi(persistent_acls=False) + with pytest.raises(OSError, match="persistent ACL"): + _windows_anchor.persist_anchor( + r"C:\sandbox\private\anchor.json", {"token": "synthetic"}, api=fake + ) + assert fake.directories == [] + assert fake.writes == [] + + +def test_fake_windows_persistence_uses_token_sid_and_handle_relative_update(): + fake = FakeAnchorApi() + result = _windows_anchor.persist_anchor( + r"C:\sandbox\private\anchor.json", + {"bot_token": "synthetic-only", "to_user_id": "user"}, + api=fake, + ) + assert result == r"C:\sandbox\private\anchor.json" + assert fake.token_sid_calls == 1 + assert [(parent, name, final) for parent, name, final, _handle in fake.directories] == [ + ("root", "sandbox", False), + ("dir-sandbox", "private", True), + ] + assert json.loads(fake.writes[0].decode("utf-8")) == { + "bot_token": "synthetic-only", + "to_user_id": "user", + } + assert len(fake.writes) == 1 + assert fake.events.index(("acl", "new-file")) < fake.events.index(("write", "new-file")) + assert not any(event == ("secure", "new-file") for event in fake.events) + assert "new-file" in fake.closed + + +def test_fake_windows_persistence_rejects_external_acl_before_update(): + fake = FakeAnchorApi(target_exists=True) + with pytest.raises(OSError): + _windows_anchor.persist_anchor( + r"C:\sandbox\private\anchor.json", {"token": "synthetic"}, api=fake + ) + assert fake.writes == [] + assert not any(event[0] == "secure" for event in fake.events) + + +def test_fake_windows_persistence_repairs_owner_only_dacl_without_write_owner(): + fake = FakeAnchorApi(target_exists=True, initial_acl=((0, 0x1 | 0x2, FULL, USER_SID),)) + _windows_anchor.persist_anchor( + r"C:\sandbox\private\anchor.json", {"token": "synthetic"}, api=fake + ) + assert fake.events.index(("secure", "existing-target")) < fake.events.index( + ("write", "existing-target") + ) + assert json.loads(fake.writes[0].decode("utf-8")) == {"token": "synthetic"} + + +def test_fake_windows_persistence_rejects_foreign_owner_and_hardlink_before_write(): + foreign = FakeAnchorApi(target_exists=True, owner_sid="S-1-5-18") + with pytest.raises(OSError, match="not owned"): + _windows_anchor.persist_anchor( + r"C:\sandbox\private\anchor.json", {"token": "synthetic"}, api=foreign + ) + assert foreign.writes == [] + assert not any(event[0] == "secure" for event in foreign.events) + + linked = FakeAnchorApi(target_exists=True) + linked.link_count = 2 + with pytest.raises(OSError): + _windows_anchor.persist_anchor( + r"C:\sandbox\private\anchor.json", {"token": "synthetic"}, api=linked + ) + assert linked.writes == [] + assert not any(event[0] == "secure" for event in linked.events) + + +def test_fake_windows_persistence_rejects_reparse_target_before_writing(): + fake = FakeAnchorApi(existing_reparse=True) + with pytest.raises(OSError, match="reparse point"): + _windows_anchor.persist_anchor( + r"C:\sandbox\private\anchor.json", {"bot_token": "synthetic"}, api=fake + ) + assert fake.writes == [] + assert not any(event[0] == "secure" for event in fake.events) + assert "reparse-target" in fake.closed + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows handle and DACL contract") +def test_native_windows_anchor_acl_update_old_handle_and_reparse_rejection(): + api = _windows_anchor._WindowsAnchorApi() + token_sid = api.token_user_sid() + descriptor = api.security_descriptor(token_sid) + root = candidate_handle = None + temp_handles = [] + private_handle = junction_handle = old_handle = current_handle = unsafe_handle = None + attribute_directory = attribute_handle = None + weak_descriptor = directory_descriptor = owner_only_descriptor = attribute_descriptor = None + candidate_path = os.path.abspath(os.environ.get("USERPROFILE") or tempfile.gettempdir()) + drive, tail = ntpath.splitdrive(candidate_path) + if not drive or not tail.startswith("\\"): + raise AssertionError("Windows temp directory must be an absolute drive-letter path") + probe_name = "native-anchor-" + os.urandom(8).hex() + unsafe_name = "unsafe-parent-" + os.urandom(8).hex() + junction_name = "junction-" + os.urandom(8).hex() + old_handle = None + try: + assert api.volume_supports_persistent_acls(drive) + root = api.open_root(drive) + directory_options = ( + _windows_anchor._FILE_DIRECTORY_FILE + | _windows_anchor._FILE_SYNCHRONOUS_IO_NONALERT + | _windows_anchor._FILE_OPEN_REPARSE_POINT + ) + parent = root + for component in (part for part in tail.split("\\") if part): + parent, _created = api.open_or_create_directory( + parent, + component, + is_final=False, + token_sid=token_sid, + descriptor=descriptor, + ) + temp_handles.append(parent) + candidate_handle = parent + + directory_descriptor = ctypes.c_void_p() + directory_sddl = "O:{0}D:P(A;OICI;FA;;;{0})".format(token_sid) + if not api.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( + directory_sddl, 1, ctypes.byref(directory_descriptor), None + ): + raise OSError(ctypes.get_last_error(), "could not build inheritable owner-only DACL") + private_handle, created = api.open_or_create_directory( + candidate_handle, + probe_name, + is_final=True, + token_sid=token_sid, + descriptor=directory_descriptor, + ) + assert created is True + owner, protected, entries = api.acl(private_handle) + _windows_anchor._validate_owner_only_directory_acl(owner, token_sid, protected, entries) + existing_directory, created = api.open_or_create_directory( + candidate_handle, + probe_name, + is_final=True, + token_sid=token_sid, + descriptor=descriptor, + ) + assert created is False + try: + owner, protected, entries = api.acl(existing_directory) + _windows_anchor._validate_owner_only_directory_acl(owner, token_sid, protected, entries) + finally: + api.close(existing_directory) + + # A same-owner legacy file with an external ACE is refused before its + # ACL or content changes; a previously opened reader keeps its access. + weak_descriptor = ctypes.c_void_p() + weak_sddl = "O:{0}D:P(A;;FA;;;{0})(A;;FA;;;WD)".format(token_sid) + if not api.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( + weak_sddl, 1, ctypes.byref(weak_descriptor), None + ): + raise OSError(ctypes.get_last_error(), "could not build test DACL") + unsafe_handle = api._relative( + candidate_handle, + unsafe_name, + _windows_anchor._FILE_LIST_DIRECTORY + | _windows_anchor._FILE_TRAVERSE + | _windows_anchor._FILE_ADD_FILE + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._DELETE + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_CREATE, + directory_options, + security_descriptor=weak_descriptor, + ) + unsafe_target = os.path.join(candidate_path, unsafe_name, "must-not-write.json") + with pytest.raises(OSError): + _windows_anchor.persist_anchor(unsafe_target, {"token": "synthetic"}) + assert not os.path.exists(unsafe_target) + + # FILE_WRITE_ATTRIBUTES alone permits an FSCTL_SET_REPARSE_POINT on + # this handle. The same ACE on a path ancestor must therefore fail policy. + attribute_descriptor = ctypes.c_void_p() + attribute_sddl = "O:{0}D:P(A;;FA;;;{0})(A;;0x100;;;WD)".format(token_sid) + if not api.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( + attribute_sddl, 1, ctypes.byref(attribute_descriptor), None + ): + raise OSError(ctypes.get_last_error(), "could not build write-attributes test DACL") + attribute_name = "reparse-capable-" + os.urandom(8).hex() + attribute_directory = api._relative( + candidate_handle, + attribute_name, + _windows_anchor._FILE_LIST_DIRECTORY + | _windows_anchor._FILE_TRAVERSE + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._DELETE + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_CREATE, + directory_options, + security_descriptor=attribute_descriptor, + ) + attribute_owner, _protected, attribute_entries = api.acl(attribute_directory) + with pytest.raises(OSError): + _windows_anchor._validate_safe_ancestor_acl( + attribute_owner, token_sid, attribute_entries + ) + attribute_target = os.path.join(candidate_path, attribute_name, "must-not-write.json") + with pytest.raises(OSError): + _windows_anchor.persist_anchor(attribute_target, {"token": "synthetic"}) + assert not os.path.exists(attribute_target) + attribute_handle = api._relative( + candidate_handle, + attribute_name, + _windows_anchor._FILE_WRITE_ATTRIBUTES + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_OPEN, + directory_options, + ) + _set_junction(api, attribute_handle, candidate_path) + with pytest.raises(OSError, match="reparse point"): + api.attributes(attribute_handle) + _delete_junction(api, attribute_handle) + api.close(attribute_handle) + attribute_handle = None + api.mark_for_delete(attribute_directory) + api.close(attribute_directory) + attribute_directory = None + weak_file = api._relative( + private_handle, + "legacy-external.json", + 0x2 + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._DELETE + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_CREATE, + _windows_anchor._FILE_NON_DIRECTORY_FILE + | _windows_anchor._FILE_SYNCHRONOUS_IO_NONALERT + | _windows_anchor._FILE_OPEN_REPARSE_POINT, + security_descriptor=weak_descriptor, + ) + broad_bytes = b'{"legacy":"external-read-risk"}' + api.write_and_flush(weak_file, broad_bytes) + api.close(weak_file) + weak_probe = api.open_relative_file(private_handle, "legacy-external.json") + old_broad_handle = api.open_relative_read_file(private_handle, "legacy-external.json") + try: + legacy_owner, legacy_protected, legacy_entries = api.acl(weak_probe) + assert legacy_owner == token_sid + assert legacy_protected + assert len(legacy_entries) == 2 + with pytest.raises(OSError): + _windows_anchor.persist_anchor( + os.path.join(candidate_path, probe_name, "legacy-external.json"), + {"legacy": "must-not-change"}, + ) + assert api.acl(weak_probe) == (legacy_owner, legacy_protected, legacy_entries) + assert api.read_handle_bytes(old_broad_handle) == broad_bytes + finally: + api.close(old_broad_handle) + api.close(weak_probe) + + # An existing owner-only DACL with OI|CI can be protected without + # requesting WRITE_OWNER; external ACEs above are never tightened. + owner_only_descriptor = ctypes.c_void_p() + owner_only_sddl = "O:{0}D:(A;OICI;FA;;;{0})".format(token_sid) + if not api.advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( + owner_only_sddl, 1, ctypes.byref(owner_only_descriptor), None + ): + raise OSError(ctypes.get_last_error(), "could not build owner-only legacy DACL") + owner_only_file = api._relative( + private_handle, + "legacy-owner.json", + 0x2 + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._DELETE + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_CREATE, + _windows_anchor._FILE_NON_DIRECTORY_FILE + | _windows_anchor._FILE_SYNCHRONOUS_IO_NONALERT + | _windows_anchor._FILE_OPEN_REPARSE_POINT, + security_descriptor=owner_only_descriptor, + ) + api.write_and_flush(owner_only_file, b'{"legacy":true}') + api.close(owner_only_file) + _windows_anchor._write_in_directory( + api, + private_handle, + "legacy-owner.json", + token_sid, + descriptor, + b'{"legacy":false}', + ) + legacy_verified = api.open_relative_read_file(private_handle, "legacy-owner.json") + try: + legacy_owner, legacy_protected, legacy_entries = api.acl(legacy_verified) + _windows_anchor._validate_owner_only_acl( + legacy_owner, token_sid, legacy_protected, legacy_entries + ) + assert api.read_handle_bytes(legacy_verified) == b'{"legacy":false}' + finally: + api.close(legacy_verified) + legacy_delete = api._relative( + private_handle, + "legacy-external.json", + _windows_anchor._DELETE + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_OPEN, + _windows_anchor._FILE_NON_DIRECTORY_FILE + | _windows_anchor._FILE_SYNCHRONOUS_IO_NONALERT + | _windows_anchor._FILE_OPEN_REPARSE_POINT, + ) + api.mark_for_delete(legacy_delete) + api.close(legacy_delete) + + _windows_anchor._write_in_directory( + api, private_handle, "anchor.json", token_sid, descriptor, b'{"token":"old"}' + ) + old_handle = api.open_relative_read_file(private_handle, "anchor.json") + old_bytes = api.read_handle_bytes(old_handle) + old_identity = api.identity(old_handle) + _windows_anchor._write_in_directory( + api, private_handle, "anchor.json", token_sid, descriptor, b'{"token":"new"}' + ) + api.seek_start(old_handle) + updated_through_old_handle = api.read_handle_bytes(old_handle) + assert api.identity(old_handle)[:3] == old_identity[:3] + assert json.loads(old_bytes.decode("utf-8")) == {"token": "old"} + assert json.loads(updated_through_old_handle.decode("utf-8")) == {"token": "new"} + current_handle = api.open_relative_read_file(private_handle, "anchor.json") + try: + assert json.loads(api.read_handle_bytes(current_handle).decode("utf-8")) == { + "token": "new" + } + finally: + api.close(current_handle) + current_handle = None + + # Build a real junction by handle, then prove OPEN_REPARSE_POINT + # returns the link object and the handle metadata gate rejects it. + junction_handle = api._relative( + candidate_handle, + junction_name, + _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._DELETE + | _windows_anchor._FILE_WRITE_ATTRIBUTES + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_CREATE, + directory_options, + security_descriptor=descriptor, + ) + _set_junction(api, junction_handle, candidate_path) + opened_junction = api._relative( + candidate_handle, + junction_name, + _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._DELETE + | _windows_anchor._FILE_WRITE_ATTRIBUTES + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_OPEN, + directory_options, + ) + try: + with pytest.raises(OSError, match="reparse point"): + api.attributes(opened_junction) + with pytest.raises(OSError, match="reparse point"): + _windows_anchor._write_in_directory( + api, + candidate_handle, + junction_name, + token_sid, + descriptor, + b'{"token":"must-not-write"}', + ) + assert not os.path.exists(os.path.join(candidate_path, "must-not-write")) + finally: + _delete_junction(api, opened_junction) + api.mark_for_delete(opened_junction) + api.close(opened_junction) + api.close(junction_handle) + junction_handle = None + + for cleanup_name in ("anchor.json", "legacy-owner.json"): + current_handle = api._relative( + private_handle, + cleanup_name, + _windows_anchor._DELETE + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_OPEN, + _windows_anchor._FILE_NON_DIRECTORY_FILE + | _windows_anchor._FILE_SYNCHRONOUS_IO_NONALERT + | _windows_anchor._FILE_OPEN_REPARSE_POINT, + ) + api.mark_for_delete(current_handle) + api.close(current_handle) + current_handle = None + api.close(old_handle) + old_handle = None + api.mark_for_delete(private_handle) + api.close(private_handle) + private_handle = None + finally: + if old_handle is not None: + api.close(old_handle) + if current_handle is not None: + api.close(current_handle) + if junction_handle is not None: + try: + _delete_junction(api, junction_handle) + api.mark_for_delete(junction_handle) + except OSError: + pass + api.close(junction_handle) + if private_handle is not None: + for cleanup_name in ("anchor.json", "legacy-external.json", "legacy-owner.json"): + try: + cleanup_handle = api._relative( + private_handle, + cleanup_name, + _windows_anchor._DELETE + | _windows_anchor._FILE_READ_ATTRIBUTES + | _windows_anchor._READ_CONTROL + | _windows_anchor._SYNCHRONIZE, + _windows_anchor._FILE_OPEN, + _windows_anchor._FILE_NON_DIRECTORY_FILE + | _windows_anchor._FILE_SYNCHRONOUS_IO_NONALERT + | _windows_anchor._FILE_OPEN_REPARSE_POINT, + ) + except OSError: + continue + try: + api.mark_for_delete(cleanup_handle) + except OSError: + pass + api.close(cleanup_handle) + try: + api.mark_for_delete(private_handle) + except OSError: + pass + api.close(private_handle) + if unsafe_handle is not None: + try: + api.mark_for_delete(unsafe_handle) + except OSError: + pass + api.close(unsafe_handle) + if attribute_handle is not None: + try: + _delete_junction(api, attribute_handle) + except OSError: + pass + api.close(attribute_handle) + if attribute_directory is not None: + try: + api.mark_for_delete(attribute_directory) + except OSError: + pass + api.close(attribute_directory) + for handle in reversed(temp_handles): + api.close(handle) + if root is not None: + api.close(root) + if weak_descriptor: + api.free_security_descriptor(weak_descriptor) + if owner_only_descriptor: + api.free_security_descriptor(owner_only_descriptor) + if attribute_descriptor: + api.free_security_descriptor(attribute_descriptor) + if directory_descriptor: + api.free_security_descriptor(directory_descriptor) + api.free_security_descriptor(descriptor) + + +def _set_junction(api, handle, target): + substitute = ("\\??\\" + target).encode("utf-16-le") + printed = target.encode("utf-16-le") + body = struct.pack(" ") == 33 + assert completed.returncode == 2, completed.stderr + error = json.loads(completed.stderr) + assert error["error_code"] == "PRESET_POLICY_VIOLATION" + assert error["reason"] == "legacy_cli_arguments_not_supported" @pytest.mark.parametrize( "report_path", ( - CTP_ROOT / "live_certification" / "simnow_penetration" / "reports" / "latest" / "summary.json", - CTP_ROOT / "live_certification" / "hongyuan_penetration" / "reports" / "latest" / "summary.json", + CTP_ROOT + / "live_certification" + / "simnow_penetration" + / "reports" + / "latest" + / "summary.json", + CTP_ROOT + / "live_certification" + / "hongyuan_penetration" + / "reports" + / "latest" + / "summary.json", ), ) def test_live_certification_reports_are_ignored(report_path): diff --git a/tests/unit/live_certification/test_managed_required_case_scope.py b/tests/unit/live_certification/test_managed_required_case_scope.py new file mode 100644 index 00000000..24467bac --- /dev/null +++ b/tests/unit/live_certification/test_managed_required_case_scope.py @@ -0,0 +1,49 @@ +"""Narrow offline checks for the current managed SimNow required scope.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +ENTRY_PATH = ( + REPO_ROOT + / "examples" + / "007_ctp" + / "live_certification" + / "simnow_penetration" + / "managed_case_entry.py" +) +_ENTRY_SPEC = importlib.util.spec_from_file_location( + "iteration41_007_managed_case_entry_required_scope", ENTRY_PATH +) +assert _ENTRY_SPEC is not None and _ENTRY_SPEC.loader is not None +managed_case_entry = importlib.util.module_from_spec(_ENTRY_SPEC) +_ENTRY_SPEC.loader.exec_module(managed_case_entry) + + +_HISTORICAL_OPTIONAL_CASE_IDS = frozenset(("O01", "O02", "O03", "TH05", "TH06")) + + +def test_registry_requires_all_33_cases_and_preserves_historical_optional_flags(): + scenarios = managed_case_entry._load_code_owned_scenarios() + + assert len(managed_case_entry._CURRENT_REQUIRED_CASE_IDS) == 33 + assert set(scenarios) == managed_case_entry._CURRENT_REQUIRED_CASE_IDS + assert { + case_id for case_id, scenario in scenarios.items() if scenario.optional + } == _HISTORICAL_OPTIONAL_CASE_IDS + assert _HISTORICAL_OPTIONAL_CASE_IDS <= managed_case_entry._CURRENT_REQUIRED_CASE_IDS + + +def test_registry_missing_o01_fails_closed(): + incomplete_case_ids = set(managed_case_entry._CURRENT_REQUIRED_CASE_IDS) - {"O01"} + + with pytest.raises( + managed_case_entry._ScopeRejected, + match="managed_ctp_certification_registry_invalid", + ): + managed_case_entry._validate_current_required_case_ids(incomplete_case_ids) diff --git a/tests/unit/live_certification/test_simnow_case_engine.py b/tests/unit/live_certification/test_simnow_case_engine.py new file mode 100644 index 00000000..77be1407 --- /dev/null +++ b/tests/unit/live_certification/test_simnow_case_engine.py @@ -0,0 +1,632 @@ +"""Offline contract checks for static plans and provider evidence provenance. + +These tests use local contract-shaped records only. They perform no provider +I/O and assert no certification PASS. +""" + +from __future__ import annotations + +import importlib +import hashlib +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" + + +@pytest.fixture +def case_engine_module(): + previous_modules = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + previous_path = sys.path[:] + for name in previous_modules: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + yield importlib.import_module("common.case_engine") + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous_modules) + sys.path[:] = previous_path + + +def _generic_evidence(module, event_kind, source, **fields): + return module.CertificationEvidence( + event_kind=event_kind, + source=source, + event_id=f"event-{event_kind}", + evidence_sha256="a" * 64, + occurred_at_utc="2026-09-28T00:00:00Z", + fields=fields, + callback_names=(), + ) + + +def _provider_event(module, *, fact, callback, sequence, **fields): + values = { + "fact": fact, + "callback_name": callback, + "source": "ctp_provider_callback", + "event_id": f"callback-{sequence}", + "session_id": "offline-contract-session", + "trading_day": "20260928", + "sequence": sequence, + "observed_at_utc": f"2026-09-28T00:00:{sequence:02d}Z", + "evidence_sha256": "b" * 64, + } + values.update(fields) + return module.ProviderCallbackEvidence(**values) + + +def test_all_33_case_plans_are_static_and_match_historical_scenario_mapping( + case_engine_module, +): + module = case_engine_module + case_dirs = sorted(path for path in (SUITE_ROOT / "cases").iterdir() if path.is_dir()) + + assert len(case_dirs) == 33 + for case_dir in case_dirs: + case_id = case_dir.name + strategy = case_dir / f"{case_id}_strategy.py" + scenario = module.SCENARIOS_BY_CASE_ID[case_id] + plan = module.load_descriptive_case_plan( + strategy, + expected_case_id=case_id, + expected_scenario_id=scenario.scenario_id, + ) + assert plan.case_id == case_id + assert plan.source_sha256 == hashlib.sha256(strategy.read_bytes()).hexdigest() + assert plan.values.get("actions") + assert plan.values.get("evidence") or plan.values.get("completion_criteria") + + +def test_every_historical_required_event_has_an_explicit_provenance_policy( + case_engine_module, +): + assert case_engine_module.unmapped_required_events() == [] + + +@pytest.mark.parametrize( + "case_id,event_kind", + [ + ("C01", "store_auth_success"), + ("M01", "store_connected"), + ("M03", "store_reconnect_success"), + ("E01", "order_reject_remote"), + ("E03", "order_reject_remote"), + ("EM01", "account_trading_disabled"), + ("EM03", "gateway_force_logout_requested"), + ("L01", "trade_execution"), + ("O01", "risk_repeat_order_detected"), + ], +) +def test_case_event_names_cannot_substitute_for_required_provenance( + case_engine_module, case_id, event_kind +): + module = case_engine_module + tracker = module.CertificationEvidenceTracker(case_id) + policy = module.EVENT_PROVENANCE_POLICIES[event_kind] + wrong_source = ( + module.EvidenceSource.MANAGED_RUNTIME + if policy.source is not module.EvidenceSource.MANAGED_RUNTIME + else module.EvidenceSource.PROVIDER_CALLBACK + ) + with pytest.raises(module.EvidenceContractError, match="requires source"): + tracker.record(_generic_evidence(module, event_kind, wrong_source)) + + +def test_native_login_evidence_requires_the_actual_callback_bundle_and_scope( + case_engine_module, +): + module = case_engine_module + tracker = module.CertificationEvidenceTracker("C01") + bad = module.CertificationEvidence( + event_kind="store_auth_success", + source=module.EvidenceSource.PROVIDER_CALLBACK, + event_id="auth-event", + evidence_sha256="c" * 64, + occurred_at_utc="2026-09-28T00:00:00Z", + fields={ + "request_id": 11, + "request_generation": 11, + "arrival_generation": 1, + "is_last": True, + "error_id": 0, + "authentication_succeeded": True, + }, + callback_names=("local_store_auth_success",), + callback_arrival=module.LocalCallbackArrival( + client_instance_id="client-1", + request_generation=11, + arrival_generation=1, + source_sequence=1, + arrived_at_utc="2026-09-28T00:00:00Z", + arrived_monotonic=1.0, + ), + ) + + with pytest.raises(module.EvidenceContractError, match="lacks native callbacks"): + tracker.record(bad) + + +def test_c01_issued_receipt_allows_equal_monotonic_tick_but_rejects_earlier_arrival( + case_engine_module, +): + module = case_engine_module + receipt = module.IssuedRequestReceipt( + request_kind="authenticate", + phase="", + request_id=11, + request_generation=7, + client_instance_id="client-1", + arrival_generation=3, + issued_at_utc="2026-09-28T00:00:00.100Z", + issued_monotonic=100.0, + receipt_id="auth-receipt-11", + ledger_entry_sha256="a" * 64, + ) + correlation = { + "request_kind": "authenticate", + "phase": "", + "request_id": 11, + "request_generation": 7, + "client_instance_id": "client-1", + "arrival_generation": 3, + } + + # A coarse Windows monotonic timer may give issue and inline callback the + # same tick. Local callback sequence still supplies strict event ordering. + assert module.validate_issued_request_receipt( + receipt, + **correlation, + arrived_at_utc="2026-09-28T00:00:00.100Z", + arrived_monotonic=100.0, + ) + assert not module.validate_issued_request_receipt( + receipt, + **correlation, + arrived_at_utc="2026-09-28T00:00:00.100Z", + arrived_monotonic=99.999, + ) + assert not module.validate_issued_request_receipt( + receipt, + **correlation, + arrived_at_utc="2026-09-28T00:00:00.099Z", + arrived_monotonic=100.001, + ) + + +def test_emergency_and_error_cases_require_external_effect_evidence(case_engine_module): + module = case_engine_module + emergency = module.CertificationEvidenceTracker("EM01") + with pytest.raises(module.EvidenceContractError, match="lacks source fields"): + emergency.record( + _generic_evidence( + module, + "account_trading_disabled", + module.EvidenceSource.CONTROL_PLANE, + account_id_masked="***1234", + reason="approved maintenance", + authorization_ref="review-1", + ) + ) + + e03 = module.CertificationEvidenceTracker("E03") + with pytest.raises(module.EvidenceContractError, match="lacks native callbacks"): + e03.record( + module.CertificationEvidence( + event_kind="order_reject_remote", + source=module.EvidenceSource.PROVIDER_CALLBACK, + event_id="reject-event", + evidence_sha256="d" * 64, + occurred_at_utc="2026-09-28T00:00:00Z", + fields={ + "order_ref": "r1", + "ErrorID": 1, + "ErrorMsg": "rejected", + "StatusMsg": "rejected", + "market_state_evidence_ref": "evidence/market.json", + "market_state_source_event_id": "provider-market-1", + }, + callback_names=("local_order_reject_remote",), + provider_session_id="session-1", + trading_day="20260928", + source_sequence=1, + ) + ) + + +def test_batch_cancel_intent_alone_is_incomplete_for_b02(case_engine_module): + module = case_engine_module + tracker = module.CertificationEvidenceTracker("B02") + tracker.record( + _generic_evidence( + module, + "batch_cancel_requested", + module.EvidenceSource.MANAGED_RUNTIME, + trace_id="trace-1", + invocation_id="invoke-1", + order_refs=["r1", "r2"], + dispatch_state="requested", + ) + ) + + result = tracker.snapshot() + assert result["state"] == module.CertificationState.INCOMPLETE.value + assert result["missing_order_callback_facts"] + assert result["certification_pass"] is False + + +def test_selected_case_engine_requires_native_callback_and_reconciliation( + case_engine_module, +): + module = case_engine_module + engine = module.CaseEvidenceEngine("T01") + engine.record_dependency( + "reviewed_order_admission", + module.DependencyState.SATISFIED, + source="reviewed_operator_evidence", + evidence_ref="review/approval-1", + ) + with pytest.raises(module.EvidenceContractError, match="CTP callback adapter"): + engine.record_callback( + _provider_event( + module, + fact=module.ProviderFact.ORDER_ACCEPTED, + callback="OnRtnOrder", + sequence=1, + source="local_order_log", + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="working", + ) + ) + + engine.record_callback( + _provider_event( + module, + fact=module.ProviderFact.ORDER_ACCEPTED, + callback="OnRtnOrder", + sequence=1, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="working", + remaining_quantity=1, + ) + ) + result = engine.snapshot() + assert result["state"] == module.EngineState.COLLECTING.value + assert result["certification_pass"] is False + assert "final_provider_order_query" in result["missing_facts"] + + +def test_t01_local_status_label_and_missing_terminal_query_cannot_reach_review( + case_engine_module, +): + module = case_engine_module + tracker = module.CertificationEvidenceTracker("T01") + tracker.record( + _generic_evidence( + module, + "order_submit_request", + module.EvidenceSource.MANAGED_RUNTIME, + trace_id="trace-1", + invocation_id="invoke-1", + order_ref="r1", + dispatch_state="requested", + ) + ) + with pytest.raises(module.EvidenceContractError, match="requires source provider_callback"): + tracker.record( + _generic_evidence( + module, + "order_status_accepted", + module.EvidenceSource.MANAGED_RUNTIME, + order_ref="r1", + external_order_id="e1", + provider_status="accepted", + ) + ) + tracker.record_provider_callback( + _provider_event( + module, + fact=module.ProviderFact.ORDER_ACCEPTED, + callback="OnRtnOrder", + sequence=1, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="working", + remaining_quantity=1, + ) + ) + + result = tracker.snapshot() + assert result["state"] != module.CertificationState.REVIEW_REQUIRED.value + assert "order_status_accepted" in result["missing_events"] + assert "order_canceled_or_filled" in result["missing_order_callback_facts"] + assert "final_provider_order_query" in result["missing_order_callback_facts"] + assert result["certification_pass"] is False + + +def test_unavailable_provider_condition_is_separate_from_missing_evidence( + case_engine_module, +): + module = case_engine_module + engine = module.CaseEvidenceEngine("B01") + engine.record_dependency( + "provider_partial_fill_opportunity", + module.DependencyState.UNAVAILABLE, + source="provider_query", + evidence_ref="query/eod-1", + reason="No order reached partial fill before the approved session ended.", + ) + + result = engine.snapshot() + assert result["state"] == module.EngineState.EXTERNAL_CONDITION_UNAVAILABLE.value + assert result["certification_pass"] is False + assert "provider_partial_fill_opportunity" in { + item["dependency_id"] for item in result["unavailable_external_dependencies"] + } + + +def test_cancel_race_fill_in_b02_requires_query_reconciliation(case_engine_module): + module = case_engine_module + engine = module.CaseEvidenceEngine("B02") + for dependency in engine.plan.dependencies: + engine.record_dependency( + dependency, + module.DependencyState.SATISFIED, + source="reviewed_operator_evidence", + evidence_ref=f"review/{dependency}", + ) + + events = [ + _provider_event( + module, + fact=module.ProviderFact.ORDER_ACCEPTED, + callback="OnRtnOrder", + sequence=1, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="working", + remaining_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.ORDER_ACCEPTED, + callback="OnRtnOrder", + sequence=2, + order_ref="r2", + external_order_id="e2", + instrument_id="rb2610", + status="working", + remaining_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.TRADE_EXECUTION, + callback="OnRtnTrade", + sequence=3, + order_ref="r1", + external_order_id="e1", + trade_id="t1", + traded_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.ORDER_CANCELED, + callback="OnRtnOrder", + sequence=4, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="canceled", + remaining_quantity=0, + traded_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.ORDER_CANCELED, + callback="OnRtnOrder", + sequence=5, + order_ref="r2", + external_order_id="e2", + instrument_id="rb2610", + status="canceled", + remaining_quantity=0, + ), + _provider_event( + module, + fact=module.ProviderFact.POSITION_SNAPSHOT, + callback="OnRspQryInvestorPosition", + sequence=6, + instrument_id="rb2610", + query_id="positions-final", + query_complete=True, + position_phase="final", + closeable_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.ORDER_SNAPSHOT, + callback="OnRspQryOrder", + sequence=7, + query_id="orders-final", + query_complete=True, + open_order_refs=(), + ), + ] + for event in events: + engine.record_callback(event) + + result = engine.snapshot() + assert result["state"] == module.EngineState.EVIDENCE_COMPLETE_REQUIRES_REVIEW.value + assert result["certification_pass"] is False + + +def test_b01_one_partial_order_cannot_reach_review(case_engine_module): + module = case_engine_module + engine = module.CaseEvidenceEngine("B01") + for dependency in engine.plan.dependencies: + engine.record_dependency( + dependency, + module.DependencyState.SATISFIED, + source="reviewed_operator_evidence", + evidence_ref=f"review/{dependency}", + ) + events = [ + _provider_event( + module, + fact=module.ProviderFact.ORDER_ACCEPTED, + callback="OnRtnOrder", + sequence=1, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="working", + remaining_quantity=2, + ), + _provider_event( + module, + fact=module.ProviderFact.ORDER_PARTIAL, + callback="OnRtnOrder", + sequence=2, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="partial", + traded_quantity=1, + remaining_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.TRADE_EXECUTION, + callback="OnRtnTrade", + sequence=3, + order_ref="r1", + external_order_id="e1", + trade_id="t1", + traded_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.ORDER_CANCELED, + callback="OnRtnOrder", + sequence=4, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="canceled", + traded_quantity=1, + remaining_quantity=0, + ), + _provider_event( + module, + fact=module.ProviderFact.POSITION_SNAPSHOT, + callback="OnRspQryInvestorPosition", + sequence=5, + instrument_id="rb2610", + query_id="positions-final", + query_complete=True, + position_phase="final", + closeable_quantity=1, + ), + _provider_event( + module, + fact=module.ProviderFact.ORDER_SNAPSHOT, + callback="OnRspQryOrder", + sequence=6, + query_id="orders-final", + query_complete=True, + open_order_refs=(), + ), + ] + for event in events: + engine.record_callback(event) + + result = engine.snapshot() + assert result["state"] == module.EngineState.COLLECTING.value + assert "every_test_order_must_be_partially_filled_then_canceled" in result["missing_facts"] + assert result["certification_pass"] is False + + +def test_filled_cancel_window_for_t03_is_an_external_unavailable_condition( + case_engine_module, +): + module = case_engine_module + engine = module.CaseEvidenceEngine("T03") + engine.record_callback( + _provider_event( + module, + fact=module.ProviderFact.ORDER_ACCEPTED, + callback="OnRtnOrder", + sequence=1, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="working", + remaining_quantity=1, + ) + ) + engine.record_callback( + _provider_event( + module, + fact=module.ProviderFact.ORDER_FILLED, + callback="OnRtnOrder", + sequence=2, + order_ref="r1", + external_order_id="e1", + instrument_id="rb2610", + status="filled", + remaining_quantity=0, + traded_quantity=1, + ) + ) + + result = engine.snapshot() + assert result["state"] == module.EngineState.EXTERNAL_CONDITION_UNAVAILABLE.value + assert "cancel_window_unavailable" in result["missing_facts"] + assert result["certification_pass"] is False + + +def test_em03_local_logout_name_without_front_disconnect_is_rejected(case_engine_module): + module = case_engine_module + tracker = module.CertificationEvidenceTracker("EM03") + evidence = module.CertificationEvidence( + event_kind="gateway_force_logout_requested", + source=module.EvidenceSource.CONTROL_PLANE, + event_id="logout-event", + evidence_sha256="e" * 64, + occurred_at_utc="2026-09-28T00:00:00Z", + fields={ + "gateway_key": "simnow-1", + "reason": "operator test", + "authorization_ref": "approval-1", + "gateway_released": True, + "operator_termination_evidence_ref": "operations/termination-1", + "post_disconnect_write_guard_evidence_ref": "audit/write-guard-1", + }, + callback_names=(), + provider_session_id="session-1", + trading_day="20260928", + source_sequence=1, + actor_id_hash="f" * 64, + signature_sha256="a" * 64, + ) + + with pytest.raises(module.EvidenceContractError, match="lacks native callbacks"): + tracker.record(evidence) + assert tracker.snapshot()["state"] == module.CertificationState.BLOCKED.value diff --git a/tests/unit/live_certification/test_simnow_completion_invariants.py b/tests/unit/live_certification/test_simnow_completion_invariants.py new file mode 100644 index 00000000..de89694a --- /dev/null +++ b/tests/unit/live_certification/test_simnow_completion_invariants.py @@ -0,0 +1,2095 @@ +"""Offline completion gates for the unregistered 007 case engine. + +Contract-shaped inputs here are synthetic and intentionally cannot establish +provider authenticity, certification PASS, or dispatch authority. The 33-case +negative loop proves only that missing scenario evidence cannot reach review; +it is not 33-case positive or full per-case acceptance coverage. +""" + +from __future__ import annotations + +import importlib +import sys +from dataclasses import replace +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +SESSION = "17" +TRADING_DAY = "20260928" +INSTRUMENT = "rb2710" +DIGEST = "a" * 64 +QUERY_CALLBACKS = ( + "OnRspQryOrder", + "OnRspQryInvestorPosition", + "OnRspQryTradingAccount", +) + + +@pytest.fixture +def completion_module(): + previous_modules = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + previous_path = sys.path[:] + for name in previous_modules: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + yield importlib.import_module("common.completion_invariants") + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous_modules) + sys.path[:] = previous_path + + +def _snapshot( + module, phase, sequence_start, timestamp, *, positions=None, funds=None, closeable_quantities=None, closeable_position_buckets=None, account_id_masked=None +): + query_error_ids = dict.fromkeys(QUERY_CALLBACKS, 0) + query_is_last = dict.fromkeys(QUERY_CALLBACKS, True) + return module.AccountReconciliationSnapshot( + phase=phase, + session_id=SESSION, + trading_day=TRADING_DAY, + occurred_at_utc=timestamp, + order_query_id=f"{phase.value}-order", + position_query_id=f"{phase.value}-position", + account_query_id=f"{phase.value}-account", + order_query_sequence=sequence_start, + position_query_sequence=sequence_start + 1, + account_query_sequence=sequence_start + 2, + open_order_refs=(), + positions=positions or {INSTRUMENT: 0}, + funds=funds or {"cash": 10000, "available_funds": 9000, "equity": 10000}, + callback_names=QUERY_CALLBACKS, + source="ctp_provider_callback", + evidence_sha256=DIGEST, + query_error_ids=query_error_ids, + query_is_last=query_is_last, + closeable_quantities=closeable_quantities or {}, + closeable_position_buckets=closeable_position_buckets or (), + account_id_masked=account_id_masked, + client_instance_id="ctp-client-1", + arrival_generation=1, + query_native_request_ids={ + "OnRspQryOrder": sequence_start + 101, + "OnRspQryInvestorPosition": sequence_start + 102, + "OnRspQryTradingAccount": sequence_start + 103, + }, + query_round_id=f"{phase.value}-query-round", + query_id_origin="local_query_coordinator", + query_round_id_origin="local_query_coordinator", + sequence_origin="local_sdk_callback_arrival", + timestamp_origin="local_sdk_capture_clock", + session_identity_origin="derived_from_same_client_generation_native_login", + ) + + +def _scenario_row( + module, kind, event_id, fields, *, sequence=0, callback_names=(), occurred_at_utc="2026-09-28T09:02:00+00:00" +): + case_engine = importlib.import_module("common.case_engine") + source_by_kind = { + "order_submit_request": case_engine.EvidenceSource.MANAGED_RUNTIME, + "order_cancel_request": case_engine.EvidenceSource.MANAGED_RUNTIME, + "batch_cancel_requested": case_engine.EvidenceSource.MANAGED_RUNTIME, + "order_status_accepted": case_engine.EvidenceSource.PROVIDER_CALLBACK, + "order_reject_remote": case_engine.EvidenceSource.PROVIDER_CALLBACK, + "trade_execution": case_engine.EvidenceSource.PROVIDER_CALLBACK, + } + provider = source_by_kind[kind] is case_engine.EvidenceSource.PROVIDER_CALLBACK + return module.CertificationEvidence( + event_kind=kind, + source=source_by_kind[kind], + event_id=event_id, + evidence_sha256=DIGEST, + occurred_at_utc=occurred_at_utc, + fields=fields, + callback_names=callback_names, + provider_session_id=SESSION if provider else "", + trading_day=TRADING_DAY if provider else "", + source_sequence=sequence, + ) + + +def _t01_evidence(module): + submit = module.ManagedOrderRequest( + request_id="submit-r1", + action=module.RequestAction.SUBMIT, + dispatch_state=module.DispatchState.DISPATCHED, + order_refs=("r1",), + occurred_at_utc="2026-09-28T09:01:00+00:00", + sequence=1, + evidence_sha256=DIGEST, + quantity=1, + ) + cancel = module.ManagedOrderRequest( + request_id="cancel-r1", + action=module.RequestAction.CANCEL, + dispatch_state=module.DispatchState.DISPATCHED, + order_refs=("r1",), + occurred_at_utc="2026-09-28T09:03:00+00:00", + sequence=2, + evidence_sha256=DIGEST, + ) + accepted = module.NativeOrderFact( + event_id="order-accepted-r1", + fact=module.NativeOrderFactKind.ACCEPTED, + callback_name="OnRtnOrder", + source="ctp_provider_callback", + order_ref="r1", + external_order_id="sys-r1", + instrument_id=INSTRUMENT, + status="accepted", + traded_quantity=0, + remaining_quantity=1, + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=4, + occurred_at_utc="2026-09-28T09:02:00+00:00", + evidence_sha256=DIGEST, + ) + canceled = module.NativeOrderFact( + event_id="order-canceled-r1", + fact=module.NativeOrderFactKind.CANCELED, + callback_name="OnRtnOrder", + source="ctp_provider_callback", + order_ref="r1", + external_order_id="sys-r1", + instrument_id=INSTRUMENT, + status="canceled", + traded_quantity=0, + remaining_quantity=0, + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=5, + occurred_at_utc="2026-09-28T09:04:00+00:00", + evidence_sha256=DIGEST, + ) + scenario = ( + _scenario_row( + module, + "order_submit_request", + "managed-submit-r1", + { + "trace_id": "trace-r1", + "invocation_id": "invoke-r1", + "order_ref": "r1", + "dispatch_state": "dispatched", + }, + ), + _scenario_row( + module, + "order_status_accepted", + "provider-accepted-r1", + {"order_ref": "r1", "external_order_id": "sys-r1", "provider_status": "accepted"}, + sequence=4, + callback_names=("OnRtnOrder",), + ), + ) + snapshots = ( + _snapshot( + module, + module.SnapshotPhase.BASELINE, + 1, + "2026-09-28T09:00:00+00:00", + ), + _snapshot( + module, + module.SnapshotPhase.FINAL, + 6, + "2026-09-28T09:05:00+00:00", + ), + ) + return module.CompletionEvidence( + case_id="T01", + scenario_evidence=scenario, + managed_requests=(submit, cancel), + order_facts=(accepted, canceled), + snapshots=snapshots, + ) + + +def _c01_login_evidence(module): + case_engine = importlib.import_module("common.case_engine") + auth = module.CertificationEvidence( + event_kind="store_auth_success", + source=case_engine.EvidenceSource.PROVIDER_CALLBACK, + event_id="ctp-auth", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:00:00+00:00", + fields={ + "request_id": 11, + "request_generation": 11, + "arrival_generation": 1, + "is_last": True, + "error_id": 0, + "authentication_succeeded": True, + }, + callback_names=("OnRspAuthenticate",), + callback_arrival=case_engine.LocalCallbackArrival( + client_instance_id="ctp-client-1", + request_generation=11, + arrival_generation=1, + source_sequence=1, + arrived_at_utc="2026-09-28T09:00:00+00:00", + arrived_monotonic=100.0, + ), + ) + login = module.CertificationEvidence( + event_kind="store_login_success", + source=case_engine.EvidenceSource.PROVIDER_CALLBACK, + event_id="ctp-login", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:00:01+00:00", + fields={ + "request_id": 12, + "request_generation": 12, + "arrival_generation": 1, + "is_last": True, + "error_id": 0, + "login_succeeded": True, + "provider_front_id": 3, + "provider_session_id": SESSION, + "trading_day": TRADING_DAY, + }, + callback_names=("OnRspUserLogin",), + provider_session_id=SESSION, + trading_day=TRADING_DAY, + provider_front_id=3, + callback_arrival=case_engine.LocalCallbackArrival( + client_instance_id="ctp-client-1", + request_generation=12, + arrival_generation=1, + source_sequence=2, + arrived_at_utc="2026-09-28T09:00:01+00:00", + arrived_monotonic=101.0, + ), + ) + return module.CompletionEvidence( + case_id="C01", + scenario_evidence=(auth, login), + snapshots=( + _snapshot(module, module.SnapshotPhase.BASELINE, 3, "2026-09-28T09:01:00+00:00"), + _snapshot(module, module.SnapshotPhase.FINAL, 6, "2026-09-28T09:05:00+00:00"), + ), + ) + + +def _validation_case_evidence(module, case_id, overrides=None): + case_engine = importlib.import_module("common.case_engine") + validation_rule = { + "V01": "instrument", + "V02": "price_tick", + "V03": "max_order_size", + }[case_id] + fields = { + "trace_id": f"trace-{case_id}", + "validator_digest": DIGEST, + "reference_data_digest": DIGEST, + "validation_rule": validation_rule, + "error_msg": "local validation rejected before dispatch", + "dispatch_absent": True, + "instrument": "UNKNOWN-RB", + "instrument_id": "UNKNOWN-RB", + "authoritative_lookup_instrument": "UNKNOWN-RB", + "authoritative_lookup_result": "not_found", + "authoritative_lookup_complete": True, + "authoritative_lookup_authoritative": True, + "authoritative_lookup_source": "sandbox-contract-master", + "authoritative_lookup_id": "query-17", + "authoritative_lookup_at_utc": "2026-09-28T09:00:00+00:00", + "proposed_price": "100.03", + "price": "100.03", + "price_tick": "0.05", + "requested_size": "11", + "size": "11", + "max_order_size": "10", + } + fields.update(overrides or {}) + if "proposed_price" in (overrides or {}) and "price" not in (overrides or {}): + fields["price"] = fields["proposed_price"] + if "requested_size" in (overrides or {}) and "size" not in (overrides or {}): + fields["size"] = fields["requested_size"] + row = module.CertificationEvidence( + event_kind="order_validation_rejected", + source=case_engine.EvidenceSource.LOCAL_VALIDATOR, + event_id=f"validation-{case_id}", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:01:00+00:00", + fields=fields, + ) + return module.CompletionEvidence( + case_id=case_id, + scenario_evidence=(row,), + snapshots=( + _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00"), + _snapshot(module, module.SnapshotPhase.FINAL, 4, "2026-09-28T09:02:00+00:00"), + ), + ) + + +def _e01_remote_rejection(module): + submit = module.ManagedOrderRequest( + request_id="submit-r1", + action=module.RequestAction.SUBMIT, + dispatch_state=module.DispatchState.DISPATCHED, + order_refs=("r1",), + occurred_at_utc="2026-09-28T09:01:00+00:00", + sequence=1, + evidence_sha256=DIGEST, + quantity=1, + ) + rejected = module.NativeOrderFact( + event_id="order-rejected-r1", + fact=module.NativeOrderFactKind.REJECTED, + callback_name="OnRspOrderInsert", + source="ctp_provider_callback", + order_ref="r1", + external_order_id="", + instrument_id=INSTRUMENT, + status="rejected", + traded_quantity=0, + remaining_quantity=0, + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=4, + occurred_at_utc="2026-09-28T09:02:00+00:00", + evidence_sha256=DIGEST, + error_id=5, + ) + scenario = ( + _scenario_row( + module, + "order_submit_request", + "managed-submit-r1", + { + "trace_id": "trace-r1", + "invocation_id": "invoke-r1", + "order_ref": "r1", + "dispatch_state": "dispatched", + }, + ), + _scenario_row( + module, + "order_reject_remote", + "provider-reject-r1", + { + "order_ref": "r1", + "ErrorID": 5, + "ErrorMsg": "insufficient funds", + "StatusMsg": "insufficient funds", + "verified_rejection_class": "insufficient_funds", + "error_mapping_evidence_ref": "independent-error-map-review", + }, + sequence=4, + callback_names=("OnRspOrderInsert",), + ), + ) + dependency = module.ExternalDependency( + dependency_id="insufficient_funds", + state="satisfied", + evidence_ref="provider-account-risk-record", + evidence_sha256=DIGEST, + fields={ + "available_funds": 10, + "required_margin": 20, + "instrument_id": INSTRUMENT, + "order_ref": "r1", + }, + ) + return module.CompletionEvidence( + case_id="E01", + scenario_evidence=scenario, + managed_requests=(submit,), + order_facts=(rejected,), + snapshots=( + _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00"), + _snapshot(module, module.SnapshotPhase.FINAL, 5, "2026-09-28T09:05:00+00:00"), + ), + external_dependencies=(dependency,), + ) + + +def _b02_cancel_fill_race(module): + requests = ( + module.ManagedOrderRequest( + f"submit-{ref}", + module.RequestAction.SUBMIT, + module.DispatchState.DISPATCHED, + (ref,), + "2026-09-28T09:01:00+00:00", + index, + DIGEST, + quantity=1, + ) + for index, ref in enumerate(("r1", "r2"), start=1) + ) + requests = tuple(requests) + ( + module.ManagedOrderRequest( + "batch-cancel", + module.RequestAction.BATCH_CANCEL, + module.DispatchState.DISPATCHED, + ("r1", "r2"), + "2026-09-28T09:03:00+00:00", + 3, + DIGEST, + ), + ) + + def order_fact(ref, fact, sequence, time, *, traded=0, remaining=1): + return module.NativeOrderFact( + event_id=f"{fact.value}-{ref}", + fact=fact, + callback_name="OnRtnOrder", + source="ctp_provider_callback", + order_ref=ref, + external_order_id=f"sys-{ref}", + instrument_id=INSTRUMENT, + status=fact.value, + traded_quantity=traded, + remaining_quantity=remaining, + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=sequence, + occurred_at_utc=time, + evidence_sha256=DIGEST, + ) + + facts = ( + order_fact("r1", module.NativeOrderFactKind.ACCEPTED, 4, "2026-09-28T09:02:00+00:00"), + order_fact("r2", module.NativeOrderFactKind.ACCEPTED, 5, "2026-09-28T09:02:01+00:00"), + order_fact( + "r2", + module.NativeOrderFactKind.FILLED, + 7, + "2026-09-28T09:04:01+00:00", + traded=1, + remaining=0, + ), + order_fact( + "r1", module.NativeOrderFactKind.CANCELED, 8, "2026-09-28T09:05:00+00:00", remaining=0 + ), + ) + trade = module.NativeTradeFact( + event_id="trade-r2", + trade_id="trade-id-r2", + order_ref="r2", + instrument_id=INSTRUMENT, + quantity=1, + direction="buy", + price=3500, + callback_name="OnRtnTrade", + source="ctp_provider_callback", + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=6, + occurred_at_utc="2026-09-28T09:04:00+00:00", + evidence_sha256=DIGEST, + external_order_id="sys-r2", + ) + submit_scenarios = tuple( + _scenario_row( + module, + "order_submit_request", + f"scenario-submit-{ref}", + { + "trace_id": f"trace-{ref}", + "invocation_id": f"invoke-{ref}", + "order_ref": ref, + "dispatch_state": "dispatched", + }, + ) + for ref in ("r1", "r2") + ) + batch_scenario = _scenario_row( + module, + "batch_cancel_requested", + "scenario-batch-cancel", + { + "trace_id": "trace-batch", + "invocation_id": "invoke-batch", + "order_refs": ["r1", "r2"], + "dispatch_state": "dispatched", + "open_order_count": 2, + }, + ) + snapshots = ( + _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00"), + _snapshot( + module, + module.SnapshotPhase.FINAL, + 9, + "2026-09-28T09:06:00+00:00", + positions={INSTRUMENT: 1}, + funds={"cash": 9999, "available_funds": 8999, "equity": 10000}, + ), + ) + return module.CompletionEvidence( + case_id="B02", + scenario_evidence=(*submit_scenarios, batch_scenario), + managed_requests=requests, + order_facts=facts, + trade_facts=(trade,), + snapshots=snapshots, + ) + + +def test_all_33_cases_need_scenario_evidence_before_review(completion_module): + """A broad fail-closed negative, not full completion coverage for 33 cases.""" + module = completion_module + from common.certification import RECONCILIATION_EXPECTATIONS + + assert len(RECONCILIATION_EXPECTATIONS) == 33 + for case_id in RECONCILIATION_EXPECTATIONS: + snapshots = ( + _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00"), + _snapshot(module, module.SnapshotPhase.FINAL, 4, "2026-09-28T09:05:00+00:00"), + ) + if case_id == "C01": + snapshots = tuple( + replace( + snapshot, + session_id="", + trading_day="", + session_identity_origin="", + ) + for snapshot in snapshots + ) + evidence = module.CompletionEvidence(case_id=case_id, snapshots=snapshots) + report = module.evaluate_case_completion(evidence) + assert report.status is not module.CompletionStatus.REVIEW_REQUIRED, case_id + assert report.certification_pass is False + assert report.dispatch_permitted is False + assert report.source_authenticity_verified is False + + +def test_t01_terminal_order_account_reconciliation_only_reaches_review(completion_module): + report = completion_module.evaluate_case_completion(_t01_evidence(completion_module)) + assert report.status is completion_module.CompletionStatus.REVIEW_REQUIRED + assert report.certification_pass is False + assert report.dispatch_permitted is False + assert report.source_authenticity_verified is False + assert report.review_reasons + + +def test_c01_requires_auth_then_login_then_account_queries(completion_module): + module = completion_module + evidence = _c01_login_evidence(module) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.INCOMPLETE + assert any("trusted issuer-side request ledger verifier" in item for item in report.missing_invariants) + auth, login = evidence.scenario_evidence + arrival_type = importlib.import_module("common.case_engine").LocalCallbackArrival + reversed_auth = replace( + auth, + occurred_at_utc="2026-09-28T09:00:02+00:00", + callback_arrival=arrival_type( + client_instance_id="ctp-client-1", + request_generation=11, + arrival_generation=1, + source_sequence=3, + arrived_at_utc="2026-09-28T09:00:02+00:00", + arrived_monotonic=102.0, + ), + ) + reversed_order = replace(evidence, scenario_evidence=(reversed_auth, login)) + with pytest.raises(module.CompletionEvidenceError, match="arrival sequence must strictly increase"): + module.evaluate_case_completion(reversed_order) + + +def test_c01_missing_login_stays_incomplete(completion_module): + module = completion_module + evidence = _c01_login_evidence(module) + unbound_snapshots = tuple( + replace( + snapshot, + session_id="", + trading_day="", + session_identity_origin="", + ) + for snapshot in evidence.snapshots + ) + report = module.evaluate_case_completion( + replace( + evidence, + scenario_evidence=evidence.scenario_evidence[:1], + snapshots=unbound_snapshots, + ) + ) + + assert report.status is module.CompletionStatus.INCOMPLETE + assert "scenario_event:store_login_success" in report.missing_invariants + assert report.certification_pass is False + assert report.dispatch_permitted is False + + +def test_c01_disconnect_generation_change_between_auth_and_login_fails(completion_module): + module = completion_module + evidence = _c01_login_evidence(module) + auth, login = evidence.scenario_evidence + changed_login = replace( + login, + fields={**login.fields, "arrival_generation": 2}, + callback_arrival=replace(login.callback_arrival, arrival_generation=2), + ) + report = module.evaluate_case_completion( + replace(evidence, scenario_evidence=(auth, changed_login)) + ) + + assert report.status is module.CompletionStatus.INCOMPLETE + assert "auth_and_login_must_share_client_and_arrival_generation" in report.contradictions + assert report.certification_pass is False + assert report.dispatch_permitted is False + + +def test_c01_auth_cannot_claim_login_only_native_identity(completion_module): + module = completion_module + evidence = _c01_login_evidence(module) + auth, login = evidence.scenario_evidence + claimed_auth = replace( + auth, + provider_session_id=SESSION, + trading_day=TRADING_DAY, + provider_front_id=3, + fields={ + **auth.fields, + "provider_front_id": 3, + "provider_session_id": SESSION, + "trading_day": TRADING_DAY, + }, + ) + + with pytest.raises(module.CompletionEvidenceError, match="OnRspAuthenticate has no provider session"): + module.evaluate_case_completion( + replace(evidence, scenario_evidence=(claimed_auth, login)) + ) + + +def test_em02_pause_receipt_must_bind_the_same_strategy_and_event(completion_module): + module = completion_module + case_engine = importlib.import_module("common.case_engine") + pause_row = module.CertificationEvidence( + event_kind="strategy_trading_paused", + source=case_engine.EvidenceSource.CONTROL_PLANE, + event_id="pause-event-A", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:02:00+00:00", + fields={ + "strategy_id": "strategy-A", + "reason": "operator pause", + "authorization_ref": "approval-A", + }, + actor_id_hash="actor-A", + signature_sha256=DIGEST, + ) + dependency = module.ExternalDependency( + dependency_id="strategy_pause_authorized", + state="satisfied", + evidence_ref="control-pause-A", + evidence_sha256=DIGEST, + fields={ + "state": "paused", + "strategy_id": "strategy-A", + "pause_event_ref": pause_row.event_id, + "pending_orders_reconciled": True, + }, + ) + evidence = module.CompletionEvidence( + case_id="EM02", + scenario_evidence=(pause_row,), + snapshots=( + _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00"), + _snapshot(module, module.SnapshotPhase.FINAL, 4, "2026-09-28T09:05:00+00:00"), + ), + external_dependencies=(dependency,), + ) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.REVIEW_REQUIRED + wrong_dependency = replace( + dependency, + fields={**dependency.fields, "strategy_id": "strategy-B", "pause_event_ref": "other"}, + ) + report = module.evaluate_case_completion( + replace(evidence, external_dependencies=(wrong_dependency,)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "strategy_pause_receipt_does_not_match_pause_event" in report.contradictions + + +def _o02_close_evidence( + module, *, direction="sell", offset="close_today", quantity=1, native_direction=None, native_offset=None +): + case_engine = importlib.import_module("common.case_engine") + native_direction = native_direction or direction + native_offset = native_offset or offset + request = module.ManagedOrderRequest( + request_id="o02-submit-1", + action=module.RequestAction.SUBMIT, + dispatch_state=module.DispatchState.DISPATCHED, + order_refs=("o02-r1",), + occurred_at_utc="2026-09-28T09:01:00+00:00", + sequence=1, + evidence_sha256=DIGEST, + quantity=quantity, + instrument_id=INSTRUMENT, + direction=direction, + offset=offset, + account_id_masked="account-A", + provider_session_id=SESSION, + request_generation=1, + intent_key=f"close:{INSTRUMENT}:{direction}", + trading_day=TRADING_DAY, + ) + blocked_repeat = replace( + request, + request_id="o02-submit-repeat", + dispatch_state=module.DispatchState.BLOCKED_PRE_DISPATCH, + occurred_at_utc="2026-09-28T09:01:30+00:00", + sequence=2, + request_generation=2, + ) + cancel = module.ManagedOrderRequest( + request_id="o02-cancel-1", + action=module.RequestAction.CANCEL, + dispatch_state=module.DispatchState.DISPATCHED, + order_refs=("o02-r1",), + occurred_at_utc="2026-09-28T09:02:30+00:00", + sequence=3, + evidence_sha256=DIGEST, + ) + accepted = module.NativeOrderFact( + event_id="o02-accepted", + fact=module.NativeOrderFactKind.ACCEPTED, + callback_name="OnRtnOrder", + source="ctp_provider_callback", + order_ref="o02-r1", + external_order_id="sys-o02-r1", + instrument_id=INSTRUMENT, + status="accepted", + traded_quantity=0, + remaining_quantity=quantity, + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=4, + occurred_at_utc="2026-09-28T09:02:00+00:00", + evidence_sha256=DIGEST, + direction=native_direction, + offset=native_offset, + account_id_masked="account-A", + ) + canceled = replace( + accepted, + event_id="o02-canceled", + fact=module.NativeOrderFactKind.CANCELED, + status="canceled", + remaining_quantity=0, + source_sequence=5, + occurred_at_utc="2026-09-28T09:03:00+00:00", + ) + repeat_event = module.CertificationEvidence( + event_kind="risk_repeat_order_detected", + source=case_engine.EvidenceSource.RUNTIME_MONITOR, + event_id="o02-repeat-monitor", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:01:45+00:00", + fields={ + "trace_id": "o02-trace", + "repeat_key": f"close:{INSTRUMENT}:{direction}", + "repeat_count": 2, + "monitor_digest": DIGEST, + "account_id_masked": "account-A", + "provider_session_id": SESSION, + "trading_day": TRADING_DAY, + }, + ) + return module.CompletionEvidence( + case_id="O02", + scenario_evidence=(repeat_event,), + managed_requests=(request, blocked_repeat, cancel), + order_facts=(accepted, canceled), + snapshots=( + _snapshot( + module, + module.SnapshotPhase.BASELINE, + 1, + "2026-09-28T09:00:00+00:00", + positions={INSTRUMENT: 2}, + closeable_quantities={INSTRUMENT: 1}, + closeable_position_buckets=( + module.CloseablePositionBucket(INSTRUMENT, "long", offset, 1), + ) + if offset in {"close_today", "close_yesterday"} + else (), + account_id_masked="account-A", + ), + _snapshot( + module, + module.SnapshotPhase.FINAL, + 6, + "2026-09-28T09:05:00+00:00", + positions={INSTRUMENT: 2}, + account_id_masked="account-A", + ), + ), + ) + + +def _em01_permission_evidence(module, *, submit_time, reject_time): + case_engine = importlib.import_module("common.case_engine") + disabled_time = "2026-09-28T09:02:00+00:00" + restored_time = "2026-09-28T09:04:00+00:00" + disabled_row = module.CertificationEvidence( + event_kind="account_trading_disabled", + source=case_engine.EvidenceSource.CONTROL_PLANE, + event_id="permission-disabled-event", + evidence_sha256=DIGEST, + occurred_at_utc=disabled_time, + fields={ + "account_id_masked": "account-A", + "reason": "permission review", + "authorization_ref": "auth-A", + "independent_account_permission_evidence_ref": "provider-permission-A", + "blocked_order_ref": "em01-r1", + "ErrorID": 5, + "ErrorMsg": "account permission denied", + "permission_restored_ref": "restore-audit-A", + }, + callback_names=("OnRspOrderInsert",), + provider_session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=4, + actor_id_hash="actor-A", + signature_sha256=DIGEST, + ) + submit_row = _scenario_row( + module, + "order_submit_request", + "em01-submit-event", + { + "trace_id": "em01-trace", + "invocation_id": "em01-invocation", + "order_ref": "em01-r1", + "dispatch_state": "dispatched", + "request_id": "em01-submit-r1", + "request_generation": 1, + "account_id_masked": "account-A", + "provider_session_id": SESSION, + "trading_day": TRADING_DAY, + }, + sequence=5, + occurred_at_utc=submit_time, + ) + reject_row = _scenario_row( + module, + "order_reject_remote", + "em01-reject-event", + { + "order_ref": "em01-r1", + "ErrorID": 5, + "ErrorMsg": "account permission denied", + "StatusMsg": "account permission denied", + "verified_rejection_class": "account_permission_denied", + "error_mapping_evidence_ref": "reviewed-error-map-A", + }, + sequence=6, + callback_names=("OnRspOrderInsert",), + occurred_at_utc=reject_time, + ) + request = module.ManagedOrderRequest( + request_id="em01-submit-r1", + action=module.RequestAction.SUBMIT, + dispatch_state=module.DispatchState.DISPATCHED, + order_refs=("em01-r1",), + occurred_at_utc=submit_time, + sequence=5, + evidence_sha256=DIGEST, + quantity=1, + account_id_masked="account-A", + provider_session_id=SESSION, + request_generation=1, + trading_day=TRADING_DAY, + ) + rejected = module.NativeOrderFact( + event_id="em01-native-reject", + fact=module.NativeOrderFactKind.REJECTED, + callback_name="OnRspOrderInsert", + source="ctp_provider_callback", + order_ref="em01-r1", + external_order_id="", + instrument_id=INSTRUMENT, + status="rejected", + traded_quantity=0, + remaining_quantity=0, + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=6, + occurred_at_utc=reject_time, + evidence_sha256=DIGEST, + error_id=5, + account_id_masked="account-A", + ) + disabled = module.ExternalDependency( + dependency_id="account_permission_disabled", + state="satisfied", + evidence_ref="disable-audit-A", + evidence_sha256=DIGEST, + fields={ + "state": "disabled", + "account_id_masked": "account-A", + "change_id": "change-A", + "provider_audit_ref": "disable-audit-A", + "occurred_at_utc": disabled_time, + "source_event_ref": disabled_row.event_id, + "provider_session_id": SESSION, + "trading_day": TRADING_DAY, + }, + ) + restored = replace( + disabled, + dependency_id="account_permission_restored", + evidence_ref="restore-audit-A", + fields={ + **disabled.fields, + "state": "restored", + "provider_audit_ref": "restore-audit-A", + "occurred_at_utc": restored_time, + }, + ) + return module.CompletionEvidence( + case_id="EM01", + scenario_evidence=(disabled_row, submit_row, reject_row), + managed_requests=(request,), + order_facts=(rejected,), + snapshots=( + _snapshot( + module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00", account_id_masked="account-A" + ), + _snapshot( + module, module.SnapshotPhase.FINAL, 7, "2026-09-28T09:05:00+00:00", account_id_masked="account-A" + ), + ), + external_dependencies=(disabled, restored), + ) + + +def test_em01_rejection_inside_permission_window_stays_review_only(completion_module): + module = completion_module + report = module.evaluate_case_completion( + _em01_permission_evidence( + module, + submit_time="2026-09-28T09:02:30+00:00", + reject_time="2026-09-28T09:03:00+00:00", + ) + ) + assert report.status is module.CompletionStatus.REVIEW_REQUIRED + assert report.certification_pass is False + assert report.dispatch_permitted is False + assert report.source_authenticity_verified is False + + +@pytest.mark.parametrize( + ("submit_time", "reject_time"), + ( + ("2026-09-28T09:01:30+00:00", "2026-09-28T09:02:30+00:00"), + ("2026-09-28T09:04:30+00:00", "2026-09-28T09:04:45+00:00"), + ), +) +def test_em01_submit_and_rejection_must_fall_inside_permission_window( + completion_module, submit_time, reject_time +): + module = completion_module + report = module.evaluate_case_completion( + _em01_permission_evidence(module, submit_time=submit_time, reject_time=reject_time) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "permission_submit_and_rejection_must_occur_while_disabled" in report.contradictions + assert report.certification_pass is False + + +def test_o02_repeat_plan_requires_a_real_close_intent_against_closeable_position( + completion_module, +): + module = completion_module + positive = module.evaluate_case_completion(_o02_close_evidence(module)) + assert positive.status is module.CompletionStatus.REVIEW_REQUIRED + assert positive.certification_pass is False + assert positive.dispatch_permitted is False + assert positive.source_authenticity_verified is False + + open_intent = module.evaluate_case_completion(_o02_close_evidence(module, offset="open")) + assert open_intent.status is module.CompletionStatus.INCOMPLETE + assert "o02_requires_typed_close_today_or_close_yesterday_bucket" in open_intent.missing_invariants + + generic_close = module.evaluate_case_completion(_o02_close_evidence(module, offset="close")) + assert generic_close.status is module.CompletionStatus.INCOMPLETE + assert "o02_requires_typed_close_today_or_close_yesterday_bucket" in generic_close.missing_invariants + + same_side = module.evaluate_case_completion(_o02_close_evidence(module, direction="buy")) + assert same_side.status is module.CompletionStatus.INCOMPLETE + assert "o02_close_direction_does_not_match_available_position_side" in same_side.contradictions + + over_close = module.evaluate_case_completion(_o02_close_evidence(module, quantity=2)) + assert over_close.status is module.CompletionStatus.INCOMPLETE + assert "o02_requested_close_quantity_exceeds_baseline_bucket" in over_close.contradictions + + +def test_o02_rejects_multiple_dispatched_submit_refs(completion_module): + module = completion_module + evidence = _o02_close_evidence(module, quantity="0.4") + first = evidence.managed_requests[0] + extra = replace(first, request_id="o02-submit-2", order_refs=("o02-r2",), quantity="0.4", request_generation=2, sequence=2) + report = module.evaluate_case_completion( + replace( + evidence, + managed_requests=(first, extra, evidence.managed_requests[1], evidence.managed_requests[2]), + ) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_requires_exactly_one_dispatched_and_one_blocked_submit" in report.contradictions + + +def test_o02_request_session_must_match_provider_snapshots(completion_module): + module = completion_module + evidence = _o02_close_evidence(module) + request = replace(evidence.managed_requests[0], provider_session_id="previous-provider-session") + repeat = replace(evidence.managed_requests[1], provider_session_id="previous-provider-session") + repeat_row = replace( + evidence.scenario_evidence[0], + fields={**evidence.scenario_evidence[0].fields, "provider_session_id": "previous-provider-session"}, + ) + report = module.evaluate_case_completion( + replace( + evidence, + managed_requests=(request, repeat, evidence.managed_requests[2]), + scenario_evidence=(repeat_row,), + ) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_request_provider_session_must_match_account_snapshots" in report.contradictions + + +def test_o02_attempts_and_monitor_cannot_relabel_snapshot_account(completion_module): + module = completion_module + evidence = _o02_close_evidence(module) + requests = tuple( + replace(request, account_id_masked="account-B") + if request.action is module.RequestAction.SUBMIT + else request + for request in evidence.managed_requests + ) + monitor = replace( + evidence.scenario_evidence[0], + fields={**evidence.scenario_evidence[0].fields, "account_id_masked": "account-B"}, + ) + report = module.evaluate_case_completion( + replace(evidence, managed_requests=requests, scenario_evidence=(monitor,)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_request_account_must_match_account_snapshots" in report.contradictions + + +def test_o02_native_order_account_must_match_snapshots(completion_module): + module = completion_module + evidence = _o02_close_evidence(module) + order_fact = replace(evidence.order_facts[0], account_id_masked="account-B") + report = module.evaluate_case_completion( + replace(evidence, order_facts=(order_fact, evidence.order_facts[1])) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_native_order_account_must_match_account_snapshots" in report.contradictions + + +def test_o02_native_trade_account_must_match_snapshots(completion_module): + module = completion_module + evidence = _o02_close_evidence(module) + accepted, canceled = evidence.order_facts + canceled = replace(canceled, source_sequence=6) + trade = module.NativeTradeFact( + event_id="o02-trade-account", + trade_id="o02-trade-account-id", + order_ref="o02-r1", + instrument_id=INSTRUMENT, + quantity=1, + direction="sell", + price=3500, + callback_name="OnRtnTrade", + source="ctp_provider_callback", + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=5, + occurred_at_utc="2026-09-28T09:02:30+00:00", + evidence_sha256=DIGEST, + offset="close_today", + external_order_id="sys-o02-r1", + account_id_masked="account-B", + ) + final = replace( + evidence.snapshots[1], + order_query_sequence=7, + position_query_sequence=8, + account_query_sequence=9, + ) + report = module.evaluate_case_completion( + replace( + evidence, + order_facts=(accepted, canceled), + trade_facts=(trade,), + snapshots=(evidence.snapshots[0], final), + ) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_native_trade_account_must_match_account_snapshots" in report.contradictions + + +def test_o02_attempt_day_must_match_snapshot_day(completion_module): + module = completion_module + evidence = _o02_close_evidence(module) + requests = tuple( + replace(request, trading_day="20260927") + if request.action is module.RequestAction.SUBMIT + else request + for request in evidence.managed_requests + ) + monitor = replace( + evidence.scenario_evidence[0], + fields={**evidence.scenario_evidence[0].fields, "trading_day": "20260927"}, + ) + report = module.evaluate_case_completion( + replace(evidence, managed_requests=requests, scenario_evidence=(monitor,)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_request_trading_day_must_match_account_snapshots" in report.contradictions + + +def test_o02_native_trade_offset_must_match_close_intent(completion_module): + module = completion_module + evidence = _o02_close_evidence(module) + accepted, canceled = evidence.order_facts + canceled = replace(canceled, source_sequence=6) + trade = module.NativeTradeFact( + event_id="o02-trade", + trade_id="o02-trade-id", + order_ref="o02-r1", + instrument_id=INSTRUMENT, + quantity=1, + direction="sell", + price=3500, + callback_name="OnRtnTrade", + source="ctp_provider_callback", + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=5, + occurred_at_utc="2026-09-28T09:02:30+00:00", + evidence_sha256=DIGEST, + offset="close_yesterday", + external_order_id="sys-o02-r1", + account_id_masked="account-A", + ) + final = replace( + evidence.snapshots[1], + order_query_sequence=7, + position_query_sequence=8, + account_query_sequence=9, + ) + report = module.evaluate_case_completion( + replace( + evidence, + order_facts=(accepted, canceled), + trade_facts=(trade,), + snapshots=(evidence.snapshots[0], final), + ) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_trade_offset_must_match_submit" in report.contradictions + + +def test_o02_scalar_maps_do_not_replace_typed_bucket(completion_module): + module = completion_module + evidence = _o02_close_evidence(module) + baseline = replace(evidence.snapshots[0], closeable_position_buckets=()) + report = module.evaluate_case_completion( + replace(evidence, snapshots=(baseline, evidence.snapshots[1])) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "o02_requires_matching_baseline_side_and_offset_closeable_bucket" in report.missing_invariants + + +def test_em01_permission_receipt_must_bind_control_event(completion_module): + module = completion_module + row = module.CertificationEvidence( + event_kind="account_trading_disabled", + source=importlib.import_module("common.case_engine").EvidenceSource.CONTROL_PLANE, + event_id="permission-event-A", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:02:00+00:00", + fields={"account_id_masked": "account-A"}, + ) + disabled = module.ExternalDependency( + dependency_id="account_permission_disabled", + state="satisfied", + evidence_ref="disable-A", + evidence_sha256=DIGEST, + fields={ + "state": "disabled", + "account_id_masked": "account-A", + "change_id": "change-A", + "provider_audit_ref": "provider-A", + "occurred_at_utc": "2026-09-28T09:02:00+00:00", + "source_event_ref": row.event_id, + }, + ) + restored = replace( + disabled, + dependency_id="account_permission_restored", + fields={ + **disabled.fields, + "state": "restored", + "occurred_at_utc": "2026-09-28T09:03:00+00:00", + }, + ) + evidence = module.CompletionEvidence( + case_id="EM01", + scenario_evidence=(row,), + external_dependencies=(disabled, restored), + ) + missing, contradictions = [], [] + module._check_external_dependencies(evidence, missing, contradictions) + assert not contradictions + wrong_disabled = replace( + disabled, fields={**disabled.fields, "source_event_ref": "unrelated-event"} + ) + missing, contradictions = [], [] + module._check_external_dependencies( + replace(evidence, external_dependencies=(wrong_disabled, restored)), + missing, + contradictions, + ) + assert "permission_disable_receipt_does_not_match_control_event" in contradictions + + +def test_em01_submit_scenario_row_must_match_receipt_time_and_generation(completion_module): + module = completion_module + evidence = _em01_permission_evidence( + module, + submit_time="2026-09-28T09:02:30+00:00", + reject_time="2026-09-28T09:03:00+00:00", + ) + rows = list(evidence.scenario_evidence) + submit_index = next(i for i, row in enumerate(rows) if row.event_kind == "order_submit_request") + rows[submit_index] = replace(rows[submit_index], occurred_at_utc="2026-09-28T09:01:30+00:00") + report = module.evaluate_case_completion(replace(evidence, scenario_evidence=tuple(rows))) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "permission_submit_scenario_row_does_not_match_managed_receipt" in report.contradictions + + +def test_em01_rejects_relabelled_submit_account_or_day(completion_module): + module = completion_module + evidence = _em01_permission_evidence( + module, + submit_time="2026-09-28T09:02:30+00:00", + reject_time="2026-09-28T09:03:00+00:00", + ) + request = replace(evidence.managed_requests[0], account_id_masked="account-B") + rows = tuple( + replace(row, fields={**row.fields, "account_id_masked": "account-B"}) + for row in evidence.scenario_evidence + ) + dependencies = tuple( + replace(dependency, fields={**dependency.fields, "account_id_masked": "account-B"}) + for dependency in evidence.external_dependencies + ) + fact = replace(evidence.order_facts[0], account_id_masked="account-B") + report = module.evaluate_case_completion( + replace( + evidence, + managed_requests=(request,), + scenario_evidence=rows, + external_dependencies=dependencies, + order_facts=(fact,), + ) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "permission_submit_account_identity_mismatch" in report.contradictions + + request = replace(evidence.managed_requests[0], trading_day="20260927") + rows = tuple( + replace(row, fields={**row.fields, "trading_day": "20260927"}) + if row.event_kind == "order_submit_request" + else row + for row in evidence.scenario_evidence + ) + report = module.evaluate_case_completion( + replace(evidence, managed_requests=(request,), scenario_evidence=rows) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "permission_submit_trading_day_mismatch" in report.contradictions + + +def test_em01_control_error_id_must_match_rejection(completion_module): + module = completion_module + evidence = _em01_permission_evidence( + module, + submit_time="2026-09-28T09:02:30+00:00", + reject_time="2026-09-28T09:03:00+00:00", + ) + rows = tuple( + replace(row, fields={**row.fields, "ErrorID": 6}) + if row.event_kind == "account_trading_disabled" + else row + for row in evidence.scenario_evidence + ) + report = module.evaluate_case_completion(replace(evidence, scenario_evidence=rows)) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "permission_control_error_and_blocked_ref_must_match_native_rejection" in report.contradictions + + +def test_em03_logout_receipt_must_bind_disconnect_event(completion_module): + module = completion_module + row = module.CertificationEvidence( + event_kind="gateway_force_logout_requested", + source=importlib.import_module("common.case_engine").EvidenceSource.CONTROL_PLANE, + event_id="logout-event-A", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:02:00+00:00", + fields={"gateway_key": "gateway-A"}, + provider_session_id=SESSION, + trading_day=TRADING_DAY, + ) + logout = module.ExternalDependency( + dependency_id="gateway_force_logout_ack", + state="satisfied", + evidence_ref="operator-ack-A", + evidence_sha256=DIGEST, + fields={ + "acknowledged": True, + "gateway_key": "gateway-A", + "session_id": SESSION, + "operator_audit_ref": "operator-A", + "source_event_ref": row.event_id, + }, + ) + evidence = module.CompletionEvidence( + case_id="EM03", scenario_evidence=(row,), external_dependencies=(logout,) + ) + missing, contradictions = [], [] + module._check_external_dependencies(evidence, missing, contradictions) + assert not contradictions + wrong_logout = replace(logout, fields={**logout.fields, "session_id": "other-session"}) + missing, contradictions = [], [] + module._check_external_dependencies( + replace(evidence, external_dependencies=(wrong_logout,)), missing, contradictions + ) + assert "force_logout_receipt_does_not_match_disconnect_event" in contradictions + + +@pytest.mark.parametrize( + ("case_id", "overrides", "expected"), + ( + ( + "V01", + {"authoritative_lookup_result": "found"}, + "authoritative_contract_lookup_did_not_report_not_found", + ), + ("V02", {"proposed_price": "100.05"}, "reported_price_is_aligned_to_contract_tick"), + ("V03", {"requested_size": "10"}, "reported_size_does_not_exceed_contract_maximum"), + ), +) +def test_validation_cases_need_an_actual_rule_violation( + completion_module, case_id, overrides, expected +): + module = completion_module + report = module.evaluate_case_completion(_validation_case_evidence(module, case_id)) + assert report.status is module.CompletionStatus.REVIEW_REQUIRED + assert report.certification_pass is False + bad_report = module.evaluate_case_completion( + _validation_case_evidence(module, case_id, overrides) + ) + assert bad_report.status is module.CompletionStatus.INCOMPLETE + assert expected in bad_report.contradictions + + +@pytest.mark.parametrize( + "overrides", + ({"proposed_price": "1E+1000000"}, {"price_tick": "1E-1000000"}), +) +def test_tick_validation_rejects_unbounded_exact_arithmetic(completion_module, overrides): + module = completion_module + report = module.evaluate_case_completion(_validation_case_evidence(module, "V02", overrides)) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "tick_validation_requires_bounded_contract_decimals" in report.missing_invariants + assert report.certification_pass is False + + +@pytest.mark.parametrize( + ("overrides", "expected"), + ( + ({"requested_size": "1E+1000000"}, "maximum_size_validation_requires_bounded_size"), + ({"max_order_size": "1E+1000000"}, "maximum_size_validation_requires_bounded_limit"), + ), +) +def test_maximum_size_validation_rejects_unbounded_numbers(completion_module, overrides, expected): + module = completion_module + report = module.evaluate_case_completion(_validation_case_evidence(module, "V03", overrides)) + assert report.status is module.CompletionStatus.INCOMPLETE + assert expected in report.missing_invariants + assert report.certification_pass is False + + +@pytest.mark.parametrize("case_id", ("T01", "B02")) +def test_managed_dispatch_cannot_follow_final_account_queries(completion_module, case_id): + module = completion_module + evidence = _t01_evidence(module) if case_id == "T01" else _b02_cancel_fill_race(module) + last_request = replace( + evidence.managed_requests[-1], + occurred_at_utc="2026-09-28T09:10:00+00:00", + sequence=999, + ) + report = module.evaluate_case_completion( + replace(evidence, managed_requests=(*evidence.managed_requests[:-1], last_request)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert ( + f"managed_request_outside_account_snapshot_window:{last_request.request_id}" + in report.contradictions + ) + + +def test_submit_receipt_must_precede_native_order_callback(completion_module): + module = completion_module + evidence = _t01_evidence(module) + late_submit = replace(evidence.managed_requests[0], occurred_at_utc="2026-09-28T09:02:30+00:00") + report = module.evaluate_case_completion( + replace(evidence, managed_requests=(late_submit, evidence.managed_requests[1])) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "managed_submit_after_native_order_callback:submit-r1" in report.contradictions + + +def test_trade_log_fields_must_match_native_trade_fact(completion_module): + module = completion_module + evidence = replace(_b02_cancel_fill_race(module), case_id="L01") + trade = evidence.trade_facts[0] + trade_row = _scenario_row( + module, + "trade_execution", + "logged-trade-r2", + { + "trade_id": trade.trade_id, + "order_ref": trade.order_ref, + "external_order_id": trade.external_order_id, + "instrument_id": "forged-instrument", + "quantity": 99, + "direction": "sell", + "price": 1, + }, + sequence=trade.source_sequence, + callback_names=("OnRtnTrade",), + ) + report = module.evaluate_case_completion( + replace(evidence, scenario_evidence=(evidence.scenario_evidence[0], trade_row)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "trade_event_fields_do_not_match_native_trade_fact" in report.contradictions + + +def test_expected_success_orders_reject_terminal_rejection_but_e01_allows_it( + completion_module, +): + module = completion_module + evidence = _t01_evidence(module) + rejected = replace( + evidence.order_facts[-1], + fact=module.NativeOrderFactKind.REJECTED, + callback_name="OnRspOrderInsert", + status="rejected", + error_id=5, + ) + evidence = replace(evidence, order_facts=(evidence.order_facts[0], rejected)) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "unexpected_provider_rejection_for_order_scenario:r1" in report.contradictions + + certification = importlib.import_module("common.certification") + for case_id in ("T01", "T02", "T03", "B01", "B02"): + candidate = replace(evidence, case_id=case_id) + _, contradictions = module._derive_invariants( + candidate, certification.RECONCILIATION_EXPECTATIONS[case_id] + ) + assert "unexpected_provider_rejection_for_order_scenario:r1" in contradictions + + remote_rejection = module.evaluate_case_completion(_e01_remote_rejection(module)) + assert remote_rejection.status is module.CompletionStatus.REVIEW_REQUIRED + assert remote_rejection.certification_pass is False + assert remote_rejection.dispatch_permitted is False + assert remote_rejection.source_authenticity_verified is False + + +def test_partial_then_canceled_with_trade_and_net_position_match_requires_review( + completion_module, +): + module = completion_module + evidence = _t01_evidence(module) + partial = replace( + evidence.order_facts[0], + event_id="order-partial-r1", + fact=module.NativeOrderFactKind.PARTIAL, + status="partial", + traded_quantity="0.25", + remaining_quantity="0.75", + source_sequence=6, + occurred_at_utc="2026-09-28T09:03:20+00:00", + ) + canceled = replace( + evidence.order_facts[1], + source_sequence=7, + occurred_at_utc="2026-09-28T09:04:00+00:00", + traded_quantity="0.25", + ) + trade = module.NativeTradeFact( + event_id="trade-r1-partial", + trade_id="trade-r1-partial-id", + order_ref="r1", + instrument_id=INSTRUMENT, + quantity="0.25", + direction="buy", + price=3500, + callback_name="OnRtnTrade", + source="ctp_provider_callback", + session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=5, + occurred_at_utc="2026-09-28T09:03:10+00:00", + evidence_sha256=DIGEST, + external_order_id="sys-r1", + ) + final = replace( + evidence.snapshots[1], + order_query_sequence=8, + position_query_sequence=9, + account_query_sequence=10, + occurred_at_utc="2026-09-28T09:05:00+00:00", + positions={INSTRUMENT: "0.25"}, + funds={"cash": 9999, "available_funds": 8999, "equity": 10000}, + ) + report = module.evaluate_case_completion( + replace( + evidence, + order_facts=(evidence.order_facts[0], partial, canceled), + trade_facts=(trade,), + snapshots=(evidence.snapshots[0], final), + ) + ) + assert report.status is module.CompletionStatus.REVIEW_REQUIRED + assert report.certification_pass is False + assert report.dispatch_permitted is False + assert report.source_authenticity_verified is False + + +def test_t01_missing_terminal_fact_and_stale_query_are_incomplete(completion_module): + module = completion_module + evidence = _t01_evidence(module) + missing_terminal = replace(evidence, order_facts=evidence.order_facts[:1]) + report = module.evaluate_case_completion(missing_terminal) + assert report.status is module.CompletionStatus.INCOMPLETE + assert any("terminal_order_state" in item for item in report.missing_invariants) + + final = replace(evidence.snapshots[1], order_query_sequence=5) + stale = replace(evidence, snapshots=(evidence.snapshots[0], final)) + report = module.evaluate_case_completion(stale) + assert report.status is module.CompletionStatus.INCOMPLETE + assert ( + "final_order_query_must_follow_all_native_order_and_trade_events" + in report.missing_invariants + ) + + +def test_order_quantity_conservation_and_trade_order_id_are_mandatory(completion_module): + module = completion_module + t01 = _t01_evidence(module) + + bad_accepted = replace(t01.order_facts[0], remaining_quantity="0.5") + report = module.evaluate_case_completion( + replace(t01, order_facts=(bad_accepted, t01.order_facts[1])) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "provider_open_quantity_conservation_mismatch:r1" in report.contradictions + + bad_partial = replace( + t01.order_facts[0], + fact=module.NativeOrderFactKind.PARTIAL, + status="partial", + traded_quantity="0.25", + remaining_quantity="0.5", + ) + report = module.evaluate_case_completion( + replace(t01, order_facts=(bad_partial, t01.order_facts[1])) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "provider_open_quantity_conservation_mismatch:r1" in report.contradictions + + b02 = _b02_cancel_fill_race(module) + bad_fill = replace(b02.order_facts[2], traded_quantity="0.5") + report = module.evaluate_case_completion( + replace(b02, order_facts=(*b02.order_facts[:2], bad_fill, b02.order_facts[3])) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "provider_filled_quantity_does_not_match_request:r2" in report.contradictions + + bad_trade = replace(b02.trade_facts[0], external_order_id="different-sys-id") + report = module.evaluate_case_completion(replace(b02, trade_facts=(bad_trade,))) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "trade_external_order_id_mismatch:r2" in report.contradictions + + +def test_no_trade_case_cannot_hide_position_or_funds_delta(completion_module): + module = completion_module + evidence = _t01_evidence(module) + changed_final = replace( + evidence.snapshots[1], + positions={INSTRUMENT: 1}, + funds={"cash": 10000, "available_funds": 9000, "equity": 10000}, + ) + report = module.evaluate_case_completion( + replace(evidence, snapshots=(evidence.snapshots[0], changed_final)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "positions_or_funds_changed_without_trade_callback" in report.contradictions + + +def test_b02_allows_cancel_fill_race_only_with_trade_and_position_reconciliation( + completion_module, +): + module = completion_module + evidence = _b02_cancel_fill_race(module) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.REVIEW_REQUIRED + assert report.certification_pass is False + assert report.dispatch_permitted is False + + bad_batch = replace(evidence.managed_requests[-1], order_refs=("r1", "ghost")) + mismatched = replace(evidence, managed_requests=(*evidence.managed_requests[:-1], bad_batch)) + report = module.evaluate_case_completion(mismatched) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "batch_cancel_refs_must_exactly_match_submitted_orders" in report.contradictions + + no_trade = replace(evidence, trade_facts=()) + report = module.evaluate_case_completion(no_trade) + assert report.status is module.CompletionStatus.INCOMPLETE + assert any("terminal_trade_quantity_mismatch" in item for item in report.contradictions) + + +def test_b01_requires_two_partial_order_lifecycles_and_trade_reconciliation(completion_module): + module = completion_module + evidence = replace(_b02_cancel_fill_race(module), case_id="B01") + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "required_native_trade_activity" not in report.missing_invariants + assert "partial_fill_required:r1" in report.missing_invariants + assert "partial_fill_required:r2" in report.missing_invariants + + +@pytest.mark.parametrize( + ("case_id", "required_invariant"), + [ + ("O01", "repeat_scenario_requires_second_submit_blocked_pre_dispatch"), + ("O02", "repeat_scenario_requires_second_submit_blocked_pre_dispatch"), + ("O03", "repeat_cancel_requires_second_attempt_blocked_pre_dispatch"), + ("TH04", "managed_dispatched_cancel_receipt"), + ], +) +def test_repeat_and_threshold_cancel_plans_need_explicit_attempt_receipts( + completion_module, case_id, required_invariant +): + module = completion_module + evidence = module.CompletionEvidence( + case_id=case_id, + snapshots=( + _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00"), + _snapshot(module, module.SnapshotPhase.FINAL, 4, "2026-09-28T09:05:00+00:00"), + ), + ) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.INCOMPLETE + assert required_invariant in report.missing_invariants + + +def test_external_reject_and_emergency_cases_require_sourced_physical_conditions(completion_module): + module = completion_module + for case_id, dependency_id in ( + ("E03", "market_state"), + ("EM01", "account_permission_disabled"), + ("EM03", "gateway_force_logout_ack"), + ): + snapshots = ( + _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00"), + _snapshot(module, module.SnapshotPhase.FINAL, 4, "2026-09-28T09:05:00+00:00"), + ) + if case_id == "C01": + snapshots = tuple( + replace( + snapshot, + session_id="", + trading_day="", + session_identity_origin="", + ) + for snapshot in snapshots + ) + evidence = module.CompletionEvidence(case_id=case_id, snapshots=snapshots) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.INCOMPLETE + assert f"external_dependency:{dependency_id}" in report.missing_invariants + + +def test_queries_require_native_error_free_final_markers(completion_module): + module = completion_module + evidence = _t01_evidence(module) + bad_query = replace( + evidence.snapshots[1], + query_is_last=dict.fromkeys(QUERY_CALLBACKS[:-1], True), + ) + with pytest.raises(module.CompletionEvidenceError, match="final callback marker"): + module.evaluate_case_completion( + replace(evidence, snapshots=(evidence.snapshots[0], bad_query)) + ) + + +def _m03_reconnect_evidence(module): + case_engine = importlib.import_module("common.case_engine") + new_session = "session-007-reconnected" + baseline = _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00") + final = replace( + _snapshot(module, module.SnapshotPhase.FINAL, 2, "2026-09-28T09:05:00+00:00"), + session_id=new_session, + order_query_id=baseline.order_query_id, + position_query_id=baseline.position_query_id, + account_query_id=baseline.account_query_id, + ) + disconnected = module.CertificationEvidence( + event_kind="store_disconnected", + source=case_engine.EvidenceSource.PROVIDER_CALLBACK, + event_id="disconnect-before-reconnect-007", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:01:00+00:00", + fields={ + "gateway_key": "ctp-gateway", + "timestamp": "2026-09-28T09:01:00+00:00", + "connection_generation": 7, + }, + callback_names=("OnFrontDisconnected",), + provider_session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=4, + ) + row = module.CertificationEvidence( + event_kind="store_reconnect_success", + source=case_engine.EvidenceSource.PROVIDER_CALLBACK, + event_id="reconnect-session-007", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:03:00+00:00", + fields={ + "gateway_key": "ctp-gateway", + "timestamp": "2026-09-28T09:03:00+00:00", + "previous_session_id": SESSION, + "new_session_id": new_session, + "connection_generation": 8, + "previous_connection_generation": 7, + "new_connection_generation": 8, + "auth_error_id": 0, + "login_error_id": 0, + "subscription_error_id": 0, + "authentication_succeeded": True, + "login_succeeded": True, + "subscription_succeeded": True, + }, + callback_names=( + "OnFrontConnected", + "OnRspAuthenticate", + "OnRspUserLogin", + "OnRspSubMarketData", + ), + provider_session_id=new_session, + trading_day=TRADING_DAY, + source_sequence=1, + ) + dependency = module.ExternalDependency( + dependency_id="external_reconnect", + state="satisfied", + evidence_ref="control-reconnect-session-007", + evidence_sha256=DIGEST, + fields={ + "gateway_key": "ctp-gateway", + "previous_session_id": SESSION, + "new_session_id": new_session, + "disconnect_event_ref": disconnected.event_id, + "reconnect_event_ref": row.event_id, + "previous_connection_generation": 7, + "new_connection_generation": 8, + }, + ) + return module.CompletionEvidence( + case_id="M03", + scenario_evidence=(disconnected, row), + snapshots=(baseline, final), + external_dependencies=(dependency,), + ) + + +def _m02_disconnect_evidence(module): + case_engine = importlib.import_module("common.case_engine") + new_session = "session-007-restored" + baseline = _snapshot(module, module.SnapshotPhase.BASELINE, 1, "2026-09-28T09:00:00+00:00") + final = replace( + _snapshot(module, module.SnapshotPhase.FINAL, 2, "2026-09-28T09:05:00+00:00"), + session_id=new_session, + order_query_id=baseline.order_query_id, + position_query_id=baseline.position_query_id, + account_query_id=baseline.account_query_id, + ) + disconnected = module.CertificationEvidence( + event_kind="store_disconnected", + source=case_engine.EvidenceSource.PROVIDER_CALLBACK, + event_id="disconnect-session-007", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:01:00+00:00", + fields={ + "gateway_key": "ctp-gateway", + "timestamp": "2026-09-28T09:01:00+00:00", + "connection_generation": 7, + }, + callback_names=("OnFrontDisconnected",), + provider_session_id=SESSION, + trading_day=TRADING_DAY, + source_sequence=4, + ) + reconnected = module.CertificationEvidence( + event_kind="store_reconnect_success", + source=case_engine.EvidenceSource.PROVIDER_CALLBACK, + event_id="restore-session-007", + evidence_sha256=DIGEST, + occurred_at_utc="2026-09-28T09:03:00+00:00", + fields={ + "gateway_key": "ctp-gateway", + "timestamp": "2026-09-28T09:03:00+00:00", + "previous_session_id": SESSION, + "new_session_id": new_session, + "connection_generation": 8, + "previous_connection_generation": 7, + "new_connection_generation": 8, + "auth_error_id": 0, + "login_error_id": 0, + "subscription_error_id": 0, + "authentication_succeeded": True, + "login_succeeded": True, + "subscription_succeeded": True, + }, + callback_names=( + "OnFrontConnected", + "OnRspAuthenticate", + "OnRspUserLogin", + "OnRspSubMarketData", + ), + provider_session_id=new_session, + trading_day=TRADING_DAY, + source_sequence=1, + ) + dependency = module.ExternalDependency( + dependency_id="external_disconnect", + state="satisfied", + evidence_ref="control-disconnect-session-007", + evidence_sha256=DIGEST, + fields={ + "gateway_key": "ctp-gateway", + "session_id": SESSION, + "provider_event_ref": disconnected.event_id, + "connection_generation": 7, + }, + ) + return module.CompletionEvidence( + case_id="M02", + scenario_evidence=(disconnected, reconnected), + snapshots=(baseline, final), + external_dependencies=(dependency,), + ) + + +def test_m02_disconnection_requires_restored_provider_session_before_final_queries( + completion_module, +): + module = completion_module + evidence = _m02_disconnect_evidence(module) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.REVIEW_REQUIRED + assert report.certification_pass is False + assert report.dispatch_permitted is False + assert report.source_authenticity_verified is False + + missing_restore = replace(evidence, scenario_evidence=evidence.scenario_evidence[:1]) + report = module.evaluate_case_completion(missing_restore) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "scenario_event:store_reconnect_success" in report.missing_invariants + + wrong_restore = replace( + evidence.scenario_evidence[1], + fields={**evidence.scenario_evidence[1].fields, "new_session_id": "other-session"}, + ) + with pytest.raises(module.CompletionEvidenceError, match="restoration"): + module.evaluate_case_completion( + replace(evidence, scenario_evidence=(evidence.scenario_evidence[0], wrong_restore)) + ) + + wrong_control = replace( + evidence.external_dependencies[0], + fields={ + **evidence.external_dependencies[0].fields, + "provider_event_ref": "other-disconnect", + }, + ) + report = module.evaluate_case_completion( + replace(evidence, external_dependencies=(wrong_control,)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "disconnect_control_receipt_does_not_match_provider_event" in report.contradictions + + +@pytest.mark.parametrize("case_id", ("M02", "M03")) +def test_restoration_failure_cannot_reach_review(completion_module, case_id): + module = completion_module + evidence = ( + _m02_disconnect_evidence(module) if case_id == "M02" else _m03_reconnect_evidence(module) + ) + failed_restore = replace( + evidence.scenario_evidence[1], + fields={ + **evidence.scenario_evidence[1].fields, + "auth_error_id": 17, + "login_succeeded": False, + "subscription_succeeded": False, + }, + ) + with pytest.raises(module.CompletionEvidenceError, match="must succeed"): + module.evaluate_case_completion( + replace( + evidence, + scenario_evidence=(evidence.scenario_evidence[0], failed_restore), + ) + ) + + contradictory_error = replace( + evidence.scenario_evidence[1], + fields={**evidence.scenario_evidence[1].fields, "ErrorID": 17}, + ) + with pytest.raises(module.CompletionEvidenceError, match="must succeed"): + module.evaluate_case_completion( + replace( + evidence, + scenario_evidence=(evidence.scenario_evidence[0], contradictory_error), + ) + ) + + +@pytest.mark.parametrize("case_id", ("M02", "M03")) +def test_baseline_query_sequence_must_precede_disconnect_in_same_session( + completion_module, case_id +): + module = completion_module + evidence = ( + _m02_disconnect_evidence(module) if case_id == "M02" else _m03_reconnect_evidence(module) + ) + baseline = replace( + evidence.snapshots[0], + order_query_sequence=10, + position_query_sequence=11, + account_query_sequence=12, + ) + report = module.evaluate_case_completion( + replace(evidence, snapshots=(baseline, evidence.snapshots[1])) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "baseline_account_queries_must_precede_disconnect_callback" in report.contradictions + + +@pytest.mark.parametrize("case_id", ("M02", "M03")) +def test_restoration_requires_new_connection_generation(completion_module, case_id): + module = completion_module + evidence = ( + _m02_disconnect_evidence(module) if case_id == "M02" else _m03_reconnect_evidence(module) + ) + restored = replace( + evidence.scenario_evidence[1], + fields={ + **evidence.scenario_evidence[1].fields, + "connection_generation": 7, + "new_connection_generation": 7, + }, + ) + report = module.evaluate_case_completion( + replace(evidence, scenario_evidence=(evidence.scenario_evidence[0], restored)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + expected = ( + "disconnect_restoration_requires_new_connection_generation" + if case_id == "M02" + else "reconnect_requires_new_connection_generation" + ) + assert expected in report.contradictions + + +def test_m03_reconnect_accepts_reset_query_sequences_only_with_correlated_sessions( + completion_module, +): + module = completion_module + evidence = _m03_reconnect_evidence(module) + report = module.evaluate_case_completion(evidence) + assert report.status is module.CompletionStatus.REVIEW_REQUIRED + assert report.certification_pass is False + assert report.dispatch_permitted is False + assert report.source_authenticity_verified is False + + bad_row = replace( + evidence.scenario_evidence[1], + fields={**evidence.scenario_evidence[1].fields, "new_session_id": "other-session"}, + ) + with pytest.raises(module.CompletionEvidenceError, match="restoration"): + module.evaluate_case_completion( + replace(evidence, scenario_evidence=(evidence.scenario_evidence[0], bad_row)) + ) + + wrong_control = replace( + evidence.external_dependencies[0], + fields={**evidence.external_dependencies[0].fields, "gateway_key": "other-gateway"}, + ) + report = module.evaluate_case_completion( + replace(evidence, external_dependencies=(wrong_control,)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "reconnect_control_receipt_does_not_match_snapshot_sessions" in report.contradictions + + same_session = replace( + evidence.snapshots[1], + session_id=SESSION, + order_query_id="final-order", + position_query_id="final-position", + account_query_id="final-account", + order_query_sequence=4, + position_query_sequence=5, + account_query_sequence=6, + ) + report = module.evaluate_case_completion( + replace(evidence, snapshots=(evidence.snapshots[0], same_session)) + ) + assert report.status is module.CompletionStatus.INCOMPLETE + assert "reconnect_snapshots_require_distinct_provider_sessions" in report.missing_invariants + + next_day = replace(evidence.snapshots[1], trading_day="20260929") + with pytest.raises(module.CompletionEvidenceError, match="one trading day"): + module.evaluate_case_completion( + replace(evidence, snapshots=(evidence.snapshots[0], next_day)) + ) diff --git a/tests/unit/live_certification/test_simnow_decision_engine.py b/tests/unit/live_certification/test_simnow_decision_engine.py new file mode 100644 index 00000000..31068231 --- /dev/null +++ b/tests/unit/live_certification/test_simnow_decision_engine.py @@ -0,0 +1,835 @@ +"""Offline contracts for the unregistered 007 typed decision layer.""" + +from __future__ import annotations + +import importlib +import sys +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +CASES_ROOT = SUITE_ROOT / "cases" +NOW = datetime(2026, 9, 28, 10, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def decision_module(): + previous_modules = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + previous_path = sys.path[:] + for name in previous_modules: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + yield importlib.import_module("common.decision_engine") + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous_modules) + sys.path[:] = previous_path + + +class ContractAuthenticator: + """Synthetic interface probe; never evidence of real provider behavior.""" + + def __init__(self, module): + self.module = module + + def authenticate(self, observation, scope): + return self.module.AuthenticationReceipt( + event_id=observation.event_id, + evidence_sha256=observation.evidence_sha256, + scope_sha256=scope.scope_sha256, + trust_domain=observation.source_domain, + verification_ref="test-only-contract-authenticator", + account_identity_sha256=scope.account_identity_sha256, + ) + + +def _plan(module, case_id, *, account_identity_sha256=""): + source = CASES_ROOT / case_id / f"{case_id}_strategy.py" + case_engine = importlib.import_module("common.case_engine") + plan = case_engine.load_descriptive_case_plan(source, expected_case_id=case_id) + scope = module.DecisionScope.for_plan( + plan, "f" * 64, account_identity_sha256=account_identity_sha256 + ) + return plan, scope + + +def _event(module, kind_name, sequence, fields, *, stream="provider"): + kind = module.ObservationKind[kind_name] + domain, callback, _ = module._POLICY[kind] + is_provider = domain is module.EvidenceTrustDomain.CTP_CALLBACK + is_auth = kind is module.ObservationKind.AUTH_SUCCESS + is_login = kind is module.ObservationKind.LOGIN_SUCCESS + is_payloadless_front = kind in { + module.ObservationKind.FRONT_CONNECTED, + module.ObservationKind.FRONT_DISCONNECTED, + } + callback_fields = dict(fields) + if is_provider: + callback_fields.setdefault("connection_generation", 1) + if is_auth or is_login: + callback_fields.setdefault("request_id", sequence) + callback_fields.setdefault("request_generation", sequence) + callback_fields.setdefault( + "arrival_generation", callback_fields.get("connection_generation", 1) + ) + callback_fields.setdefault("is_last", True) + callback_fields.setdefault("error_id", 0) + callback_fields.setdefault("success", True) + if is_login: + callback_fields.setdefault("provider_front_id", 3) + callback_fields.setdefault("provider_session_id", "17") + callback_fields.setdefault("trading_day", "20260928") + local_time = ( + NOW + timedelta(seconds=sequence) if is_auth or is_login else NOW + ).isoformat().replace("+00:00", "Z") + return module.NativeObservation( + kind=kind, + source_domain=domain, + event_id=f"event-{kind_name}-{sequence}", + evidence_sha256=f"{sequence:064x}", + occurred_at_utc=local_time, + sequence=sequence, + stream_id=stream, + callback_name=callback, + provider_session_id=("" if is_auth or is_payloadless_front else "17") if is_provider else "", + trading_day="20260928" if is_provider and not is_auth and not is_payloadless_front else "", + fields=callback_fields, + provider_front_id=3 if is_login else None, + client_instance_id="ctp-client-1" if is_provider else "", + request_generation=sequence if is_auth or is_login else 0, + request_id_origin="native_callback_argument" if is_auth or is_login else "", + request_generation_origin=( + "local_request_generation_binding" if is_auth or is_login else "" + ), + arrival_generation=callback_fields.get("connection_generation", 1) if is_provider else 0, + session_identity_origin=( + "unavailable_on_native_authentication_response" + if is_auth + else "native_login_response_fields" + if is_login + else "unavailable_on_native_front_connection_callback" + if is_payloadless_front + else "derived_from_same_client_generation_native_login" + if is_provider + else "" + ), + arrived_at_utc=local_time if is_provider else "", + arrived_monotonic=float(sequence) if is_provider else 0.0, + sequence_origin="local_sdk_callback_arrival" if is_provider else "", + timestamp_origin="local_sdk_capture_clock" if is_provider else "", + event_id_origin="local_sdk_callback_arrival" if is_provider else "", + provider_issued_event_id=False, + connection_generation_origin="local_connection_generation" if is_payloadless_front else "", + ) + + +def _record(module, engine, kind, seq, fields, *, stream="provider"): + event = _event(module, kind, seq, fields, stream=stream) + assert engine.record(event) + + +def test_m03_reconnect_requires_one_new_provider_session_and_correlated_control_receipt( + decision_module, +): + module = decision_module + old_session, new_session = "1", "2" + specs = ( + ( + "FRONT_DISCONNECTED", + 1, + old_session, + {"gateway_key": "g1", "reason": "link_lost", "connection_generation": 7}, + ), + ("FRONT_CONNECTED", 2, new_session, {"gateway_key": "g1", "connection_generation": 8}), + ( + "AUTH_SUCCESS", + 3, + new_session, + {"error_id": 0, "success": True, "connection_generation": 8}, + ), + ( + "LOGIN_SUCCESS", + 4, + new_session, + {"error_id": 0, "success": True, "connection_generation": 8}, + ), + ( + "MARKET_SUBSCRIPTION_ACK", + 5, + new_session, + { + "instrument_id": "rb2710", + "error_id": 0, + "success": True, + "connection_generation": 8, + }, + ), + ( + "EXTERNAL_CONDITION", + 1, + "", + { + "condition_id": "external_reconnect", + "state": "satisfied", + "evidence_ref": "operator-reconnect-1", + "gateway_key": "g1", + "previous_session_id": old_session, + "new_session_id": new_session, + "previous_connection_generation": 7, + "new_connection_generation": 8, + "disconnect_event_ref": "m03-FRONT_DISCONNECTED-0", + "reconnect_event_ref": "m03-FRONT_CONNECTED-1", + }, + ), + ) + + def evaluate(mutated=None): + plan, scope = _plan(module, "M03") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + for index, (kind, sequence, session, fields) in enumerate(mutated or specs): + local_stream = f"provider-generation-{fields.get('connection_generation', 0)}" + event = _event(module, kind, sequence, fields, stream=local_stream) + callback_fields = dict(event.fields) + if kind == "LOGIN_SUCCESS": + callback_fields.update( + { + "provider_front_id": 3, + "provider_session_id": session, + "trading_day": "20260928", + } + ) + if kind in {"AUTH_SUCCESS", "LOGIN_SUCCESS"}: + callback_fields["arrival_generation"] = fields.get( + "connection_generation", 1 + ) + local_time = (NOW + timedelta(seconds=index)).isoformat().replace("+00:00", "Z") + event = replace( + event, + event_id=f"m03-{kind}-{index}", + occurred_at_utc=local_time, + arrived_at_utc=local_time if kind in {"AUTH_SUCCESS", "LOGIN_SUCCESS"} else "", + provider_session_id=( + session if kind in {"LOGIN_SUCCESS", "MARKET_SUBSCRIPTION_ACK"} else "" + ), + provider_front_id=3 if kind == "LOGIN_SUCCESS" else None, + trading_day=( + "20260928" if kind in {"LOGIN_SUCCESS", "MARKET_SUBSCRIPTION_ACK"} else "" + ), + arrival_generation=fields.get("connection_generation", 1) + if kind in {"AUTH_SUCCESS", "LOGIN_SUCCESS"} + else event.arrival_generation, + fields=callback_fields, + ) + try: + assert engine.record(event) + except module.DecisionError: + break + return engine.evaluate(now_utc=NOW + timedelta(seconds=10)) + + result = evaluate() + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert result.certification_pass is False + assert result.dispatch_permitted is False + + for kind_to_misbind in ("LOGIN_SUCCESS", "MARKET_SUBSCRIPTION_ACK"): + mixed = tuple( + (kind, sequence, "3" if kind == kind_to_misbind else session, fields) + for kind, sequence, session, fields in specs + ) + result = evaluate(mixed) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + + wrong_control = tuple( + ( + kind, + sequence, + session, + {**fields, "new_session_id": "3"} if kind == "EXTERNAL_CONDITION" else fields, + ) + for kind, sequence, session, fields in specs + ) + result = evaluate(wrong_control) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + + same_generation = tuple( + ( + kind, + sequence, + session, + {**fields, "connection_generation": 7} + if kind != "EXTERNAL_CONDITION" and session == new_session + else {**fields, "new_connection_generation": 7} + if kind == "EXTERNAL_CONDITION" + else fields, + ) + for kind, sequence, session, fields in specs + ) + result = evaluate(same_generation) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + + out_of_order = ( + specs[0], + specs[1], + specs[2], + ("MARKET_SUBSCRIPTION_ACK", 3, new_session, specs[4][3]), + ("LOGIN_SUCCESS", 4, new_session, specs[3][3]), + specs[5], + ) + result = evaluate(out_of_order) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + + +def test_all_33_descriptive_plans_have_explicit_typed_specs_and_default_block( + decision_module, +): + module = decision_module + case_ids = sorted(path.name for path in CASES_ROOT.iterdir() if path.is_dir()) + assert len(case_ids) == 33 + assert set(module.CASE_INTENT_SPECS) == set(case_ids) + assert all( + kind in module._POLICY + for spec in module.CASE_INTENT_SPECS.values() + for kind in spec.required_kinds + ) + + for case_id in case_ids: + plan, scope = _plan(module, case_id) + engine = module.CaseIntentDecisionEngine(plan, scope) + result = engine.evaluate() + assert result.status is module.DecisionStatus.BLOCKED + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.intent_candidate is None + assert result.missing_conditions == ("trusted_authenticator_not_configured",) + + +def test_t01_candidate_requires_verified_native_session_tick_and_review_only_gate( + decision_module, +): + module = decision_module + plan, scope = _plan(module, "T01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + _record(module, engine, "AUTH_SUCCESS", 1, {"error_id": 0, "success": True}) + _record(module, engine, "LOGIN_SUCCESS", 2, {"error_id": 0, "success": True}) + _record(module, engine, "FRONT_CONNECTED", 3, {"gateway_key": "g1"}) + _record( + module, + engine, + "MARKET_SUBSCRIPTION_ACK", + 4, + {"instrument_id": "rb2610", "success": True, "error_id": 0}, + ) + _record( + module, + engine, + "MARKET_TICK", + 5, + {"instrument_id": "rb2610", "bid": "100", "ask": "101", "last": "100.5", "price_tick": "1"}, + ) + _record( + module, + engine, + "ORDER_ADMISSION", + 1, + { + "case_id": "T01", + "intent_kind": "open", + "approval_ref": "review-only-1", + "maximum_quantity": 1, + "approval_state": "REVIEW_ONLY", + "dispatch_permitted": False, + }, + stream="managed", + ) + + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.REVIEW_REQUIRED + assert result.intent_candidate.intent_kind is module.IntentKind.OPEN_ORDER_CANDIDATE + assert result.intent_candidate.dispatch_permitted is False + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert not hasattr(engine, "submit") + assert not hasattr(engine, "cancel") + assert not hasattr(engine, "dispatch") + + +def test_callback_name_and_local_event_text_without_authenticator_stay_blocked( + decision_module, +): + module = decision_module + plan, scope = _plan(module, "T01") + engine = module.CaseIntentDecisionEngine(plan, scope) + fake = _event(module, "LOGIN_SUCCESS", 1, {"error_id": 0, "success": True}) + assert engine.record(fake) is False + result = engine.evaluate() + assert result.status is module.DecisionStatus.BLOCKED + assert result.intent_candidate is None + assert result.rejected_observations == ("event-LOGIN_SUCCESS-1:authenticator_unavailable",) + + +def test_receipt_cannot_be_replayed_across_case_scope(decision_module): + module = decision_module + + class WrongScopeAuthenticator: + def authenticate(self, event, scope): + return module.AuthenticationReceipt( + event.event_id, + event.evidence_sha256, + "0" * 64, + event.source_domain, + "wrong-scope-receipt", + ) + + plan, scope = _plan(module, "C01") + engine = module.CaseIntentDecisionEngine(plan, scope, WrongScopeAuthenticator()) + event = _event(module, "AUTH_SUCCESS", 1, {"error_id": 0, "success": True}) + assert engine.record(event) is False + assert engine.evaluate().status is module.DecisionStatus.BLOCKED + assert "verification_receipt_mismatch" in engine.evaluate().rejected_observations[0] + + +def test_c01_auth_keeps_native_scope_null_and_missing_login_is_incomplete(decision_module): + module = decision_module + plan, scope = _plan(module, "C01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + auth = _event(module, "AUTH_SUCCESS", 1, {"error_id": 0, "success": True}) + + assert auth.provider_front_id is None + assert auth.provider_session_id == "" + assert auth.trading_day == "" + assert auth.timestamp_origin == "local_sdk_capture_clock" + assert auth.sequence_origin == "local_sdk_callback_arrival" + assert engine.record(auth) + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.INCOMPLETE + assert "login_success_required" in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +def test_c01_auth_and_login_must_remain_on_same_client_arrival_generation(decision_module): + module = decision_module + plan, scope = _plan(module, "C01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + auth = _event(module, "AUTH_SUCCESS", 1, {"error_id": 0, "success": True}) + login = _event(module, "LOGIN_SUCCESS", 2, {"error_id": 0, "success": True}) + login_fields = {**login.fields, "arrival_generation": 2} + login = replace(login, arrival_generation=2, fields=login_fields) + + assert engine.record(auth) + assert engine.record(login) + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.INCOMPLETE + assert "auth_login_same_client_generation_and_distinct_requests_required" in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +def test_c01_rejects_legacy_auth_session_and_day_claims(decision_module): + module = decision_module + plan, scope = _plan(module, "C01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + auth = _event(module, "AUTH_SUCCESS", 1, {"error_id": 0, "success": True}) + legacy = replace(auth, provider_session_id="17", trading_day="20260928") + + with pytest.raises(module.DecisionError, match="OnRspAuthenticate cannot claim provider FrontID"): + engine.record(legacy) + + +@pytest.mark.parametrize( + "callback_kind", + ("FRONT_CONNECTED", "FRONT_DISCONNECTED", "AUTH_SUCCESS"), +) +@pytest.mark.parametrize( + "alias", + ( + "front_id", + "FrontID", + "provider_front_id", + "session_id", + "SessionID", + "provider_session_id", + "trading_day", + "TradingDay", + "provider_trading_day", + "account_id_masked", + "masked_account_id", + "provider_account_id", + "account_identity_sha256", + "opaque_session_ref", + "local_session_id", + "local_front_id", + "local_provider_front_id", + "local_provider_session_id", + "local_trading_day", + "local_account_id_masked", + ), +) +def test_payloadless_front_and_auth_reject_login_identity_aliases( + decision_module, callback_kind, alias +): + module = decision_module + plan, scope = _plan(module, "M02") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + event = _event(module, callback_kind, 1, {alias: "forged-identity"}) + + with pytest.raises(module.DecisionError, match="cannot carry login identity alias"): + engine.record(event) + + +@pytest.mark.parametrize("callback_kind", ("FRONT_CONNECTED", "FRONT_DISCONNECTED", "AUTH_SUCCESS")) +def test_payloadless_front_and_auth_allow_only_scope_bound_local_metadata( + decision_module, callback_kind +): + module = decision_module + plan, scope = _plan( + module, + "C01" if callback_kind == "AUTH_SUCCESS" else "M02", + account_identity_sha256="f" * 64, + ) + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + event = _event( + module, + callback_kind, + 1, + { + "local_client_id": "ctp-client-1", + "client_id": "ctp-client-1", + "local_connection_generation": 1, + "local_account_identity_sha256": scope.account_identity_sha256, + }, + ) + + engine._validate_no_login_identity_aliases(event) + if callback_kind == "AUTH_SUCCESS": + assert engine.record(event) + + +@pytest.mark.parametrize( + "callback_kind", + ("FRONT_CONNECTED", "FRONT_DISCONNECTED", "AUTH_SUCCESS"), +) +def test_payloadless_callback_rejects_unbound_local_scope_fingerprint( + decision_module, callback_kind +): + module = decision_module + plan, scope = _plan( + module, + "C01" if callback_kind == "AUTH_SUCCESS" else "M02", + account_identity_sha256="f" * 64, + ) + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + event = _event( + module, + callback_kind, + 1, + {"local_account_identity_sha256": "a" * 64}, + ) + + with pytest.raises(module.DecisionError, match="must exactly match the bound decision scope"): + engine.record(event) + + +def test_authentication_allows_native_broker_and_user_echoes_without_session_identity( + decision_module, +): + module = decision_module + plan, scope = _plan(module, "C01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + event = _event( + module, + "AUTH_SUCCESS", + 1, + { + "BrokerID": "broker-1", + "UserID": "user-1", + "UserProductInfo": "product-1", + "AppID": "app-1", + "AppType": "type-1", + "error_id": 0, + "success": True, + }, + ) + + assert engine.record(event) + assert event.provider_front_id is None + assert event.provider_session_id == "" + assert event.trading_day == "" + + +def test_account_identity_fingerprint_is_bound_by_authentication_receipt(decision_module): + module = decision_module + + class WrongAccountBindingAuthenticator(ContractAuthenticator): + def authenticate(self, observation, scope): + receipt = super().authenticate(observation, scope) + return replace(receipt, account_identity_sha256="0" * 64) + + plan, scope = _plan(module, "T01") + scope = module.DecisionScope.for_plan( + plan, scope.scope_sha256, account_identity_sha256="a" * 64 + ) + engine = module.CaseIntentDecisionEngine(plan, scope, WrongAccountBindingAuthenticator(module)) + event = _event(module, "LOGIN_SUCCESS", 1, {"error_id": 0, "success": True}) + + assert engine.record(event) is False + snapshot = engine.evaluate(now_utc=NOW) + assert snapshot.status is module.DecisionStatus.BLOCKED + assert snapshot.intent_candidate is None + assert "verification_receipt_mismatch" in snapshot.rejected_observations[0] + + +def test_b01_one_partial_order_does_not_make_batch_cancel_candidate(decision_module): + module = decision_module + plan, scope = _plan(module, "B01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + _record(module, engine, "AUTH_SUCCESS", 1, {"error_id": 0, "success": True}) + _record(module, engine, "LOGIN_SUCCESS", 2, {"error_id": 0, "success": True}) + _record(module, engine, "FRONT_CONNECTED", 3, {"gateway_key": "g1"}) + _record( + module, + engine, + "MARKET_SUBSCRIPTION_ACK", + 4, + {"instrument_id": "rb2610", "success": True, "error_id": 0}, + ) + _record( + module, + engine, + "MARKET_TICK", + 5, + {"instrument_id": "rb2610", "bid": "100", "ask": "101", "last": "100", "price_tick": "1"}, + ) + _record( + module, + engine, + "ORDER_ADMISSION", + 1, + { + "case_id": "B01", + "intent_kind": "batch_cancel", + "approval_ref": "review-only-batch", + "maximum_quantity": 2, + "approval_state": "REVIEW_ONLY", + "dispatch_permitted": False, + "batch_cancel_supported": True, + }, + stream="managed", + ) + _record( + module, + engine, + "ORDER_ACCEPTED", + 6, + { + "order_ref": "r1", + "external_order_id": "e1", + "instrument_id": "rb2610", + "status": "working", + "remaining_quantity": 2, + "traded_quantity": 0, + }, + ) + _record( + module, + engine, + "ORDER_PARTIAL", + 7, + { + "order_ref": "r1", + "external_order_id": "e1", + "instrument_id": "rb2610", + "status": "partial", + "traded_quantity": 1, + "remaining_quantity": 1, + }, + ) + _record( + module, engine, "TRADE_EXECUTION", 8, {"order_ref": "r1", "trade_id": "t1", "quantity": 1} + ) + _record( + module, + engine, + "ORDER_QUERY", + 9, + {"query_id": "q1", "complete": True, "open_order_refs": ["r1"]}, + ) + + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert ( + "two_partial_orders_trade_reconciliation_and_batch_capability_required" + in result.missing_conditions + ) + assert result.certification_pass is False + + +def test_b02_requires_two_provider_orders_in_latest_complete_open_query(decision_module): + module = decision_module + plan, scope = _plan(module, "B02") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + _record(module, engine, "AUTH_SUCCESS", 1, {"error_id": 0, "success": True}) + _record(module, engine, "LOGIN_SUCCESS", 2, {"error_id": 0, "success": True}) + _record(module, engine, "FRONT_CONNECTED", 3, {"gateway_key": "g1"}) + _record( + module, + engine, + "MARKET_SUBSCRIPTION_ACK", + 4, + {"instrument_id": "rb2610", "success": True, "error_id": 0}, + ) + _record( + module, + engine, + "MARKET_TICK", + 5, + {"instrument_id": "rb2610", "bid": 100, "ask": 101, "last": 100, "price_tick": 1}, + ) + _record( + module, + engine, + "ORDER_ADMISSION", + 1, + { + "case_id": "B02", + "intent_kind": "batch_cancel", + "approval_ref": "review-only-batch", + "maximum_quantity": 2, + "approval_state": "REVIEW_ONLY", + "dispatch_permitted": False, + "batch_cancel_supported": True, + }, + stream="managed", + ) + for sequence, ref in ((6, "r1"), (7, "r2")): + _record( + module, + engine, + "ORDER_ACCEPTED", + sequence, + { + "order_ref": ref, + "external_order_id": f"e-{ref}", + "instrument_id": "rb2610", + "status": "working", + "remaining_quantity": 1, + "traded_quantity": 0, + }, + ) + _record( + module, + engine, + "ORDER_QUERY", + 8, + {"query_id": "q1", "complete": True, "open_order_refs": ["r1"]}, + ) + + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert ( + "two_current_native_open_orders_and_batch_capability_required" in result.missing_conditions + ) + + +def test_e03_requires_external_condition_submit_receipt_and_native_rejection( + decision_module, +): + module = decision_module + plan, scope = _plan(module, "E03") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + _record(module, engine, "LOGIN_SUCCESS", 1, {"error_id": 0, "success": True}) + _record(module, engine, "FRONT_CONNECTED", 2, {"gateway_key": "g1"}) + _record( + module, + engine, + "MARKET_TICK", + 3, + { + "instrument_id": "rb2610", + "bid": "100", + "ask": "101", + "last": "100", + "price_tick": "1", + "market_state": "closed", + }, + ) + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert "external_condition" in result.missing_conditions + assert "order_submit_receipt" in result.missing_conditions + assert "order_rejected" in result.missing_conditions + + +def test_em03_requires_external_ack_disconnect_and_write_guard(decision_module): + module = decision_module + plan, scope = _plan(module, "EM03") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + _record( + module, + engine, + "FRONT_DISCONNECTED", + 1, + {"gateway_key": "g1", "reason": "provider disconnect"}, + ) + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert "gateway_logout_authorized" in result.missing_conditions + assert "post_disconnect_write_blocked" in result.missing_conditions + + +def test_authenticated_unavailable_external_dependency_is_not_success( + decision_module, +): + module = decision_module + plan, scope = _plan(module, "E01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + _record( + module, + engine, + "EXTERNAL_CONDITION", + 1, + { + "condition_id": "insufficient_funds", + "state": "unavailable", + "evidence_ref": "external/ref-1", + "reason": "account state not provided", + }, + stream="control", + ) + result = engine.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.EXTERNAL_CONDITION_UNAVAILABLE + assert result.intent_candidate is None + assert result.certification_pass is False + + +def test_c01_duplicate_native_request_ids_remain_incomplete(decision_module): + module = decision_module + plan, scope = _plan(module, "C01") + engine = module.CaseIntentDecisionEngine(plan, scope, ContractAuthenticator(module)) + auth = _event(module, "AUTH_SUCCESS", 1, {"request_id": 7}) + login = _event(module, "LOGIN_SUCCESS", 2, {"request_id": 7}) + for event in (auth, login): + assert engine.record(event) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=10)) + assert result.status is module.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None diff --git a/tests/unit/live_certification/test_simnow_investor_id_redaction.py b/tests/unit/live_certification/test_simnow_investor_id_redaction.py index 23843090..25a54c43 100644 --- a/tests/unit/live_certification/test_simnow_investor_id_redaction.py +++ b/tests/unit/live_certification/test_simnow_investor_id_redaction.py @@ -1,6 +1,4 @@ -import contextlib import importlib -import io import sys import types from pathlib import Path @@ -62,43 +60,10 @@ def simnow_runtime(): ("12***34", "***"), ], ) -def test_started_store_never_displays_complete_investor_id( - simnow_runtime, monkeypatch, investor_id, expected_mask +def test_investor_id_display_value_never_contains_complete_investor_id( + simnow_runtime, investor_id, expected_mask ): runtime = simnow_runtime - config = { - "investor_id": investor_id, - "td_address": "tcp://trading.invalid:1", - "md_address": "tcp://market.invalid:2", - } - - class FakeStore: - def __init__(self, provider, **kwargs): - assert provider == "ctp" - assert kwargs == config - - def start(self): - pass - - def stop(self): - pass - - monkeypatch.setattr(runtime, "BtApiStore", FakeStore) - monkeypatch.setattr(runtime.cfg, "create_config", lambda _env_key: config) - monkeypatch.setitem( - runtime.cfg.SIMNOW_ENVIRONMENTS, - "new_7x24", - {"name": "单测环境", "td_address": "", "md_address": ""}, - ) - monkeypatch.delenv("CERTIFICATION_REPORT_DIR", raising=False) - monkeypatch.delenv("CERTIFICATION_CASE_ID", raising=False) - - output = io.StringIO() - with contextlib.redirect_stdout(output), runtime.started_store( - env_key="new_7x24", case_id="unit", report_dir="" - ): - pass - - display_text = output.getvalue() + display_text = f"InvestorID: {runtime._mask_investor_id(investor_id)}" assert investor_id not in display_text assert f"InvestorID: {expected_mask}" in display_text diff --git a/tests/unit/live_certification/test_simnow_order_case_strategies.py b/tests/unit/live_certification/test_simnow_order_case_strategies.py new file mode 100644 index 00000000..6c34887e --- /dev/null +++ b/tests/unit/live_certification/test_simnow_order_case_strategies.py @@ -0,0 +1,971 @@ +"""Synthetic offline contracts for T01/T02/T03/B01/B02/L01 observers. + +The contract authenticator below is deliberately synthetic. Complete examples +can reach only REVIEW_REQUIRED, and none of these tests claims provider +authenticity, SimNow acceptance, or a certification PASS. +""" + +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import json +import sys +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +CASES_ROOT = SUITE_ROOT / "cases" +CASE_IDS = ("T01", "T02", "T03", "B01", "B02", "L01") +BASE_TIME = datetime(2026, 9, 28, 9, 0, tzinfo=timezone.utc) +SCOPE_SHA256 = "d" * 64 +ACCOUNT_IDENTITY_SHA256 = hashlib.sha256(b"synthetic-contract-test-account").hexdigest() +EVALUATION_TIME = BASE_TIME + timedelta(seconds=30) + + +@pytest.fixture +def strategy_context(): + previous_modules = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + previous_path = sys.path[:] + for name in previous_modules: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + case_engine = importlib.import_module("common.case_engine") + decision = importlib.import_module("common.decision_engine") + strategies = {} + for case_id in CASE_IDS: + path = CASES_ROOT / case_id / f"{case_id}_strategy.py" + spec = importlib.util.spec_from_file_location(f"order_strategy_{case_id}", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + strategies[case_id] = module + yield case_engine, decision, strategies + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous_modules) + sys.path[:] = previous_path + + +class ContractAuthenticator: + """Synthetic shape verifier, never a provider or managed-runtime verifier.""" + + def __init__(self, decision): + self.decision = decision + + def authenticate(self, observation, scope): + return self.decision.AuthenticationReceipt( + event_id=observation.event_id, + evidence_sha256=observation.evidence_sha256, + scope_sha256=scope.scope_sha256, + trust_domain=observation.source_domain, + verification_ref="synthetic-test-contract-only", + account_identity_sha256=scope.account_identity_sha256, + ) + + +def _strategy(strategy_context, case_id, *, account_identity_sha256=ACCOUNT_IDENTITY_SHA256): + case_engine, decision, strategies = strategy_context + path = CASES_ROOT / case_id / f"{case_id}_strategy.py" + plan = case_engine.load_descriptive_case_plan(path, expected_case_id=case_id) + scope = decision.DecisionScope.for_plan( + plan, SCOPE_SHA256, account_identity_sha256=account_identity_sha256 + ) + strategy = strategies[case_id].create_strategy(plan, scope, ContractAuthenticator(decision)) + return decision, strategy + + +def _envelope( + decision, + case_id, + kind_name, + sequence, + offset, + fields, + *, + source_domain=None, + callback=None, + stream="native", + session="session-1", + trading_day="20260928", +): + kind = decision.ObservationKind[kind_name] + default_domains = { + "FRONT_CONNECTED": decision.EvidenceTrustDomain.CTP_CALLBACK, + "AUTH_SUCCESS": decision.EvidenceTrustDomain.CTP_CALLBACK, + "LOGIN_SUCCESS": decision.EvidenceTrustDomain.CTP_CALLBACK, + "MARKET_SUBSCRIPTION_ACK": decision.EvidenceTrustDomain.CTP_CALLBACK, + "MARKET_TICK": decision.EvidenceTrustDomain.CTP_CALLBACK, + "ORDER_ADMISSION": decision.EvidenceTrustDomain.MANAGED_RUNTIME, + "ORDER_SUBMIT_RECEIPT": decision.EvidenceTrustDomain.MANAGED_RUNTIME, + "ORDER_ACCEPTED": decision.EvidenceTrustDomain.CTP_CALLBACK, + "ORDER_PARTIAL": decision.EvidenceTrustDomain.CTP_CALLBACK, + "ORDER_CANCELED": decision.EvidenceTrustDomain.CTP_CALLBACK, + "ORDER_FILLED": decision.EvidenceTrustDomain.CTP_CALLBACK, + "ORDER_REJECTED": decision.EvidenceTrustDomain.CTP_CALLBACK, + "TRADE_EXECUTION": decision.EvidenceTrustDomain.CTP_CALLBACK, + "ORDER_QUERY": decision.EvidenceTrustDomain.CTP_CALLBACK, + "POSITION_QUERY": decision.EvidenceTrustDomain.CTP_CALLBACK, + "EXTERNAL_CONDITION": decision.EvidenceTrustDomain.CONTROL_PLANE, + "SYSTEM_LOG": decision.EvidenceTrustDomain.MANAGED_RUNTIME, + } + callbacks = { + "FRONT_CONNECTED": "OnFrontConnected", + "AUTH_SUCCESS": "OnRspAuthenticate", + "LOGIN_SUCCESS": "OnRspUserLogin", + "MARKET_SUBSCRIPTION_ACK": "OnRspSubMarketData", + "MARKET_TICK": "OnRtnDepthMarketData", + "ORDER_ADMISSION": "", + "ORDER_SUBMIT_RECEIPT": "", + "ORDER_ACCEPTED": "OnRtnOrder", + "ORDER_PARTIAL": "OnRtnOrder", + "ORDER_CANCELED": "OnRtnOrder", + "ORDER_FILLED": "OnRtnOrder", + "ORDER_REJECTED": "OnRspOrderInsert", + "TRADE_EXECUTION": "OnRtnTrade", + "ORDER_QUERY": "OnRspQryOrder", + "POSITION_QUERY": ( + "OnRspQryTradingAccount" + if fields.get("query_family") == "funds" + else "OnRspQryInvestorPosition" + ), + "EXTERNAL_CONDITION": "", + "SYSTEM_LOG": "", + } + source_domain = source_domain or default_domains[kind_name] + callback = callbacks[kind_name] if callback is None else callback + provider = source_domain is decision.EvidenceTrustDomain.CTP_CALLBACK + is_auth = kind_name == "AUTH_SUCCESS" + is_login = kind_name == "LOGIN_SUCCESS" + is_payloadless_front = kind_name in {"FRONT_CONNECTED", "FRONT_DISCONNECTED"} + fields = dict(fields) + if kind_name == "FRONT_CONNECTED": + fields.setdefault("connection_generation", 1) + if is_auth or is_login: + # Synthetic native callback IDs stay independent of local arrival sequence. + fields.setdefault("request_id", 1 if is_auth else 2) + fields.setdefault("request_generation", 1) + fields.setdefault("arrival_generation", 1) + fields.setdefault("is_last", True) + fields.setdefault("error_id", 0) + if is_login: + fields.setdefault("provider_front_id", 1) + fields.setdefault("provider_session_id", session) + fields.setdefault("trading_day", trading_day) + event_id = f"{case_id}-{kind_name}-{sequence}-{stream}" + occurred_at = BASE_TIME + timedelta(seconds=offset) + occurred_at_utc = occurred_at.isoformat().replace("+00:00", "Z") + if is_auth: + session_identity_origin = "unavailable_on_native_authentication_response" + elif is_login: + session_identity_origin = "native_login_response_fields" + elif is_payloadless_front: + session_identity_origin = "unavailable_on_native_front_connection_callback" + elif provider: + session_identity_origin = "derived_from_same_client_generation_native_login" + else: + session_identity_origin = "" + native_login_identity = is_login + native_session_id = session if provider and not (is_auth or is_payloadless_front) else "" + native_trading_day = trading_day if provider and not (is_auth or is_payloadless_front) else "" + return decision.NativeObservation( + kind=kind, + source_domain=source_domain, + event_id=event_id, + evidence_sha256=hashlib.sha256(event_id.encode()).hexdigest(), + occurred_at_utc=occurred_at_utc, + sequence=sequence, + stream_id=stream, + callback_name=callback, + provider_session_id=native_session_id, + trading_day=native_trading_day, + fields=fields, + provider_front_id=1 if native_login_identity else None, + client_instance_id="synthetic-ctp-client" if provider else "", + request_generation=1 if is_auth or is_login else 0, + request_id_origin="native_callback_argument" if is_auth or is_login else "", + request_generation_origin=("local_request_generation_binding" if is_auth or is_login else ""), + arrival_generation=1 if provider else 0, + session_identity_origin=session_identity_origin, + event_id_origin="local_sdk_callback_arrival" if provider else "", + provider_issued_event_id=False, + arrived_at_utc=occurred_at_utc if provider else "", + arrived_monotonic=float(sequence) if provider else 0.0, + sequence_origin="local_sdk_callback_arrival" if provider else "", + timestamp_origin="local_sdk_capture_clock" if provider else "", + connection_generation_origin="local_connection_generation" if provider else "", + ) + + +def _system(decision, case_id, name, sequence, offset, **fields): + event_id = f"{case_id}-SYSTEM_LOG-{name}-{sequence}" + base = { + "trace_id": f"trace-{event_id}", + "gateway_key": "gateway-1", + "log_digest": hashlib.sha256(event_id.encode()).hexdigest(), + "event_name": name, + "session_id": "session-1", + **fields, + } + return _envelope( + decision, + case_id, + "SYSTEM_LOG", + sequence, + offset, + base, + stream="runtime", + ) + + +def _query(decision, case_id, family, phase, sequence, offset, data, *, snapshot_id): + kind_name = "ORDER_QUERY" if family == "orders" else "POSITION_QUERY" + query_id = f"{case_id}-{phase}-{family}-query" + fields = { + "query_family": family, + "phase": phase, + "snapshot_id": snapshot_id, + "query_id": query_id, + "complete": True, + "is_last": True, + "error_id": 0, + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + **data, + } + return _envelope(decision, case_id, kind_name, sequence, offset, fields) + + +def _snapshot_events( + decision, + case_id, + phase, + sequence, + offset, + positions, + closeable, + funds, + open_refs=(), +): + snapshot_id = f"{case_id}-{phase}-snapshot" + return [ + _query( + decision, + case_id, + "orders", + phase, + sequence, + offset, + {"open_order_refs_json": json.dumps(list(open_refs))}, + snapshot_id=snapshot_id, + ), + _query( + decision, + case_id, + "positions", + phase, + sequence + 1, + offset + 1, + { + "positions_json": json.dumps(positions, sort_keys=True), + "closeable_quantities_json": json.dumps(closeable, sort_keys=True), + }, + snapshot_id=snapshot_id, + ), + _query( + decision, + case_id, + "funds", + phase, + sequence + 2, + offset + 2, + {"funds_json": json.dumps(funds, sort_keys=True)}, + snapshot_id=snapshot_id, + ), + ] + + +def _order_status( + decision, case_id, kind, sequence, offset, order_ref, quantity, *, traded, remaining +): + statuses = { + "ORDER_ACCEPTED": "accepted", + "ORDER_PARTIAL": "partial", + "ORDER_CANCELED": "canceled", + "ORDER_FILLED": "filled", + } + return _envelope( + decision, + case_id, + kind, + sequence, + offset, + { + "order_ref": order_ref, + "external_order_id": f"exchange-{order_ref}", + "instrument_id": "rb2610", + "status": statuses[kind], + "traded_quantity": str(traded), + "remaining_quantity": str(remaining), + }, + ) + + +def _trade(decision, case_id, sequence, offset, order_ref, quantity): + return _envelope( + decision, + case_id, + "TRADE_EXECUTION", + sequence, + offset, + { + "trade_id": f"trade-{order_ref}", + "order_ref": order_ref, + "external_order_id": f"exchange-{order_ref}", + "instrument_id": "rb2610", + "direction": "buy", + "quantity": str(quantity), + "price": "3500", + }, + ) + + +def _complete_observations(decision, case_id): + quantity = 2 if case_id in {"B01", "B02"} else 1 + order_refs = ["order-1", "order-2"] if case_id in {"B01", "B02"} else ["order-1"] + baseline_positions = {"rb2610": "3"} if case_id == "T02" else {} + baseline_closeable = {"rb2610": "2"} if case_id == "T02" else {} + initial_funds = {"cash": "100000", "available_funds": "90000", "equity": "100000"} + events = [ + _system(decision, case_id, "session_started", 1, -60), + _envelope(decision, case_id, "FRONT_CONNECTED", 1, 0, {"gateway_key": "gateway-1"}), + _envelope( + decision, + case_id, + "LOGIN_SUCCESS", + 2, + 1, + {"success": True, "error_id": 0}, + ), + _envelope( + decision, + case_id, + "MARKET_SUBSCRIPTION_ACK", + 3, + 2, + {"success": True, "instrument_id": "rb2610"}, + ), + _envelope( + decision, + case_id, + "MARKET_TICK", + 4, + 3, + { + "instrument_id": "rb2610", + "bid": "3500", + "ask": "3501", + "last": "3500.5", + "price_tick": "1", + }, + ), + _system( + decision, + case_id, + "store_connected", + 2, + 6.2, + market_connection=True, + trade_connection=True, + ), + _system( + decision, case_id, "store_ready", 3, 6.4, market_connection=True, trade_connection=True + ), + _envelope( + decision, + case_id, + "ORDER_ADMISSION", + 1, + 6.5, + { + "case_id": case_id, + "intent_kind": "close" if case_id == "T02" else "open", + "approval_ref": f"review-{case_id}", + "maximum_quantity": "2", + }, + stream="admission", + ), + ] + events.extend( + _snapshot_events( + decision, + case_id, + "baseline", + 5, + 4, + baseline_positions, + baseline_closeable, + initial_funds, + ) + ) + for index, order_ref in enumerate(order_refs, 1): + events.append( + _envelope( + decision, + case_id, + "ORDER_SUBMIT_RECEIPT", + index, + 7 + index / 10, + { + "action": "submit", + "request_id": f"submit-{order_ref}", + "order_ref": order_ref, + "dispatch_state": "dispatched", + "quantity": str(quantity), + "instrument_id": "rb2610", + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "direction": "sell" if case_id == "T02" else "buy", + "offset": "close" if case_id == "T02" else "open", + "trace_id": f"trace-submit-{order_ref}", + "invocation_id": f"invoke-submit-{order_ref}", + }, + stream="managed-submit", + ) + ) + + provider_sequence = 8 + cancel_event = None + cancel_log_data = None + if case_id in {"T01", "T02", "T03"}: + events.append( + _order_status( + decision, + case_id, + "ORDER_ACCEPTED", + provider_sequence, + 10, + "order-1", + quantity, + traded=0, + remaining=quantity, + ) + ) + provider_sequence += 1 + cancel_log_data = { + "request_id": "cancel-order-1", + "invocation_id": "invoke-cancel-order-1", + "order_ref": "order-1", + "dispatch_state": "dispatched", + } + cancel_event = _system(decision, case_id, "order_cancel_request", 4, 11, **cancel_log_data) + events.append(cancel_event) + events.append( + _order_status( + decision, + case_id, + "ORDER_CANCELED", + provider_sequence, + 12, + "order-1", + quantity, + traded=0, + remaining=0, + ) + ) + provider_sequence += 1 + final_positions = baseline_positions + final_funds = initial_funds + elif case_id == "B01": + for index, order_ref in enumerate(order_refs): + events.append( + _order_status( + decision, + case_id, + "ORDER_ACCEPTED", + provider_sequence, + 10 + index, + order_ref, + quantity, + traded=0, + remaining=quantity, + ) + ) + provider_sequence += 1 + for index, order_ref in enumerate(order_refs): + events.append( + _order_status( + decision, + case_id, + "ORDER_PARTIAL", + provider_sequence, + 12 + index, + order_ref, + quantity, + traded=1, + remaining=1, + ) + ) + provider_sequence += 1 + events.append(_trade(decision, case_id, provider_sequence, 13 + index, order_ref, 1)) + provider_sequence += 1 + batch_fields = { + "request_id": "batch-cancel-1", + "invocation_id": "invoke-batch-cancel-1", + "dispatch_state": "dispatched", + "order_refs_json": json.dumps(order_refs), + "partial_count": 2, + } + cancel_event = _system(decision, case_id, "batch_cancel_requested", 4, 16, **batch_fields) + events.append(cancel_event) + for index, order_ref in enumerate(order_refs): + events.append( + _order_status( + decision, + case_id, + "ORDER_CANCELED", + provider_sequence, + 18 + index, + order_ref, + quantity, + traded=1, + remaining=0, + ) + ) + provider_sequence += 1 + final_positions = {"rb2610": "2"} + final_funds = {"cash": "99990", "available_funds": "89990", "equity": "100000"} + elif case_id == "B02": + for index, order_ref in enumerate(order_refs): + events.append( + _order_status( + decision, + case_id, + "ORDER_ACCEPTED", + provider_sequence, + 10 + index, + order_ref, + quantity, + traded=0, + remaining=quantity, + ) + ) + provider_sequence += 1 + batch_fields = { + "request_id": "batch-cancel-1", + "invocation_id": "invoke-batch-cancel-1", + "dispatch_state": "dispatched", + "order_refs_json": json.dumps(order_refs), + "open_order_count": 2, + } + cancel_event = _system(decision, case_id, "batch_cancel_requested", 4, 12, **batch_fields) + events.append(cancel_event) + events.append( + _order_status( + decision, + case_id, + "ORDER_CANCELED", + provider_sequence, + 13, + "order-2", + quantity, + traded=0, + remaining=0, + ) + ) + provider_sequence += 1 + events.append(_trade(decision, case_id, provider_sequence, 14, "order-1", quantity)) + provider_sequence += 1 + events.append( + _order_status( + decision, + case_id, + "ORDER_FILLED", + provider_sequence, + 15, + "order-1", + quantity, + traded=quantity, + remaining=0, + ) + ) + provider_sequence += 1 + final_positions = {"rb2610": "2"} + final_funds = {"cash": "99990", "available_funds": "89990", "equity": "100000"} + else: + events.append( + _order_status( + decision, + case_id, + "ORDER_ACCEPTED", + provider_sequence, + 10, + "order-1", + quantity, + traded=0, + remaining=quantity, + ) + ) + provider_sequence += 1 + events.append(_trade(decision, case_id, provider_sequence, 11, "order-1", quantity)) + provider_sequence += 1 + events.append( + _order_status( + decision, + case_id, + "ORDER_FILLED", + provider_sequence, + 12, + "order-1", + quantity, + traded=quantity, + remaining=0, + ) + ) + provider_sequence += 1 + final_positions = {"rb2610": "1"} + final_funds = {"cash": "99995", "available_funds": "89995", "equity": "100000"} + + events.extend( + _snapshot_events( + decision, + case_id, + "final", + provider_sequence, + 20, + final_positions, + {}, + final_funds, + ) + ) + cancel_count = int(cancel_event is not None) + submit_count = len(order_refs) + runtime_sequence = 5 if cancel_event else 4 + events.extend( + [ + _system( + decision, + case_id, + "session_stopped", + runtime_sequence, + 26, + clean_shutdown=True, + gateway_released=True, + exit_code=0, + ), + _system( + decision, + case_id, + "write_activity_summary", + runtime_sequence + 1, + 27, + snapshot_complete=True, + snapshot_digest="e" * 64, + order_submit_count=submit_count, + order_cancel_count=cancel_count, + broker_write_count=submit_count + cancel_count, + ), + ] + ) + return events + + +def _feed(strategy, events): + for event in sorted( + events, key=lambda item: datetime.fromisoformat(item.occurred_at_utc.replace("Z", "+00:00")) + ): + assert strategy.on_envelope(event) + + +def _replace_event_fields(event, **updates): + return replace(event, fields={**event.fields, **updates}) + + +@pytest.mark.parametrize("case_id", CASE_IDS) +def test_complete_order_observation_contract_stops_at_review_required(strategy_context, case_id): + decision, strategy = _strategy(strategy_context, case_id) + _feed(strategy, _complete_observations(decision, case_id)) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert ( + result.state + is importlib.import_module("common.read_only_case_strategy").ReadOnlyState.REVIEW_REQUIRED + ) + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.source_authenticity_verified is False + assert not result.missing_conditions + + +@pytest.mark.parametrize("case_id", ("T01", "B01")) +def test_managed_submit_contract_must_match_subscribed_market_contract(strategy_context, case_id): + decision, strategy = _strategy(strategy_context, case_id) + events = _complete_observations(decision, case_id) + events = [ + _replace_event_fields(event, instrument_id="UNRELATED-CONTRACT") + if event.kind is decision.ObservationKind.ORDER_SUBMIT_RECEIPT + else event + for event in events + ] + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "managed_submit_instrument_must_match_market_tick" in result.missing_conditions + + +def test_native_order_contract_must_match_managed_submit(strategy_context): + decision, strategy = _strategy(strategy_context, "T01") + events = _complete_observations(decision, "T01") + events = [ + _replace_event_fields(event, instrument_id="UNRELATED-CONTRACT") + if event.kind is decision.ObservationKind.ORDER_ACCEPTED + else event + for event in events + ] + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "native_order_fact_instrument_must_match_managed_submit" in result.missing_conditions + + +def test_order_candidate_without_scope_account_fingerprint_stays_incomplete(strategy_context): + decision, strategy = _strategy(strategy_context, "T01", account_identity_sha256="") + _feed(strategy, _complete_observations(decision, "T01")) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "sealed_account_identity_fingerprint_required" in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +def test_all_order_queries_and_submit_receipt_share_scope_account_fingerprint(strategy_context): + decision, strategy = _strategy(strategy_context, "T01") + events = _complete_observations(decision, "T01") + events = [ + _replace_event_fields(event, account_identity_sha256="e" * 64) + if event.kind is decision.ObservationKind.POSITION_QUERY + and event.fields.get("phase") == "final" + and event.fields.get("query_family") == "funds" + else event + for event in events + ] + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "account_identity_scope_mismatch" in result.missing_conditions + + +def test_raw_account_identifier_cannot_replace_scope_fingerprint(strategy_context): + decision, strategy = _strategy(strategy_context, "T01", account_identity_sha256="") + events = _complete_observations(decision, "T01") + provider_order = next( + event + for event in events + if event.kind is decision.ObservationKind.ORDER_ACCEPTED + ) + events[events.index(provider_order)] = _replace_event_fields( + provider_order, account_identity="arbitrary-account-name" + ) + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "sealed_account_identity_fingerprint_required" in result.missing_conditions + + +def test_event_timeline_shifted_ten_years_into_past_is_incomplete(strategy_context): + decision, strategy = _strategy(strategy_context, "T01") + events = [] + for event in _complete_observations(decision, "T01"): + shifted = datetime.fromisoformat(event.occurred_at_utc.replace("Z", "+00:00")) + shifted -= timedelta(days=3650) + events.append(replace(event, occurred_at_utc=shifted.isoformat().replace("+00:00", "Z"))) + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert result.missing_conditions == ("evidence_outside_candidate_freshness_window",) + + +def test_order_candidate_requires_explicit_aware_utc_evaluation_clock(strategy_context): + decision, strategy = _strategy(strategy_context, "T01") + _feed(strategy, _complete_observations(decision, "T01")) + + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError, + match="aware UTC datetime", + ): + strategy.evaluate(now_utc=datetime(2026, 9, 28, 9, 0)) + + +def test_l01_trade_log_projects_complete_native_trade_facts(strategy_context): + decision, strategy = _strategy(strategy_context, "L01") + _feed(strategy, _complete_observations(decision, "L01")) + + completion = strategy._build_completion_evidence() + trade_row = next( + row for row in completion.scenario_evidence if row.event_kind == "trade_execution" + ) + trade_fact = completion.trade_facts[0] + assert trade_row.fields["instrument_id"] == trade_fact.instrument_id + assert trade_row.fields["quantity"] == trade_fact.quantity + assert trade_row.fields["direction"] == trade_fact.direction + assert trade_row.fields["price"] == trade_fact.price + + +def test_l01_trade_log_field_mismatch_stays_incomplete(strategy_context): + decision, strategy = _strategy(strategy_context, "L01") + _feed(strategy, _complete_observations(decision, "L01")) + + completion = strategy._build_completion_evidence() + trade_row = next( + row for row in completion.scenario_evidence if row.event_kind == "trade_execution" + ) + mismatched_fields = dict(trade_row.fields, quantity="999") + mismatched_row = replace(trade_row, fields=mismatched_fields) + evidence = replace( + completion, + scenario_evidence=tuple( + mismatched_row if row is trade_row else row for row in completion.scenario_evidence + ), + ) + report = importlib.import_module("common.completion_invariants").evaluate_case_completion( + evidence + ) + + assert report.status.value == "INCOMPLETE" + assert "trade_event_fields_do_not_match_native_trade_fact" in report.contradictions + + +def test_final_funds_query_is_required_for_terminal_order_review(strategy_context): + decision, strategy = _strategy(strategy_context, "T01") + events = _complete_observations(decision, "T01") + events = [ + event + for event in events + if not ( + event.kind is decision.ObservationKind.POSITION_QUERY + and event.fields.get("phase") == "final" + and event.fields.get("query_family") == "funds" + ) + ] + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "final_requires_one_order_position_and_funds_query" in result.missing_conditions + + +def test_cancel_request_without_provider_terminal_callback_stays_incomplete(strategy_context): + decision, strategy = _strategy(strategy_context, "T01") + events = [ + event + for event in _complete_observations(decision, "T01") + if event.kind is not decision.ObservationKind.ORDER_CANCELED + ] + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "open_order_case_requires_cancel_terminal:order-1" in result.missing_conditions + + +def test_close_case_requires_opposite_side_confirmed_closeable_position(strategy_context): + decision, strategy = _strategy(strategy_context, "T02") + events = _complete_observations(decision, "T02") + baseline = next( + event + for event in events + if event.kind is decision.ObservationKind.POSITION_QUERY + and event.fields.get("phase") == "baseline" + and event.fields.get("query_family") == "positions" + ) + fields = dict(baseline.fields) + fields["closeable_quantities_json"] = json.dumps({"rb2610": "0"}) + events[events.index(baseline)] = decision.NativeObservation( + **{**baseline.__dict__, "fields": fields} + ) + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert ( + "close_submit_must_be_within_opposite_side_closeable_position" in result.missing_conditions + ) + + +def test_b01_needs_partial_fills_for_every_order_before_batch_cancel(strategy_context): + decision, strategy = _strategy(strategy_context, "B01") + events = _complete_observations(decision, "B01") + events = [ + event + for event in events + if not ( + event.kind is decision.ObservationKind.ORDER_PARTIAL + and event.fields.get("order_ref") == "order-2" + ) + ] + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert "every_batch_partial_order_requires_partial_callback" in result.missing_conditions + + +def test_b02_fill_race_requires_trade_and_position_reconciliation(strategy_context): + decision, strategy = _strategy(strategy_context, "B02") + events = _complete_observations(decision, "B02") + events = [ + event + for event in events + if not ( + event.kind is decision.ObservationKind.TRADE_EXECUTION + and event.fields.get("order_ref") == "order-1" + ) + ] + _feed(strategy, events) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "INCOMPLETE" + assert any("trade" in condition for condition in result.missing_conditions) + + +def test_explicit_external_unavailability_does_not_become_pass(strategy_context): + decision, strategy = _strategy(strategy_context, "T02") + event = _envelope( + decision, + "T02", + "EXTERNAL_CONDITION", + 1, + 0, + { + "condition_id": "closeable_position_confirmed", + "state": "unavailable", + "evidence_ref": "control-plane-evidence-1", + "reason": "provider account has no closeable position", + }, + stream="control-plane", + ) + assert strategy.on_envelope(event) + + result = strategy.evaluate(now_utc=EVALUATION_TIME) + assert result.state.value == "EXTERNAL_CONDITION_UNAVAILABLE" + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.unavailable_conditions == ( + "closeable_position_confirmed:provider account has no closeable position", + ) diff --git a/tests/unit/live_certification/test_simnow_penetration_certification.py b/tests/unit/live_certification/test_simnow_penetration_certification.py index 8f82acda..25798b28 100644 --- a/tests/unit/live_certification/test_simnow_penetration_certification.py +++ b/tests/unit/live_certification/test_simnow_penetration_certification.py @@ -70,6 +70,38 @@ def load_suite(suite_name): return run_case, certification, result_mod +def test_simnow_admission_rejects_missing_typed_interfaces(): + load_suite("simnow_penetration") + runtime = importlib.import_module("common.runtime") + + class MissingPreflight: + def __getattr__(self, name): + if name == "get_ctp_preflight_snapshot": + raise AttributeError(name) + raise AssertionError(f"unexpected store access: {name}") + + denied = runtime.ensure_ctp_trading_admission(MissingPreflight(), "SA701") + assert denied.ok is False + assert "preflight interface unavailable" in denied.reason + + class MissingArm: + def get_ctp_preflight_snapshot(self, symbol, *, timeout): + assert (symbol, timeout) == ("SA701", 15.0) + return {"snapshot_sha256": "a" * 64} + + def get_ctp_query_health(self): + return {"evidence_complete": True} + + def __getattr__(self, name): + if name == "arm_registered_sim_execution": + raise AttributeError(name) + raise AssertionError(f"unexpected store access: {name}") + + denied = runtime.ensure_ctp_trading_admission(MissingArm(), "SA701") + assert denied.ok is False + assert "execution admission interface unavailable" in denied.reason + + def test_loading_each_suite_restores_preexisting_module_objects(monkeypatch): previous_common = types.ModuleType("common") previous_common.__path__ = [] @@ -155,8 +187,17 @@ def test_case_result_contains_canonical_trace_and_audit_event(suite_name, tmp_pa ] assert payload["audit_events"][0]["scenario_id"] == "AUTH-01" assert payload["audit_events"][0]["trace_id"] == payload["trace_id"] - assert payload["required_events_present"] is True - assert payload["missing_required_events"] == [] + if suite_name == "simnow_penetration": + assert payload["status"] == "FAIL" + assert payload["required_events_present"] is False + assert payload["observed_events"] == [] + assert payload["missing_required_events"] == payload["required_events"] + assert payload["evidence_fields_present"] is False + assert payload["missing_evidence_fields"] == payload["evidence_fields"] + assert result_mod.PASS_UNAVAILABLE_REASON in payload["failure_reason"] + else: + assert payload["required_events_present"] is True + assert payload["missing_required_events"] == [] result_mod.save_result(result, tmp_path) @@ -165,7 +206,11 @@ def test_case_result_contains_canonical_trace_and_audit_event(suite_name, tmp_pa assert str(tmp_path / "audit.jsonl") in saved["evidence"] assert len(audit_lines) == 1 - assert json.loads(audit_lines[0])["scenario_id"] == "AUTH-01" + saved_audit = json.loads(audit_lines[0]) + assert saved_audit["scenario_id"] == "AUTH-01" + if suite_name == "simnow_penetration": + assert saved["status"] == "FAIL" + assert saved_audit["status"] == "FAIL" @pytest.mark.parametrize("suite_name", SUITE_NAMES) @@ -180,13 +225,117 @@ def test_case_result_surfaces_missing_required_events(suite_name): payload = result.to_dict() assert payload["scenario_id"] == "TRADE-OPEN-01" + if suite_name == "simnow_penetration": + assert payload["status"] == "FAIL" + assert payload["required_events_present"] is False + assert payload["missing_required_events"] == [ + "order_submit_request", + "order_status_accepted", + ] + assert payload["audit_events"][0]["missing_required_events"] == [ + "order_submit_request", + "order_status_accepted", + ] + assert result_mod.PASS_UNAVAILABLE_REASON in payload["failure_reason"] + else: + assert payload["status"] == "FAIL" + assert payload["required_events_present"] is False + assert payload["missing_required_events"] == ["order_status_accepted"] + assert payload["audit_events"][0]["missing_required_events"] == [ + "order_status_accepted" + ] + assert "Missing required certification evidence" in payload["failure_reason"] + + +def test_simnow_bare_pass_result_and_save_are_fail_closed(tmp_path): + _, certification, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + scenario = certification.get_certification_scenario("C01") + details = { + "events": list(scenario.required_events), + **dict.fromkeys(scenario.evidence_fields, "caller-claimed"), + "certification_evidence": {"claimed": True}, + } + logs = tmp_path / "logs" + logs.mkdir() + (logs / "events.log").write_text( + "\n".join( + json.dumps( + { + "event_type": event, + "source": "runtime_provider_validator_receipt", + "event_id": f"claimed-{index}", + "evidence_sha256": "a" * 64, + "trace_id": "claimed-trace", + } + ) + for index, event in enumerate(scenario.required_events) + ) + + "\n", + encoding="utf-8", + ) + + with result_mod.CaseTimer("C01", scenario.name, "new_7x24") as timer: + result = timer.pass_result(details=details) + + assert result.status == "FAIL" + assert result.failure_reason == result_mod.PASS_UNAVAILABLE_REASON + assert result.observed_events == [] + assert result.missing_required_events == list(scenario.required_events) + assert result.missing_evidence_fields == list(scenario.evidence_fields) + assert "certification_evidence" not in result.details + + result = evidence.attach_reconciliation(result, tmp_path) + assert result.status == "FAIL" + assert result.observed_events == [] + assert result.missing_required_events == list(scenario.required_events) + assert result.missing_evidence_fields == list(scenario.evidence_fields) + assert result.details["certification_evidence"] == {} + + def forge_pass_result(): + result.status = "PASS" + result.observed_events = list(scenario.required_events) + result.required_events_present = True + result.missing_required_events = [] + result.missing_evidence_fields = [] + result.evidence_fields_present = True + result.details["certification_evidence"] = {"claimed": True} + result.audit_events[0]["status"] = "PASS" + + # Public result accessors must fail-close even before persistence. + forge_pass_result() + assert result.exit_code() == result_mod.EXIT_FAIL + assert result.status == "FAIL" + assert result.failure_reason == result_mod.PASS_UNAVAILABLE_REASON + assert result.observed_events == [] + assert result.required_events_present is False + + forge_pass_result() + payload = result.to_dict() assert payload["status"] == "FAIL" + assert payload["failure_reason"] == result_mod.PASS_UNAVAILABLE_REASON + assert payload["observed_events"] == [] assert payload["required_events_present"] is False - assert payload["missing_required_events"] == ["order_status_accepted"] - assert payload["audit_events"][0]["missing_required_events"] == [ - "order_status_accepted" - ] - assert "Missing required certification evidence" in payload["failure_reason"] + assert "certification_evidence" not in payload["details"] + assert payload["audit_events"][0]["status"] == "FAIL" + + # The persistence guard is independent of the public accessors. + forge_pass_result() + result_mod.save_result(result, tmp_path) + + saved = json.loads((tmp_path / "result.json").read_text(encoding="utf-8")) + saved_audit = json.loads( + (tmp_path / "audit.jsonl").read_text(encoding="utf-8").splitlines()[0] + ) + assert saved["status"] == "FAIL" + assert saved["failure_reason"] == result_mod.PASS_UNAVAILABLE_REASON + assert saved["observed_events"] == [] + assert saved["missing_required_events"] == list(scenario.required_events) + assert saved["missing_evidence_fields"] == list(scenario.evidence_fields) + assert "certification_evidence" not in saved["details"] + assert saved_audit["status"] == "FAIL" + assert saved_audit["observed_events"] == [] + assert saved_audit["required_events_present"] is False @pytest.mark.parametrize("suite_name", SUITE_NAMES) @@ -246,11 +395,8 @@ def test_reconciliation_compares_account_positions_orders_and_trades( assert str(tmp_path / "reconciliation.json") in result.evidence -@pytest.mark.parametrize("suite_name", SUITE_NAMES) -def test_reconciliation_revalidates_required_evidence_from_log_files( - suite_name, tmp_path -): - _, _, result_mod = load_suite(suite_name) +def test_untrusted_jsonl_does_not_revalidate_required_order_evidence(tmp_path): + _, _, result_mod = load_suite("simnow_penetration") evidence = importlib.import_module("common.evidence") snapshots = [ @@ -295,23 +441,22 @@ def test_reconciliation_revalidates_required_evidence_from_log_files( result = timer.pass_result(details={"events": ["order_submit_request"]}) assert result.status == "FAIL" - assert result.missing_required_events == ["order_status_accepted"] + assert result.missing_required_events == [ + "order_submit_request", + "order_status_accepted", + ] result = evidence.attach_reconciliation(result, tmp_path) - assert result.status == "PASS" - assert result.missing_required_events == [] - assert result.missing_evidence_fields == [] - assert "order_status_accepted" in result.observed_events - assert result.details["certification_evidence"]["external_order_id"] == "sys-1" - assert result.details["reconciliation"]["strict_reconciliation_pass"] is True + assert result.status == "FAIL" + assert result.missing_required_events == ["order_submit_request", "order_status_accepted"] + assert "order_status_accepted" not in result.observed_events + assert result.details["certification_evidence"] == {} + assert result.details["reconciliation"]["strict_reconciliation_pass"] is False -@pytest.mark.parametrize("suite_name", SUITE_NAMES) -def test_reconciliation_derives_threshold_fields_from_runtime_logs( - suite_name, tmp_path -): - _, _, result_mod = load_suite(suite_name) +def test_source_less_threshold_log_does_not_revalidate_threshold_case(tmp_path): + _, _, result_mod = load_suite("simnow_penetration") evidence = importlib.import_module("common.evidence") snapshots = [ @@ -344,6 +489,12 @@ def test_reconciliation_derives_threshold_fields_from_runtime_logs( + json.dumps( { "event_type": "risk_threshold_triggered", + "source": "runtime_monitor_receipt", + "event_id": "claimed-threshold-event", + "evidence_sha256": "a" * 64, + "monitor_digest": "b" * 64, + "timestamp": "2026-09-28T00:00:01Z", + "trace_id": "claimed-trace", "details": {"counter": "submit_count", "value": 2, "threshold": 2}, } ) @@ -352,24 +503,168 @@ def test_reconciliation_derives_threshold_fields_from_runtime_logs( ) with result_mod.CaseTimer("TH02", "报单笔数达到阈值预警", "new_7x24") as timer: - result = timer.pass_result(details={}) + result = timer.pass_result( + details={ + "events": ["risk_threshold_triggered"], + "order_threshold": 2, + "submitted_order_count": 2, + } + ) + + result = evidence.attach_reconciliation(result, tmp_path) + cert = result.details["certification_evidence"] assert result.status == "FAIL" + assert "risk_threshold_triggered" in result.missing_required_events + assert "risk_threshold_triggered" not in result.observed_events + assert "order_threshold" not in cert + assert cert == {} + + +@pytest.mark.parametrize( + ("case_id", "event_type", "activity_types", "untrusted_field", "details"), + [ + ( + "TH01", + "risk_threshold_configured", + (), + "order_threshold", + {"thresholds": {"submit_count": 5}}, + ), + ( + "TH03", + "risk_threshold_configured", + (), + "cancel_threshold", + {"thresholds": {"submit_cancel_total": 10}}, + ), + ( + "TH04", + "risk_threshold_triggered", + ("order_submit_request", "order_cancel_request"), + "cancel_threshold", + {"counter": "submit_cancel_total", "threshold": 10, "value": 10}, + ), + ( + "TH05", + "risk_threshold_configured", + (), + "repeat_threshold", + {"thresholds": {"duplicate_order": 3}, "repeat_window_sec": 60}, + ), + ( + "TH06", + "risk_threshold_triggered", + ("order_submit_request",), + "repeat_threshold", + {"counter": "duplicate_order", "threshold": 3, "value": 3}, + ), + ( + "L03", + "risk_monitor_event", + (), + "metric", + {"metric": "order_rejection_rate"}, + ), + ], +) +def test_legacy_monitor_receipt_claims_do_not_satisfy_th_or_l03( + case_id, event_type, activity_types, untrusted_field, details, tmp_path +): + _, certification, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + scenario = certification.get_certification_scenario(case_id) + + snapshots = [ + { + "label": "before_action", + "balance": {"cash": 1000.0, "value": 1000.0}, + "positions": [], + "open_orders": [], + }, + { + "label": "after_action_before_stop", + "balance": {"cash": 1000.0, "value": 1000.0}, + "positions": [], + "open_orders": [], + }, + ] + (tmp_path / "state_snapshots.json").write_text( + json.dumps(snapshots), encoding="utf-8" + ) + logs = tmp_path / "logs" + logs.mkdir() + rows = [ + {"event_type": activity_type, "order_ref": f"local-{index}"} + for index, activity_type in enumerate(activity_types, start=1) + ] + rows.append( + { + "event_type": event_type, + "source": "runtime_monitor_receipt", + "event_id": f"claimed-{case_id}", + "evidence_sha256": "a" * 64, + "monitor_digest": "b" * 64, + "timestamp": "2026-09-28T00:00:01Z", + "trace_id": "claimed-trace", + "details": details, + } + ) + (logs / "monitor.log").write_text( + "\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8" + ) + + with result_mod.CaseTimer(case_id, scenario.name, "new_7x24") as timer: + result = timer.pass_result( + details={ + "events": [event_type], + **dict.fromkeys(scenario.evidence_fields, "caller-claimed"), + } + ) result = evidence.attach_reconciliation(result, tmp_path) - assert result.status == "PASS" - assert result.missing_required_events == [] - assert result.missing_evidence_fields == [] - assert result.details["certification_evidence"]["order_threshold"] == 2 - assert result.details["certification_evidence"]["submitted_order_count"] == 2 + cert = result.details["certification_evidence"] + assert result.status == "FAIL" + assert scenario.required_events[0] in result.missing_required_events + assert scenario.required_events[0] not in result.observed_events + assert untrusted_field not in cert -@pytest.mark.parametrize("suite_name", SUITE_NAMES) -def test_disconnect_session_stop_revalidates_as_store_disconnected( - suite_name, tmp_path -): - _, _, result_mod = load_suite(suite_name) +def test_legacy_monitoring_summary_cannot_override_request_derived_counts(): + _, _, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + result = result_mod.CaseTimer("M04", "正常统计报单笔数").blocked_result( + "offline monitor receipt negative probe", + details={"submitted_order_count": 99}, + ) + + derived = evidence._derive_runtime_evidence( + result, + [ + {"event_type": "order_submit_request", "order_ref": "actual-request-1"}, + { + "event_type": "monitoring_summary", + "source": "runtime_monitor_receipt", + "event_id": "claimed-summary", + "evidence_sha256": "a" * 64, + "monitor_digest": "b" * 64, + "timestamp": "2026-09-28T00:00:01Z", + "details": { + "submit_count": 99, + "submit_threshold": 5, + "thresholds": {"submit_count": 5}, + }, + }, + ], + [], + ) + + assert derived == {"observed_events": [], "field_names": set(), "values": {}} + + +def test_local_session_stop_does_not_revalidate_as_provider_disconnect(tmp_path): + _, _, result_mod = load_suite("simnow_penetration") evidence = importlib.import_module("common.evidence") snapshots = [ @@ -398,6 +693,14 @@ def test_disconnect_session_stop_revalidates_as_store_disconnected( "event_time": "2026-06-18T12:00:00", } ) + + "\n" + + json.dumps( + { + "event_type": "store_disconnected", + "status": "disconnected", + "timestamp": "2026-06-18T12:00:00Z", + } + ) + "\n", encoding="utf-8", ) @@ -411,15 +714,145 @@ def test_disconnect_session_stop_revalidates_as_store_disconnected( result = evidence.attach_reconciliation(result, tmp_path) - assert result.status == "PASS" - assert "store_disconnected" in result.observed_events + assert result.status == "FAIL" + assert "store_disconnected" in result.missing_required_events -@pytest.mark.parametrize("suite_name", SUITE_NAMES) -def test_local_validation_rejects_do_not_count_as_real_order_activity( - suite_name, tmp_path -): - _, _, result_mod = load_suite(suite_name) +def test_result_details_cannot_claim_provider_reconnect_without_log_evidence(tmp_path): + _, _, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + snapshots = [ + { + "label": "before_action", + "balance": {"cash": 1000.0}, + "positions": [], + "open_orders": [], + }, + { + "label": "after_action_before_stop", + "env": "gateway", + "balance": {"cash": 1000.0}, + "positions": [], + "open_orders": [], + }, + ] + (tmp_path / "state_snapshots.json").write_text( + json.dumps(snapshots), encoding="utf-8" + ) + logs = tmp_path / "logs" + logs.mkdir() + (logs / "system.log").write_text( + json.dumps( + { + "event_type": "store_reconnect_success", + "timestamp": "2026-09-28T00:00:00Z", + "gateway_key": "gateway", + } + ) + + "\n", + encoding="utf-8", + ) + + with result_mod.CaseTimer("M03", "断线后显示重连成功", "gateway") as timer: + result = timer.pass_result( + details={ + "events": ["store_reconnect_success"], + "gateway_key": "gateway", + "timestamp": "2026-09-28T00:00:00Z", + "previous_session_id": "session-a", + "new_session_id": "session-b", + } + ) + + assert result.status == "FAIL" + assert result.failure_reason == result_mod.PASS_UNAVAILABLE_REASON + result = evidence.attach_reconciliation(result, tmp_path) + + assert result.status == "FAIL" + assert "store_reconnect_success" in result.missing_required_events + assert set(result.missing_evidence_fields) == {"gateway_key", "timestamp"} + + +def test_no_open_order_expectation_requires_a_post_action_snapshot(tmp_path): + _, _, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + logs = tmp_path / "logs" + logs.mkdir() + (logs / "order.log").write_text( + json.dumps({"event_type": "order_submit_request", "order_ref": "bt-1"}) + + "\n" + + json.dumps( + { + "event_type": "order_submit_accepted", + "order_ref": "bt-1", + "external_order_id": "provider-1", + } + ) + + "\n", + encoding="utf-8", + ) + + with result_mod.CaseTimer("T01", "正常下达开仓指令", "gateway") as timer: + result = timer.pass_result(details={}) + + result = evidence.attach_reconciliation(result, tmp_path) + + assert result.status == "FAIL" + check = result.details["reconciliation"]["checks"]["post_action_open_orders"] + assert check["passed"] is False + + +def test_account_change_without_trade_fails_allowed_if_trade_reconciliation(tmp_path): + _, _, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + snapshots = [ + { + "label": "before_action", + "balance": {"cash": 1000.0, "value": 1000.0}, + "positions": [], + "open_orders": [], + }, + { + "label": "after_action_before_stop", + "balance": {"cash": 900.0, "value": 900.0}, + "positions": [{"instrument": "rb2610", "direction": "long", "volume": 1}], + "open_orders": [], + }, + ] + (tmp_path / "state_snapshots.json").write_text( + json.dumps(snapshots), encoding="utf-8" + ) + logs = tmp_path / "logs" + logs.mkdir() + (logs / "order.log").write_text( + json.dumps( + {"event_type": "order_submit_request", "order_ref": "bt-1"} + ) + + "\n" + + json.dumps( + { + "event_type": "order_submit_accepted", + "order_ref": "bt-1", + "external_order_id": "provider-1", + } + ) + + "\n", + encoding="utf-8", + ) + + with result_mod.CaseTimer("T01", "正常下达开仓指令", "gateway") as timer: + result = timer.pass_result(details={}) + + result = evidence.attach_reconciliation(result, tmp_path) + + assert result.status == "FAIL" + check = result.details["reconciliation"]["checks"]["account_position_change"] + assert check["trade_events"] == 0 + assert check["passed"] is False + + +def test_local_validation_rejects_do_not_count_as_real_order_activity(tmp_path): + _, _, result_mod = load_suite("simnow_penetration") evidence = importlib.import_module("common.evidence") snapshots = [ @@ -476,12 +909,13 @@ def test_local_validation_rejects_do_not_count_as_real_order_activity( result = evidence.attach_reconciliation(result, tmp_path) - assert result.status == "PASS" + assert result.status == "FAIL" + assert "order_validation_rejected" in result.missing_required_events + assert "order_validation_rejected" not in result.observed_events assert result.details["reconciliation"]["checks"]["order_activity"]["passed"] is True assert result.details["reconciliation"]["event_counts"]["order_events"] == 0 -@pytest.mark.parametrize("suite_name", SUITE_NAMES) @pytest.mark.parametrize( ("case_id", "error_code", "error_msg"), [ @@ -489,10 +923,10 @@ def test_local_validation_rejects_do_not_count_as_real_order_activity( ("E02", "50", "CTP:平今仓位不足"), ], ) -def test_remote_ctp_order_rejection_revalidates_error_cases( - suite_name, case_id, error_code, error_msg, tmp_path +def test_source_less_remote_ctp_order_rejection_does_not_revalidate_error_cases( + case_id, error_code, error_msg, tmp_path ): - _, _, result_mod = load_suite(suite_name) + _, _, result_mod = load_suite("simnow_penetration") evidence = importlib.import_module("common.evidence") snapshots = [ @@ -548,23 +982,164 @@ def test_remote_ctp_order_rejection_revalidates_error_cases( cert = result.details["certification_evidence"] reconciliation = result.details["reconciliation"] - assert result.status == "PASS" - assert "order_reject_remote" in result.observed_events - assert cert["ErrorID"] == error_code - assert cert["ErrorMsg"] == error_msg - assert cert["StatusMsg"] == error_msg + assert result.status == "FAIL" + assert "order_reject_remote" in result.missing_required_events + assert "order_reject_remote" not in result.observed_events + assert "ErrorID" not in cert + assert "ErrorMsg" not in cert + assert "StatusMsg" not in cert assert reconciliation["checks"]["order_activity"]["expected"] == "required" assert reconciliation["checks"]["order_activity"]["passed"] is True assert reconciliation["checks"]["trade_activity"]["passed"] is True assert reconciliation["checks"]["account_position_unchanged"]["passed"] is True -@pytest.mark.parametrize("suite_name", SUITE_NAMES) -def test_error_log_case_accepts_validation_error_log_event(suite_name): - _, certification, result_mod = load_suite(suite_name) +@pytest.mark.parametrize( + ("case_id", "event_type", "activity_type"), + [ + ("E01", "order_rejected", "order_submit_request"), + ("E02", "order_rejected", "order_submit_request"), + ("E03", "order_rejected", "order_submit_request"), + ("EM01", "account_trading_disabled", "order_submit_request"), + ("EM02", "strategy_trading_paused", "strategy_run"), + ("EM03", "gateway_force_logout_requested", "gateway_stop"), + ("O01", "risk_repeat_order_detected", "order_submit_request"), + ("O02", "risk_repeat_order_detected", "order_submit_request"), + ("O03", "risk_repeat_cancel_detected", "order_cancel_request"), + ], +) +def test_legacy_high_risk_receipt_claims_are_not_trusted_from_jsonl( + case_id, event_type, activity_type, tmp_path +): + _, certification, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + scenario = certification.get_certification_scenario(case_id) + + snapshots = [ + { + "label": "before_action", + "balance": {"cash": 1000.0, "value": 1000.0}, + "positions": [], + "open_orders": [], + }, + { + "label": "after_action_before_stop", + "balance": {"cash": 1000.0, "value": 1000.0}, + "positions": [], + "open_orders": [], + }, + ] + (tmp_path / "state_snapshots.json").write_text( + json.dumps(snapshots), encoding="utf-8" + ) + logs = tmp_path / "logs" + logs.mkdir() + activity = {"event_type": activity_type, "order_ref": "local-order-1"} + if activity_type == "order_cancel_request": + activity["cancel_ref"] = "local-cancel-1" + receipt_source = "ctp_provider_callback" + callback_name = "OnRspOrderInsert" + if event_type.startswith("risk_repeat_"): + receipt_source = "runtime_monitor_receipt" + callback_name = "" + elif event_type in { + "account_trading_disabled", + "strategy_trading_paused", + "gateway_force_logout_requested", + }: + receipt_source = "control_plane_receipt" + if event_type == "gateway_force_logout_requested": + callback_name = "OnFrontDisconnected" + receipt_claim = { + "event_type": event_type, + "source": receipt_source, + "event_id": f"claimed-{case_id}", + "evidence_sha256": "a" * 64, + "timestamp": "2026-09-28T00:00:01Z", + "provider": "ctp", + "callback_name": callback_name, + "session_id": "claimed-session", + "trading_day": "20260928", + "sequence": 1, + "order_ref": "local-order-1", + "ErrorID": 31, + "ErrorMsg": "CTP: claimed remote rejection", + "StatusMsg": "claimed status", + "verified_rejection_class": { + "E01": "insufficient_funds", + "E02": "insufficient_position", + "E03": "market_state", + }.get(case_id, "account_permission_denied"), + "error_mapping_evidence_ref": "claimed-mapping", + "market_state_evidence_ref": "claimed-market-state", + "market_state_source_event_id": "claimed-market-event", + "account_id_masked": "masked-account", + "reason": "claimed reason", + "authorization_ref": "claimed-authorization", + "independent_account_permission_evidence_ref": "claimed-permission", + "blocked_order_ref": "local-order-1", + "permission_restored_ref": "claimed-restoration", + "strategy_id": "local-strategy", + "gateway_key": "local-gateway", + "operator_termination_evidence_ref": "claimed-termination", + "post_disconnect_write_guard_evidence_ref": "claimed-write-guard", + "gateway_released": True, + "actor_id_hash": "claimed-actor", + "signature_sha256": "b" * 64, + "monitor_digest": "c" * 64, + "trace_id": "claimed-trace", + "repeat_key": "claimed-repeat-key", + "repeat_count": 2, + } + (logs / "events.log").write_text( + json.dumps(activity) + + "\n" + + json.dumps(receipt_claim) + + "\n", + encoding="utf-8", + ) + + with result_mod.CaseTimer(case_id, scenario.name, "new_7x24") as timer: + result = timer.pass_result( + details={ + "events": [event_type], + **dict.fromkeys(scenario.evidence_fields, "caller-claimed"), + } + ) + + result = evidence.attach_reconciliation(result, tmp_path) + + cert = result.details["certification_evidence"] + assert result.status == "FAIL" + assert scenario.required_events[0] in result.missing_required_events + assert scenario.required_events[0] not in result.observed_events + assert not set(scenario.evidence_fields).intersection(cert) + + +def test_untrusted_validation_log_does_not_pass_l04(tmp_path): + _, certification, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") scenario = certification.get_certification_scenario("L04") assert scenario.required_events == ("order_validation_rejected",) + logs = tmp_path / "logs" + logs.mkdir() + (logs / "error.log").write_text( + json.dumps( + { + "event_type": "order_validation_rejected", + "source": "local_validator_receipt", + "validator_digest": "a" * 64, + "reference_data_digest": "b" * 64, + "dispatch_absent": True, + "trace_id": "claimed-trace", + "error_code": "invalid_price_tick", + "error_msg": "invalid tick", + } + ) + + "\n", + encoding="utf-8", + ) with result_mod.CaseTimer("L04", "错误提示信息记录", "new_7x24") as timer: result = timer.pass_result( @@ -576,7 +1151,160 @@ def test_error_log_case_accepts_validation_error_log_event(suite_name): } ) - assert result.status == "PASS" + result = evidence.attach_reconciliation(result, tmp_path) + + assert result.status == "FAIL" + assert result.missing_required_events == ["order_validation_rejected"] + assert set(scenario.evidence_fields).issubset(result.missing_evidence_fields) + assert result.details["certification_evidence"] == {} + + +def test_all_33_source_less_legacy_certification_scenarios_fail_closed(tmp_path): + _, certification, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + scenarios = certification.all_certification_scenarios() + assert len(scenarios) == 33 + + required_event_types = sorted( + {event for scenario in scenarios for event in scenario.required_events} + ) + required_fields = sorted( + {field for scenario in scenarios for field in scenario.evidence_fields} + ) + source_by_event = { + "store_auth_success": "ctp_provider_callback", + "store_login_success": "ctp_provider_callback", + "store_connected": "ctp_provider_callback", + "store_disconnected": "ctp_provider_callback", + "store_reconnect_success": "ctp_provider_callback", + "order_submit_request": "managed_runtime_receipt", + "order_cancel_request": "managed_runtime_receipt", + "order_status_accepted": "ctp_provider_callback", + "order_status_canceled": "ctp_provider_callback", + "trade_execution": "ctp_provider_callback", + "risk_repeat_order_detected": "runtime_monitor_receipt", + "risk_repeat_cancel_detected": "runtime_monitor_receipt", + "risk_threshold_configured": "runtime_monitor_receipt", + "risk_threshold_triggered": "runtime_monitor_receipt", + "risk_monitor_event": "runtime_monitor_receipt", + "order_validation_rejected": "local_validator_receipt", + "order_reject_remote": "ctp_provider_callback", + "account_trading_disabled": "control_plane_receipt", + "strategy_trading_paused": "control_plane_receipt", + "gateway_force_logout_requested": "control_plane_receipt", + "batch_cancel_requested": "managed_runtime_receipt", + "store_ready": "ctp_provider_callback", + } + claimed_fields = dict.fromkeys(required_fields, "claimed-value") + event_rows = [] + for sequence, event_type in enumerate(required_event_types, start=1): + event_rows.append( + { + "event_type": event_type, + "source": source_by_event[event_type], + "event_id": f"claimed-{sequence}", + "evidence_sha256": "a" * 64, + "monitor_digest": "b" * 64, + "signature_sha256": "c" * 64, + "actor_id_hash": "claimed-actor", + "callback_names": [ + "OnRspOrderInsert", + "OnErrRtnOrderInsert", + "OnRtnOrder", + "OnRtnTrade", + "OnFrontConnected", + "OnFrontDisconnected", + "OnRspUserLogin", + ], + "callback_name": "OnRspOrderInsert", + "provider": "ctp", + "session_id": "claimed-session", + "provider_session_id": "claimed-session", + "trading_day": "20260928", + "sequence": sequence, + "source_sequence": sequence, + "timestamp": "2026-09-28T00:00:01Z", + "observed_at_utc": "2026-09-28T00:00:01Z", + "order_ref": "claimed-order-ref", + "external_order_id": "claimed-provider-order", + "trade_id": "claimed-trade-id", + "ErrorID": 31, + "ErrorMsg": "claimed error", + "StatusMsg": "claimed status", + "verified_rejection_class": "insufficient_funds", + "error_mapping_evidence_ref": "claimed-error-map", + "authorization_ref": "claimed-authorization", + "independent_account_permission_evidence_ref": "claimed-permission", + "permission_restored_ref": "claimed-restoration", + "operator_termination_evidence_ref": "claimed-termination", + "post_disconnect_write_guard_evidence_ref": "claimed-write-guard", + "gateway_released": True, + "dispatch_absent": True, + "validator_digest": "d" * 64, + "reference_data_digest": "e" * 64, + "trace_id": "claimed-trace", + "metric": "claimed-metric", + "repeat_key": "claimed-repeat-key", + "repeat_count": 2, + "details": claimed_fields, + **claimed_fields, + } + ) + # These source-less rows produce the old accepted/canceled/trade aliases. + event_rows.extend( + [ + {"event_type": "order_submit_accepted", "status": "Accepted"}, + {"event_type": "order_status_probe", "status": "Accepted"}, + {"event_type": "order_cancel_probe", "status": "Canceled"}, + {"event_type": "order_partial_probe", "status": "Partial"}, + {"event_type": "", "trade_id": "claimed-trade", "status": "Completed"}, + ] + ) + + for scenario in scenarios: + report_dir = tmp_path / scenario.case_id + logs = report_dir / "logs" + logs.mkdir(parents=True) + (report_dir / "state_snapshots.json").write_text( + json.dumps( + [ + { + "label": "before_action", + "balance": {"cash": 1000.0}, + "positions": [], + "open_orders": [], + }, + { + "label": "after_action_before_stop", + "balance": {"cash": 1000.0}, + "positions": [], + "open_orders": [], + }, + ] + ), + encoding="utf-8", + ) + (logs / "all-events.log").write_text( + "\n".join(json.dumps(row) for row in event_rows) + "\n", + encoding="utf-8", + ) + + with result_mod.CaseTimer( + scenario.case_id, scenario.name, "new_7x24" + ) as timer: + result = timer.pass_result( + details={ + "events": list(scenario.required_events), + **dict.fromkeys(scenario.evidence_fields, "caller-claimed"), + } + ) + result = evidence.attach_reconciliation(result, report_dir) + + assert result.status == "FAIL", scenario.case_id + assert result.missing_required_events == list(scenario.required_events) + assert not set(scenario.required_events).intersection(result.observed_events) + assert set(scenario.evidence_fields).issubset(result.missing_evidence_fields) + assert result.details["certification_evidence"] == {} @pytest.mark.parametrize("suite_name", SUITE_NAMES) @@ -844,3 +1572,97 @@ def test_trade_log_case_waits_for_real_trade_before_passing(suite_name): assert "order is self.close_order" not in source assert "self.open_order_ref == order.ref" in source assert "self.close_order_ref == order.ref" in source + + +def test_b01_partial_count_ignores_local_partial_labels(): + _, _, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + + local_partial_events = [ + { + "event_type": "order_status_partial", + "provider": "ctp", + "status": "partial", + "order_ref": "local-1", + "filled": 1, + "remaining": 1, + }, + { + "event_type": "order_status_partial", + "provider": "ctp", + "status": "partial", + "order_ref": "local-2", + "filled": 1, + "remaining": 1, + }, + ] + result = result_mod.CaseTimer("B01", "batch partial cancel").blocked_result( + "offline negative probe", + details={ + "events": ["order_status_partial", "order_status_partial"], + "partial_count": 2, + }, + ) + + derived = evidence._derive_runtime_evidence(result, local_partial_events, []) + + assert "partial_count" not in derived["values"] + assert "partial_count" not in derived["field_names"] + + +def test_b01_partial_count_does_not_trust_callback_shaped_jsonl(): + _, _, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + + def callback(order_ref, sequence): + return { + "event_type": "order_status_partial", + "provider": "ctp", + "source": "ctp_provider_callback", + "callback_name": "OnRtnOrder", + "event_id": f"callback-{sequence}", + "session_id": "offline-contract-session", + "trading_day": "20260928", + "sequence": sequence, + "observed_at_utc": f"2026-09-28T00:00:{sequence:02d}Z", + "evidence_sha256": "a" * 64, + "status": "partial", + "order_ref": order_ref, + "traded_quantity": 1, + "remaining_quantity": 1, + } + + result = result_mod.CaseTimer("B01", "batch partial cancel").blocked_result( + "offline contract probe", details={"partial_count": 99} + ) + derived = evidence._derive_runtime_evidence( + result, + [callback("provider-1", 1), callback("provider-1", 2), callback("provider-2", 3)], + [], + ) + + assert derived == {"observed_events": [], "field_names": set(), "values": {}} + + +def test_b01_partial_count_does_not_trust_raw_callback_shaped_jsonl(): + _, _, result_mod = load_suite("simnow_penetration") + evidence = importlib.import_module("common.evidence") + result = result_mod.CaseTimer("B01", "batch partial cancel").blocked_result( + "offline raw callback contract probe" + ) + event = { + "source": "ctp_provider_callback", + "callback_name": "OnRtnOrder", + "event_id": "raw-callback-1", + "session_id": "offline-contract-session", + "trading_day": "20260928", + "sequence": 1, + "observed_at_utc": "2026-09-28T00:00:01Z", + "evidence_sha256": "b" * 64, + "order_ref": "provider-raw-1", + "details": {"OrderStatus": "1", "VolumeTraded": 1, "VolumeTotal": 1}, + } + + derived = evidence._derive_runtime_evidence(result, [event], []) + + assert derived == {"observed_events": [], "field_names": set(), "values": {}} diff --git a/tests/unit/live_certification/test_simnow_read_only_case_strategies.py b/tests/unit/live_certification/test_simnow_read_only_case_strategies.py new file mode 100644 index 00000000..c9c20ea4 --- /dev/null +++ b/tests/unit/live_certification/test_simnow_read_only_case_strategies.py @@ -0,0 +1,854 @@ +"""Offline contracts for the first six unregistered read-only case strategies. + +The synthetic authenticator below exercises the interface only. These tests +prove that absent or malformed evidence cannot advance the candidates; they do +not prove real provider behavior, source authenticity, or certification PASS. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +CASES_ROOT = SUITE_ROOT / "cases" +CASE_IDS = ("C01", "M01", "L02", "TH01", "TH03", "TH05") +BASE_TIME = datetime(2026, 9, 28, 9, 0, tzinfo=timezone.utc) +SCOPE_SHA256 = "f" * 64 + + +@pytest.fixture +def strategy_modules(): + previous_modules = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + previous_path = sys.path[:] + for name in previous_modules: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + case_engine = importlib.import_module("common.case_engine") + decision = importlib.import_module("common.decision_engine") + strategies = {} + for case_id in CASE_IDS: + path = CASES_ROOT / case_id / f"{case_id}_strategy.py" + spec = importlib.util.spec_from_file_location(f"strategy_{case_id}", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + strategies[case_id] = module + yield case_engine, decision, strategies + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous_modules) + sys.path[:] = previous_path + + +class ContractAuthenticator: + """Synthetic interface probe; never evidence of real provider behavior.""" + + def __init__(self, decision): + self.decision = decision + + def authenticate(self, observation, scope): + return self.decision.AuthenticationReceipt( + event_id=observation.event_id, + evidence_sha256=observation.evidence_sha256, + scope_sha256=scope.scope_sha256, + trust_domain=observation.source_domain, + verification_ref="test-only-contract-authenticator", + ) + + +def _strategy(strategy_modules, case_id, authenticator=True): + case_engine, decision, strategies = strategy_modules + source = CASES_ROOT / case_id / f"{case_id}_strategy.py" + plan = case_engine.load_descriptive_case_plan(source, expected_case_id=case_id) + scope = decision.DecisionScope.for_plan(plan, SCOPE_SHA256) + verifier = ContractAuthenticator(decision) if authenticator else None + strategy = strategies[case_id].create_strategy(plan, scope, verifier) + return decision, strategy + + +def _observation( + decision, + case_id, + kind, + sequence, + offset_seconds, + fields, + *, + stream="provider", + session="provider-session-1", + trading_day="20260928", + callback=None, + domain=None, +): + kind_value = decision.ObservationKind[kind] + if domain is None: + domain = { + "AUTH_SUCCESS": decision.EvidenceTrustDomain.CTP_CALLBACK, + "LOGIN_SUCCESS": decision.EvidenceTrustDomain.CTP_CALLBACK, + "FRONT_CONNECTED": decision.EvidenceTrustDomain.CTP_CALLBACK, + "MARKET_SUBSCRIPTION_ACK": decision.EvidenceTrustDomain.CTP_CALLBACK, + "MARKET_TICK": decision.EvidenceTrustDomain.CTP_CALLBACK, + "SYSTEM_LOG": decision.EvidenceTrustDomain.MANAGED_RUNTIME, + "MONITOR_CONFIGURATION": decision.EvidenceTrustDomain.MONITOR, + "MONITOR_LOG": decision.EvidenceTrustDomain.MONITOR, + }[kind] + callbacks = { + "AUTH_SUCCESS": "OnRspAuthenticate", + "LOGIN_SUCCESS": "OnRspUserLogin", + "FRONT_CONNECTED": "OnFrontConnected", + "MARKET_SUBSCRIPTION_ACK": "OnRspSubMarketData", + "MARKET_TICK": "OnRtnDepthMarketData", + "SYSTEM_LOG": "", + "MONITOR_CONFIGURATION": "", + "MONITOR_LOG": "", + } + if callback is None: + callback = callbacks[kind] + is_provider = domain is decision.EvidenceTrustDomain.CTP_CALLBACK + when = BASE_TIME + timedelta(seconds=offset_seconds) + observation_fields = dict(fields) + c01_auth_login = case_id == "C01" and kind in {"AUTH_SUCCESS", "LOGIN_SUCCESS"} + payloadless_front = kind == "FRONT_CONNECTED" + c01_session = ( + "17" + if case_id == "C01" and kind == "LOGIN_SUCCESS" and session == "provider-session-1" + else session + ) + if payloadless_front: + observation_fields.setdefault("connection_generation", 1) + if c01_auth_login: + observation_fields.setdefault("request_id", 100 + sequence) + observation_fields.setdefault("request_generation", 1000 + sequence) + observation_fields.setdefault("arrival_generation", 1) + observation_fields.setdefault("is_last", True) + observation_fields.setdefault("error_id", 0) + observation_fields.setdefault("success", True) + if kind == "LOGIN_SUCCESS": + observation_fields.setdefault("provider_front_id", 3) + observation_fields.setdefault("provider_session_id", c01_session) + observation_fields.setdefault("trading_day", trading_day) + return decision.NativeObservation( + kind=kind_value, + source_domain=domain, + event_id=f"{case_id}-{kind}-{sequence}-{stream}", + evidence_sha256=f"{sequence:064x}", + occurred_at_utc=when.isoformat().replace("+00:00", "Z"), + sequence=sequence, + stream_id=stream, + callback_name=callback, + provider_session_id=( + c01_session + if is_provider and kind not in {"AUTH_SUCCESS", "FRONT_CONNECTED"} + else "" + ), + trading_day=( + trading_day + if is_provider and kind not in {"AUTH_SUCCESS", "FRONT_CONNECTED"} + else "" + ), + fields=observation_fields, + provider_front_id=(3 if c01_auth_login and kind == "LOGIN_SUCCESS" else None), + client_instance_id=("ctp-client-1" if c01_auth_login or payloadless_front else ""), + request_generation=(1000 + sequence if c01_auth_login else 0), + request_id_origin=("native_callback_argument" if c01_auth_login else ""), + request_generation_origin=( + "local_request_generation_binding" if c01_auth_login else "" + ), + arrival_generation=(1 if c01_auth_login or payloadless_front else 0), + session_identity_origin=( + "unavailable_on_native_authentication_response" + if c01_auth_login and kind == "AUTH_SUCCESS" + else "native_login_response_fields" + if c01_auth_login + else "" + ), + arrived_at_utc=(when.isoformat().replace("+00:00", "Z") if c01_auth_login else ""), + arrived_monotonic=(when.timestamp() if c01_auth_login else 0.0), + sequence_origin=("local_sdk_callback_arrival" if c01_auth_login else ""), + timestamp_origin=("local_sdk_capture_clock" if c01_auth_login else ""), + event_id_origin=("local_sdk_callback_arrival" if c01_auth_login else ""), + connection_generation_origin=( + "local_connection_generation" if payloadless_front else "" + ), + provider_issued_event_id=False, + ) + + +def _system_event(decision, case_id, event_name, sequence, offset_seconds, **extra): + return _observation( + decision, + case_id, + "SYSTEM_LOG", + sequence, + offset_seconds, + { + "trace_id": f"trace-{case_id}-{event_name}", + "gateway_key": "gateway-1", + "log_digest": f"{sequence:064x}", + "event_name": event_name, + "session_id": "runtime-session-1" if case_id == "C01" else "provider-session-1", + **extra, + }, + stream="runtime", + ) + + +def _complete_observations(decision, case_id): + events = [] + if case_id == "C01": + events.extend( + [ + _observation( + decision, + case_id, + "AUTH_SUCCESS", + 1, + 0, + {"success": True, "error_id": 0}, + ), + _observation( + decision, + case_id, + "LOGIN_SUCCESS", + 2, + 60, + {"success": True, "error_id": 0}, + ), + ] + ) + events.extend( + [ + _system_event( + decision, + case_id, + "store_auth_success", + 1, + 10, + callback_event_id="C01-AUTH_SUCCESS-1-provider", + callback_received_at_utc=BASE_TIME.isoformat().replace("+00:00", "Z"), + client_instance_id="ctp-client-1", + arrival_generation=1, + provider_issued_event_id=False, + ), + _system_event( + decision, + case_id, + "store_login_success", + 2, + 70, + callback_event_id="C01-LOGIN_SUCCESS-2-provider", + callback_received_at_utc=(BASE_TIME + timedelta(seconds=60)) + .isoformat() + .replace("+00:00", "Z"), + client_instance_id="ctp-client-1", + arrival_generation=1, + trading_day="20260928", + provider_front_id=3, + provider_session_id="17", + provider_issued_event_id=False, + ), + ] + ) + else: + events.extend( + [ + _observation( + decision, + case_id, + "FRONT_CONNECTED", + 1, + 0, + {"gateway_key": "gateway-1"}, + ), + _observation( + decision, + case_id, + "LOGIN_SUCCESS", + 2, + 60, + {"success": True, "error_id": 0}, + ), + _observation( + decision, + case_id, + "MARKET_SUBSCRIPTION_ACK", + 3, + 120, + {"success": True, "instrument_id": "rb2610"}, + ), + ] + ) + + is_logged_case = case_id in {"L02", "TH01", "TH03", "TH05"} + if is_logged_case: + events.append( + _system_event( + decision, + case_id, + "session_started", + 1, + -60, + **({"process_id": "process-1"} if case_id == "L02" else {}), + ) + ) + monitor_case = case_id in {"TH01", "TH03", "TH05"} + if monitor_case: + events.append( + _observation( + decision, + case_id, + "MARKET_TICK", + 4, + 150, + { + "instrument_id": "rb2610", + "bid": "3500", + "ask": "3501", + "last": "3500.5", + "price_tick": "1", + }, + ) + ) + spec = importlib.import_module("common.read_only_case_strategy").READ_ONLY_CASE_SPECS[ + case_id + ] + config_fields = { + "metric": spec.expected_metric, + "threshold": spec.expected_threshold, + "configuration_digest": "a" * 64, + "monitor_digest": "b" * 64, + } + if case_id == "TH05": + config_fields["window_seconds"] = "60" + events.extend( + [ + _observation( + decision, + case_id, + "MONITOR_CONFIGURATION", + 1, + -40, + config_fields, + stream="monitor", + ), + _observation( + decision, + case_id, + "MONITOR_LOG", + 2, + -39, + { + "trace_id": f"trace-{case_id}-threshold", + "metric": spec.expected_metric, + "monitor_digest": "b" * 64, + "event_name": "risk_threshold_configured", + "threshold": spec.expected_threshold, + "configuration_digest": "a" * 64, + **({"window_seconds": "60"} if case_id == "TH05" else {}), + }, + stream="monitor", + ), + ] + ) + + runtime_sequence = 2 if case_id == "C01" else (1 if is_logged_case else 0) + if case_id in {"M01", "L02", "TH01", "TH03", "TH05"}: + runtime_sequence += 1 + events.append( + _system_event( + decision, + case_id, + "store_connected", + runtime_sequence, + 180, + market_connection=True, + trade_connection=True, + ) + ) + if case_id in {"M01", "L02", "TH01", "TH03", "TH05"}: + runtime_sequence += 1 + events.append( + _system_event( + decision, + case_id, + "store_ready", + runtime_sequence, + 190, + market_connection=True, + trade_connection=True, + ) + ) + runtime_sequence += 1 + events.append( + _system_event( + decision, + case_id, + "session_stopped", + runtime_sequence, + 240, + clean_shutdown=True, + gateway_released=True, + exit_code=0, + **({"process_id": "process-1"} if case_id == "L02" else {}), + ) + ) + runtime_sequence += 1 + events.append( + _system_event( + decision, + case_id, + "write_activity_summary", + runtime_sequence, + 300, + snapshot_complete=True, + snapshot_digest="c" * 64, + order_submit_count=0, + order_cancel_count=0, + broker_write_count=0, + ) + ) + return events + + +def _feed(strategy, events): + for event in events: + assert strategy.on_envelope(event) + + +@pytest.mark.parametrize("case_id", CASE_IDS) +def test_complete_typed_read_only_plan_stops_at_review_required(strategy_modules, case_id): + decision, strategy = _strategy(strategy_modules, case_id) + _feed(strategy, _complete_observations(decision, case_id)) + + result = strategy.evaluate() + assert result.case_id == case_id + expected_state = ( + importlib.import_module("common.read_only_case_strategy").ReadOnlyState.INCOMPLETE + if case_id == "C01" + else importlib.import_module("common.read_only_case_strategy").ReadOnlyState.REVIEW_REQUIRED + ) + assert result.state is expected_state + if case_id == "C01": + assert "trusted_c01_issued_request_ledger_verifier_required" in result.missing_conditions + assert "c01_requires_baseline_and_final_native_order_position_account_queries" in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.source_authenticity_verified is False + if case_id == "C01": + assert result.missing_conditions + else: + assert not result.missing_conditions + + +def test_no_authenticator_rejects_callback_strings_and_stays_blocked(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01", authenticator=False) + event = _observation(decision, "C01", "AUTH_SUCCESS", 1, 0, {"success": True, "error_id": 0}) + + assert strategy.on_envelope(event) is False + result = strategy.evaluate() + assert result.state.value == "BLOCKED" + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.source_authenticity_verified is False + assert result.evidence_event_ids == () + assert result.rejected_events == ( + "C01-AUTH_SUCCESS-1-provider:trusted_authenticator_unavailable", + ) + + +def test_missing_clean_shutdown_and_zero_write_snapshot_is_incomplete(strategy_modules): + decision, strategy = _strategy(strategy_modules, "M01") + events = [ + event + for event in _complete_observations(decision, "M01") + if event.fields.get("event_name") != "write_activity_summary" + ] + _feed(strategy, events) + + result = strategy.evaluate() + assert result.state.value == "INCOMPLETE" + assert "system_log:write_activity_summary" in result.missing_conditions + assert result.certification_pass is False + + +def test_nonzero_write_counter_cannot_complete_read_only_case(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + events = _complete_observations(decision, "C01") + summary = next( + event + for event in events + if event.kind is decision.ObservationKind.SYSTEM_LOG + and event.fields["event_name"] == "write_activity_summary" + ) + altered = dict(summary.fields) + altered["order_submit_count"] = 1 + events[events.index(summary)] = decision.NativeObservation( + **{**summary.__dict__, "fields": altered} + ) + _feed(strategy, events) + + result = strategy.evaluate() + assert result.state.value == "INCOMPLETE" + assert ( + "complete_zero_order_cancel_and_broker_write_summary_required" in result.missing_conditions + ) + + +def test_c01_log_must_correlate_to_native_authentication_callback(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + events = _complete_observations(decision, "C01") + auth_log = next( + event + for event in events + if event.kind is decision.ObservationKind.SYSTEM_LOG + and event.fields["event_name"] == "store_auth_success" + ) + altered = dict(auth_log.fields) + altered["callback_event_id"] = "unrelated-local-event" + events[events.index(auth_log)] = decision.NativeObservation( + **{**auth_log.__dict__, "fields": altered} + ) + _feed(strategy, events) + + result = strategy.evaluate() + assert result.state.value == "INCOMPLETE" + assert "authentication_log_must_reference_native_callback" in result.missing_conditions + + +@pytest.mark.parametrize( + "forged_field", + ( + "local_session_id", + "local_front_id", + "local_provider_session_id", + "local_trading_day", + "local_account_identity_sha256", + ), +) +def test_c01_read_only_auth_rejects_unbound_local_identity_aliases( + strategy_modules, forged_field +): + decision, strategy = _strategy(strategy_modules, "C01") + event = _observation( + decision, + "C01", + "AUTH_SUCCESS", + 1, + 0, + {"success": True, "error_id": 0, forged_field: "forged-identity"}, + ) + readonly_module = importlib.import_module("common.read_only_case_strategy") + + with pytest.raises(readonly_module.ReadOnlyStrategyError): + strategy.on_envelope(event) + assert strategy.evaluate().state is readonly_module.ReadOnlyState.BLOCKED + + +def test_accepted_callback_fields_are_snapshotted(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + events = _complete_observations(decision, "C01") + auth = events.pop(0) + assert strategy.on_envelope(auth) + auth.fields["success"] = False + _feed(strategy, events) + + result = strategy.evaluate() + assert result.state.value == "INCOMPLETE" + + +def test_market_tick_must_be_typed_and_use_positive_tick_size(strategy_modules): + decision, strategy = _strategy(strategy_modules, "TH01") + event = _observation( + decision, + "TH01", + "MARKET_TICK", + 1, + 0, + { + "instrument_id": "rb2610", + "bid": "10", + "ask": "11", + "last": "10.5", + "price_tick": "0", + }, + ) + + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError + ): + strategy.on_envelope(event) + + +def test_local_event_name_with_wrong_trust_domain_is_rejected(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + event = _observation( + decision, + "C01", + "AUTH_SUCCESS", + 1, + 0, + {"success": True, "error_id": 0}, + domain=decision.EvidenceTrustDomain.LOCAL_VALIDATOR, + callback="OnRspAuthenticate", + ) + + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError + ): + strategy.on_envelope(event) + + +def test_authentication_is_unscoped_and_login_identity_is_native(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + auth = _observation(decision, "C01", "AUTH_SUCCESS", 1, 0, {"success": True, "error_id": 0}) + login = _observation( + decision, + "C01", + "LOGIN_SUCCESS", + 2, + 60, + {"success": True, "error_id": 0}, + session="18", + ) + assert strategy.on_envelope(auth) + assert auth.provider_front_id is None + assert auth.provider_session_id == "" + assert auth.trading_day == "" + assert strategy.on_envelope(login) + assert login.provider_session_id == "18" + assert login.provider_front_id == 3 + + +def test_c01_missing_login_does_not_complete(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + auth = _observation(decision, "C01", "AUTH_SUCCESS", 1, 0, {"success": True, "error_id": 0}) + strategy.on_envelope(auth) + result = strategy.evaluate(now_utc=BASE_TIME + timedelta(seconds=120)) + assert result.state.value == "INCOMPLETE" + assert decision.ObservationKind.LOGIN_SUCCESS.value in result.missing_conditions + + +def test_c01_disconnect_generation_change_between_auth_and_login_fails(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + events = _complete_observations(decision, "C01") + login = next(event for event in events if event.kind is decision.ObservationKind.LOGIN_SUCCESS) + login_fields = {**login.fields, "arrival_generation": 2} + login = decision.NativeObservation( + **{**login.__dict__, "arrival_generation": 2, "fields": login_fields} + ) + events[events.index(next(event for event in events if event.kind is decision.ObservationKind.LOGIN_SUCCESS))] = login + _feed(strategy, events) + result = strategy.evaluate(now_utc=BASE_TIME + timedelta(seconds=120)) + assert result.state.value == "INCOMPLETE" + assert "authentication_and_login_must_share_connection_generation" in result.missing_conditions + + +def test_c01_rejects_legacy_auth_provider_identity_and_time_claims(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + auth = _observation(decision, "C01", "AUTH_SUCCESS", 1, 0, {"success": True, "error_id": 0}) + legacy = decision.NativeObservation( + **{ + **auth.__dict__, + "provider_session_id": "17", + "trading_day": "20260928", + "provider_front_id": 3, + } + ) + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError, + match="cannot claim provider front/session/day/time/sequence", + ): + strategy.on_envelope(legacy) + + +def test_system_connection_requires_both_native_fronts_and_is_unique(strategy_modules): + decision, strategy = _strategy(strategy_modules, "L02") + events = _complete_observations(decision, "L02") + connected = next( + event + for event in events + if event.kind is decision.ObservationKind.SYSTEM_LOG + and event.fields["event_name"] == "store_connected" + ) + altered = dict(connected.fields) + altered["trade_connection"] = False + events[events.index(connected)] = decision.NativeObservation( + **{**connected.__dict__, "fields": altered} + ) + _feed(strategy, events) + + result = strategy.evaluate() + assert result.state.value == "INCOMPLETE" + assert "store_connected_log_must_confirm_both_provider_fronts" in result.missing_conditions + + +@pytest.mark.parametrize( + "case_id,field,value,condition", + [ + ("TH01", "threshold", 6, "runtime_threshold_does_not_match_case_plan"), + ("TH03", "monitor_digest", "d" * 64, "matching_timestamped_monitor_log_required"), + ("TH05", "window_seconds", "0", "positive_repeat_window_required"), + ], +) +def test_monitor_threshold_or_window_must_match_managed_plan( + strategy_modules, case_id, field, value, condition +): + decision, strategy = _strategy(strategy_modules, case_id) + events = _complete_observations(decision, case_id) + config = next(event for event in events if event.kind.value == "monitor_configuration") + altered = dict(config.fields) + altered[field] = value + events[events.index(config)] = decision.NativeObservation( + **{**config.__dict__, "fields": altered} + ) + _feed(strategy, events) + + result = strategy.evaluate() + assert result.state.value == "INCOMPLETE" + assert condition in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +def test_c01_duplicate_native_request_id_cannot_reach_review(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + events = _complete_observations(decision, "C01") + auth = next(event for event in events if event.kind is decision.ObservationKind.AUTH_SUCCESS) + login = next(event for event in events if event.kind is decision.ObservationKind.LOGIN_SUCCESS) + login_fields = {**login.fields, "request_id": auth.fields["request_id"]} + events[events.index(login)] = decision.NativeObservation( + **{**login.__dict__, "fields": login_fields} + ) + _feed(strategy, events) + result = strategy.evaluate() + assert result.state.value == "INCOMPLETE" + assert "authentication_and_login_native_request_ids_must_differ" in result.missing_conditions + + +def test_public_factory_rejects_caller_issued_request_verifier(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + common_strategy = importlib.import_module("common.read_only_case_strategy") + + class AlwaysTrueVerifier: + def verify(self, receipt, event, scope): + return True + + with pytest.raises(TypeError, match="unexpected keyword argument 'request_ledger_verifier'"): + common_strategy.create_read_only_strategy( + "C01", + strategy.plan, + strategy.scope, + ContractAuthenticator(decision), + request_ledger_verifier=AlwaysTrueVerifier(), + ) + + _feed(strategy, _complete_observations(decision, "C01")) + result = strategy.evaluate(now_utc=BASE_TIME + timedelta(seconds=120)) + assert result.state.value == "INCOMPLETE" + assert "trusted_c01_issued_request_ledger_verifier_required" in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +def test_authentication_shape_is_sessionless_outside_c01(strategy_modules): + decision, strategy = _strategy(strategy_modules, "M01") + event = _observation( + decision, "M01", "AUTH_SUCCESS", 1, 0, {"success": True, "error_id": 0} + ) + event = decision.NativeObservation(**{**event.__dict__, "provider_session_id": "17"}) + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError, + match="OnRspAuthenticate cannot claim", + ): + strategy.on_envelope(event) + + +@pytest.mark.parametrize( + "field, value", + [ + ("provider_timestamp_utc", "2026-09-28T09:00:00Z"), + ("provider_sequence", 12), + ("provider_event_id", "native-login-event"), + ], +) +def test_login_callback_cannot_claim_provider_time_sequence_or_event_id( + strategy_modules, field, value +): + decision, strategy = _strategy(strategy_modules, "C01") + login = _observation( + decision, + "C01", + "LOGIN_SUCCESS", + 2, + 1, + {"success": True, "error_id": 0, "request_id": 102}, + session="17", + ) + altered = decision.NativeObservation( + **{**login.__dict__, "fields": {**login.fields, field: value}} + ) + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError, + match="auth/login callback cannot claim provider time, sequence, or event ID", + ): + strategy.on_envelope(altered) + + +def test_auth_receipt_must_match_native_request_id(strategy_modules): + decision, strategy = _strategy(strategy_modules, "C01") + auth = _observation( + decision, + "C01", + "AUTH_SUCCESS", + 1, + 0, + {"success": True, "error_id": 0, "request_id": 101}, + ) + case_engine = importlib.import_module("common.case_engine") + receipt = case_engine.IssuedRequestReceipt( + request_kind="authenticate", + phase="", + request_id=999, + request_generation=auth.request_generation, + client_instance_id=auth.client_instance_id, + arrival_generation=auth.arrival_generation, + issued_at_utc=(BASE_TIME - timedelta(seconds=1)).isoformat(), + issued_monotonic=auth.arrived_monotonic - 1, + receipt_id="auth-issued-receipt-1", + ledger_entry_sha256="a" * 64, + ) + altered = decision.NativeObservation(**{**auth.__dict__, "issued_request_receipt": receipt}) + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError, + match="C01 auth/login receipt does not match native callback", + ): + strategy.on_envelope(altered) + + +def test_front_connected_cannot_claim_login_identity(strategy_modules): + decision, strategy = _strategy(strategy_modules, "M01") + event = _observation( + decision, "M01", "FRONT_CONNECTED", 1, 0, {"gateway_key": "gateway-1"} + ) + event = decision.NativeObservation( + **{**event.__dict__, "provider_session_id": "17", "trading_day": "20260928"} + ) + with pytest.raises( + importlib.import_module("common.read_only_case_strategy").ReadOnlyStrategyError, + match="OnFrontConnected has no native provider", + ): + strategy.on_envelope(event) diff --git a/tests/unit/live_certification/test_simnow_read_only_scenario_strategies.py b/tests/unit/live_certification/test_simnow_read_only_scenario_strategies.py new file mode 100644 index 00000000..317f21ea --- /dev/null +++ b/tests/unit/live_certification/test_simnow_read_only_scenario_strategies.py @@ -0,0 +1,415 @@ +"""Offline typed-state contracts for selected 007 SimNow strategy bindings. + +The test authenticator is synthetic and only checks the decision interface. +None of these events is real provider, account-control, or certification evidence. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import sys +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +CASES_ROOT = SUITE_ROOT / "cases" +CASE_IDS = ("V01", "V02", "V03", "E01", "E02", "E03", "EM01", "EM02", "EM03", "L04") +NOW = datetime(2026, 9, 28, 10, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def scenario_context(): + previous_modules = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + previous_path = sys.path[:] + for name in previous_modules: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + decision_module = importlib.import_module("common.decision_engine") + case_engine = importlib.import_module("common.case_engine") + strategies = {} + for case_id in CASE_IDS: + path = CASES_ROOT / case_id / f"{case_id}_strategy.py" + spec = importlib.util.spec_from_file_location( + f"_simnow_{case_id.lower()}_readonly_strategy_test", path + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + strategies[case_id] = module + yield decision_module, case_engine, strategies + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous_modules) + sys.path[:] = previous_path + + +class SyntheticContractAuthenticator: + """Test-only receipt generator; it does not verify a real source.""" + + def __init__(self, decision_module): + self.module = decision_module + + def authenticate(self, observation, scope): + return self.module.AuthenticationReceipt( + event_id=observation.event_id, + evidence_sha256=observation.evidence_sha256, + scope_sha256=scope.scope_sha256, + trust_domain=observation.source_domain, + verification_ref="synthetic-contract-test-only", + ) + + +def _engine(context, case_id, *, authenticated=True): + decision_module, case_engine, strategies = context + source = CASES_ROOT / case_id / f"{case_id}_strategy.py" + plan = case_engine.load_descriptive_case_plan(source, expected_case_id=case_id) + scope = decision_module.DecisionScope.for_plan(plan, "f" * 64) + strategy_type = getattr(strategies[case_id], f"{case_id}ReadOnlyStrategy") + authenticator = SyntheticContractAuthenticator(decision_module) if authenticated else None + return decision_module, strategy_type(plan, scope, authenticator) + + +def _event(decision_module, kind_name, sequence, fields, *, stream="source", occurred_at=NOW): + kind = decision_module.ObservationKind[kind_name] + domain, callback, _ = decision_module._POLICY[kind] + is_provider = domain is decision_module.EvidenceTrustDomain.CTP_CALLBACK + return decision_module.NativeObservation( + kind=kind, + source_domain=domain, + event_id=f"synthetic-{kind_name}-{sequence}-{stream}", + evidence_sha256=f"{sequence:064x}", + occurred_at_utc=occurred_at.isoformat().replace("+00:00", "Z"), + sequence=sequence, + stream_id=stream, + callback_name=callback, + provider_session_id="synthetic-session" if is_provider else "", + trading_day="20260928" if is_provider else "", + fields=fields, + ) + + +def _record(decision_module, strategy, kind, sequence, fields, **kwargs): + assert strategy.record(_event(decision_module, kind, sequence, fields, **kwargs)) + + +def test_all_selected_bindings_keep_static_plan_and_default_blocked(scenario_context): + decision_module, _, strategies = scenario_context + + for case_id in CASE_IDS: + strategy_module = strategies[case_id] + assert case_id == strategy_module.CASE_ID + assert hasattr(strategy_module, "CASE_PLAN") or hasattr(strategy_module, "PLAN") + module, strategy = _engine(scenario_context, case_id, authenticated=False) + snapshot = strategy.evaluate(now_utc=NOW) + assert snapshot.case_id == case_id + assert snapshot.status is module.DecisionStatus.BLOCKED + assert snapshot.certification_pass is False + assert snapshot.dispatch_permitted is False + assert snapshot.intent_candidate is None + assert not any(hasattr(strategy, name) for name in ("submit", "cancel", "dispatch")) + + assert "bt_api_ctp" not in sys.modules + + +def test_local_validation_cases_require_local_validator_and_zero_dispatch(scenario_context): + module, strategy = _engine(scenario_context, "V01") + _record( + module, + strategy, + "VALIDATION_REJECTION", + 1, + { + "order_ref": "local-v01", + "rule": "instrument", + "error_message": "instrument lookup miss", + "validator_digest": "a" * 64, + "reference_data_digest": "b" * 64, + "instrument_id": "invalid-test-id", + "instrument_lookup_found": False, + }, + ) + _record( + module, + strategy, + "DISPATCH_ABSENCE", + 1, + {"order_ref": "local-v01", "dispatch_count": 0, "audit_digest": "c" * 64}, + stream="managed", + ) + + snapshot = strategy.evaluate(now_utc=NOW) + assert snapshot.status is module.DecisionStatus.REVIEW_REQUIRED + assert snapshot.intent_candidate is not None + assert snapshot.intent_candidate.dispatch_permitted is False + assert snapshot.certification_pass is False + assert module._POLICY[module.ObservationKind.VALIDATION_REJECTION][0] is ( + module.EvidenceTrustDomain.LOCAL_VALIDATOR + ) + assert module._POLICY[module.ObservationKind.ORDER_REJECTED][0] is ( + module.EvidenceTrustDomain.CTP_CALLBACK + ) + + wrong_origin = replace( + _event( + module, + "VALIDATION_REJECTION", + 2, + { + "order_ref": "bad-origin", + "rule": "instrument", + "error_message": "claimed provider reject", + "validator_digest": "d" * 64, + "reference_data_digest": "e" * 64, + "instrument_id": "invalid-test-id", + "instrument_lookup_found": False, + }, + ), + source_domain=module.EvidenceTrustDomain.CTP_CALLBACK, + callback_name="OnRspOrderInsert", + ) + with pytest.raises(module.DecisionError, match="incorrect source domain/callback"): + strategy.record(wrong_origin) + + +@pytest.mark.parametrize( + ("case_id", "condition_id"), + ( + ("E01", "insufficient_funds"), + ("E02", "insufficient_position"), + ("E03", "market_state"), + ), +) +def test_remote_rejection_cases_require_external_condition_and_provider_error_id( + scenario_context, case_id, condition_id +): + module, strategy = _engine(scenario_context, case_id) + _record( + module, + strategy, + "EXTERNAL_CONDITION", + 1, + { + "condition_id": condition_id, + "state": "unavailable", + "evidence_ref": "synthetic-test-only-unavailable", + "reason": "no real external condition evidence supplied", + }, + stream="control", + ) + snapshot = strategy.evaluate(now_utc=NOW) + assert snapshot.status is module.DecisionStatus.EXTERNAL_CONDITION_UNAVAILABLE + assert snapshot.external_unavailability + assert snapshot.intent_candidate is None + assert snapshot.certification_pass is False + assert snapshot.dispatch_permitted is False + + local = _event( + module, + "VALIDATION_REJECTION", + 1, + { + "order_ref": "local-only", + "rule": "instrument", + "error_message": "local validator result", + "validator_digest": "a" * 64, + "reference_data_digest": "b" * 64, + }, + stream="validator", + ) + with pytest.raises(module.DecisionError, match="not required by case"): + strategy.record(local) + + wrong_error_id = replace( + _event( + module, + "ORDER_REJECTED", + 1, + { + "order_ref": "provider-reject", + "instrument_id": "rb2710", + "error_id": 0, + "error_message": "zero is not a remote rejection", + "rejection_class": condition_id, + }, + ), + sequence=2, + ) + with pytest.raises(module.DecisionError, match="positive provider error id"): + strategy.record(wrong_error_id) + + +@pytest.mark.parametrize( + ("case_id", "required_kinds", "control_kinds"), + ( + ( + "EM01", + { + "LOGIN_SUCCESS", + "FRONT_CONNECTED", + "MARKET_TICK", + "ORDER_ADMISSION", + "ACCOUNT_PERMISSION_DISABLED", + "ORDER_SUBMIT_RECEIPT", + "ORDER_REJECTED", + "ACCOUNT_PERMISSION_RESTORED", + "ORDER_QUERY", + }, + {"ACCOUNT_PERMISSION_DISABLED", "ACCOUNT_PERMISSION_RESTORED"}, + ), + ( + "EM02", + {"STRATEGY_PAUSED", "ORDER_QUERY"}, + {"STRATEGY_PAUSED"}, + ), + ( + "EM03", + { + "GATEWAY_LOGOUT_AUTHORIZED", + "FRONT_DISCONNECTED", + "POST_DISCONNECT_WRITE_BLOCKED", + }, + {"GATEWAY_LOGOUT_AUTHORIZED"}, + ), + ), +) +def test_emergency_cases_block_without_real_control_plane_evidence( + scenario_context, case_id, required_kinds, control_kinds +): + module, strategy = _engine(scenario_context, case_id, authenticated=False) + snapshot = strategy.evaluate(now_utc=NOW) + assert snapshot.status is module.DecisionStatus.BLOCKED + assert {kind.name for kind in strategy.spec.required_kinds} == required_kinds + assert snapshot.intent_candidate is None + assert snapshot.certification_pass is False + assert snapshot.dispatch_permitted is False + for kind_name in control_kinds: + assert module._POLICY[module.ObservationKind[kind_name]][0] is ( + module.EvidenceTrustDomain.CONTROL_PLANE + ) + + +def test_v02_v03_and_l04_keep_tick_size_admission_and_log_source_typed(scenario_context): + module, v02 = _engine(scenario_context, "V02") + _record( + module, + v02, + "MARKET_TICK", + 1, + {"instrument_id": "rb2710", "bid": "100", "ask": "101", "last": "100.5", "price_tick": "1"}, + occurred_at=NOW - timedelta(seconds=1), + ) + _record( + module, + v02, + "VALIDATION_REJECTION", + 1, + { + "order_ref": "local-v02", + "rule": "price_tick", + "error_message": "price does not align to tick", + "validator_digest": "a" * 64, + "reference_data_digest": "b" * 64, + "proposed_price": "100.5", + }, + stream="validator", + ) + _record( + module, + v02, + "DISPATCH_ABSENCE", + 1, + {"order_ref": "local-v02", "dispatch_count": 0, "audit_digest": "c" * 64}, + stream="managed", + ) + result = v02.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False + + module, v03 = _engine(scenario_context, "V03") + _record( + module, + v03, + "ORDER_ADMISSION", + 1, + { + "case_id": "V03", + "intent_kind": "validation", + "approval_ref": "synthetic-review-only", + "approval_state": "REVIEW_ONLY", + "dispatch_permitted": False, + "maximum_quantity": 1, + "maximum_order_size": "5", + }, + stream="managed", + ) + _record( + module, + v03, + "VALIDATION_REJECTION", + 1, + { + "order_ref": "local-v03", + "rule": "max_order_size", + "error_message": "request exceeds local maximum", + "validator_digest": "d" * 64, + "reference_data_digest": "e" * 64, + "requested_size": "6", + "maximum_order_size": "5", + }, + stream="validator", + ) + _record( + module, + v03, + "DISPATCH_ABSENCE", + 2, + {"order_ref": "local-v03", "dispatch_count": 0, "audit_digest": "f" * 64}, + stream="managed", + ) + result = v03.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False + + module, l04 = _engine(scenario_context, "L04") + _record( + module, + l04, + "VALIDATION_REJECTION", + 1, + { + "order_ref": "local-log-v04", + "rule": "price_tick", + "error_message": "local price-step validation failed", + "validator_digest": "1" * 64, + "reference_data_digest": "2" * 64, + "trace_id": "synthetic-local-error-log", + "error_code": "LOCAL_PRICE_STEP", + }, + stream="validator", + ) + _record( + module, + l04, + "DISPATCH_ABSENCE", + 1, + {"order_ref": "local-log-v04", "dispatch_count": 0, "audit_digest": "3" * 64}, + stream="managed", + ) + result = l04.evaluate(now_utc=NOW) + assert result.status is module.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False + assert module._POLICY[module.ObservationKind.VALIDATION_REJECTION][0] is ( + module.EvidenceTrustDomain.LOCAL_VALIDATOR + ) diff --git a/tests/unit/live_certification/test_simnow_remaining_read_only_case_strategies.py b/tests/unit/live_certification/test_simnow_remaining_read_only_case_strategies.py new file mode 100644 index 00000000..6d6b8221 --- /dev/null +++ b/tests/unit/live_certification/test_simnow_remaining_read_only_case_strategies.py @@ -0,0 +1,1002 @@ +"""Synthetic contracts for the remaining unregistered typed 007 strategies. + +The authenticator and receipts here are deliberately fake interface probes. +These tests make no claim about CTP, SimNow, source authenticity, or certification. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import sys +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +CASES_ROOT = SUITE_ROOT / "cases" +NOW = datetime(2026, 9, 28, 10, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def case_modules(): + previous_modules = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + previous_path = sys.path[:] + for name in previous_modules: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + decision = importlib.import_module("common.decision_engine") + case_engine = importlib.import_module("common.case_engine") + strategies = {} + for case_id in ("M02", "M03", "M04", "M05", "L03"): + path = CASES_ROOT / case_id / f"{case_id}_strategy.py" + spec = importlib.util.spec_from_file_location(f"strategy_{case_id}_test", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + strategies[case_id] = module + yield decision, case_engine, strategies + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous_modules) + sys.path[:] = previous_path + + +class SyntheticAuthenticator: + """Not a production verifier; it only exercises the injection contract.""" + + def __init__(self, module): + self.module = module + + def authenticate(self, event, scope): + return self.module.AuthenticationReceipt( + event_id=event.event_id, + evidence_sha256=event.evidence_sha256, + scope_sha256=scope.scope_sha256, + trust_domain=event.source_domain, + verification_ref="synthetic-test-only", + ) + + +def _make_engine(decision, case_engine, strategies, case_id): + plan = case_engine.load_descriptive_case_plan( + CASES_ROOT / case_id / f"{case_id}_strategy.py", expected_case_id=case_id + ) + scope = decision.DecisionScope.for_plan(plan, "f" * 64) + return plan, strategies[case_id].create_strategy(plan, scope, SyntheticAuthenticator(decision)) + + +def _event( + module, + kind_name, + event_id, + sequence, + seconds, + fields, + *, + session="", + day="", + stream=None, + front_id=None, +): + kind = module.ObservationKind[kind_name] + domain, callback, _ = module._POLICY[kind] + is_provider = domain is module.EvidenceTrustDomain.CTP_CALLBACK + is_auth = kind is module.ObservationKind.AUTH_SUCCESS + is_login = kind is module.ObservationKind.LOGIN_SUCCESS + is_payloadless_front = kind in { + module.ObservationKind.FRONT_CONNECTED, + module.ObservationKind.FRONT_DISCONNECTED, + } + event_fields = dict(fields) + if is_provider: + event_fields.setdefault("connection_generation", 1) + occurred_at = (NOW + timedelta(seconds=seconds)).isoformat().replace("+00:00", "Z") + provider_front_id = None + provider_session_id = session if is_provider and not (is_auth or is_payloadless_front) else "" + trading_day = day if is_provider and not (is_auth or is_payloadless_front) else "" + client_instance_id = "" + arrival_generation = 0 + session_identity_origin = "" + request_generation = 0 + request_id_origin = "" + request_generation_origin = "" + event_id_origin = "" + arrived_at_utc = "" + arrived_monotonic = 0.0 + sequence_origin = "" + timestamp_origin = "" + connection_generation_origin = "" + + if is_login: + if type(front_id) is not int or front_id < 1: + raise AssertionError("login fixtures require an explicit positive native FrontID") + event_fields.setdefault("provider_front_id", front_id) + event_fields.setdefault("provider_session_id", session) + event_fields.setdefault("trading_day", day) + provider_front_id = event_fields["provider_front_id"] + provider_session_id = event_fields["provider_session_id"] + trading_day = event_fields["trading_day"] + session_identity_origin = "native_login_response_fields" + elif is_auth: + session_identity_origin = "unavailable_on_native_authentication_response" + + if is_auth or is_login: + connection_generation = event_fields.setdefault("connection_generation", 1) + arrival_generation = event_fields.setdefault("arrival_generation", connection_generation) + event_fields.setdefault("request_id", sequence) + request_generation = event_fields.setdefault("request_generation", sequence) + event_fields.setdefault("is_last", True) + request_id_origin = "native_callback_argument" + request_generation_origin = "local_request_generation_binding" + client_instance_id = "synthetic-client-1" + event_id_origin = "local_sdk_callback_arrival" + arrived_at_utc = occurred_at + arrived_monotonic = float(sequence) + sequence_origin = "local_sdk_callback_arrival" + timestamp_origin = "local_sdk_capture_clock" + elif is_payloadless_front: + arrival_generation = event_fields["connection_generation"] + client_instance_id = "synthetic-client-1" + session_identity_origin = "unavailable_on_native_front_connection_callback" + connection_generation_origin = "local_connection_generation" + elif is_provider: + arrival_generation = event_fields["connection_generation"] + client_instance_id = "synthetic-client-1" + session_identity_origin = "derived_from_same_client_generation_native_login" + + return module.NativeObservation( + kind=kind, + source_domain=domain, + event_id=event_id, + evidence_sha256=f"{sequence + 1:064x}", + occurred_at_utc=occurred_at, + sequence=sequence, + stream_id=stream or domain.value, + callback_name=callback, + provider_front_id=provider_front_id, + provider_session_id=provider_session_id, + trading_day=trading_day, + fields=event_fields, + client_instance_id=client_instance_id, + request_generation=request_generation, + request_id_origin=request_id_origin, + request_generation_origin=request_generation_origin, + arrival_generation=arrival_generation, + session_identity_origin=session_identity_origin, + event_id_origin="local_sdk_callback_arrival" if is_provider else event_id_origin, + provider_issued_event_id=False, + arrived_at_utc=occurred_at if is_provider else arrived_at_utc, + arrived_monotonic=float(sequence) if is_provider else arrived_monotonic, + sequence_origin="local_sdk_callback_arrival" if is_provider else sequence_origin, + timestamp_origin="local_sdk_capture_clock" if is_provider else timestamp_origin, + connection_generation_origin=connection_generation_origin, + ) + +def _record(engine, event): + assert engine.record(event) + + +def _recovery_events(module, *, bad_summary=False): + old, new, day, gateway = "79", "80", "20260928", "gateway-a" + generation_old, generation_new = 8, 9 + pre_disconnect_login = _event( + module, + "LOGIN_SUCCESS", + "old-login", + 1, + 0, + {"error_id": 0, "success": True, "connection_generation": generation_old}, + session=old, + day=day, + front_id=7, + stream="ctp", + ) + callbacks = [ + _event( + module, + "FRONT_DISCONNECTED", + "old-disconnect", + 2, + 1, + { + "gateway_key": gateway, + "reason": "controlled_transport_fault", + "connection_generation": generation_old, + }, + session=old, + day=day, + stream="ctp", + ), + _event( + module, + "FRONT_CONNECTED", + "new-front", + 3, + 4, + {"gateway_key": gateway, "connection_generation": generation_new}, + session=new, + day=day, + stream="ctp", + ), + _event( + module, + "AUTH_SUCCESS", + "new-auth", + 4, + 5, + {"error_id": 0, "success": True, "connection_generation": generation_new}, + session=new, + day=day, + stream="ctp", + ), + _event( + module, + "LOGIN_SUCCESS", + "new-login", + 5, + 6, + {"error_id": 0, "success": True, "connection_generation": generation_new}, + session=new, + day=day, + front_id=7, + stream="ctp", + ), + _event( + module, + "MARKET_SUBSCRIPTION_ACK", + "new-subscription", + 6, + 7, + { + "instrument_id": "rb2710", + "error_id": 0, + "success": True, + "connection_generation": generation_new, + }, + session=new, + day=day, + stream="ctp", + ), + ] + control_disconnect = _event( + module, + "EXTERNAL_CONDITION", + "external-disconnect", + 1, + 2, + { + "condition_id": "external_disconnect", + "state": "satisfied", + "evidence_ref": "controlled-fault-ref", + "provider_event_ref": "old-disconnect", + "session_id": old, + "connection_generation": generation_old, + "gateway_key": gateway, + "snapshot_event_id": "disconnect-snapshot-1", + "snapshot_sha256": "a" * 64, + }, + stream="control", + ) + control_reconnect = _event( + module, + "EXTERNAL_CONDITION", + "external-reconnect", + 2, + 8, + { + "condition_id": "external_reconnect", + "state": "satisfied", + "evidence_ref": "controlled-recovery-ref", + "disconnect_event_ref": "old-disconnect", + "reconnect_event_ref": "new-front", + "previous_session_id": old, + "new_session_id": new, + "previous_connection_generation": generation_old, + "new_connection_generation": generation_new, + "gateway_key": gateway, + "snapshot_event_id": "reconnect-snapshot-1", + "snapshot_sha256": "b" * 64, + }, + stream="control", + ) + disconnect_log = _event( + module, + "SYSTEM_LOG", + "runtime-disconnected", + 1, + 3, + { + "trace_id": "trace-disconnect", + "gateway_key": gateway, + "log_digest": "c" * 64, + "event_name": "store_disconnected", + "session_id": old, + "connection_generation": generation_old, + "provider_event_id": "old-disconnect", + }, + stream="runtime", + ) + summary = _event( + module, + "SYSTEM_LOG", + "runtime-reconnected", + 2, + 9, + { + "trace_id": "trace-reconnect", + "gateway_key": gateway, + "log_digest": "d" * 64, + "event_name": "store_reconnect_success", + "session_id": new, + "connection_generation": generation_new, + "callback_names": ( + "OnFrontConnected", + "OnRspAuthenticate", + "OnRspUserLogin", + "OnRspSubMarketData", + ), + "provider_event_ids": tuple(item.event_id for item in callbacks[1:]), + "auth_error_id": 0 if not bad_summary else 1, + "login_error_id": 0, + "subscription_error_id": 0, + "authentication_succeeded": True, + "login_succeeded": True, + "subscription_succeeded": True, + "external_event_id": "external-reconnect", + "snapshot_event_id": "reconnect-snapshot-1", + "snapshot_sha256": "b" * 64, + "previous_session_id": old, + "new_session_id": new, + "previous_connection_generation": generation_old, + "new_connection_generation": generation_new, + }, + stream="runtime", + ) + return [ + pre_disconnect_login, + callbacks[0], + disconnect_log, + control_disconnect, + *callbacks[1:], + control_reconnect, + summary, + ] + + +@pytest.mark.parametrize("case_id", ["M02", "M03"]) +def test_disconnect_cases_require_new_generation_callbacks_and_bound_recovery_summary( + case_modules, case_id +): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, case_id) + for event in _recovery_events(decision): + _record(engine, event) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=12)) + assert result.status is decision.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.intent_candidate is not None + assert result.intent_candidate.dispatch_permitted is False + + +@pytest.mark.parametrize("case_id", ["M02", "M03"]) +def test_disconnect_recovery_without_pre_disconnect_native_login_stays_incomplete( + case_modules, case_id +): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, case_id) + for event in _recovery_events(decision)[1:]: + _record(engine, event) + + result = engine.evaluate(now_utc=NOW + timedelta(seconds=12)) + assert result.status is decision.DecisionStatus.INCOMPLETE + assert "one_pre_disconnect_native_login_binding_required" in result.missing_conditions + assert result.intent_candidate is None + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +@pytest.mark.parametrize("case_id", ["M02", "M03"]) +def test_disconnect_recovery_rejects_same_generation_provider_identity_change( + case_modules, case_id +): + decision, case_engine, strategies = case_modules + plan, engine = _make_engine(decision, case_engine, strategies, case_id) + old_login = _event( + decision, + "LOGIN_SUCCESS", + "old-login", + 1, + 0, + {"error_id": 0, "success": True, "connection_generation": 8}, + session="79", + day="20260928", + front_id=7, + stream="ctp", + ) + changed_login = _event( + decision, + "LOGIN_SUCCESS", + "same-generation-changed-login", + 2, + 1, + {"error_id": 0, "success": True, "connection_generation": 8}, + session="80", + day="20260928", + front_id=7, + stream="ctp", + ) + _record(engine, old_login) + with pytest.raises(decision.DecisionError, match="same-generation login"): + engine.record(changed_login) + + result = engine.evaluate(now_utc=NOW + timedelta(seconds=12)) + assert result.status is decision.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.rejected_observations + + +@pytest.mark.parametrize("case_id", ["M02", "M03"]) +def test_disconnect_recovery_rejects_cross_account_new_login_receipt(case_modules, case_id): + decision, case_engine, strategies = case_modules + plan, baseline = _make_engine(decision, case_engine, strategies, case_id) + + class CrossAccountOnReconnect(SyntheticAuthenticator): + def authenticate(self, event, scope): + receipt = super().authenticate(event, scope) + if event.event_id == "new-login": + return replace(receipt, account_identity_sha256="a" * 64) + return receipt + + engine = strategies[case_id].create_strategy( + plan, baseline.scope, CrossAccountOnReconnect(decision) + ) + events = _recovery_events(decision) + for event in events: + if event.event_id == "new-login": + assert engine.record(event) is False + break + _record(engine, event) + + result = engine.evaluate(now_utc=NOW + timedelta(seconds=12)) + assert result.status is decision.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert any("verification_receipt_mismatch" in row for row in result.rejected_observations) + + +@pytest.mark.parametrize("case_id", ["M02", "M03"]) +@pytest.mark.parametrize("kind", ["FRONT_CONNECTED", "FRONT_DISCONNECTED"]) +def test_payloadless_front_callback_cannot_claim_provider_session(case_modules, case_id, kind): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, case_id) + front = _event( + decision, + kind, + f"{kind.lower()}-with-invented-session", + 1, + 0, + {"gateway_key": "gateway-a", "connection_generation": 1}, + stream="ctp", + ) + forged = replace(front, provider_session_id="79", trading_day="20260928") + + with pytest.raises(decision.DecisionError, match="cannot claim provider FrontID"): + engine.record(forged) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=2)) + assert result.status is decision.DecisionStatus.BLOCKED + assert result.intent_candidate is None + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +@pytest.mark.parametrize("case_id", ["M02", "M03"]) +@pytest.mark.parametrize("kind", ["FRONT_DISCONNECTED", "AUTH_SUCCESS"]) +@pytest.mark.parametrize( + "alias", + ["local_session_id", "local_front_id", "local_provider_session_id", "local_trading_day"], +) +def test_disconnect_recovery_rejects_local_login_identity_aliases( + case_modules, case_id, kind, alias +): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, case_id) + events = _recovery_events(decision) + target_index = next( + index for index, event in enumerate(events) if event.kind.name == kind + ) + + for event in events[:target_index]: + _record(engine, event) + target = events[target_index] + forged = replace(target, fields={**target.fields, alias: "forged-provider-identity"}) + with pytest.raises(decision.DecisionError, match="cannot carry login identity alias"): + engine.record(forged) + + result = engine.evaluate(now_utc=NOW + timedelta(seconds=12)) + assert result.status is decision.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +@pytest.mark.parametrize("case_id", ["M02", "M03"]) +def test_disconnect_cases_reject_failed_recovery_summary_and_failed_native_login( + case_modules, case_id +): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, case_id) + for event in _recovery_events(decision, bad_summary=True)[:-1]: + _record(engine, event) + with pytest.raises(decision.DecisionError, match="three zero provider error ids"): + engine.record(_recovery_events(decision, bad_summary=True)[-1]) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=12)) + assert result.status is decision.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + + _, failed_engine = _make_engine(decision, case_engine, strategies, case_id) + failed_login = _event( + decision, + "LOGIN_SUCCESS", + "failed-login", + 1, + 1, + {"error_id": 9, "success": False, "connection_generation": 9}, + session="80", + day="20260928", + front_id=7, + stream="failed-provider", + ) + with pytest.raises(decision.DecisionError): + failed_engine.record(failed_login) + assert ( + failed_engine.evaluate(now_utc=NOW + timedelta(seconds=2)).status + is decision.DecisionStatus.BLOCKED + ) + + +def _ready_provider_events(module, *, include_tick, order_start): + session, day = "42", "20260928" + events = [ + _event( + module, + "AUTH_SUCCESS", + "auth", + 1, + 1, + {"error_id": 0, "success": True}, + session=session, + day=day, + stream="ctp", + ), + _event( + module, + "FRONT_CONNECTED", + "front", + 2, + 2, + {"gateway_key": "gateway-a"}, + session=session, + day=day, + stream="ctp", + ), + _event( + module, + "LOGIN_SUCCESS", + "login", + 3, + 3, + {"error_id": 0, "success": True}, + session=session, + day=day, + front_id=7, + stream="ctp", + ), + _event( + module, + "MARKET_SUBSCRIPTION_ACK", + "sub", + 4, + 4, + {"instrument_id": "rb2710", "error_id": 0, "success": True}, + session=session, + day=day, + stream="ctp", + ), + ] + sequence = 5 + if include_tick: + events.append( + _event( + module, + "MARKET_TICK", + "tick", + sequence, + 5, + { + "instrument_id": "rb2710", + "bid": "100", + "ask": "101", + "last": "100.5", + "price_tick": "1", + }, + session=session, + day=day, + stream="ctp", + ) + ) + sequence += 1 + return events, sequence + + +def _admission(module, case_id, intent): + return _event( + module, + "ORDER_ADMISSION", + f"admission-{case_id}", + 1, + 5, + { + "case_id": case_id, + "intent_kind": intent, + "approval_ref": f"review-only-{case_id}", + "maximum_quantity": 1, + "approval_state": "REVIEW_ONLY", + "dispatch_permitted": False, + }, + stream=f"admission-{case_id}", + ) + + +def _submit_receipt(module, *, event_id="submit-receipt", order_ref="order-1"): + return _event( + module, + "ORDER_SUBMIT_RECEIPT", + event_id, + 1, + 5, + { + "order_ref": order_ref, + "request_id": "submit-request-1", + "trace_id": "trace-submit-1", + "dispatch_state": "submitted", + }, + stream="submit-runtime", + ) + + +def _order_callback(module, kind, event_id, sequence, seconds, *, order_ref="order-1", extra=None): + fields = { + "order_ref": order_ref, + "external_order_id": "external-order-1", + "instrument_id": "rb2710", + "status": "accepted" if kind == "ORDER_ACCEPTED" else "canceled", + "remaining_quantity": "1" if kind == "ORDER_ACCEPTED" else "0", + "traded_quantity": "0", + } + if extra: + fields.update(extra) + return _event( + module, + kind, + event_id, + sequence, + seconds, + fields, + session="42", + day="20260928", + stream="ctp", + ) + + +def test_m04_count_receipt_is_bound_to_runtime_request_native_ack_and_cleanup(case_modules): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, "M04") + ready, sequence = _ready_provider_events(decision, include_tick=True, order_start=1) + for event in ready: + _record(engine, event) + _record(engine, _admission(decision, "M04", "open")) + _record(engine, _submit_receipt(decision)) + _record( + engine, + _event( + decision, + "MONITOR_LOG", + "submit-monitor", + 1, + 6, + { + "event_name": "order_submit_request", + "metric": "submit_count", + "count": 1, + "request_id": "submit-request-1", + "order_ref": "order-1", + "trace_id": "trace-submit-1", + "request_receipt_event_id": "submit-receipt", + "monitor_digest": "a" * 64, + }, + stream="monitor", + ), + ) + _record(engine, _order_callback(decision, "ORDER_ACCEPTED", "accepted", sequence, 7)) + _record(engine, _order_callback(decision, "ORDER_CANCELED", "canceled", sequence + 1, 8)) + _record( + engine, + _event( + decision, + "ORDER_QUERY", + "final-order-query", + sequence + 2, + 9, + {"query_id": "query-final", "complete": True, "open_order_refs": ()}, + session="42", + day="20260928", + stream="ctp", + ), + ) + _record( + engine, + _event( + decision, + "POSITION_QUERY", + "final-position-query", + sequence + 3, + 10, + { + "query_id": "position-final", + "complete": True, + "phase": "final", + "instrument_id": "rb2710", + "closeable_quantity": 0, + }, + session="42", + day="20260928", + stream="ctp", + ), + ) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=6)) + assert result.status is decision.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False and result.dispatch_permitted is False + + +def test_m04_mismatched_monitor_count_cannot_produce_candidate(case_modules): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, "M04") + ready, sequence = _ready_provider_events(decision, include_tick=True, order_start=1) + for event in ready: + _record(engine, event) + _record(engine, _admission(decision, "M04", "open")) + _record(engine, _submit_receipt(decision)) + _record( + engine, + _event( + decision, + "MONITOR_LOG", + "bad-monitor", + 1, + 6, + { + "event_name": "order_submit_request", + "metric": "submit_count", + "count": 2, + "request_id": "submit-request-1", + "order_ref": "order-1", + "trace_id": "trace-submit-1", + "request_receipt_event_id": "submit-receipt", + "monitor_digest": "a" * 64, + }, + stream="monitor", + ), + ) + _record(engine, _order_callback(decision, "ORDER_ACCEPTED", "accepted", sequence, 7)) + _record(engine, _order_callback(decision, "ORDER_CANCELED", "canceled", sequence + 1, 8)) + _record( + engine, + _event( + decision, + "ORDER_QUERY", + "orders-final", + sequence + 2, + 9, + {"query_id": "q", "complete": True, "open_order_refs": ()}, + session="42", + day="20260928", + stream="ctp", + ), + ) + _record( + engine, + _event( + decision, + "POSITION_QUERY", + "positions-final", + sequence + 3, + 10, + { + "query_id": "p", + "complete": True, + "phase": "final", + "instrument_id": "rb2710", + "closeable_quantity": 0, + }, + session="42", + day="20260928", + stream="ctp", + ), + ) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=6)) + assert result.status is decision.DecisionStatus.INCOMPLETE + assert result.intent_candidate is None + + +def test_m05_cancel_monitor_receipt_is_tied_to_open_ref_ack_and_empty_final_query(case_modules): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, "M05") + ready, sequence = _ready_provider_events(decision, include_tick=False, order_start=1) + for event in ready: + _record(engine, event) + _record(engine, _admission(decision, "M05", "cancel")) + _record(engine, _submit_receipt(decision, event_id="origin-submit")) + _record(engine, _order_callback(decision, "ORDER_ACCEPTED", "accepted", sequence, 5)) + baseline = _event( + decision, + "ORDER_QUERY", + "baseline-query", + sequence + 1, + 6, + {"query_id": "query-before-cancel", "complete": True, "open_order_refs": ("order-1",)}, + session="42", + day="20260928", + stream="ctp", + ) + _record(engine, baseline) + _record( + engine, + _event( + decision, + "MONITOR_LOG", + "cancel-monitor", + 1, + 7, + { + "event_name": "order_cancel_request", + "metric": "cancel_count", + "count": 1, + "request_id": "cancel-request-1", + "order_ref": "order-1", + "trace_id": "trace-cancel-1", + "origin_trace_id": "trace-submit-1", + "request_receipt_ref": "cancel-request-receipt-1", + "source_query_event_id": "baseline-query", + "monitor_digest": "e" * 64, + }, + stream="monitor", + ), + ) + _record( + engine, + _order_callback( + decision, + "ORDER_CANCELED", + "canceled", + sequence + 2, + 8, + extra={"cancel_request_id": "cancel-request-1"}, + ), + ) + _record( + engine, + _event( + decision, + "ORDER_QUERY", + "final-query", + sequence + 3, + 9, + {"query_id": "query-final", "complete": True, "open_order_refs": ()}, + session="42", + day="20260928", + stream="ctp", + ), + ) + _record( + engine, + _event( + decision, + "POSITION_QUERY", + "final-position", + sequence + 4, + 10, + { + "query_id": "position-final", + "complete": True, + "phase": "final", + "instrument_id": "rb2710", + "closeable_quantity": 0, + }, + session="42", + day="20260928", + stream="ctp", + ), + ) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=11)) + assert result.status is decision.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False and result.dispatch_permitted is False + + +def test_l03_monitor_event_binds_request_receipt_provider_ack_and_runtime_log(case_modules): + decision, case_engine, strategies = case_modules + _, engine = _make_engine(decision, case_engine, strategies, "L03") + ready, sequence = _ready_provider_events(decision, include_tick=False, order_start=1) + for event in ready: + _record(engine, event) + _record(engine, _admission(decision, "L03", "open")) + _record(engine, _submit_receipt(decision)) + accepted = _order_callback(decision, "ORDER_ACCEPTED", "accepted", sequence, 6) + accepted = replace( + accepted, + fields={**accepted.fields, "gateway_key": "gateway-a", "connection_generation": 9}, + ) + _record(engine, accepted) + monitor = _event( + decision, + "MONITOR_LOG", + "monitor-event", + 1, + 7, + { + "event_name": "order_submit_request", + "metric": "submit_count", + "count": 1, + "request_id": "submit-request-1", + "order_ref": "order-1", + "trace_id": "trace-submit-1", + "request_receipt_event_id": "submit-receipt", + "monitor_digest": "a" * 64, + "session_id": "42", + "gateway_key": "gateway-a", + "trading_day": "20260928", + "connection_generation": 9, + }, + stream="monitor", + ) + _record(engine, monitor) + _record( + engine, + _event( + decision, + "SYSTEM_LOG", + "runtime-monitor-log", + 1, + 8, + { + "event_name": "monitor_observation", + "trace_id": "trace-submit-1", + "gateway_key": "gateway-a", + "log_digest": "d" * 64, + "monitor_event_id": "monitor-event", + "request_id": "submit-request-1", + "order_ref": "order-1", + "session_id": "42", + "trading_day": "20260928", + "connection_generation": 9, + }, + stream="runtime", + ), + ) + result = engine.evaluate(now_utc=NOW + timedelta(seconds=9)) + assert result.status is decision.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False and result.dispatch_permitted is False diff --git a/tests/unit/live_certification/test_simnow_th04_combined_threshold.py b/tests/unit/live_certification/test_simnow_th04_combined_threshold.py new file mode 100644 index 00000000..7091eb2b --- /dev/null +++ b/tests/unit/live_certification/test_simnow_th04_combined_threshold.py @@ -0,0 +1,396 @@ +"""Focused contracts for TH04's combined managed submit/cancel threshold.""" + +from __future__ import annotations + +import importlib +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +CASE_PATH = SUITE_ROOT / "cases" / "TH04" / "TH04_strategy.py" +NOW = datetime(2026, 9, 28, 10, 0, tzinfo=timezone.utc) +CONFIG_DIGEST = "a" * 64 +MONITOR_DIGEST = "b" * 64 + + +class SyntheticAuthenticator: + """Test-only interface fake; it is not source-authenticity evidence.""" + + def __init__(self, module): + self.module = module + + def authenticate(self, event, scope): + return self.module.AuthenticationReceipt( + event_id=event.event_id, + evidence_sha256=event.evidence_sha256, + scope_sha256=scope.scope_sha256, + trust_domain=event.source_domain, + verification_ref="test-only-synthetic-authenticator", + ) + + +@pytest.fixture +def decision_module(): + previous = { + name: module + for name, module in sys.modules.items() + if name == "common" or name.startswith("common.") + } + old_path = sys.path[:] + old_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True + for name in previous: + sys.modules.pop(name, None) + sys.path.insert(0, str(SUITE_ROOT)) + try: + yield importlib.import_module("common.decision_engine") + finally: + for name in list(sys.modules): + if name == "common" or name.startswith("common."): + sys.modules.pop(name, None) + sys.modules.update(previous) + sys.path[:] = old_path + sys.dont_write_bytecode = old_bytecode + + +def _plan_and_engine(module): + case_engine = importlib.import_module("common.case_engine") + plan = case_engine.load_descriptive_case_plan(CASE_PATH, expected_case_id="TH04") + scope = module.DecisionScope.for_plan(plan, "f" * 64) + engine = module.CaseIntentDecisionEngine(plan, scope, SyntheticAuthenticator(module)) + return engine + + +def _event(module, kind_name, sequence, fields, *, stream, provider=False): + kind = module.ObservationKind[kind_name] + domain, callback, _ = module._POLICY[kind] + fields = dict(fields) + callback_metadata = {} + provider_session_id = "10001" if provider else "" + trading_day = "20260928" if provider else "" + provider_front_id = None + if kind is module.ObservationKind.LOGIN_SUCCESS: + # OnRspUserLogin is the source of FrontID/SessionID/TradingDay. Local + # request, arrival, sequence, and clock fields stay explicitly local. + fields.update( + request_id=1, + request_generation=1, + arrival_generation=1, + is_last=True, + error_id=0, + success=True, + provider_front_id=1, + provider_session_id=provider_session_id, + trading_day=trading_day, + ) + provider_front_id = 1 + callback_metadata.update( + client_instance_id="test-native-client", + request_id_origin="native_callback_argument", + request_generation=1, + request_generation_origin="local_request_generation_binding", + arrival_generation=1, + sequence_origin="local_sdk_callback_arrival", + timestamp_origin="local_sdk_capture_clock", + event_id_origin="local_sdk_callback_arrival", + provider_issued_event_id=False, + arrived_at_utc=(NOW - timedelta(seconds=10 - sequence)) + .isoformat() + .replace("+00:00", "Z"), + arrived_monotonic=float(sequence), + connection_generation_origin="local_connection_generation", + session_identity_origin="native_login_response_fields", + ) + return module.NativeObservation( + kind=kind, + source_domain=domain, + event_id=f"{kind_name.lower()}-{sequence}", + evidence_sha256=f"{sequence:064x}", + occurred_at_utc=(NOW - timedelta(seconds=10 - sequence)).isoformat().replace("+00:00", "Z"), + sequence=sequence, + stream_id=stream, + callback_name=callback, + provider_session_id=provider_session_id, + trading_day=trading_day, + provider_front_id=provider_front_id, + fields=fields, + **callback_metadata, + ) + + +def _record_positive_evidence(module, engine, *, duplicate_cancel_callback=False): + facts = ( + ( + "ORDER_ACCEPTED", + { + "order_ref": "R1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + }, + ), + ( + "ORDER_ACCEPTED", + { + "order_ref": "R2", + "external_order_id": "sys-2", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + }, + ), + ( + "ORDER_CANCELED", + { + "order_ref": "R1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "canceled", + "remaining_quantity": "0", + "traded_quantity": "0", + }, + ), + ) + # The login response alone introduces native provider identity. It is an + # offline fixture, not provider authentication or issuer-ledger evidence. + provider_events = [ + _event(module, "LOGIN_SUCCESS", 1, {}, stream="provider", provider=True) + ] + for sequence, (kind, fields) in enumerate(facts, 2): + provider_events.append( + _event(module, kind, sequence, fields, stream="provider", provider=True) + ) + if duplicate_cancel_callback: + provider_events.append( + _event( + module, + "ORDER_CANCELED", + 5, + { + "order_ref": "R1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "canceled", + "remaining_quantity": "0", + "traded_quantity": "0", + }, + stream="provider", + provider=True, + ) + ) + + managed = ( + ("ORDER_SUBMIT_RECEIPT", "submit-1", "submit", "R1", "dispatched"), + ("ORDER_SUBMIT_RECEIPT", "submit-2", "submit", "R2", "dispatched"), + ("ORDER_CANCEL_RECEIPT", "cancel-1", "cancel", "R1", "dispatched"), + ("ORDER_CANCEL_RECEIPT", "blocked-cancel", "cancel", "R1", "blocked_pre_dispatch"), + ) + managed_events = [] + for sequence, (kind, request_id, action, order_ref, dispatch_state) in enumerate(managed, 1): + managed_events.append( + _event( + module, + kind, + sequence, + { + "request_id": request_id, + "action": action, + "order_ref": order_ref, + "trace_id": f"trace-{request_id}", + "dispatch_state": dispatch_state, + }, + stream="managed", + ) + ) + + config = _event( + module, + "MONITOR_CONFIGURATION", + 1, + { + "metric": "combined_order_cancel_count", + "threshold": 3, + "configuration_digest": CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + }, + stream="monitor", + ) + native_source_ids = tuple( + sorted( + event.event_id + for event in provider_events + if event.kind + in { + module.ObservationKind.ORDER_ACCEPTED, + module.ObservationKind.ORDER_CANCELED, + } + ) + ) + trigger = _event( + module, + "MONITOR_TRIGGER", + 2, + { + "metric": "combined_order_cancel_count", + "threshold": 3, + "observed_value": 3, + "configuration_digest": CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + "source_request_ids": ("cancel-1", "submit-1", "submit-2"), + "source_native_event_ids": native_source_ids, + }, + stream="monitor", + ) + for event in (*provider_events, *managed_events, config, trigger): + assert engine.record(event) + return engine + + +def test_th04_counts_dispatched_submit_plus_cancel_requests_not_cancel_callback_refs( + decision_module, +): + engine = _record_positive_evidence(decision_module, _plan_and_engine(decision_module)) + result = engine.evaluate(now_utc=NOW) + assert result.status is decision_module.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.intent_candidate is not None + # Three dispatched requests include two submits and one cancel. The extra + # blocked retry and native callback cardinality do not raise that count. + assert result.intent_candidate.correlation_refs == ("R1", "R2") + + +def test_th04_native_cancel_callback_count_does_not_replace_managed_request_count( + decision_module, +): + engine = _record_positive_evidence( + decision_module, + _plan_and_engine(decision_module), + duplicate_cancel_callback=True, + ) + result = engine.evaluate(now_utc=NOW) + assert result.status is decision_module.DecisionStatus.REVIEW_REQUIRED + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +@pytest.mark.parametrize( + "mutate, expected_missing", + ( + ( + lambda fields: fields.update(configuration_digest="c" * 64), + "threshold_trigger_not_correlated_to_native_activity", + ), + ( + lambda fields: fields.update(source_request_ids=("cancel-1", "submit-1")), + "threshold_trigger_not_correlated_to_native_activity", + ), + ( + lambda fields: fields.update(source_native_event_ids=("unrelated-event",)), + "threshold_trigger_not_correlated_to_native_activity", + ), + ( + lambda fields: fields.update( + observed_value=4, + source_request_ids=("blocked-cancel", "cancel-1", "submit-1", "submit-2"), + ), + "threshold_trigger_not_correlated_to_native_activity", + ), + ), +) +def test_th04_requires_bound_config_and_exact_managed_and_native_source_ids( + decision_module, mutate, expected_missing +): + engine = _plan_and_engine(decision_module) + _record_positive_evidence(decision_module, engine) + trigger = engine._last(decision_module.ObservationKind.MONITOR_TRIGGER) + fields = dict(trigger.fields) + mutate(fields) + engine._events[engine._events.index(trigger)] = trigger.__class__( + **{**trigger.__dict__, "fields": fields} + ) + result = engine.evaluate(now_utc=NOW) + assert result.status is decision_module.DecisionStatus.INCOMPLETE + assert expected_missing in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +def test_th04_cancel_receipt_must_match_native_open_order_reference(decision_module): + engine = _plan_and_engine(decision_module) + _record_positive_evidence(decision_module, engine) + receipt = engine._all(decision_module.ObservationKind.ORDER_CANCEL_RECEIPT)[0] + fields = dict(receipt.fields) + fields["order_ref"] = "UNKNOWN" + engine._events[engine._events.index(receipt)] = receipt.__class__( + **{**receipt.__dict__, "fields": fields} + ) + result = engine.evaluate(now_utc=NOW) + assert result.status is decision_module.DecisionStatus.INCOMPLETE + assert "threshold_trigger_not_correlated_to_native_activity" in result.missing_conditions + + +def test_th04_two_submit_requests_cannot_share_one_provider_order_ref(decision_module): + engine = _plan_and_engine(decision_module) + _record_positive_evidence(decision_module, engine) + receipt = engine._all(decision_module.ObservationKind.ORDER_SUBMIT_RECEIPT)[1] + fields = dict(receipt.fields) + fields["order_ref"] = "R1" + engine._events[engine._events.index(receipt)] = receipt.__class__( + **{**receipt.__dict__, "fields": fields} + ) + result = engine.evaluate(now_utc=NOW) + assert result.status is decision_module.DecisionStatus.INCOMPLETE + assert "threshold_trigger_not_correlated_to_native_activity" in result.missing_conditions + + +def test_th04_missing_cancel_receipt_fields_are_rejected_and_case_stays_incomplete( + decision_module, +): + engine = _plan_and_engine(decision_module) + assert engine.record( + _event( + decision_module, + "MONITOR_CONFIGURATION", + 1, + { + "metric": "combined_order_cancel_count", + "threshold": 3, + "configuration_digest": CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + }, + stream="monitor", + ) + ) + malformed = _event( + decision_module, + "ORDER_CANCEL_RECEIPT", + 1, + {"order_ref": "R1", "trace_id": "trace-1", "dispatch_state": "dispatched"}, + stream="managed", + ) + with pytest.raises(decision_module.DecisionError, match="request_id, action"): + engine.record(malformed) + result = engine.evaluate(now_utc=NOW) + assert result.status is decision_module.DecisionStatus.INCOMPLETE + assert decision_module.ObservationKind.ORDER_CANCEL_RECEIPT.value in result.missing_conditions + + +def test_th04_missing_cancel_receipt_remains_incomplete(decision_module): + engine = _plan_and_engine(decision_module) + _record_positive_evidence(decision_module, engine) + engine._events = [ + event + for event in engine._events + if event.kind is not decision_module.ObservationKind.ORDER_CANCEL_RECEIPT + ] + result = engine.evaluate(now_utc=NOW) + assert result.status is decision_module.DecisionStatus.INCOMPLETE + assert decision_module.ObservationKind.ORDER_CANCEL_RECEIPT.value in result.missing_conditions diff --git a/tests/unit/live_certification/test_simnow_typed_six_case_states.py b/tests/unit/live_certification/test_simnow_typed_six_case_states.py new file mode 100644 index 00000000..d2c6b70c --- /dev/null +++ b/tests/unit/live_certification/test_simnow_typed_six_case_states.py @@ -0,0 +1,1354 @@ +"""Offline contracts for the unregistered six-case typed-state candidates.""" + +from __future__ import annotations + +import importlib +import sys +from dataclasses import dataclass, replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SUITE_ROOT = REPO_ROOT / "examples" / "007_ctp" / "live_certification" / "simnow_penetration" +CASES_ROOT = SUITE_ROOT / "cases" +NOW = datetime(2026, 9, 28, 10, 0, tzinfo=timezone.utc) +CONFIG_DIGEST = "a" * 64 +MONITOR_DIGEST = "b" * 64 +ACCOUNT_IDENTITY_SHA256 = "c" * 64 + +_STRATEGY_CLASS = { + "O01": "O01TypedScenarioState", + "O02": "O02TypedScenarioState", + "O03": "O03TypedScenarioState", + "TH02": "TH02TypedScenarioState", + "TH04": "TH04TypedScenarioState", + "TH06": "TH06TypedScenarioState", +} + + +@dataclass(frozen=True) +class ManagedProof: + request_id: str + evidence_sha256: str + scope_sha256: str + source: str + verification_ref: str + account_identity_sha256: str + + +class SyntheticAuthenticator: + """Interface-only fake. This does not authenticate real evidence.""" + + def __init__(self, module): + self.module = module + + def authenticate(self, event, scope): + return self.module.AuthenticationReceipt( + event_id=event.event_id, + evidence_sha256=event.evidence_sha256, + scope_sha256=scope.scope_sha256, + trust_domain=event.source_domain, + verification_ref="test-only-synthetic-verifier", + account_identity_sha256=scope.account_identity_sha256, + ) + + def authenticate_managed_receipt(self, receipt, scope): + return ManagedProof( + request_id=receipt.request_id, + evidence_sha256=receipt.evidence_sha256, + scope_sha256=scope.scope_sha256, + source=receipt.source, + verification_ref="test-only-synthetic-managed-verifier", + account_identity_sha256=scope.account_identity_sha256, + ) + + +@pytest.fixture +def loaded_case_modules(): + names = { + name: module + for name, module in sys.modules.items() + if name == "common" + or name.startswith("common.") + or name == "_typed_scenario_state_candidate" + } + old_path = sys.path[:] + old_dont_write_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True + for name in names: + sys.modules.pop(name, None) + sys.path.insert(0, str(CASES_ROOT)) + sys.path.insert(0, str(SUITE_ROOT)) + try: + decision = importlib.import_module("common.decision_engine") + case_engine = importlib.import_module("common.case_engine") + strategies = {} + for case_id in _STRATEGY_CLASS: + path = CASES_ROOT / case_id / f"{case_id}_strategy.py" + spec = importlib.util.spec_from_file_location(f"candidate_{case_id}", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + strategies[case_id] = module + yield decision, case_engine, strategies + finally: + for name in list(sys.modules): + if ( + name == "common" + or name.startswith("common.") + or name == "_typed_scenario_state_candidate" + ): + sys.modules.pop(name, None) + sys.modules.update(names) + sys.path[:] = old_path + sys.dont_write_bytecode = old_dont_write_bytecode + + +def _context( + decision, case_engine, strategies, case_id, *, account_identity_sha256=ACCOUNT_IDENTITY_SHA256 +): + plan_path = CASES_ROOT / case_id / f"{case_id}_strategy.py" + plan = case_engine.load_descriptive_case_plan(plan_path, expected_case_id=case_id) + scope = decision.DecisionScope.for_plan(plan, "f" * 64, account_identity_sha256) + auth = SyntheticAuthenticator(decision) + engine = decision.CaseIntentDecisionEngine(plan, scope, auth) + strategy = strategies[case_id] + state_class = getattr(strategy, _STRATEGY_CLASS[case_id]) + return state_class(engine, auth), auth, scope + + +def _make_event(decision, kind_name, sequence, fields, *, stream=None, when=None): + kind = decision.ObservationKind[kind_name] + domain, callback, _ = decision._POLICY[kind] + provider = domain is decision.EvidenceTrustDomain.CTP_CALLBACK + fields = dict(fields) + arrived_at = when or NOW - timedelta(seconds=4) + arrived_at_utc = arrived_at.isoformat().replace("+00:00", "Z") + is_auth = kind is decision.ObservationKind.AUTH_SUCCESS + is_login = kind is decision.ObservationKind.LOGIN_SUCCESS + is_payloadless_front = kind in { + decision.ObservationKind.FRONT_CONNECTED, + decision.ObservationKind.FRONT_DISCONNECTED, + } + if provider and (is_auth or is_payloadless_front) and "account_identity_sha256" in fields: + fields["local_account_identity_sha256"] = fields.pop("account_identity_sha256") + + # Front connectivity carries no native FrontID/SessionID/TradingDay. Those + # identity values belong to the later OnRspUserLogin response fixture. + if provider and (is_auth or is_login): + fields.setdefault("request_id", 1 if is_auth else 2) + fields.setdefault("request_generation", 1) + fields.setdefault("arrival_generation", 1) + fields.setdefault("is_last", True) + fields.setdefault("success", True) + fields.setdefault("error_id", 0) + if is_login: + fields.setdefault("provider_front_id", 7) + fields.setdefault("provider_session_id", "10007") + fields.setdefault("trading_day", "20260928") + + provider_session_id = "10007" if provider and not (is_auth or is_payloadless_front) else "" + trading_day = "20260928" if provider and not (is_auth or is_payloadless_front) else "" + provider_front_id = fields.get("provider_front_id") if is_login else None + callback_metadata = {} + if provider: + callback_metadata.update( + client_instance_id="test-native-client", + arrival_generation=1, + sequence_origin="local_sdk_callback_arrival", + timestamp_origin="local_sdk_capture_clock", + event_id_origin="local_sdk_callback_arrival", + provider_issued_event_id=False, + arrived_at_utc=arrived_at_utc, + arrived_monotonic=float(sequence), + connection_generation_origin="local_connection_generation", + ) + if is_auth or is_login: + callback_metadata.update( + request_generation=1, + request_id_origin="native_callback_argument", + request_generation_origin="local_request_generation_binding", + session_identity_origin=( + "unavailable_on_native_authentication_response" + if is_auth + else "native_login_response_fields" + ), + ) + return decision.NativeObservation( + kind=kind, + source_domain=domain, + event_id=f"{kind_name.lower()}-{sequence}", + evidence_sha256=f"{sequence:064x}", + occurred_at_utc=arrived_at_utc, + sequence=sequence, + stream_id=stream or ("provider" if provider else domain.value), + callback_name=callback, + provider_session_id=provider_session_id, + trading_day=trading_day, + provider_front_id=provider_front_id, + fields=fields, + **callback_metadata, + ) + + +def _record(state, event, *, supporting=False, configuration=False): + if configuration: + assert state.record_monitor_configuration(event) + elif supporting: + assert state.record_supporting_observation(event) + else: + assert state.record(event), state._rejected + + +def _record_provider_login_binding(decision, state): + """Seed provider session scope from its native login callback.""" + if state.CASE_ID == "O01": + for kind, sequence, fields, offset in ( + ("FRONT_CONNECTED", 1, {"gateway_key": "g1"}, 12), + ("AUTH_SUCCESS", 2, {"error_id": 0, "success": True}, 11), + ): + event = _make_event( + decision, + kind, + sequence, + { + **fields, + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + }, + when=NOW - timedelta(seconds=offset), + ) + _record(state, event) + event = _make_event( + decision, + "LOGIN_SUCCESS", + 3 if state.CASE_ID == "O01" else 1, + { + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + "error_id": 0, + "success": True, + "provider_front_id": 7, + "provider_session_id": "10007", + "trading_day": "20260928", + }, + when=NOW - timedelta(seconds=10), + ) + _record(state, event) + + +def _base_native(decision, state, case_id, *, mutation=None): + provider_seq = 0 + monitor_seq = 0 + managed_seq = 0 + + def add(kind, fields, *, when=None, stream=None, supporting=False, configuration=False): + nonlocal provider_seq, monitor_seq, managed_seq + enum_kind = decision.ObservationKind[kind] + domain = decision._POLICY[enum_kind][0] + if domain is decision.EvidenceTrustDomain.CTP_CALLBACK: + provider_seq += 1 + sequence = provider_seq + elif domain is decision.EvidenceTrustDomain.MONITOR: + monitor_seq += 1 + sequence = monitor_seq + else: + managed_seq += 1 + sequence = managed_seq + fields = dict(fields) + if domain is decision.EvidenceTrustDomain.CTP_CALLBACK: + # This is local case-scope binding metadata, not a CTP callback field. + fields.setdefault("account_identity_sha256", ACCOUNT_IDENTITY_SHA256) + fields.setdefault("connection_generation", 1) + if mutation == "generation_mismatch" and kind == "ORDER_ACCEPTED": + fields["connection_generation"] = 2 + if mutation == "native_instrument_mismatch" and kind == "ORDER_ACCEPTED": + fields["instrument_id"] = "cu2710" + if mutation == "stale_native_event" and kind == "ORDER_ACCEPTED": + when = NOW - timedelta(days=3650) + event = _make_event( + decision, + kind, + sequence, + fields, + stream=stream, + when=when or NOW - timedelta(seconds=3), + ) + _record(state, event, supporting=supporting, configuration=configuration) + return event + + def add_session_and_tick(): + front = add( + "FRONT_CONNECTED", {"gateway_key": "g1"}, when=NOW - timedelta(seconds=12) + ) + assert front.provider_front_id is None + assert front.provider_session_id == "" + assert front.trading_day == "" + assert not any( + front.fields.get(name) not in (None, "") + for name in ("provider_front_id", "provider_session_id", "trading_day") + ) + add("AUTH_SUCCESS", {"error_id": 0, "success": True}, when=NOW - timedelta(seconds=11)) + add( + "LOGIN_SUCCESS", + { + "error_id": 0, + "success": True, + "provider_front_id": 7, + "provider_session_id": "10007", + "trading_day": "20260928", + }, + when=NOW - timedelta(seconds=10), + ) + add( + "MARKET_SUBSCRIPTION_ACK", + {"instrument_id": "rb2710", "error_id": 0, "success": True}, + when=NOW - timedelta(seconds=8), + ) + add( + "MARKET_TICK", + { + "instrument_id": "rb2710", + "bid": "100", + "ask": "101", + "last": "100.5", + "price_tick": "1", + }, + when=NOW - timedelta(seconds=1.5), + ) + + if case_id in {"TH04", "TH06"}: + # These specs admit login only as a read-only identity prerequisite; + # front/auth callbacks do not substitute for native login identity. + add( + "LOGIN_SUCCESS", + { + "error_id": 0, + "success": True, + "provider_front_id": 7, + "provider_session_id": "10007", + "trading_day": "20260928", + }, + when=NOW - timedelta(seconds=12), + ) + + if case_id in {"O01", "O02", "TH02"}: + add_session_and_tick() + if case_id == "O02": + add( + "POSITION_QUERY", + { + "query_id": "q-pos", + "complete": True, + "instrument_id": "rb2710", + "phase": "baseline", + "closeable_quantity": "1", + }, + when=NOW - timedelta(seconds=1.8), + ) + if case_id in {"O01", "O02", "TH06"}: + accepted_fields = { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + } + add( + "ORDER_ACCEPTED", + accepted_fields, + when=NOW - timedelta(seconds=1), + supporting=True, + ) + if case_id == "O03": + add( + "ORDER_ACCEPTED", + { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + }, + when=NOW - timedelta(seconds=3), + ) + add( + "ORDER_QUERY", + {"query_id": "q-order", "complete": True, "open_order_refs": ("ref-1",)}, + when=NOW - timedelta(seconds=2.5), + ) + if case_id == "TH02": + add( + "ORDER_ACCEPTED", + { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + }, + when=NOW - timedelta(seconds=1), + ) + add( + "ORDER_ACCEPTED", + { + "order_ref": "ref-2", + "external_order_id": "sys-2", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + }, + when=NOW - timedelta(seconds=0.3), + ) + if case_id == "TH04": + add( + "ORDER_ACCEPTED", + { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + }, + when=NOW - timedelta(seconds=7), + supporting=True, + ) + add( + "ORDER_ACCEPTED", + { + "order_ref": "ref-2", + "external_order_id": "sys-2", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + }, + when=NOW - timedelta(seconds=5.5), + supporting=True, + ) + add( + "ORDER_CANCELED", + { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "canceled", + "remaining_quantity": "0", + "traded_quantity": "0", + }, + when=NOW - timedelta(seconds=3), + ) + return add + + +def _receipt( + state, + receipt_type, + *, + request_id, + intent_id, + action, + dispatch_state, + order_ref, + sequence, + mutation=None, +): + if state.CASE_ID == "TH04": + occurred_at = NOW - timedelta(seconds=10 - sequence * 2) + else: + occurred_at = NOW - timedelta(seconds=1.4 if sequence == 1 else 0.6) + row = receipt_type( + request_id=request_id, + intent_id=intent_id, + action=action, + dispatch_state=dispatch_state, + order_ref=order_ref, + instrument_id="cu2710" if mutation == "receipt_instrument_mismatch" else "rb2710", + repeat_key=f"repeat-{intent_id}-{order_ref}", + account_identity_sha256=( + "d" * 64 if mutation == "receipt_account_mismatch" else ACCOUNT_IDENTITY_SHA256 + ), + configuration_digest=CONFIG_DIGEST, + threshold=3 + if action == "cancel" and request_id.startswith("th04") + else (3 if state.CASE_ID == "TH04" else 2), + window_seconds=10.0, + occurred_at_utc=occurred_at.isoformat().replace("+00:00", "Z"), + sequence=sequence, + evidence_sha256=f"{sequence + 100:064x}", + ) + assert state.record_managed_receipt(row) + return row + + +def _complete_case(decision, case_engine, strategies, case_id, *, mutation=None): + state, _, _ = _context(decision, case_engine, strategies, case_id) + add = _base_native(decision, state, case_id, mutation=mutation) + receipt_type = importlib.import_module("_typed_scenario_state_candidate").ManagedIntentReceipt + metric = { + "O01": "repeat_order_count", + "O02": "repeat_order_count", + "O03": "repeat_cancel_count", + "TH02": "submitted_order_count", + "TH04": "combined_order_cancel_count", + "TH06": "repeat_order_count", + }[case_id] + threshold = 3 if case_id == "TH04" else 2 + add( + "MONITOR_CONFIGURATION", + { + "metric": metric, + "threshold": threshold, + "window_seconds": 10.0, + "configuration_digest": CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + }, + configuration=True, + when=NOW - timedelta(seconds=15), + ) + + if case_id in {"O01", "O02", "O03", "TH06"}: + action = {"O01": "open", "O02": "close", "O03": "cancel", "TH06": "open"}[case_id] + if case_id in {"O01", "O02", "O03"}: + add( + "ORDER_ADMISSION", + { + "case_id": case_id, + "intent_kind": action, + "intent_id": "intent-1", + "approval_ref": "approval-test", + "approval_state": "REVIEW_ONLY", + "maximum_quantity": "1", + "dispatch_permitted": False, + }, + when=NOW - timedelta(seconds=1.8), + ) + ref = "ref-1" + _receipt( + state, + receipt_type, + request_id=f"{case_id.lower()}-req-1", + intent_id="intent-1", + action=action, + dispatch_state="dispatched", + order_ref=ref, + sequence=1, + mutation=mutation, + ) + second_ref = "other-ref" if mutation == "receipt_ref" else ref + _receipt( + state, + receipt_type, + request_id=f"{case_id.lower()}-req-2", + intent_id="intent-1", + action=action, + dispatch_state="blocked_pre_dispatch", + order_ref=second_ref, + sequence=2, + mutation=mutation, + ) + repeat_key = ( + "unrelated-intent-key" if mutation == "repeat_key" else f"repeat-intent-1-{ref}" + ) + source_request_ids = ( + ("unrelated-request",) + if mutation == "repeat_source" + else (f"{case_id.lower()}-req-1", f"{case_id.lower()}-req-2") + ) + repeat_window = 11.0 if mutation == "repeat_window" else 10.0 + add( + "REPEAT_GUARD", + { + "action_kind": action, + "intent_id": "intent-1", + "repeat_key": repeat_key, + "repeat_count": 2, + "order_refs": (ref,), + "source_request_ids": source_request_ids, + "threshold": threshold, + "window_seconds": repeat_window, + "configuration_digest": CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + }, + when=NOW - timedelta(seconds=0.4), + ) + if case_id == "TH06": + add( + "MONITOR_TRIGGER", + { + "metric": metric, + "threshold": threshold, + "observed_value": 2, + "configuration_digest": "c" * 64 + if mutation == "config_digest" + else CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + "source_request_ids": ("th06-req-1", "th06-req-2"), + "source_event_ids": ("order_accepted-2",), + }, + when=NOW - timedelta(seconds=0.2), + ) + elif case_id == "TH02": + _receipt( + state, + receipt_type, + request_id="th02-req-1", + intent_id="intent-1", + action="open", + dispatch_state="dispatched", + order_ref="ref-1", + sequence=1, + mutation=mutation, + ) + _receipt( + state, + receipt_type, + request_id="th02-req-2", + intent_id="intent-2", + action="open", + dispatch_state="dispatched", + order_ref="ref-2", + sequence=2, + mutation=mutation, + ) + add( + "MONITOR_TRIGGER", + { + "metric": metric, + "threshold": threshold, + "observed_value": 2, + "configuration_digest": "c" * 64 if mutation == "config_digest" else CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + "source_request_ids": ("th02-req-1", "th02-req-2"), + "source_event_ids": ("order_accepted-6", "order_accepted-7"), + }, + when=NOW - timedelta(seconds=0.1), + ) + elif case_id == "TH04": + _receipt( + state, + receipt_type, + request_id="th04-req-1", + intent_id="intent-1", + action="open", + dispatch_state="dispatched", + order_ref="ref-1", + sequence=1, + mutation=mutation, + ) + _receipt( + state, + receipt_type, + request_id="th04-req-2", + intent_id="intent-2", + action="open", + dispatch_state="dispatched", + order_ref="ref-2", + sequence=2, + mutation=mutation, + ) + _receipt( + state, + receipt_type, + request_id="th04-req-3", + intent_id="intent-3", + action="cancel", + dispatch_state="dispatched", + order_ref="ref-1", + sequence=3, + mutation=mutation, + ) + _receipt( + state, + receipt_type, + request_id="th04-req-4", + intent_id="intent-4", + action="cancel", + dispatch_state="blocked_pre_dispatch", + order_ref="ref-1", + sequence=4, + mutation=mutation, + ) + for request_id, action, order_ref, dispatch_state in ( + ("th04-req-1", "submit", "ref-1", "dispatched"), + ("th04-req-2", "submit", "ref-2", "dispatched"), + ("th04-req-3", "cancel", "ref-1", "dispatched"), + ("th04-req-4", "cancel", "ref-1", "blocked_pre_dispatch"), + ): + event_kind = "ORDER_SUBMIT_RECEIPT" if action == "submit" else "ORDER_CANCEL_RECEIPT" + event_when = { + "th04-req-1": NOW - timedelta(seconds=8), + "th04-req-2": NOW - timedelta(seconds=6), + "th04-req-3": NOW - timedelta(seconds=4), + "th04-req-4": NOW - timedelta(seconds=2), + }[request_id] + add( + event_kind, + { + "request_id": request_id, + "action": action, + "order_ref": order_ref, + "trace_id": f"trace-{request_id}", + "dispatch_state": dispatch_state, + }, + when=event_when, + ) + add( + "MONITOR_TRIGGER", + { + "metric": metric, + "threshold": threshold, + "observed_value": 2 if mutation == "observed_count" else 3, + "configuration_digest": CONFIG_DIGEST, + "monitor_digest": MONITOR_DIGEST, + "source_request_ids": ("th04-req-1", "th04-req-2", "th04-req-3"), + "source_native_event_ids": ( + "order_accepted-2", + "order_accepted-3", + "order_canceled-4", + ), + }, + when=NOW - timedelta(seconds=0.1), + ) + result = state.evaluate(now_utc=NOW) + return state, result + + +@pytest.mark.parametrize("case_id", tuple(_STRATEGY_CLASS)) +def test_six_case_states_reach_review_only_with_correlated_typed_sources( + loaded_case_modules, case_id +): + decision, case_engine, strategies = loaded_case_modules + state, result = _complete_case(decision, case_engine, strategies, case_id) + assert result.status == "REVIEW_REQUIRED", result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.source_authenticity_verified is False + assert not result.missing_conditions + assert len(state._events) > 0 + + +@pytest.mark.parametrize( + "case_id,mutator", + ( + ("O01", "repeat_source"), + ("O02", "receipt_ref"), + ("O03", "repeat_key"), + ("TH02", "config_digest"), + ("TH04", "observed_count"), + ("TH06", "repeat_window"), + ), +) +def test_each_case_rejects_a_cross_source_or_threshold_mismatch( + loaded_case_modules, case_id, mutator +): + decision, case_engine, strategies = loaded_case_modules + state, _ = _complete_case(decision, case_engine, strategies, case_id, mutation=mutator) + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert result.certification_pass is False + assert result.dispatch_permitted is False + assert result.missing_conditions + + +@pytest.mark.parametrize( + "case_id,mutation,expected_condition", + ( + ( + "O01", + "native_instrument_mismatch", + "native_order_must_match_current_subscribed_tick_instrument", + ), + ( + "O02", + "native_instrument_mismatch", + "native_order_must_match_current_subscribed_tick_instrument", + ), + ( + "TH02", + "native_instrument_mismatch", + "native_order_must_match_current_subscribed_tick_instrument", + ), + ("O01", "stale_native_event", "provider_event_stale_at_evaluation"), + ("O03", "stale_native_event", "provider_event_stale_at_evaluation"), + ("TH04", "stale_native_event", "provider_event_stale_at_evaluation"), + ("TH06", "stale_native_event", "provider_event_stale_at_evaluation"), + ( + "O03", + "generation_mismatch", + "provider_evidence_must_share_one_connection_generation", + ), + ), +) +def test_provider_instrument_session_generation_and_freshness_are_coherent( + loaded_case_modules, case_id, mutation, expected_condition +): + decision, case_engine, strategies = loaded_case_modules + _, result = _complete_case(decision, case_engine, strategies, case_id, mutation=mutation) + assert result.status == "INCOMPLETE" + assert expected_condition in result.missing_conditions + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +@pytest.mark.parametrize("case_id", tuple(_STRATEGY_CLASS)) +def test_each_order_candidate_requires_a_scope_account_identity_binding( + loaded_case_modules, case_id +): + decision, case_engine, strategies = loaded_case_modules + state, _, _ = _context(decision, case_engine, strategies, case_id, account_identity_sha256="") + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert "account_identity_scope_binding_required" in result.missing_conditions + assert result.dispatch_permitted is False + + +@pytest.mark.parametrize("case_id", tuple(_STRATEGY_CLASS)) +def test_managed_receipt_instrument_must_match_native_order(loaded_case_modules, case_id): + decision, case_engine, strategies = loaded_case_modules + _, result = _complete_case( + decision, + case_engine, + strategies, + case_id, + mutation="receipt_instrument_mismatch", + ) + assert result.status == "INCOMPLETE" + assert any( + condition.startswith("managed_receipt_instrument") + for condition in result.missing_conditions + ) + + +@pytest.mark.parametrize( + "case_id,api,account_digest", + ( + ("O01", "supporting", ""), + ("O01", "supporting", "d" * 64), + ("TH02", "required", ""), + ("TH02", "required", "d" * 64), + ), +) +def test_native_provider_observation_rejects_missing_or_wrong_scope_account_digest( + loaded_case_modules, case_id, api, account_digest +): + decision, case_engine, strategies = loaded_case_modules + state, _, _ = _context(decision, case_engine, strategies, case_id) + if api == "supporting": + _record_provider_login_binding(decision, state) + event = _make_event( + decision, + "ORDER_ACCEPTED", + 4 if api == "supporting" else 1, + { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "connection_generation": 1, + "account_identity_sha256": account_digest, + }, + when=NOW - timedelta(seconds=1), + ) + if api == "supporting": + assert state.record_supporting_observation(event) is False + else: + assert state.record(event) is False + assert event not in state._events + expected = ( + "local_account_scope_fingerprint_required" + if not account_digest + else "local_account_scope_fingerprint_mismatch" + ) + assert any(expected in item for item in state._rejected) + assert state.evaluate(now_utc=NOW).status != "REVIEW_REQUIRED" + + +def test_cross_account_native_fact_blocks_a_previously_reviewable_candidate( + loaded_case_modules, +): + decision, case_engine, strategies = loaded_case_modules + state, result = _complete_case(decision, case_engine, strategies, "O01") + assert result.status == "REVIEW_REQUIRED" + + wrong_account = _make_event( + decision, + "ORDER_ACCEPTED", + 7, + { + "order_ref": "ref-cross-account", + "external_order_id": "sys-cross-account", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + "connection_generation": 1, + "account_identity_sha256": "d" * 64, + }, + when=NOW - timedelta(seconds=0.5), + ) + assert state.record_supporting_observation(wrong_account) is False + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert result.certification_pass is False + assert result.dispatch_permitted is False + + +def test_rejected_monitor_event_poisons_a_previously_reviewable_candidate( + loaded_case_modules, +): + decision, case_engine, strategies = loaded_case_modules + state, result = _complete_case(decision, case_engine, strategies, "O01") + assert result.status == "REVIEW_REQUIRED" + + invalid_configuration = _make_event( + decision, + "MONITOR_CONFIGURATION", + 1000, + {"metric": "repeat_order_count"}, + ) + with pytest.raises(ValueError, match="monitor configuration is missing typed"): + state.record_monitor_configuration(invalid_configuration) + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert "rejected_evidence_present" in result.missing_conditions + + +@pytest.mark.parametrize( + "api_name,exception_type,expected_message", + ( + ("record", ValueError, "event kind is not a native decision-engine observation"), + ("record_supporting_observation", ValueError, "case does not permit"), + ("record_monitor_configuration", ValueError, "only monitor_configuration"), + ("record_managed_receipt", TypeError, "must use ManagedIntentReceipt"), + ), +) +def test_rejection_latch_survives_unreadable_evidence_ids( + loaded_case_modules, api_name, exception_type, expected_message +): + decision, case_engine, strategies = loaded_case_modules + state, result = _complete_case(decision, case_engine, strategies, "O01") + assert result.status == "REVIEW_REQUIRED" + + class PoisonEvent: + kind = "not_a_native_event" + + @property + def event_id(self): + raise RuntimeError("event_id must not replace the validation error") + + @property + def request_id(self): + raise RuntimeError("request_id must not replace the validation error") + + with pytest.raises(exception_type, match=expected_message): + getattr(state, api_name)(PoisonEvent()) + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert "rejected_evidence_present" in result.missing_conditions + assert any(item.startswith("unknown-evidence:rejected_") for item in state._rejected) + + +def test_cross_generation_supporting_fact_is_rejected_and_poisons_review( + loaded_case_modules, +): + decision, case_engine, strategies = loaded_case_modules + state, result = _complete_case(decision, case_engine, strategies, "O01") + assert result.status == "REVIEW_REQUIRED" + + event = _make_event( + decision, + "ORDER_ACCEPTED", + 1000, + { + "order_ref": "ref-foreign-generation", + "external_order_id": "sys-foreign-generation", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 2, + }, + ) + + assert state.record_supporting_observation(event) is False + assert event not in state._events + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert "rejected_evidence_present" in result.missing_conditions + assert any( + "provider_evidence_must_share_one_connection_generation" in item + for item in state._rejected + ) + + +@pytest.mark.parametrize("case_id", ("TH04", "TH06")) +def test_threshold_identity_requires_native_login_not_front_or_auth( + loaded_case_modules, case_id +): + decision, case_engine, strategies = loaded_case_modules + state, _, _ = _context(decision, case_engine, strategies, case_id) + front = _make_event( + decision, + "FRONT_CONNECTED", + 1, + { + "gateway_key": "g1", + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + }, + when=NOW - timedelta(seconds=3), + ) + auth = _make_event( + decision, + "AUTH_SUCCESS", + 2, + { + "error_id": 0, + "success": True, + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + }, + when=NOW - timedelta(seconds=2), + ) + for event in (front, auth): + with pytest.raises(decision.DecisionError, match="not required by case"): + state.record(event) + assert event not in state._events + + order = _make_event( + decision, + "ORDER_ACCEPTED", + 3, + { + "order_ref": "ref-unbound-login", + "external_order_id": "sys-unbound-login", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "traded_quantity": "0", + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + }, + ) + assert state.record_supporting_observation(order) is False + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert "native_provider_evidence_required" in result.missing_conditions + assert not any(event.kind.name == "LOGIN_SUCCESS" for event in state._events) + assert any( + "provider_evidence_must_share_one_connection_generation" in item + for item in state._rejected + ) + + +def test_front_auth_and_login_fixtures_use_their_native_identity_sources(loaded_case_modules): + decision, case_engine, strategies = loaded_case_modules + state, _, _ = _context(decision, case_engine, strategies, "O01") + provider_scope = { + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + } + + front = _make_event( + decision, + "FRONT_CONNECTED", + 1, + {"gateway_key": "g1", **provider_scope}, + when=NOW - timedelta(seconds=12), + ) + assert state.record(front) + assert front.provider_front_id is None + assert front.provider_session_id == "" + assert front.trading_day == "" + + auth = _make_event( + decision, + "AUTH_SUCCESS", + 2, + {"error_id": 0, "success": True, **provider_scope}, + when=NOW - timedelta(seconds=11), + ) + assert state.record(auth) + assert auth.provider_front_id is None + assert auth.provider_session_id == "" + assert auth.trading_day == "" + + login = _make_event( + decision, + "LOGIN_SUCCESS", + 3, + { + "error_id": 0, + "success": True, + "provider_front_id": 7, + "provider_session_id": "10007", + "trading_day": "20260928", + **provider_scope, + }, + when=NOW - timedelta(seconds=10), + ) + assert state.record(login) + assert login.provider_front_id == 7 + assert login.provider_session_id == "10007" + assert login.trading_day == "20260928" + assert login.session_identity_origin == "native_login_response_fields" + + +def test_auth_callback_rejects_fabricated_login_identity(loaded_case_modules): + decision, case_engine, strategies = loaded_case_modules + state, result = _complete_case(decision, case_engine, strategies, "O01") + assert result.status == "REVIEW_REQUIRED" + + auth = _make_event( + decision, + "AUTH_SUCCESS", + 7, + { + "error_id": 0, + "success": True, + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + }, + when=NOW - timedelta(seconds=0.5), + ) + forged = replace( + auth, + event_id="auth-with-fabricated-login-identity", + provider_front_id=7, + provider_session_id="10007", + trading_day="20260928", + fields={ + **auth.fields, + "provider_front_id": 7, + "provider_session_id": "10007", + "trading_day": "20260928", + }, + ) + with pytest.raises(ValueError, match="OnRspAuthenticate cannot claim native login identity"): + state.record(forged) + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert "rejected_evidence_present" in result.missing_conditions + + +@pytest.mark.parametrize( + "session_id,trading_day,expected_message", + ( + ("", "20260928", "native callback requires provider session and trading day"), + ("10008", "20260928", "must match native login identity"), + ("10007", "20260929", "must match native login identity"), + ), +) +def test_followup_callback_requires_the_native_login_scope( + loaded_case_modules, session_id, trading_day, expected_message +): + decision, case_engine, strategies = loaded_case_modules + state, result = _complete_case(decision, case_engine, strategies, "O01") + assert result.status == "REVIEW_REQUIRED" + + followup = _make_event( + decision, + "MARKET_TICK", + 7, + { + "instrument_id": "rb2710", + "bid": "100", + "ask": "101", + "last": "100.5", + "price_tick": "1", + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + "connection_generation": 1, + }, + when=NOW - timedelta(seconds=0.5), + ) + wrong_scope = replace( + followup, + event_id=f"wrong-login-scope-{session_id or 'missing'}-{trading_day}", + sequence=7, + provider_session_id=session_id, + trading_day=trading_day, + ) + with pytest.raises(ValueError, match=expected_message): + state.record(wrong_scope) + result = state.evaluate(now_utc=NOW) + assert result.status == "INCOMPLETE" + assert "rejected_evidence_present" in result.missing_conditions + + +@pytest.mark.parametrize("account_digest", ("", "d" * 64)) +def test_managed_receipt_rejects_missing_or_different_scope_account_digest( + loaded_case_modules, account_digest +): + decision, case_engine, strategies = loaded_case_modules + state, _, _ = _context(decision, case_engine, strategies, "O01") + receipt_type = importlib.import_module("_typed_scenario_state_candidate").ManagedIntentReceipt + receipt = receipt_type( + request_id="account-bound-request", + intent_id="intent-1", + action="open", + dispatch_state="dispatched", + order_ref="ref-1", + instrument_id="rb2710", + repeat_key="key-1", + account_identity_sha256=account_digest, + configuration_digest=CONFIG_DIGEST, + threshold=2, + window_seconds=10.0, + occurred_at_utc=NOW.isoformat().replace("+00:00", "Z"), + sequence=1, + evidence_sha256="d" * 64, + ) + assert state.record_managed_receipt(receipt) is False + expected = ( + "managed_receipt_account_identity_sha256_required" + if not account_digest + else "managed_receipt_account_identity_scope_mismatch" + ) + assert any(expected in item for item in state._rejected) + + +@pytest.mark.parametrize( + "case_id,api,status,remaining,expected_condition", + ( + ("O01", "supporting", "rejected", "1", "native_order_accepted_status_invalid"), + ( + "O01", + "supporting", + "working", + "-1", + "native_order_accepted_remaining_quantity_must_be_positive", + ), + ( + "O01", + "supporting", + "working", + "0", + "native_order_accepted_remaining_quantity_must_be_positive", + ), + ( + "O01", + "supporting", + "working", + "NaN", + "native_order_remaining_quantity_must_be_finite_decimal", + ), + ( + "TH02", + "required", + "working", + "Infinity", + "native_order_remaining_quantity_must_be_finite_decimal", + ), + ), +) +def test_order_accepted_semantics_reject_invalid_status_or_quantity( + loaded_case_modules, case_id, api, status, remaining, expected_condition +): + decision, case_engine, strategies = loaded_case_modules + state, _, _ = _context(decision, case_engine, strategies, case_id) + if api == "supporting": + _record_provider_login_binding(decision, state) + event = _make_event( + decision, + "ORDER_ACCEPTED", + 4 if api == "supporting" else 1, + { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": status, + "remaining_quantity": remaining, + "connection_generation": 1, + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + }, + when=NOW - timedelta(seconds=1), + ) + if api == "supporting": + assert state.record_supporting_observation(event) is False + else: + assert state.record(event) is False + assert event not in state._events + assert any(expected_condition in item for item in state._rejected) + + +def test_managed_receipt_requires_explicit_verifier_and_rejects_wrong_scope( + loaded_case_modules, +): + decision, case_engine, strategies = loaded_case_modules + state, _auth, _scope = _context(decision, case_engine, strategies, "O01") + receipt_type = importlib.import_module("_typed_scenario_state_candidate").ManagedIntentReceipt + receipt = receipt_type( + request_id="unverified", + intent_id="intent-1", + action="open", + dispatch_state="dispatched", + order_ref="ref-1", + instrument_id="rb2710", + repeat_key="key-1", + account_identity_sha256=ACCOUNT_IDENTITY_SHA256, + configuration_digest=CONFIG_DIGEST, + threshold=2, + window_seconds=10.0, + occurred_at_utc=NOW.isoformat().replace("+00:00", "Z"), + sequence=1, + evidence_sha256="c" * 64, + ) + state.authenticator = object() + assert state.record_managed_receipt(receipt) is False + + class WrongScopeAuthenticator(SyntheticAuthenticator): + def authenticate_managed_receipt(self, managed_receipt, managed_scope): + proof = super().authenticate_managed_receipt(managed_receipt, managed_scope) + return replace(proof, scope_sha256="e" * 64) + + state.authenticator = WrongScopeAuthenticator(decision) + assert state.record_managed_receipt(receipt) is False + + class WrongAccountAuthenticator(SyntheticAuthenticator): + def authenticate_managed_receipt(self, managed_receipt, managed_scope): + proof = super().authenticate_managed_receipt(managed_receipt, managed_scope) + return replace(proof, account_identity_sha256="e" * 64) + + state.authenticator = WrongAccountAuthenticator(decision) + assert state.record_managed_receipt(receipt) is False + + +def test_native_authentication_proof_must_bind_scope_account_identity(loaded_case_modules): + decision, case_engine, strategies = loaded_case_modules + state, _, _ = _context(decision, case_engine, strategies, "O01") + _record_provider_login_binding(decision, state) + event = _make_event( + decision, + "ORDER_ACCEPTED", + 4, + { + "order_ref": "ref-1", + "external_order_id": "sys-1", + "instrument_id": "rb2710", + "status": "working", + "remaining_quantity": "1", + "connection_generation": 1, + "account_identity_sha256": ACCOUNT_IDENTITY_SHA256, + }, + when=NOW - timedelta(seconds=1), + ) + + class WrongAccountAuthenticator(SyntheticAuthenticator): + def authenticate(self, native_event, scope): + proof = super().authenticate(native_event, scope) + return replace(proof, account_identity_sha256="e" * 64) + + state.authenticator = WrongAccountAuthenticator(decision) + assert state.record_supporting_observation(event) is False + assert event not in state._events + assert any("supporting_native_auth_denied" in item for item in state._rejected) diff --git a/tests/unit/notifications/test_rate_limit_retry.py b/tests/unit/notifications/test_rate_limit_retry.py index cca3015a..8d9ab6f7 100644 --- a/tests/unit/notifications/test_rate_limit_retry.py +++ b/tests/unit/notifications/test_rate_limit_retry.py @@ -1,6 +1,7 @@ """AC32-13 / AC32-14: local rate limiting, retry policy and flush delivery.""" import time +from types import SimpleNamespace import backtrader as bt from backtrader.notifications import core as notify_core @@ -217,13 +218,26 @@ def test_dedup_requires_an_explicit_cooldown(notifier_env): assert len(transport.requests) == 3 -def test_dedup_window_expires(notifier_env): +def test_dedup_window_expires(notifier_env, monkeypatch): """After the cooldown elapses the key is delivered again.""" + clock = {"now": 0.0} + monkeypatch.setattr( + notify_core, + "time", + SimpleNamespace(monotonic=lambda: clock["now"], sleep=time.sleep), + ) _, transport, _ = notifier_env([{"channel": "ntfy", "topic": "t"}], dedup_cooldown=0.05) - bt.send_message("first", dedup_key="k", wait=True) + first = bt.send_message("first", dedup_key="k", wait=True) + assert first.outcomes[0].ok is True + assert len(transport.requests) == 1 + + # Stay 1 ms inside the window, then advance 1 ms beyond its expiry. + clock["now"] = 0.05 - 0.001 suppressed = bt.send_message("second", dedup_key="k", wait=True) assert suppressed.outcomes[0].error_category == "deduped" - time.sleep(0.06) + assert len(transport.requests) == 1 + + clock["now"] = 0.05 + 0.001 delivered = bt.send_message("third", dedup_key="k", wait=True) assert delivered.outcomes[0].ok is True assert len(transport.requests) == 2 diff --git a/tests/unit/notifications/test_session_channels.py b/tests/unit/notifications/test_session_channels.py index 358d679a..ef7823d1 100644 --- a/tests/unit/notifications/test_session_channels.py +++ b/tests/unit/notifications/test_session_channels.py @@ -8,7 +8,10 @@ import json import os +from pathlib import Path import stat +import subprocess +import tempfile import pytest @@ -18,6 +21,16 @@ from .conftest import FakeTransport, json_response +@pytest.fixture +def anchor_dir(tmp_path): + """Use a path beneath the user profile on Windows, avoiding AppData ACLs.""" + if os.name != "nt": + yield tmp_path + return + with tempfile.TemporaryDirectory(dir=os.environ["USERPROFILE"]) as directory: + yield Path(directory) + + def _clawbot(*, context_token=None, transport=None, **options): """Configure a ClawBot channel and return its transport.""" transport = transport if transport is not None else FakeTransport() @@ -28,6 +41,27 @@ def _clawbot(*, context_token=None, transport=None, **options): return transport +def _assert_windows_owner_only_acl(path, grant): + """Check the Windows ACL rather than POSIX mode bits. + + Windows ``stat`` mode bits only describe the read-only attribute. Anchor + credential protection is therefore validated through the native ACL that + ``persist_anchor`` installs. + """ + + result = subprocess.run( + ["icacls", str(path)], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + entries = [line.strip() for line in result.stdout.splitlines() if ":(" in line] + assert len(entries) == 1, result.stdout + assert os.environ["USERNAME"].casefold() in entries[0].casefold() + assert grant in entries[0].replace(" ", "") + + def test_clawbot_unbound_reports_not_bound_without_network(): """An unbound ClawBot fails fast and never touches the network.""" transport = _clawbot() @@ -133,17 +167,57 @@ def test_clawbot_poll_classifies_transport_failure(): assert bt.poll_clawbot_once() == 0 -def test_anchor_persistence_uses_owner_only_permissions(tmp_path): - """Anchor files are written with mode 0600 under a 0700 directory.""" - path = tmp_path / "nested" / "wechat_clawbot.json" +def test_anchor_persistence_uses_owner_only_permissions(anchor_dir): + """Anchor files have an owner-only POSIX mode or Windows ACL.""" + path = anchor_dir / "nested" / "wechat_clawbot.json" notify_session.persist_anchor(str(path), {"bot_token": "t", "to_user_id": "u"}) assert path.exists() - assert stat.S_IMODE(path.stat().st_mode) == 0o600 - assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + if os.name == "nt": + _assert_windows_owner_only_acl(path, "(F)") + _assert_windows_owner_only_acl(path.parent, "(F)") + else: + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 assert notify_session.load_anchor(str(path))["bot_token"] == "t" -def test_bind_clawbot_runs_the_qr_flow_and_persists(tmp_path): +@pytest.mark.skipif(os.name != "nt", reason="Windows same-handle update contract") +def test_anchor_in_place_update_is_visible_through_old_handle(): + """Windows updates the opened file object, so existing handles see new bytes.""" + from backtrader.notifications import _windows_anchor + + with tempfile.TemporaryDirectory(dir=os.environ["USERPROFILE"]) as temp_root: + path = Path(temp_root) / "nested" / "anchor.json" + old = {"bot_token": "old-token", "to_user_id": "old-user"} + new = {"bot_token": "new-token", "to_user_id": "new-user"} + notify_session.persist_anchor(str(path), old) + + api = _windows_anchor._WindowsAnchorApi() + token_sid = api.token_user_sid() + descriptor = api.security_descriptor(token_sid) + parent_handle, handles = _windows_anchor._open_secure_directory( + api, str(path.parent), token_sid, descriptor + ) + old_handle = None + try: + old_handle = api.open_relative_read_file(parent_handle, path.name) + old_bytes = api.read_handle_bytes(old_handle) + old_identity = api.identity(old_handle) + notify_session.persist_anchor(str(path), new) + api.seek_start(old_handle) + assert json.loads(api.read_handle_bytes(old_handle).decode("utf-8")) == new + assert api.identity(old_handle)[:3] == old_identity[:3] + assert json.loads(old_bytes.decode("utf-8")) == old + assert notify_session.load_anchor(str(path)) == new + finally: + if old_handle is not None: + api.close(old_handle) + for handle in reversed(handles): + api.close(handle) + api.free_security_descriptor(descriptor) + + +def test_bind_clawbot_runs_the_qr_flow_and_persists(anchor_dir): """The QR flow stores the anchor and applies it to the live driver.""" transport = FakeTransport( responses=[ @@ -158,11 +232,13 @@ def test_bind_clawbot_runs_the_qr_flow_and_persists(tmp_path): ) seen = [] anchor = bt.bind_wechat_clawbot( - qr_callback=seen.append, persist_path=str(tmp_path / "clawbot.json"), poll_interval=0.0 + qr_callback=seen.append, + persist_path=str(anchor_dir / "nested" / "clawbot.json"), + poll_interval=0.0, ) assert seen == ["https://example.com/qr"] assert anchor["bot_token"] == "bot-1" - assert (tmp_path / "clawbot.json").exists() + assert (anchor_dir / "nested" / "clawbot.json").exists() outcome = bt.send_message("after binding", wait=True).outcomes[0] assert outcome.ok is True @@ -322,20 +398,20 @@ def test_bind_qq_bot_requires_an_anchor(): bt.bind_qq_bot() -def test_bind_qq_bot_persists_and_applies_the_anchor(tmp_path): +def test_bind_qq_bot_persists_and_applies_the_anchor(anchor_dir): """An explicit anchor registration is persisted and applied live.""" transport = _qq() assert bt.send_message("before", wait=True).outcomes[0].error_category == "not_bound" - path = tmp_path / "qq.json" + path = anchor_dir / "nested" / "qq.json" bt.bind_qq_bot(user_openid="openid-9", persist_path=str(path)) assert path.exists() assert bt.send_message("after", wait=True).outcomes[0].ok is True assert any("/v2/users/openid-9/messages" in r.url for r in transport.requests) -def test_qq_loads_no_anchor_from_disk_automatically(tmp_path): +def test_qq_loads_no_anchor_from_disk_automatically(anchor_dir): """Anchors are never read implicitly: the default-silence rule holds.""" - path = tmp_path / "qq.json" + path = anchor_dir / "nested" / "qq.json" notify_session.persist_anchor(str(path), {"user_openid": "openid-from-disk"}) _qq() # Nothing was configured with the file, so the channel stays unbound. diff --git a/tests/unit/runtime/test_ctp_configured_front_check.py b/tests/unit/runtime/test_ctp_configured_front_check.py new file mode 100644 index 00000000..1aa90207 --- /dev/null +++ b/tests/unit/runtime/test_ctp_configured_front_check.py @@ -0,0 +1,583 @@ +"""Fake-only tests for the explicit, credential-free CTP front check.""" + +from __future__ import annotations + +import builtins +import importlib +import io +import json +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from backtrader_runtime import cli +from backtrader_runtime import ctp_configured_front_check as front_check +from backtrader_runtime.ctp_front_pair_probe import ( + CtpConfiguredFrontPair, + CtpFrontEndpointEvidence, + CtpFrontPairEvidence, + CtpFrontPairProbeError, + CtpFrontPairSelection, + CtpFrontProbeSample, +) +from backtrader_runtime.ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding +from backtrader_runtime.errors import PRESET_POLICY_VIOLATION, RuntimeConfigError +from backtrader_runtime.inventory import ( + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR, + ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID, + iteration41_runtime_registry, +) + + +MD_ONE = "tcp://private-md-one.invalid:10130" +TD_ONE = "tcp://private-td-one.invalid:10131" +MD_TWO = "tcp://private-md-two.invalid:10132" +TD_TWO = "tcp://private-td-two.invalid:10133" +ACCOUNT_MARKER = "account-value-must-not-leak" +SECRET_MARKER = "secret-value-must-not-leak" + + +class _Registration: + runtime_id = ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID + runtime_dir = ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR + profiles: tuple[()] = () + + +class _Registry: + def __init__(self, registration: _Registration, binding: object) -> None: + self.registration = registration + self.binding = binding + + def require_runtime_dir(self, _path: object) -> _Registration: + return self.registration + + def require_ctp_simnow_readonly_binding(self, _runtime_id: str) -> object: + return self.binding + + +def _fixture(): + registration = _Registration() + binding = CtpSimNowConfigReadOnlyBinding(runtime_id=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_ID) + registry = _Registry(registration, binding) + front_pairs = ( + {"td_front": TD_ONE, "md_front": MD_ONE}, + {"td_front": TD_TWO, "md_front": MD_TWO}, + ) + private = SimpleNamespace( + instrument_id="IF2612", + exchange_id="CFFEX", + hedge_flag="1", + broker_id=ACCOUNT_MARKER, + user_id=ACCOUNT_MARKER, + password=SECRET_MARKER, + ) + effective = SimpleNamespace( + config=SimpleNamespace(strategy_dir=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR), + registration=registration, + profile=object(), + mode="simulation", + preset="sandbox", + ) + return registry, binding, registration, effective, private, front_pairs + + +def _pair_evidence( + index: int, + *, + td_front: str, + md_front: str, + md_connections: int, + td_connections: int, +) -> CtpFrontPairEvidence: + md_samples = tuple( + CtpFrontProbeSample( + connected=sample_index < md_connections, + latency_ms=1.0 if sample_index < md_connections else None, + ) + for sample_index in range(3) + ) + td_samples = tuple( + CtpFrontProbeSample( + connected=sample_index < td_connections, + latency_ms=1.0 if sample_index < td_connections else None, + ) + for sample_index in range(3) + ) + reachable = md_connections >= 2 and td_connections >= 2 + pair = CtpConfiguredFrontPair(md_front=md_front, td_front=td_front) + return CtpFrontPairEvidence( + config_index=index, + pair=pair, + md=CtpFrontEndpointEvidence(md_front, md_samples), + td=CtpFrontEndpointEvidence(td_front, td_samples), + reachable=reachable, + latency_score_ms=1.0 if reachable else None, + ) + + +def _selection(index: int = 1) -> CtpFrontPairSelection: + pairs = ( + _pair_evidence(0, td_front=TD_ONE, md_front=MD_ONE, md_connections=0, td_connections=1), + _pair_evidence(1, td_front=TD_TWO, md_front=MD_TWO, md_connections=3, td_connections=3), + ) + pair = pairs[index].pair + return CtpFrontPairSelection( + pair=pair, + config_index=index, + latency_score_ms=1.0, + evidence=pairs, + timeout_seconds=3.0, + repeated_samples=3, + ) + + +def test_majority_connected_pair_projects_reachable_without_exposing_fronts(monkeypatch): + registry, _binding, _registration, effective, _private, _pairs = ( + _install_fake_profile_gates(monkeypatch) + ) + candidate = _pair_evidence( + 1, td_front=TD_TWO, md_front=MD_TWO, md_connections=2, td_connections=3 + ) + rejected = _pair_evidence( + 0, td_front=TD_ONE, md_front=MD_ONE, md_connections=1, td_connections=3 + ) + selection = replace(_selection(1), evidence=(rejected, candidate)) + monkeypatch.setattr(front_check, "select_ctp_front_pair", lambda *_a, **_k: selection) + + result = front_check.check_configured_ctp_fronts(effective, registry) + public = result.as_public_dict() + + assert result.selected_config_index == 1 + assert public["pairs"][0]["status"] == "partial" + assert public["pairs"][1]["status"] == "reachable" + assert public["pairs"][1]["md_connected_count"] == 2 + assert "private-md" not in json.dumps(public) + + +def _install_fake_profile_gates(monkeypatch): + registry, binding, registration, effective, private, front_pairs = _fixture() + monkeypatch.setattr( + front_check, + "require_ctp_sandbox_profile_runtime", + lambda _effective, _registry: effective.profile, + ) + monkeypatch.setattr( + CtpSimNowConfigReadOnlyBinding, + "_sealed_private_config", + lambda _self, _effective, _registry: (private, registration, front_pairs), + ) + return registry, binding, registration, effective, private, front_pairs + + +def test_probe_uses_only_sealed_pairs_and_emits_value_free_counts(monkeypatch): + registry, _binding, _registration, effective, _private, pairs = _install_fake_profile_gates( + monkeypatch + ) + monkeypatch.setenv("CTP_SIMNOW_SET", "set2_7x24") + monkeypatch.setenv("CTP_FRONT_PAIR_INDEX", "0") + monkeypatch.setenv("CTP_FRONT_PAIR_SET", "set1") + monkeypatch.setenv("CTP_FRONT_PAIR_TIME", "2099-01-01T00:00:00Z") + calls: list[object] = [] + + def select(front_pairs: object, **kwargs: object) -> CtpFrontPairSelection: + calls.append((front_pairs, kwargs)) + assert front_pairs == pairs + assert kwargs == { + "timeout_seconds": 3.0, + "max_pairs": 8, + "repeated_samples": 3, + } + return _selection(1) + + monkeypatch.setattr(front_check, "select_ctp_front_pair", select) + + def reject_if_used(*_args: object, **_kwargs: object) -> None: + pytest.fail("front check must not resolve credentials") + + from backtrader_runtime import credential_resolver + + monkeypatch.setattr(credential_resolver, "resolve_runtime_credentials", reject_if_used) + original_import = importlib.import_module + + def guarded_import(name: str, *args: object, **kwargs: object): + if name.startswith("bt_api_"): + pytest.fail("front check must not import an SDK") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(importlib, "import_module", guarded_import) + from backtrader_runtime import ctp_i10_attempt_latch + + monkeypatch.setattr( + ctp_i10_attempt_latch.PersistentI10OneShotAttemptLatch, + "__init__", + lambda *_args, **_kwargs: pytest.fail("front check must not touch the I10 latch"), + ) + imported_capabilities: list[str] = [] + original_builtin_import = builtins.__import__ + + def guarded_builtin_import(name: str, *args: object, **kwargs: object): + if name.startswith("bt_api_"): + imported_capabilities.append(name) + pytest.fail("front check must not import provider SDK modules") + return original_builtin_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_builtin_import) + + result = front_check.check_configured_ctp_fronts(effective, registry) + public = result.as_public_dict() + rendered = json.dumps(public, sort_keys=True) + + assert result.succeeded is True + assert result.selected_config_index == 1 + assert [pair["config_index"] for pair in public["pairs"]] == [0, 1] + assert public["pairs"][0] == { + "config_index": 0, + "md_connected_count": 0, + "md_sample_count": 3, + "status": "partial", + "td_connected_count": 1, + "td_sample_count": 3, + } + assert public["pairs"][1]["status"] == "reachable" + assert public["credential_resolver_invoked"] is False + assert public["sdk_imported"] is False + assert public["authentication_attempted"] is False + assert public["provider_login_started"] is False + assert public["trading_writes"] == public["settlement_writes"] == 0 + assert public["order_submission_authorized"] is False + assert public["tcp_probe_only"] is True + assert not any(marker in rendered for marker in (MD_ONE, TD_ONE, MD_TWO, TD_TWO)) + assert ACCOUNT_MARKER not in rendered + assert SECRET_MARKER not in rendered + assert len(calls) == 1 + assert imported_capabilities == [] + + +def test_all_failed_pairs_return_redacted_per_index_counts(monkeypatch): + registry, _binding, _registration, effective, _private, pairs = _install_fake_profile_gates( + monkeypatch + ) + evidence = ( + _pair_evidence(0, td_front=TD_ONE, md_front=MD_ONE, md_connections=0, td_connections=0), + _pair_evidence(1, td_front=TD_TWO, md_front=MD_TWO, md_connections=1, td_connections=0), + ) + + def no_reachable_pair(_front_pairs: object, **_kwargs: object) -> CtpFrontPairSelection: + raise CtpFrontPairProbeError("no_configured_front_pair_reachable", evidence) + + monkeypatch.setattr(front_check, "select_ctp_front_pair", no_reachable_pair) + result = front_check.check_configured_ctp_fronts(effective, registry) + public = result.as_public_dict() + rendered = json.dumps(public, sort_keys=True) + + assert result.succeeded is False + assert result.status == "no_pair_reachable" + assert result.selected_config_index is None + assert [ + (pair["config_index"], pair["md_connected_count"], pair["td_connected_count"]) + for pair in public["pairs"] + ] == [ + (0, 0, 0), + (1, 1, 0), + ] + assert not any(marker in rendered for marker in (MD_ONE, TD_ONE, MD_TWO, TD_TWO)) + assert ACCOUNT_MARKER not in rendered + assert SECRET_MARKER not in rendered + assert public["trading_writes"] == public["settlement_writes"] == 0 + + +def test_success_selection_evidence_must_match_every_sealed_candidate(monkeypatch): + registry, _binding, _registration, effective, _private, _pairs = _install_fake_profile_gates( + monkeypatch + ) + invalid = _selection(1) + evidence = list(invalid.evidence) + evidence[0] = _pair_evidence( + 0, + td_front=TD_TWO, + md_front=MD_TWO, + md_connections=0, + td_connections=1, + ) + invalid = CtpFrontPairSelection( + pair=invalid.pair, + config_index=invalid.config_index, + latency_score_ms=invalid.latency_score_ms, + evidence=tuple(evidence), + timeout_seconds=invalid.timeout_seconds, + repeated_samples=invalid.repeated_samples, + ) + monkeypatch.setattr(front_check, "select_ctp_front_pair", lambda *_a, **_k: invalid) + + with pytest.raises(RuntimeConfigError): + front_check.check_configured_ctp_fronts(effective, registry) + + +def test_success_selection_rejects_forged_latency_and_nonfastest_pair(monkeypatch): + registry, _binding, _registration, effective, _private, _pairs = _install_fake_profile_gates( + monkeypatch + ) + selection = _selection(1) + forged = replace(selection, latency_score_ms=999.0) + monkeypatch.setattr(front_check, "select_ctp_front_pair", lambda *_a, **_k: forged) + with pytest.raises(RuntimeConfigError): + front_check.check_configured_ctp_fronts(effective, registry) + + faster = _pair_evidence( + 0, td_front=TD_ONE, md_front=MD_ONE, md_connections=3, td_connections=3 + ) + slower = _pair_evidence( + 1, td_front=TD_TWO, md_front=MD_TWO, md_connections=3, td_connections=3 + ) + slower = replace( + slower, + md=CtpFrontEndpointEvidence( + MD_TWO, + tuple(replace(sample, latency_ms=2.0) for sample in slower.md.samples), + ), + td=CtpFrontEndpointEvidence( + TD_TWO, + tuple(replace(sample, latency_ms=2.0) for sample in slower.td.samples), + ), + latency_score_ms=2.0, + ) + nonfastest = replace(selection, evidence=(faster, slower), latency_score_ms=2.0) + monkeypatch.setattr(front_check, "select_ctp_front_pair", lambda *_a, **_k: nonfastest) + with pytest.raises(RuntimeConfigError): + front_check.check_configured_ctp_fronts(effective, registry) + + +@pytest.mark.parametrize( + "invalid_evidence", ["single_sample", "missing_latency", "repeated_samples"] +) +def test_success_selection_rejects_incomplete_probe_samples(monkeypatch, invalid_evidence): + registry, _binding, _registration, effective, _private, _pairs = _install_fake_profile_gates( + monkeypatch + ) + selection = _selection(1) + evidence = list(selection.evidence) + selected_evidence = evidence[1] + if invalid_evidence == "single_sample": + md = CtpFrontEndpointEvidence(selected_evidence.md.front, selected_evidence.md.samples[:1]) + evidence[1] = replace(selected_evidence, md=md) + elif invalid_evidence == "missing_latency": + samples = list(selected_evidence.md.samples) + samples[0] = replace(samples[0], latency_ms=None) + md = CtpFrontEndpointEvidence(selected_evidence.md.front, tuple(samples)) + evidence[1] = replace(selected_evidence, md=md) + elif invalid_evidence == "repeated_samples": + selection = replace(selection, repeated_samples=2) + invalid = replace(selection, evidence=tuple(evidence)) + monkeypatch.setattr(front_check, "select_ctp_front_pair", lambda *_a, **_k: invalid) + + with pytest.raises(RuntimeConfigError): + front_check.check_configured_ctp_fronts(effective, registry) + + +def test_cli_returns_nonzero_and_keeps_all_failed_pair_counts(monkeypatch): + registration = _Registration() + + class _CliRegistry: + def require_runtime_dir(self, _path: object) -> _Registration: + return registration + + effective = SimpleNamespace( + mode="simulation", + preset="sandbox", + config=SimpleNamespace(strategy_dir=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR), + ) + monkeypatch.setattr(cli, "_require_config_driven_ctp_binding", lambda *_args: object()) + monkeypatch.setattr(cli, "validate_runtime_config", lambda *_args: effective) + + class _FailedCheck: + succeeded = False + + def as_public_dict(self) -> dict[str, object]: + return { + "status": "no_pair_reachable", + "pairs": [ + {"config_index": 0, "md_connected_count": 0, "td_connected_count": 0}, + {"config_index": 1, "md_connected_count": 1, "td_connected_count": 0}, + ], + } + + monkeypatch.setattr(cli, "dispatch_registered_ctp_front_check", lambda *_args: _FailedCheck()) + stdout = io.StringIO() + stderr = io.StringIO() + status = cli.main( + ["check-ctp-fronts", "--strategy-dir", str(ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR)], + registry=_CliRegistry(), # type: ignore[arg-type] + environ={"CTP_SIMNOW_SET": "set2_7x24", "CTP_FRONT_PAIR_INDEX": "0"}, + stdout=stdout, + stderr=stderr, + ) + + assert status == 2 + assert stdout.getvalue() == "" + payload = json.loads(stderr.getvalue()) + assert payload["status"] == "no_pair_reachable" + assert payload["pairs"] == [ + {"config_index": 0, "md_connected_count": 0, "td_connected_count": 0}, + {"config_index": 1, "md_connected_count": 1, "td_connected_count": 0}, + ] + + +def test_live_config_at_private_runtime_directory_is_refused_before_probe(monkeypatch): + registry, _binding, registration, effective, _private, _pairs = _install_fake_profile_gates( + monkeypatch + ) + effective.mode = "live" + effective.preset = "managed_live_direct" + calls: list[str] = [] + monkeypatch.setattr( + front_check, "select_ctp_front_pair", lambda *_a, **_k: calls.append("probe") + ) + + with pytest.raises(RuntimeConfigError): + front_check.check_configured_ctp_fronts(effective, registry) + assert calls == [] + assert registration.runtime_dir == ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR + + +def test_front_check_parser_has_no_mode_or_address_override(): + args = cli.build_parser().parse_args( + ["check-ctp-fronts", "--strategy-dir", str(ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR)] + ) + assert args.command == "check-ctp-fronts" + assert args.strategy_dir == ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR + assert not hasattr(args, "mode") + assert not hasattr(args, "preset") + assert not hasattr(args, "md_front") + assert not hasattr(args, "td_front") + assert not hasattr(args, "front_set") + + +def test_real_registry_rejects_unsealed_effective_before_probe(monkeypatch): + probes: list[str] = [] + monkeypatch.setattr( + front_check, + "select_ctp_front_pair", + lambda *_args, **_kwargs: probes.append("probe"), + ) + fake_effective = SimpleNamespace( + mode="simulation", + preset="sandbox", + config=SimpleNamespace(strategy_dir=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR), + ) + + with pytest.raises(RuntimeConfigError): + front_check.check_configured_ctp_fronts( + fake_effective, # type: ignore[arg-type] + iteration41_runtime_registry(), + ) + assert probes == [] + + +def test_cli_rejects_same_directory_live_config_before_front_probe(monkeypatch): + registration = _Registration() + + class _CliRegistry: + def require_runtime_dir(self, _path: object) -> _Registration: + return registration + + registry = _CliRegistry() + live_effective = SimpleNamespace( + mode="live", + preset="managed_live_direct", + config=SimpleNamespace(strategy_dir=ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR), + ) + validated: list[object] = [] + dispatched: list[str] = [] + monkeypatch.setattr(cli, "_require_config_driven_ctp_binding", lambda *_args: object()) + monkeypatch.setattr( + cli, + "validate_runtime_config", + lambda path, _registry: validated.append(path) or live_effective, + ) + monkeypatch.setattr( + cli, + "dispatch_registered_ctp_front_check", + lambda *_args: dispatched.append("probe") or None, + ) + monkeypatch.setattr(cli, "_operator_error_payload", lambda error, **_kwargs: error.as_dict()) + stdout = io.StringIO() + stderr = io.StringIO() + + status = cli.main( + ["check-ctp-fronts", "--strategy-dir", str(ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR)], + registry=registry, # type: ignore[arg-type] + environ={"CTP_SIMNOW_SET": "set2_7x24"}, + stdout=stdout, + stderr=stderr, + ) + error = json.loads(stderr.getvalue()) + + assert status == 2 + assert validated == [ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR] + assert dispatched == [] + assert error["reason"] == "ctp_front_check_profile_required" + assert stdout.getvalue() == "" + + +def test_cli_rejects_unregistered_directory_and_missing_config_without_probe(monkeypatch): + calls: list[str] = [] + + class _Unregistered: + def require_runtime_dir(self, _path: object) -> None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "unregistered", + field_path="strategy_dir", + reason="runtime_not_registered", + ) + + monkeypatch.setattr(cli, "_operator_error_payload", lambda error, **_kwargs: error.as_dict()) + validate_calls: list[str] = [] + monkeypatch.setattr( + cli, + "validate_runtime_config", + lambda *_args: validate_calls.append("validate") or None, + ) + monkeypatch.setattr( + cli, "dispatch_registered_ctp_front_check", lambda *_a: calls.append("probe") + ) + status = cli.main( + ["check-ctp-fronts", "--strategy-dir", "unregistered"], + registry=_Unregistered(), # type: ignore[arg-type] + environ={}, + stdout=io.StringIO(), + stderr=io.StringIO(), + ) + assert status == 2 + assert validate_calls == [] + assert calls == [] + + registration = _Registration() + + class _Registered: + def require_runtime_dir(self, _path: object) -> _Registration: + return registration + + monkeypatch.setattr(cli, "_require_config_driven_ctp_binding", lambda *_args: object()) + + def missing_config(*_args: object) -> None: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "config required", + field_path="config", + reason="config_required", + ) + + monkeypatch.setattr(cli, "validate_runtime_config", missing_config) + stderr = io.StringIO() + status = cli.main( + ["check-ctp-fronts", "--strategy-dir", str(ITERATION41_013_3_CTP_PRIVATE_RUNTIME_DIR)], + registry=_Registered(), # type: ignore[arg-type] + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + assert status == 2 + assert json.loads(stderr.getvalue())["reason"] == "config_required" + assert calls == [] diff --git a/tests/unit/runtime/test_ctp_guardian_request_journal.py b/tests/unit/runtime/test_ctp_guardian_request_journal.py new file mode 100644 index 00000000..c85280d4 --- /dev/null +++ b/tests/unit/runtime/test_ctp_guardian_request_journal.py @@ -0,0 +1,369 @@ +"""Offline contracts for the unregistered read-only guardian request journal.""" + +from __future__ import annotations + +import hashlib +import os +import sqlite3 +import tempfile +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Optional + +import pytest + +from backtrader_runtime import ctp_guardian_request_journal as journal_module +from backtrader_runtime.ctp_guardian_request_journal import ( + GUARDIAN_FIXED_OPERATION, + GUARDIAN_REQUEST_BUDGET_NS, + GUARDIAN_REQUEST_DATABASE_NAME, + GUARDIAN_REQUEST_TRADING_CAPABILITIES, + GUARDIAN_REQUEST_WRITE_AUTHORIZED, + GuardianRequestJournal, + GuardianRequestJournalError, +) + + +@pytest.fixture +def private_state_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + if os.name == "nt": + state_dir = Path(tempfile.mkdtemp(prefix="guardian-journal-", dir=str(Path.home()))) + request.addfinalizer( + lambda: __import__("shutil").rmtree(str(state_dir), ignore_errors=True) + ) + from backtrader_runtime.ctp_simnow_signed_review import _protect_windows_path_acl + + _protect_windows_path_acl(state_dir, is_directory=True) + else: + state_dir = tmp_path / "private-guardian-state" + state_dir.mkdir() + state_dir.chmod(0o700) + return state_dir + + +def _accepted( + journal: GuardianRequestJournal, + request_id: str = "a" * 32, + *, + t0_ns: Optional[int] = None, +) -> None: + if t0_ns is None: + t0_ns = journal_module.time.monotonic_ns() + accepted = journal.persist_accepted( + request_id, + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=t0_ns + GUARDIAN_REQUEST_BUDGET_NS, + ) + assert accepted is True + + +def test_acceptance_is_durable_and_read_back_before_service_can_start_worker( + private_state_dir: Path, +) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + t0_ns = journal_module.time.monotonic_ns() + + assert ( + journal.persist_accepted( + "a" * 32, + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=t0_ns + GUARDIAN_REQUEST_BUDGET_NS, + ) + is True + ) + + record = journal.get_request("a" * 32) + assert record is not None + assert record.identity == "account-a" + assert record.operation == GUARDIAN_FIXED_OPERATION == "ctp_readonly_preflight" + assert record.state == "RUNNING" + assert record.accepted_monotonic_ns == t0_ns + assert record.deadline_monotonic_ns == t0_ns + GUARDIAN_REQUEST_BUDGET_NS + assert journal.has_blocker() is True + assert GUARDIAN_REQUEST_WRITE_AUTHORIZED is False + assert GUARDIAN_REQUEST_TRADING_CAPABILITIES == () + + +def test_duplicate_request_id_never_creates_a_second_acceptance( + private_state_dir: Path, +) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + _accepted(journal) + original = journal.get_request("a" * 32) + + with pytest.raises(GuardianRequestJournalError, match="guardian_request_id_duplicate"): + _accepted(journal) + + assert journal.get_request("a" * 32) == original + + +def test_concurrent_duplicate_request_id_has_one_durable_acceptance( + private_state_dir: Path, +) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + t0_ns = journal_module.time.monotonic_ns() + barrier = threading.Barrier(2) + + def attempt() -> object: + barrier.wait() + try: + return journal.persist_accepted( + "a" * 32, + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=t0_ns + GUARDIAN_REQUEST_BUDGET_NS, + ) + except GuardianRequestJournalError as exc: + return exc.reason + + with ThreadPoolExecutor(max_workers=2) as pool: + results = tuple(pool.map(lambda _index: attempt(), range(2))) + + assert sorted(results, key=str) == [True, "guardian_request_id_duplicate"] + record = journal.get_request("a" * 32) + assert record is not None and record.state == "RUNNING" + + +def test_acceptance_readback_failure_keeps_a_durable_blocker( + private_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + original_connect = sqlite3.connect + connect_calls = [0] + + def fail_readback(*args: object, **kwargs: object) -> sqlite3.Connection: + connect_calls[0] += 1 + if connect_calls[0] == 2: + raise PermissionError("injected read-back failure") + return original_connect(*args, **kwargs) + + monkeypatch.setattr(journal_module.sqlite3, "connect", fail_readback) + t0_ns = journal_module.time.monotonic_ns() + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_unavailable"): + journal.persist_accepted( + "a" * 32, + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=t0_ns + GUARDIAN_REQUEST_BUDGET_NS, + ) + monkeypatch.setattr(journal_module.sqlite3, "connect", original_connect) + + assert journal.has_blocker() is True + journal.close() + restarted = GuardianRequestJournal(private_state_dir, "account-a") + record = restarted.get_request("a" * 32) + assert record is not None and record.state == "UNKNOWN" + + +def test_restart_turns_in_flight_request_unknown_and_late_receipt_cannot_upgrade_it( + private_state_dir: Path, +) -> None: + first = GuardianRequestJournal(private_state_dir, "account-a") + _accepted(first) + first.close() + + restarted = GuardianRequestJournal(private_state_dir, "account-a") + recovered = restarted.get_request("a" * 32) + assert recovered is not None + assert recovered.state == "UNKNOWN" + assert recovered.terminal_reason == "restart_recovery" + assert recovered.receipt_digest is not None + assert len(recovered.receipt_digest) == 64 + assert restarted.has_blocker() is True + + with pytest.raises(GuardianRequestJournalError, match="guardian_request_unknown_terminal"): + restarted.record_observed("a" * 32, hashlib.sha256(b"late").hexdigest()) + with pytest.raises(GuardianRequestJournalError, match="guardian_request_unknown_blocks"): + _accepted(restarted, "b" * 32) + assert restarted.get_request("a" * 32) == recovered + + +def test_explicit_timeout_and_late_completion_keep_unknown_terminal( + private_state_dir: Path, +) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + _accepted(journal) + + unknown = journal.mark_unknown("a" * 32, "transport_uncertain") + late_digest = hashlib.sha256(b"verified-looking-late-receipt").hexdigest() + with pytest.raises(GuardianRequestJournalError, match="guardian_request_unknown_terminal"): + journal.record_observed("a" * 32, late_digest) + + assert journal.get_request("a" * 32) == unknown + assert unknown.state == "UNKNOWN" + assert journal.has_blocker() is True + + +def test_deadline_expiry_persists_unknown_and_late_completion_is_rejected( + private_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + t0_ns = 10_000_000_000 + fake_now = [t0_ns + 1, t0_ns + 2] + + monkeypatch.setattr(journal_module.time, "monotonic_ns", lambda: fake_now[0]) + assert ( + journal.persist_accepted( + "a" * 32, + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=t0_ns + GUARDIAN_REQUEST_BUDGET_NS, + ) + is True + ) + + fake_now[0] = t0_ns + GUARDIAN_REQUEST_BUDGET_NS + with pytest.raises(GuardianRequestJournalError, match="guardian_request_deadline_expired"): + journal.record_observed("a" * 32, hashlib.sha256(b"late").hexdigest()) + + record = journal.get_request("a" * 32) + assert record is not None and record.state == "UNKNOWN" + assert record.terminal_reason == "deadline_expired" + with pytest.raises(GuardianRequestJournalError, match="guardian_request_unknown_terminal"): + journal.record_observed("a" * 32, hashlib.sha256(b"even-later").hexdigest()) + + +def test_commit_readback_crossing_deadline_downgrades_observation_to_unknown( + private_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + t0_ns = 20_000_000_000 + fake_now = [t0_ns + 1] + monkeypatch.setattr(journal_module.time, "monotonic_ns", lambda: fake_now[0]) + assert ( + journal.persist_accepted( + "a" * 32, + service_t0_monotonic_ns=t0_ns, + deadline_monotonic_ns=t0_ns + GUARDIAN_REQUEST_BUDGET_NS, + ) + is True + ) + + deadline_ns = t0_ns + GUARDIAN_REQUEST_BUDGET_NS + # record_observed samples just before D; the post-commit check sees D+1. + calls = iter((deadline_ns - 1, deadline_ns + 1, deadline_ns + 2)) + monkeypatch.setattr(journal_module.time, "monotonic_ns", lambda: next(calls)) + with pytest.raises(GuardianRequestJournalError, match="guardian_request_deadline_expired"): + journal.record_observed("a" * 32, hashlib.sha256(b"candidate").hexdigest()) + + record = journal.get_request("a" * 32) + assert record is not None + assert record.state == "UNKNOWN" + assert record.terminal_reason == "deadline_expired" + + +def test_observed_receipt_is_terminal_across_restart(private_state_dir: Path) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + _accepted(journal) + digest = hashlib.sha256(b"externally-verified-receipt").hexdigest() + + observed = journal.record_observed("a" * 32, digest) + assert observed.state == "OBSERVED" + assert observed.receipt_digest == digest + journal.close() + + reopened = GuardianRequestJournal(private_state_dir, "account-a") + assert reopened.get_request("a" * 32) == observed + assert reopened.has_blocker() is False + + +def test_database_identity_is_fixed_and_mismatch_fails_closed(private_state_dir: Path) -> None: + journal = GuardianRequestJournal(private_state_dir, "account-a") + _accepted(journal) + + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_identity_mismatch"): + GuardianRequestJournal(private_state_dir, "account-b") + + assert journal.get_request("a" * 32) is not None + + +def test_unsafe_configuration_and_nonfixed_deadline_fail_closed( + tmp_path: Path, private_state_dir: Path +) -> None: + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_config_invalid"): + GuardianRequestJournal(Path("relative-state"), "account-a") + + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_identity_invalid"): + GuardianRequestJournal(private_state_dir, "account/a") + + journal = GuardianRequestJournal(private_state_dir, "account-a") + with pytest.raises(GuardianRequestJournalError, match="guardian_request_id_invalid"): + journal.persist_accepted( + "not-a-uuid", + service_t0_monotonic_ns=1, + deadline_monotonic_ns=1 + GUARDIAN_REQUEST_BUDGET_NS, + ) + with pytest.raises(GuardianRequestJournalError, match="guardian_request_deadline_invalid"): + journal.persist_accepted( + "a" * 32, + service_t0_monotonic_ns=1, + deadline_monotonic_ns=1 + GUARDIAN_REQUEST_BUDGET_NS - 1, + ) + + if os.name != "nt": + shared_dir = tmp_path / "shared-state" + shared_dir.mkdir() + shared_dir.chmod(0o755) + with pytest.raises( + GuardianRequestJournalError, match="guardian_journal_permissions_invalid" + ): + GuardianRequestJournal(shared_dir, "account-a") + + +def test_corrupt_database_or_database_open_failure_is_never_treated_as_empty( + private_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + database = private_state_dir / GUARDIAN_REQUEST_DATABASE_NAME + database.write_bytes(b"not a sqlite database") + if os.name != "nt": + database.chmod(0o600) + else: + from backtrader_runtime.ctp_simnow_signed_review import _protect_windows_path_acl + + _protect_windows_path_acl(database, is_directory=False) + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_unavailable"): + GuardianRequestJournal(private_state_dir, "account-a") + + database.unlink() + journal = GuardianRequestJournal(private_state_dir, "account-a") + + def denied_connect(*args: object, **kwargs: object) -> sqlite3.Connection: + raise PermissionError("injected database open failure") + + monkeypatch.setattr(journal_module.sqlite3, "connect", denied_connect) + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_unavailable"): + journal.has_blocker() + + +def test_database_acl_verifier_failure_fails_closed_on_windows( + private_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + if os.name != "nt": + pytest.skip("Windows file-handle ACL check") + journal = GuardianRequestJournal(private_state_dir, "account-a") + + def denied_acl(_entry: os.stat_result) -> None: + raise GuardianRequestJournalError("guardian_journal_permissions_invalid") + + monkeypatch.setattr(journal, "_verify_windows_private_database", denied_acl) + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_permissions_invalid"): + journal.has_blocker() + + +def test_windows_sqlite_sidecar_acl_policy_rejects_broad_or_wrong_owner() -> None: + user_sid = "S-1-5-21-10-20-30-1001" + private_entries = ( + (0, 0x10, 0x001F01FF, user_sid), + (0, 0x10, 0x001F01FF, "S-1-5-18"), + (0, 0x10, 0x001F01FF, "S-1-5-32-544"), + ) + journal_module._validate_windows_sqlite_sidecar_acl(user_sid, user_sid, private_entries) + + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_permissions_invalid"): + journal_module._validate_windows_sqlite_sidecar_acl( + user_sid, + user_sid, + private_entries + ((0, 0x10, 0x001F01FF, "S-1-1-0"),), + ) + with pytest.raises(GuardianRequestJournalError, match="guardian_journal_permissions_invalid"): + journal_module._validate_windows_sqlite_sidecar_acl( + "S-1-5-21-10-20-30-1002", user_sid, private_entries + ) diff --git a/tests/unit/runtime/test_ctp_guardian_service.py b/tests/unit/runtime/test_ctp_guardian_service.py new file mode 100644 index 00000000..bd276f60 --- /dev/null +++ b/tests/unit/runtime/test_ctp_guardian_service.py @@ -0,0 +1,369 @@ +"""Contract tests for the offline, fixed-operation guardian service core.""" + +from __future__ import annotations + +import json +import threading + +import pytest + +from backtrader_runtime.ctp_guardian_service import ( + FIXED_OPERATION, + GuardianService, + GuardianServiceError, + OperationResult, + SERVICE_OPERATION_BUDGET_SECONDS, + _make_request, + _decode_response, + request_readonly_preflight, +) + + +def _service( + *, + result=None, + clock_values=(1_000, 1_100, 1_200), + events=None, + persist=True, + verify=True, +): + times = iter(clock_values) + if result is None: + result = OperationResult(True, True, True, True, "observation_complete") + + def clock(): + if events is not None: + events.append("clock") + return next(times) + + def verify_peer(peer): + if events is not None: + events.append(("verify", peer)) + return verify + + def persist_accepted(request_id, *, service_t0_monotonic_ns, deadline_monotonic_ns): + if events is not None: + events.append(("persist", request_id, service_t0_monotonic_ns, deadline_monotonic_ns)) + return persist + + def run_fixed_operation(operation, *, deadline_monotonic_ns): + if events is not None: + events.append(("run", operation, deadline_monotonic_ns)) + return result + + service = GuardianService( + verify_peer, + persist_accepted, + run_fixed_operation, + monotonic_ns=clock, + ) + return service + + +def _response(service, *, peer="verified-local-client"): + _request_id, raw = _make_request() + return _decode_response(service.handle(raw, peer), expected_request_id=_request_id) + + +def test_request_contains_only_fixed_operation_and_request_identity(): + request_id, raw = _make_request() + request = json.loads(raw) + + assert set(request) == {"schema", "operation", "request_id"} + assert request["operation"] == FIXED_OPERATION + assert request["request_id"] == request_id + assert not any("path" in key or "argv" in key or "env" in key for key in request) + assert SERVICE_OPERATION_BUDGET_SECONDS == 300 + + +def test_operation_reason_is_a_fixed_code_not_arbitrary_text(): + with pytest.raises(GuardianServiceError, match="operation_result_invalid"): + OperationResult(True, True, True, True, "account123") + + +@pytest.mark.parametrize("timeout", [True, 0, -1, float("nan"), 301]) +def test_client_wait_timeout_is_bounded_locally(timeout): + with pytest.raises(ValueError, match="guardian_client_timeout_out_of_range"): + request_readonly_preflight(None, client_timeout_seconds=timeout) + + +def test_request_with_extra_path_or_duplicate_field_is_rejected_before_dispatch(): + events = [] + service = _service(events=events) + request_id, raw = _make_request() + request = json.loads(raw) + request["receipt_path"] = "C:/private/receipt.json" + request["budget_seconds"] = 1 + response = json.loads(service.handle(json.dumps(request).encode("ascii"), object())) + + assert response["state"] == "unknown" + assert response["reason"] == "request_fields_invalid" + assert response["request_id"] is None + assert events == [] + + duplicated = ( + b'{"schema":"backtrader_ctp_readonly_guardian_request.v1",' + b'"operation":"ctp_readonly_preflight","request_id":"' + + request_id.encode("ascii") + + b'","request_id":"' + + request_id.encode("ascii") + + b'"}' + ) + duplicate_response = json.loads(service.handle(duplicated, object())) + assert duplicate_response["reason"] == "request_duplicate_key" + assert events == [] + + +def test_identity_validation_precedes_t0_and_failed_identity_never_records_or_runs(): + events = [] + service = _service(events=events, verify=False) + result = _response(service) + + assert result.state == "unknown" + assert result.reason == "client_identity_unverified" + assert result.service_t0_monotonic_ns is None + assert events == [("verify", "verified-local-client")] + + +def test_t0_deadline_acceptance_and_runner_order_are_fixed(): + events = [] + service = _service(events=events, clock_values=(100, 200, 300)) + result = _response(service) + + assert result.state == "observed" + assert result.service_t0_monotonic_ns == 100 + expected_deadline = 100 + SERVICE_OPERATION_BUDGET_SECONDS * 1_000_000_000 + assert result.deadline_monotonic_ns == expected_deadline + assert events[0] == ("verify", "verified-local-client") + assert events[1] == "clock" + assert events[2][0] == "persist" + assert events[2][2:] == (100, expected_deadline) + assert events[3] == "clock" + assert events[4] == ("run", FIXED_OPERATION, expected_deadline) + assert events[5] == "clock" + + +def test_unconfirmed_durable_acceptance_fails_closed_before_runner(): + events = [] + service = _service(events=events, persist=False) + result = _response(service) + + assert result.state == "unknown" + assert result.reason == "accepted_record_unverified" + assert result.operation_observed is False + assert not any(event[0] == "run" for event in events if isinstance(event, tuple)) + + +def test_acceptance_that_finishes_at_deadline_does_not_dispatch(): + events = [] + deadline = SERVICE_OPERATION_BUDGET_SECONDS * 1_000_000_000 + service = _service(events=events, clock_values=(0, deadline, deadline + 1)) + result = _response(service) + + assert result.state == "unknown" + assert result.reason == "service_deadline_exceeded" + assert result.deadline_monotonic_ns == deadline + assert not any(event[0] == "run" for event in events if isinstance(event, tuple)) + + +@pytest.mark.parametrize( + "operation_result, expected_state, expected_reason, expected_job_empty", + [ + ( + OperationResult(True, None, True, True, "observation_complete"), + "unknown", + "job_empty_unverified", + None, + ), + ( + OperationResult(True, False, True, True, "observation_complete"), + "unknown", + "job_empty_unverified", + False, + ), + ( + OperationResult(False, True, True, True, "observation_missing"), + "unknown", + "observation_missing", + True, + ), + ( + OperationResult(True, True, True, True, "observation_complete"), + "observed", + "readonly_observation_complete", + True, + ), + ], +) +def test_operation_observation_and_job_empty_are_distinct_evidence( + operation_result, expected_state, expected_reason, expected_job_empty +): + result = _response(_service(result=operation_result)) + + assert result.state == expected_state + assert result.reason == expected_reason + assert result.job_empty_observed is expected_job_empty + if expected_state == "observed": + assert result.native_close_observed is True + assert result.receipt_durable is True + + +@pytest.mark.parametrize( + "operation_result, expected_reason", + [ + ( + OperationResult(True, True, None, True, "observation_complete"), + "native_close_unverified", + ), + ( + OperationResult(True, True, True, None, "observation_complete"), + "receipt_durability_unverified", + ), + ], +) +def test_observation_requires_native_close_and_durable_receipt(operation_result, expected_reason): + result = _response(_service(result=operation_result)) + + assert result.state == "unknown" + assert result.reason == expected_reason + + +def test_late_runner_result_remains_unknown_even_if_it_reports_success(): + deadline = SERVICE_OPERATION_BUDGET_SECONDS * 1_000_000_000 + service = _service( + result=OperationResult(True, True, True, True, "observation_complete"), + clock_values=(0, 1, deadline), + ) + result = _response(service) + + assert result.state == "unknown" + assert result.reason == "service_deadline_exceeded" + assert result.operation_observed is False + assert result.job_empty_observed is True + assert result.native_close_observed is True + assert result.receipt_durable is True + + +def test_client_timeout_returns_immutable_unknown_and_discards_late_response(): + release = threading.Event() + finished = threading.Event() + service = _service() + + class DelayedTransport: + def verify_server_identity(self): + return True + + def exchange(self, raw_request): + release.wait(2.0) + response = service.handle(raw_request, "verified-local-client") + finished.set() + return response + + result = request_readonly_preflight(DelayedTransport(), client_timeout_seconds=0.01) + assert result.state == "unknown" + assert result.reason == "client_timeout" + assert result.service_t0_monotonic_ns is None + + release.set() + assert finished.wait(1.0) + assert result.state == "unknown" + assert result.reason == "client_timeout" + + +def test_missing_service_and_missing_transport_fail_closed(): + result = request_readonly_preflight(None) + + assert result.state == "unknown" + assert result.reason == "guardian_service_unavailable" + assert result.operation_observed is False + assert result.job_empty_observed is None + + +def test_transport_without_server_identity_binding_fails_closed_before_exchange(): + class UnboundTransport: + called = False + + def exchange(self, raw_request): + self.called = True + return b"{}" + + transport = UnboundTransport() + result = request_readonly_preflight(transport, client_timeout_seconds=1.0) + + assert result.state == "unknown" + assert result.reason == "guardian_transport_unverified" + assert transport.called is False + + +def test_mismatched_or_invalid_service_response_is_unknown(): + class InvalidTransport: + def verify_server_identity(self): + return True + + def exchange(self, raw_request): + return json.dumps( + { + "schema": "backtrader_ctp_readonly_guardian_response.v1", + "state": "observed", + "reason": "readonly_observation_complete", + "request_id": "0" * 32, + "service_t0_monotonic_ns": 1, + "deadline_monotonic_ns": 2, + "operation_observed": True, + "job_empty_observed": True, + "native_close_observed": True, + "receipt_durable": True, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + + result = request_readonly_preflight(InvalidTransport(), client_timeout_seconds=1.0) + + assert result.state == "unknown" + assert result.reason == "guardian_response_unverified" + assert result.service_t0_monotonic_ns is None + + +def test_observed_response_requires_both_operation_and_job_evidence(): + request_id, _raw = _make_request() + invalid = { + "schema": "backtrader_ctp_readonly_guardian_response.v1", + "state": "observed", + "reason": "readonly_observation_complete", + "request_id": request_id, + "service_t0_monotonic_ns": 1, + "deadline_monotonic_ns": 2, + "operation_observed": True, + "job_empty_observed": None, + "native_close_observed": True, + "receipt_durable": None, + } + + with pytest.raises(GuardianServiceError, match="response_binding_invalid"): + _decode_response( + json.dumps(invalid, sort_keys=True, separators=(",", ":")).encode("ascii"), + expected_request_id=request_id, + ) + + +def test_observed_response_requires_the_fixed_completion_reason(): + request_id, _raw = _make_request() + invalid = { + "schema": "backtrader_ctp_readonly_guardian_response.v1", + "state": "observed", + "reason": "observation_complete", + "request_id": request_id, + "service_t0_monotonic_ns": 1, + "deadline_monotonic_ns": 2, + "operation_observed": True, + "job_empty_observed": True, + "native_close_observed": True, + "receipt_durable": True, + } + + with pytest.raises(GuardianServiceError, match="response_binding_invalid"): + _decode_response( + json.dumps(invalid, sort_keys=True, separators=(",", ":")).encode("ascii"), + expected_request_id=request_id, + ) diff --git a/tests/unit/runtime/test_ctp_preflight.py b/tests/unit/runtime/test_ctp_preflight.py new file mode 100644 index 00000000..44db2998 --- /dev/null +++ b/tests/unit/runtime/test_ctp_preflight.py @@ -0,0 +1,673 @@ +"""Zero-SDK tests for the sealed CTP SimNow read-only preflight contract.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from dataclasses import replace +from pathlib import Path +from typing import Any, Optional, Tuple + +import pytest + +import backtrader_runtime.ctp_preflight as ctp_preflight +from backtrader_runtime import ( + RegisteredRuntime, + RuntimeRegistry, + load_runtime_config, + resolve_runtime_config, +) +from backtrader_runtime.ctp_preflight import ( + CTP_PROVIDER, + REQUIRED_CTP_READ_ONLY_QUERIES, + CtpReadOnlyPreflightError, + CtpReadOnlyQuerySnapshot, + CtpReadOnlySessionIdentity, + run_ctp_simnow_readonly_preflight, +) +from backtrader_runtime.policy import MANAGED_WRITE_CAPABILITIES +from backtrader_runtime.provider_deployment import ( + ACTIVE_RECEIPT_STATUS, + PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION, + ProviderDeploymentRegistration, +) +from backtrader_runtime.provider_preflight import ProviderSessionPreflightRegistration +from backtrader_runtime.test_execution_profile import ( + TEST_EXECUTION_PROFILE_SCHEMA_VERSION, + TestExecutionPreflightContext as _TestExecutionPreflightContext, +) + + +NOW = 1_700_200_000.0 +SECRET_REF = "os_secret_store:iteration41.ctp.simnow" +CAPABILITY_MODULES = ( + "bt_api_ctp", + "bt_api_execution", + "bt_api_monitor", + "bt_api_py", + "bt_api_risk", +) + + +def test_public_ctp_identity_omits_reversible_account_hash() -> None: + account_hash = hashlib.sha256(b"9999:synthetic-user").hexdigest() + identity = CtpReadOnlySessionIdentity( + provider="ctp", + environment="simnow_set1", + account_fingerprint_sha256=account_hash, + trading_day="20260921", + connection_generation=1, + ) + + public = json.dumps(identity.as_public_dict()) + assert account_hash not in public + assert account_hash not in repr(identity) + assert account_hash not in repr(_snapshot(identity)) + assert "synthetic-user" not in public + assert identity.as_public_dict()["account_scope"] == "redacted" + + +@pytest.fixture(autouse=True) +def ctp_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """Control the actual wall-clock lookup used by every validation layer.""" + + clock = [NOW] + monkeypatch.setattr(ctp_preflight.time, "time", lambda: clock[0]) + return clock + + +class _AcceptingReceiptVerifier: + def verify(self, receipt, canonical_payload: bytes) -> bool: + del receipt + return bool(canonical_payload) + + +class _AcceptingProfileVerifier: + def verify(self, profile, canonical_payload: bytes) -> bool: + del profile + return bool(canonical_payload) + + +def _write_config(runtime_dir: Path) -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "config.yaml").write_text( + """config_schema_version: 4 +strategy: + id: example.ctp +runtime: + mode: simulation + preset: sandbox +parameters: {{}} +secrets_ref: {secret_ref} +""".format( + secret_ref=SECRET_REF + ), + encoding="utf-8", + ) + + +def _sealed_inputs(runtime_dir: Path) -> dict[str, Any]: + _write_config(runtime_dir) + registry = RuntimeRegistry( + ( + RegisteredRuntime( + runtime_dir=runtime_dir, + runtime_id="example.ctp.simnow", + strategy_id="example.ctp", + allowed_presets=("sandbox",), + allowed_secrets_refs=(SECRET_REF,), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + sandbox_write_policy="receipt_required", + approval_receipt_digest="a" * 64, + capability_modules=CAPABILITY_MODULES, + ), + ), + registry_id="iteration41.ctp-preflight-test", + ) + effective = resolve_runtime_config( + load_runtime_config(runtime_dir, registry=registry), registry + ) + deployment = ProviderDeploymentRegistration( + registration_id="iteration41.ctp.simnow-readonly-preflight", + runtime_id=effective.registration.runtime_id, + strategy_id=effective.strategy_id, + provider=CTP_PROVIDER, + environment="simnow_set2", + allowed_secrets_refs=(SECRET_REF,), + approval_receipt_digest="a" * 64, + account_fingerprint_sha256="b" * 64, + artifact_sha256="c" * 64, + effective_config_digest=effective.effective_digest, + capability_receipt_digest="d" * 64, + required_capability_modules=tuple(sorted(CAPABILITY_MODULES)), + ) + provider_registration = ProviderSessionPreflightRegistration( + deployment=deployment, + mode="simulation", + preset="sandbox", + account_access="sandbox_direct_provider", + ) + provider_receipt = { + "account_fingerprint_sha256": deployment.account_fingerprint_sha256, + "approval_receipt_digest": deployment.approval_receipt_digest, + "artifact_sha256": deployment.artifact_sha256, + "capability_receipt_digest": deployment.capability_receipt_digest, + "created_at": NOW - 10.0, + "effective_config_digest": deployment.effective_config_digest, + "environment": deployment.environment, + "expires_at": NOW + 60.0, + "provider": deployment.provider, + "receipt_id": "ctp-simnow-receipt-1", + "registration_id": deployment.registration_id, + "required_capability_modules": list(deployment.required_capability_modules), + "revoked_at": None, + "runtime_id": deployment.runtime_id, + "schema_version": PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION, + "secrets_ref": SECRET_REF, + "status": ACTIVE_RECEIPT_STATUS, + "strategy_id": deployment.strategy_id, + } + test_profile = { + "account_fingerprint_sha256": deployment.account_fingerprint_sha256, + "approval_receipt_digest": deployment.approval_receipt_digest, + "allowed_instruments": ["rb2401"], + "artifact_sha256": deployment.artifact_sha256, + "capability_receipt_digest": deployment.capability_receipt_digest, + "cleanup_required": True, + "created_at": NOW - 10.0, + "effective_config_digest": deployment.effective_config_digest, + "environment": "simnow", + "expires_at": NOW + 60.0, + "max_external_writes": 0, + "max_quantity": "1", + "profile_id": "ctp-simnow-readonly", + "provider": CTP_PROVIDER, + "reconciliation_required": True, + "schema_version": TEST_EXECUTION_PROFILE_SCHEMA_VERSION, + "valid_from": NOW - 10.0, + } + test_profile_context = _TestExecutionPreflightContext( + provider=CTP_PROVIDER, + environment="simnow", + account_fingerprint_sha256=deployment.account_fingerprint_sha256, + approval_receipt_digest=deployment.approval_receipt_digest, + effective_config_digest=deployment.effective_config_digest, + artifact_sha256=deployment.artifact_sha256, + capability_receipt_digest=deployment.capability_receipt_digest, + instrument="rb2401", + quantity="1", + requested_external_writes=0, + cleanup_ready=True, + reconciliation_ready=True, + ) + return { + "effective": effective, + "provider_receipt": provider_receipt, + "provider_receipt_verifier": _AcceptingReceiptVerifier(), + "provider_registration": provider_registration, + "registry": registry, + "test_profile": test_profile, + "test_profile_context": test_profile_context, + "test_profile_verifier": _AcceptingProfileVerifier(), + } + + +def _identity(*, generation: int = 7) -> CtpReadOnlySessionIdentity: + return CtpReadOnlySessionIdentity( + provider=CTP_PROVIDER, + environment="simnow_set2", + account_fingerprint_sha256="b" * 64, + trading_day="20260923", + connection_generation=generation, + ) + + +def _snapshot(identity: CtpReadOnlySessionIdentity) -> CtpReadOnlyQuerySnapshot: + return CtpReadOnlyQuerySnapshot.from_query_digests( + identity, + tuple( + (name, hashlib.sha256(name.encode("ascii")).hexdigest()) + for name in REQUIRED_CTP_READ_ONLY_QUERIES + ), + ) + + +def test_optional_native_certificate_digest_is_bound_to_snapshot_digest() -> None: + identity = _identity() + local = _snapshot(identity) + native = CtpReadOnlyQuerySnapshot.from_query_digests( + identity, + local.query_digests, + native_certificate_sha256="a" * 64, + rate_exchange_scopes=( + ("margin_rates", "unverified"), + ("commission_rates", "exact"), + ), + ) + assert native.native_certificate_sha256 == "a" * 64 + assert native.rate_exchange_scopes == ( + ("commission_rates", "exact"), + ("margin_rates", "unverified"), + ) + assert native.snapshot_sha256 != local.snapshot_sha256 + assert native.as_public_dict()["native_certificate_sha256"] == "a" * 64 + assert native.as_public_dict()["rate_exchange_scopes"] == native.rate_exchange_scopes + with pytest.raises(CtpReadOnlyPreflightError) as caught: + replace(native, native_certificate_sha256="b" * 64) + assert caught.value.reason == "snapshot_digest_mismatch" + with pytest.raises(CtpReadOnlyPreflightError) as scope_tampered: + replace( + native, rate_exchange_scopes=(("margin_rates", "exact"), ("commission_rates", "exact")) + ) + assert scope_tampered.value.reason == "snapshot_digest_mismatch" + + +def test_native_certificate_requires_complete_typed_rate_exchange_scopes() -> None: + identity = _identity() + digests = tuple( + (name, hashlib.sha256(name.encode("ascii")).hexdigest()) + for name in REQUIRED_CTP_READ_ONLY_QUERIES + ) + with pytest.raises(CtpReadOnlyPreflightError) as missing: + CtpReadOnlyQuerySnapshot.from_query_digests( + identity, digests, native_certificate_sha256="a" * 64 + ) + assert missing.value.reason == "missing_rate_exchange_scope" + with pytest.raises(CtpReadOnlyPreflightError) as unknown: + CtpReadOnlyQuerySnapshot.from_query_digests( + identity, + digests, + native_certificate_sha256="a" * 64, + rate_exchange_scopes=(("margin_rates", "unknown"), ("commission_rates", "exact")), + ) + assert unknown.value.reason == "invalid_rate_exchange_scope" + + +class _FakeReadOnlySession: + def __init__( + self, + identities: Tuple[CtpReadOnlySessionIdentity, ...], + snapshot: Optional[CtpReadOnlyQuerySnapshot], + *, + query_error: Optional[Exception] = None, + ) -> None: + self._identities = identities + self._snapshot = snapshot + self._query_error = query_error + self.identity_calls = 0 + self.snapshot_calls = 0 + self.close_calls = 0 + self.forbidden_calls = {"order": 0, "cancel": 0, "settlement": 0, "arm": 0} + + def read_identity(self) -> CtpReadOnlySessionIdentity: + index = min(self.identity_calls, len(self._identities) - 1) + self.identity_calls += 1 + return self._identities[index] + + def read_query_snapshot(self) -> CtpReadOnlyQuerySnapshot: + self.snapshot_calls += 1 + if self._query_error is not None: + raise self._query_error + assert self._snapshot is not None + return self._snapshot + + def close_read_only(self) -> None: + self.close_calls += 1 + + def submit_order(self) -> None: + self.forbidden_calls["order"] += 1 + + def cancel_order(self) -> None: + self.forbidden_calls["cancel"] += 1 + + def confirm_settlement(self) -> None: + self.forbidden_calls["settlement"] += 1 + + def arm_execution(self) -> None: + self.forbidden_calls["arm"] += 1 + + +class _FakeFactory: + def __init__(self, session: _FakeReadOnlySession) -> None: + self.session = session + self.open_calls = 0 + self.request = None + + def open_read_only(self, request) -> _FakeReadOnlySession: + self.open_calls += 1 + self.request = request + return self.session + + +def _run(inputs: dict[str, Any], factory: Optional[_FakeFactory] = None, **overrides: Any): + arguments = dict(inputs) + arguments["session_factory"] = factory + arguments.update(overrides) + return run_ctp_simnow_readonly_preflight(**arguments) + + +def _assert_zero_writes(session: _FakeReadOnlySession) -> None: + assert session.forbidden_calls == {"order": 0, "cancel": 0, "settlement": 0, "arm": 0} + + +def test_sealed_inputs_return_stable_zero_write_evidence(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + session = _FakeReadOnlySession((identity, identity), _snapshot(identity)) + factory = _FakeFactory(session) + + result = _run(inputs, factory) + + assert factory.open_calls == 1 + assert factory.request.provider == CTP_PROVIDER + assert factory.request.environment == "simnow_set2" + assert factory.request.account_fingerprint_sha256 == "b" * 64 + assert "b" * 64 not in repr(factory.request) + assert session.identity_calls == 2 + assert session.snapshot_calls == 1 + assert session.close_calls == 1 + _assert_zero_writes(session) + assert result.provider == CTP_PROVIDER + assert result.trading_day == "20260923" + assert result.connection_generation == 7 + assert result.test_profile_id == "ctp-simnow-readonly" + assert result.execution_authorized is False + assert result.external_writes_authorized is False + assert result.order_submission_authorized is False + assert result.cancellation_authorized is False + assert result.settlement_authorized is False + assert result.arming_authorized is False + assert "b" * 64 not in repr(result) + with pytest.raises(TypeError, match="not an execution authorization"): + bool(result) + + +def test_rejected_sealed_inputs_never_construct_a_session(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + session = _FakeReadOnlySession((identity, identity), _snapshot(identity)) + factory = _FakeFactory(session) + + with pytest.raises(CtpReadOnlyPreflightError) as default_reject: + _run(inputs, factory, provider_receipt_verifier=None) + assert default_reject.value.reason == "provider_binding_validation_failed" + assert factory.open_calls == 0 + + mismatched_context = replace(inputs["test_profile_context"], environment="sandbox") + with pytest.raises(CtpReadOnlyPreflightError) as profile_mismatch: + _run(inputs, factory, test_profile_context=mismatched_context) + assert profile_mismatch.value.reason == "test_profile_environment_mismatch" + assert factory.open_calls == 0 + _assert_zero_writes(session) + + +def test_generation_change_rejects_and_closes_without_writes(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity(generation=7) + changed_identity = replace(identity, connection_generation=8) + session = _FakeReadOnlySession((identity, changed_identity), _snapshot(identity)) + + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + + assert caught.value.reason == "session_identity_changed" + assert session.identity_calls == 2 + assert session.snapshot_calls == 1 + assert session.close_calls == 1 + _assert_zero_writes(session) + + +def test_receipt_bound_account_identity_mismatch_never_reaches_query(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + mismatched_identity = CtpReadOnlySessionIdentity( + provider=CTP_PROVIDER, + environment="simnow_set2", + account_fingerprint_sha256="f" * 64, + trading_day="20260923", + connection_generation=7, + ) + session = _FakeReadOnlySession( + (mismatched_identity, mismatched_identity), + _snapshot(mismatched_identity), + ) + + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + + assert caught.value.reason == "session_identity_mismatch" + assert session.identity_calls == 1 + assert session.snapshot_calls == 0 + assert session.close_calls == 1 + _assert_zero_writes(session) + + +@pytest.mark.parametrize("kind", ("missing", "duplicate")) +def test_incomplete_or_duplicate_query_summary_fails_closed(tmp_path: Path, kind: str) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + snapshot = _snapshot(identity) + if kind == "missing": + object.__setattr__(snapshot, "query_digests", snapshot.query_digests[:-1]) + expected_reason = "missing_required_query" + else: + object.__setattr__( + snapshot, + "query_digests", + (snapshot.query_digests[0], snapshot.query_digests[0], *snapshot.query_digests[1:]), + ) + expected_reason = "duplicate_query" + session = _FakeReadOnlySession((identity, identity), snapshot) + + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + + assert caught.value.reason == expected_reason + assert session.close_calls == 1 + _assert_zero_writes(session) + + +def test_session_exception_and_missing_factory_fail_closed(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + session = _FakeReadOnlySession( + (identity, identity), + _snapshot(identity), + query_error=RuntimeError("untrusted provider failure"), + ) + + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + assert caught.value.reason == "read_only_session_failed" + assert session.close_calls == 1 + _assert_zero_writes(session) + + with pytest.raises(CtpReadOnlyPreflightError) as no_factory: + _run(inputs) + assert no_factory.value.reason == "session_factory_required" + + +def test_public_route_rejects_caller_supplied_historical_time(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + factory = _FakeFactory(_FakeReadOnlySession((identity, identity), _snapshot(identity))) + + with pytest.raises(TypeError, match="unexpected keyword argument 'now'"): + _run(inputs, factory, now=NOW - 1_000.0) + assert factory.open_calls == 0 + + +def test_session_deadline_is_shortest_receipt_or_profile_expiry(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + inputs["test_profile"]["expires_at"] = NOW + 20.0 + identity = _identity() + factory = _FakeFactory(_FakeReadOnlySession((identity, identity), _snapshot(identity))) + + _run(inputs, factory) + + assert factory.request.valid_until == NOW + 20.0 + + +def test_verifier_expiry_prevents_session_factory_call( + tmp_path: Path, ctp_clock: list[float] +) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + factory = _FakeFactory(_FakeReadOnlySession((identity, identity), _snapshot(identity))) + + class _SlowReceiptVerifier: + def verify(self, receipt, canonical_payload: bytes) -> bool: + del receipt, canonical_payload + ctp_clock[0] = NOW + 60.0 + return True + + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, factory, provider_receipt_verifier=_SlowReceiptVerifier()) + + assert caught.value.reason == "provider_binding_validation_failed" + assert factory.open_calls == 0 + + +def test_factory_expiry_closes_session_and_rejects(tmp_path: Path, ctp_clock: list[float]) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + session = _FakeReadOnlySession((identity, identity), _snapshot(identity)) + + class _SlowFactory(_FakeFactory): + def open_read_only(self, request) -> _FakeReadOnlySession: + opened = super().open_read_only(request) + ctp_clock[0] = NOW + 60.0 + return opened + + factory = _SlowFactory(session) + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, factory) + + assert caught.value.reason == "session_deadline_expired" + assert factory.open_calls == 1 + assert session.close_calls == 1 + assert session.identity_calls == 0 + _assert_zero_writes(session) + + +def test_read_expiry_closes_session_and_rejects(tmp_path: Path, ctp_clock: list[float]) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + + class _SlowReadSession(_FakeReadOnlySession): + def read_query_snapshot(self) -> CtpReadOnlyQuerySnapshot: + snapshot = super().read_query_snapshot() + ctp_clock[0] = NOW + 60.0 + return snapshot + + session = _SlowReadSession((identity, identity), _snapshot(identity)) + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + + assert caught.value.reason == "session_deadline_expired" + assert session.close_calls == 1 + _assert_zero_writes(session) + + +def test_wall_clock_rewind_cannot_extend_session_deadline( + tmp_path: Path, ctp_clock: list[float], monkeypatch: pytest.MonkeyPatch +) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + monotonic_clock = [1_000.0] + monkeypatch.setattr(ctp_preflight.time, "monotonic", lambda: monotonic_clock[0]) + + class _RewindingSession(_FakeReadOnlySession): + def read_query_snapshot(self) -> CtpReadOnlyQuerySnapshot: + snapshot = super().read_query_snapshot() + ctp_clock[0] = NOW - 1_000.0 + monotonic_clock[0] = 1_060.0 + return snapshot + + session = _RewindingSession((identity, identity), _snapshot(identity)) + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + + assert caught.value.reason == "session_deadline_expired" + assert session.close_calls == 1 + _assert_zero_writes(session) + + +def test_close_expiry_rejects_preflight_result(tmp_path: Path, ctp_clock: list[float]) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + + class _SlowCloseSession(_FakeReadOnlySession): + def close_read_only(self) -> None: + super().close_read_only() + ctp_clock[0] = NOW + 60.0 + + session = _SlowCloseSession((identity, identity), _snapshot(identity)) + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + + assert caught.value.reason == "session_deadline_expired" + assert session.close_calls == 1 + _assert_zero_writes(session) + + +def test_read_attribute_failure_still_closes_session_once(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + + class _BrokenAttributeSession(_FakeReadOnlySession): + def __getattribute__(self, name): + if name == "read_identity": + raise RuntimeError("untrusted SDK attribute error") + return super().__getattribute__(name) + + session = _BrokenAttributeSession((identity, identity), _snapshot(identity)) + with pytest.raises(CtpReadOnlyPreflightError) as caught: + _run(inputs, _FakeFactory(session)) + + assert caught.value.reason == "read_only_session_failed" + assert session.close_calls == 1 + _assert_zero_writes(session) + + +def test_interrupted_read_still_closes_session_once(tmp_path: Path) -> None: + inputs = _sealed_inputs(tmp_path / "runtime") + identity = _identity() + + class _InterruptedSession(_FakeReadOnlySession): + def read_query_snapshot(self) -> CtpReadOnlyQuerySnapshot: + self.snapshot_calls += 1 + raise KeyboardInterrupt + + session = _InterruptedSession((identity, identity), _snapshot(identity)) + with pytest.raises(KeyboardInterrupt): + _run(inputs, _FakeFactory(session)) + + assert session.identity_calls == 1 + assert session.snapshot_calls == 1 + assert session.close_calls == 1 + _assert_zero_writes(session) + + +def test_public_route_rejects_legacy_handbuilt_observation_signature() -> None: + with pytest.raises(TypeError): + run_ctp_simnow_readonly_preflight(object(), object()) # type: ignore[call-arg] + + +def test_module_imports_no_ctp_sdk_or_backtrader_framework() -> None: + program = """ +import sys +import backtrader_runtime.ctp_preflight +blocked = ('backtrader', 'bt_api', 'bt_api_py', 'bt_api_ctp', 'bt_api_execution', 'bt_api_risk', 'bt_api_monitor') +assert not any(name == item or name.startswith(item + '.') for item in blocked for name in sys.modules) +""" + result = subprocess.run( + [sys.executable, "-c", program], + cwd=Path(__file__).resolve().parents[3], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/unit/runtime/test_ctp_simnow_managed_md_bridge.py b/tests/unit/runtime/test_ctp_simnow_managed_md_bridge.py new file mode 100644 index 00000000..b8124ab1 --- /dev/null +++ b/tests/unit/runtime/test_ctp_simnow_managed_md_bridge.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Optional + +import pytest + +from backtrader_runtime.ctp_simnow_managed_md_bridge import ( + CtpSimNowManagedMdBridgeError, + CtpSimNowManagedMdTickBridge, + ManagedCtpMdLeaseSnapshot, + ManagedCtpMdSourceIdentity, + ManagedCtpMdTick, +) +from backtrader_runtime.ctp_simnow_managed_runtime import CtpSimNowNativeReadiness + + +_CONFIG = "a" * 64 +_REGISTRATION = "b" * 64 +_ACCOUNT = "c" * 64 +_MD_FRONT = "tcp://127.0.0.1:11001" +_TD_FRONT = "tcp://127.0.0.1:12001" +_INSTRUMENT = "rb2701" +_EXCHANGE = "SHFE" + + +def _scope(): + registration = SimpleNamespace( + digest=_REGISTRATION, + account_fingerprint_sha256=_ACCOUNT, + md_front=_MD_FRONT, + td_front=_TD_FRONT, + instrument_id=_INSTRUMENT, + exchange_id=_EXCHANGE, + ) + selection = SimpleNamespace( + execution_registration=registration, + config_digest=_CONFIG, + ) + readiness = CtpSimNowNativeReadiness( + config_digest=_CONFIG, + registration_digest=_REGISTRATION, + account_fingerprint_sha256=_ACCOUNT, + md_front=_MD_FRONT, + td_front=_TD_FRONT, + td_ready=True, + md_ready=True, + ) + return selection, readiness + + +def _identity(**changes): + values = { + "config_digest": _CONFIG, + "registration_digest": _REGISTRATION, + "account_fingerprint_sha256": _ACCOUNT, + "lease_generation": 7, + "md_front": _MD_FRONT, + "td_front": _TD_FRONT, + "instrument_id": _INSTRUMENT, + "exchange_id": _EXCHANGE, + "connection_generation": 7, + "subscription_epoch": 3, + "subscription_instrument_id": _INSTRUMENT, + "subscription_acknowledged": True, + "first_tick_observed": True, + "ready_tick_sequence": 100, + "ready_event_timestamp": 1_799_999_999.0, + "ready_received_monotonic_ns": 9_000, + } + values.update(changes) + return ManagedCtpMdSourceIdentity(**values) + + +def _tick(identity=None, **changes): + values = { + "identity": identity or _identity(), + "sequence": 101, + "event_timestamp": 1_800_000_000.0, + "received_monotonic_ns": 10_000, + "instrument_id": _INSTRUMENT, + "exchange_id": _EXCHANGE, + "last_price": 3500.0, + "volume_delta": 1.0, + "bid_price": 3499.0, + "ask_price": 3501.0, + "bid_volume": 2.0, + "ask_volume": 3.0, + "trading_day": "20260928", + "action_day": "20260928", + "update_time": "09:00:00", + "update_millisec": 1, + "stale": False, + "stale_reason": "", + } + values.update(changes) + return ManagedCtpMdTick(**values) + + +class _FakeLeaseOwnedSource: + def __init__(self, identity, ticks=()): + self.identity = identity + self.ticks = list(ticks) + self.lease_snapshot_reads = 0 + self.poll_calls = 0 + self.lease_active = True + self.lease_generation = identity.lease_generation + self.after_poll = None + + @property + def lease_snapshot(self) -> ManagedCtpMdLeaseSnapshot: + self.lease_snapshot_reads += 1 + return ManagedCtpMdLeaseSnapshot( + account_fingerprint_sha256=_ACCOUNT, + lease_generation=self.lease_generation, + active=self.lease_active, + ) + + def poll_tick(self) -> Optional[ManagedCtpMdTick]: + self.poll_calls += 1 + tick = self.ticks.pop(0) if self.ticks else None + if self.after_poll is not None: + self.after_poll() + return tick + + def start(self): # pragma: no cover - must never be called + raise AssertionError("bridge must not start the source") + + def subscribe(self, *_args): # pragma: no cover - must never be called + raise AssertionError("bridge must not subscribe the source") + + def close(self): # pragma: no cover - bridge must not own client lifecycle + raise AssertionError("bridge must not close the source") + + +def _bridge(source=None, *, identity=None, readiness=None): + selection, ready = _scope() + return CtpSimNowManagedMdTickBridge( + selection, + readiness or ready, + source or _FakeLeaseOwnedSource(identity or _identity()), + ) + + +def test_bridge_yields_only_new_ticks_and_leaves_source_lifecycle_owned_elsewhere(): + source_identity = _identity() + source = _FakeLeaseOwnedSource(source_identity, [_tick(source_identity), None]) + bridge = _bridge(source) + + assert bridge.identity == source_identity + assert bridge.poll_tick() == _tick(source_identity) + assert bridge.poll_tick() is None + assert source.lease_snapshot_reads >= 5 + assert source.poll_calls == 2 + + bridge.retire() + with pytest.raises(CtpSimNowManagedMdBridgeError, match="managed_md_bridge_closed"): + bridge.poll_tick() + + +@pytest.mark.parametrize( + ("changes", "reason"), + [ + ({"md_front": "tcp://127.0.0.1:11999"}, "managed_md_source_scope_mismatch"), + ({"account_fingerprint_sha256": "d" * 64}, "managed_md_source_scope_mismatch"), + ( + {"instrument_id": "cu2701", "subscription_instrument_id": "cu2701"}, + "managed_md_source_scope_mismatch", + ), + ({"subscription_acknowledged": False}, "managed_md_source_not_ready"), + ({"first_tick_observed": False}, "managed_md_source_not_ready"), + ({"subscription_epoch": 0}, "managed_md_source_not_ready"), + ({"connection_generation": 0}, "managed_md_source_not_ready"), + ], +) +def test_bridge_rejects_wrong_or_unready_source_identity(changes, reason): + source = _FakeLeaseOwnedSource(_identity(**changes)) + with pytest.raises(CtpSimNowManagedMdBridgeError) as rejected: + _bridge(source) + assert rejected.value.reason == reason + assert source.poll_calls == 0 + + +def test_bridge_requires_matching_native_readiness(): + selection, _ = _scope() + wrong_readiness = CtpSimNowNativeReadiness( + config_digest="d" * 64, + registration_digest=_REGISTRATION, + account_fingerprint_sha256=_ACCOUNT, + md_front=_MD_FRONT, + td_front=_TD_FRONT, + td_ready=True, + md_ready=True, + ) + source = _FakeLeaseOwnedSource(_identity()) + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_readiness_scope_mismatch", + ): + CtpSimNowManagedMdTickBridge(selection, wrong_readiness, source) + assert source.poll_calls == 0 + + +def test_bridge_checks_lease_before_pinning_source_identity(): + source = _FakeLeaseOwnedSource(_identity()) + source.lease_active = False + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_account_lease_lost", + ): + _bridge(source) + assert source.lease_snapshot_reads == 1 + assert source.poll_calls == 0 + + +def test_bridge_requires_identity_and_lease_snapshot_to_share_generation(): + source = _FakeLeaseOwnedSource(_identity()) + source.lease_generation = 8 + + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_source_lease_generation_mismatch", + ): + _bridge(source) + assert source.poll_calls == 0 + + +@pytest.mark.parametrize( + ("tick", "reason"), + [ + (object(), "managed_md_tick_shape_unknown"), + (_tick(sequence=100), "managed_md_tick_stale_or_out_of_order"), + ( + _tick(identity=_identity(connection_generation=8)), + "managed_md_tick_scope_or_generation_mismatch", + ), + (_tick(event_timestamp=1_799_999_998.0), "managed_md_tick_stale_or_out_of_order"), + (_tick(received_monotonic_ns=9_000), "managed_md_tick_stale_or_out_of_order"), + (_tick(instrument_id="cu2701"), "managed_md_tick_instrument_mismatch"), + (_tick(stale=True, stale_reason="old generation"), "managed_md_tick_stale_or_out_of_order"), + (_tick(last_price=float("nan")), "managed_md_tick_fields_invalid"), + (_tick(bid_price=3502.0, ask_price=3501.0), "managed_md_tick_fields_invalid"), + ], +) +def test_bridge_poisoned_by_unknown_stale_or_invalid_tick(tick, reason): + source_identity = _identity() + bridge = _bridge(_FakeLeaseOwnedSource(source_identity, [tick])) + with pytest.raises(CtpSimNowManagedMdBridgeError) as rejected: + bridge.poll_tick() + assert rejected.value.reason == reason + with pytest.raises(CtpSimNowManagedMdBridgeError) as poisoned: + bridge.poll_tick() + assert poisoned.value.reason == reason + + +def test_bridge_rejects_generation_change_and_lost_lease(): + source_identity = _identity() + source = _FakeLeaseOwnedSource(source_identity, [_tick(source_identity)]) + bridge = _bridge(source) + source.identity = _identity(connection_generation=8) + + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_source_generation_or_scope_changed", + ): + bridge.poll_tick() + assert source.poll_calls == 0 + + source = _FakeLeaseOwnedSource(source_identity, [_tick(source_identity)]) + bridge = _bridge(source) + source.lease_active = False + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_account_lease_lost", + ): + bridge.poll_tick() + + +def test_bridge_rechecks_scope_after_poll_before_exposing_tick(): + source_identity = _identity() + source = _FakeLeaseOwnedSource(source_identity, [_tick(source_identity)]) + bridge = _bridge(source) + + def change_source_scope(): + source.identity = _identity(subscription_epoch=4) + + source.after_poll = change_source_scope + + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_source_generation_or_scope_changed", + ): + bridge.poll_tick() + + +def test_bridge_rejects_lease_loss_and_reacquisition_during_poll(): + source_identity = _identity() + source = _FakeLeaseOwnedSource(source_identity, [_tick(source_identity)]) + bridge = _bridge(source) + renewed_generation = 8 + + def lose_and_reacquire_lease(): + source.lease_active = False + source.lease_generation = renewed_generation + source.identity = _identity(lease_generation=renewed_generation) + source.lease_active = True + + source.after_poll = lose_and_reacquire_lease + + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_account_lease_changed", + ): + bridge.poll_tick() + assert source.poll_calls == 1 + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_account_lease_changed", + ): + bridge.poll_tick() + assert source.poll_calls == 1 + + +def test_bridge_rejects_boolean_only_lease_source(): + selection, readiness = _scope() + source = SimpleNamespace( + identity=_identity(), + assert_lease_active=lambda _account: True, + poll_tick=lambda: pytest.fail("boolean-only source must not be polled"), + ) + + with pytest.raises( + CtpSimNowManagedMdBridgeError, + match="managed_md_lease_snapshot_unavailable", + ): + CtpSimNowManagedMdTickBridge(selection, readiness, source) diff --git a/tests/unit/runtime/test_ctp_simnow_md_diagnostic_projection.py b/tests/unit/runtime/test_ctp_simnow_md_diagnostic_projection.py new file mode 100644 index 00000000..728c82aa --- /dev/null +++ b/tests/unit/runtime/test_ctp_simnow_md_diagnostic_projection.py @@ -0,0 +1,88 @@ +"""No-provider tests for the fixed MD diagnostic projection.""" + +from __future__ import annotations + +import json + +import pytest + +from backtrader_runtime.ctp_sdk_market_readonly import CtpSdkMarketReadOnlyError +from backtrader_runtime.ctp_simnow_md_diagnostic import _close_projection, _emit + + +@pytest.mark.parametrize( + ("close_state", "stop_returned", "join_pending"), + [ + ("native_stop_method_unknown", False, False), + ("native_stop_method_invalid", False, False), + ("native_stop_receipt_unknown", True, False), + ("native_stop_receipt_inconsistent", True, False), + ("native_stop_incomplete", True, False), + ("native_join_pending", True, True), + ("native_join_state_unknown", True, False), + ("stop_failed", False, False), + ], +) +def test_incomplete_native_close_is_never_reported_closed( + close_state: str, stop_returned: bool, join_pending: bool +) -> None: + error = CtpSdkMarketReadOnlyError( + "market_client_stop_failed", + close_state=close_state, + client_stop_returned=stop_returned, + ) + assert _close_projection(error) == { + "client_stop_returned": stop_returned, + "probe_session_closed": False, + "native_join_pending": join_pending, + "native_shutdown_uncertain": True, + } + + +def test_returned_stop_and_non_market_failure_have_distinct_projection() -> None: + error = CtpSdkMarketReadOnlyError( + "market_login_timeout", + close_state="stop_returned", + client_stop_returned=True, + ) + assert _close_projection(error) == { + "client_stop_returned": True, + "probe_session_closed": True, + "native_join_pending": False, + "native_shutdown_uncertain": False, + } + assert _close_projection(ValueError("provider message")) == { + "client_stop_returned": False, + "probe_session_closed": False, + "native_join_pending": False, + "native_shutdown_uncertain": False, + } + unreturned = CtpSdkMarketReadOnlyError( + "market_client_stop_failed", close_state="stop_returned" + ) + assert _close_projection(unreturned)["probe_session_closed"] is False + + +def test_fixed_json_callback_fields_never_include_provider_payload( + capsys: pytest.CaptureFixture[str], +) -> None: + _emit( + status="rejected", + reason="market_client_stop_failed", + stage="market_data", + login_callback_count=1, + login_callback_disposition="request_id_mismatch", + login_request_id_relation="zero", + login_response_error_status="nonzero", + client_stop_returned=True, + native_join_pending=True, + native_shutdown_uncertain=True, + ) + payload = json.loads(capsys.readouterr().out) + assert payload["login_callback_count"] == 1 + assert payload["login_callback_disposition"] == "request_id_mismatch" + assert payload["login_request_id_relation"] == "zero" + assert payload["login_response_error_status"] == "nonzero" + assert payload["trading_writes"] == payload["settlement_writes"] == 0 + assert payload["order_submission_authorized"] is False + assert not any(key in payload for key in ("broker_id", "user_id", "password", "md_front")) diff --git a/tests/unit/runtime/test_ctp_simnow_operational_window.py b/tests/unit/runtime/test_ctp_simnow_operational_window.py new file mode 100644 index 00000000..a2b46a7c --- /dev/null +++ b/tests/unit/runtime/test_ctp_simnow_operational_window.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +from dataclasses import replace +from decimal import Decimal + +import pytest + +from backtrader_runtime.ctp_f14_external_admission import ( + CtpF14ActionRequest, + CtpF14AdmissionError, + CtpF14ExternalAdmissionClaim, + CtpF14SessionBinding, + claim_f14_action, +) +from backtrader_runtime.ctp_simnow_operational_window import ( + CtpSimNowOperationalActionRequest, + CtpSimNowOperationalRiskLimits, + CtpSimNowOperationalWindowError, + CtpSimNowOperationalWindowPermit, + CtpSimNowOperationalWindowScope, + CtpSimNowQueryKind, + CtpSimNowQueryRequest, + CtpSimNowSingleQueryObservation, + require_active_simnow_operational_window_permit, + require_simnow_operational_window_permit, + require_simnow_single_query_observation, +) + + +_ACCOUNT = "a" * 64 +_CONFIG = "b" * 64 +_EFFECTIVE = "c" * 64 +_REGISTRATION = "d" * 64 +_ARTIFACTS = "e" * 64 +_SESSION = "f" * 64 +_FILTERS = "1" * 64 +_ACTION = "2" * 64 +_APPROVAL = "3" * 64 +_TARGET = "4" * 64 + + +def _scope(**changes: object) -> CtpSimNowOperationalWindowScope: + values: dict[str, object] = { + "window_id": "simnow-window-7", + "runtime_id": "iteration41.ctp.simnow-test", + "environment": "simnow", + "mode": "simulation", + "preset": "sandbox", + "account_fingerprint_sha256": _ACCOUNT, + "config_digest": _CONFIG, + "effective_digest": _EFFECTIVE, + "registration_digest": _REGISTRATION, + "artifact_set_digest": _ARTIFACTS, + "session_id": "session-7", + "trading_day": "20260925", + "connection_generation": 7, + "session_identity_digest": _SESSION, + "selected_front_pair": ("tcp://127.0.0.1:11001", "tcp://127.0.0.1:12001"), + "instrument_id": "rb2701", + "exchange_id": "SHFE", + "hedge_flag": "1", + "risk_limits": CtpSimNowOperationalRiskLimits( + max_order_quantity=1, + max_gross_position_quantity=1, + max_live_test_orders=1, + max_order_notional=Decimal("5000"), + ), + } + values.update(changes) + return CtpSimNowOperationalWindowScope(**values) # type: ignore[arg-type] + + +def _action( + *, + scope: CtpSimNowOperationalWindowScope | None = None, + action_kind: str = "SUBMIT", + **changes: object, +) -> CtpSimNowOperationalActionRequest: + values: dict[str, object] = { + "scope": scope or _scope(), + "action_kind": action_kind, + "action_id": "action-52", + "action_digest": _ACTION, + "approval_digest": _APPROVAL, + "requested_quantity": 1 if action_kind == "SUBMIT" else None, + "requested_notional": Decimal("3450") if action_kind == "SUBMIT" else None, + "target_digest": _TARGET if action_kind == "CANCEL" else None, + "target_remaining_quantity": 1 if action_kind == "CANCEL" else None, + } + values.update(changes) + return CtpSimNowOperationalActionRequest(**values) # type: ignore[arg-type] + + +def _permit( + request: CtpSimNowOperationalActionRequest, **changes: object +) -> CtpSimNowOperationalWindowPermit: + values: dict[str, object] = { + "binding": request, + "permit_id": "permit-100", + "review_authority_id": "fake-reviewer", + "review_digest": "5" * 64, + "revocation_epoch": 3, + "issued_at_utc": 1_790_000_000.0, + "expires_at_utc": 1_790_000_030.0, + } + values.update(changes) + return CtpSimNowOperationalWindowPermit(**values) # type: ignore[arg-type] + + +class _FakeReviewer: + def __init__(self, permit: object) -> None: + self.permit = permit + self.active_result: object = True + self.fail_issue = False + self.fail_check = False + self.requests: list[CtpSimNowOperationalActionRequest] = [] + self.checked: list[CtpSimNowOperationalWindowPermit] = [] + + def issue_action_review(self, request: CtpSimNowOperationalActionRequest) -> object: + self.requests.append(request) + if self.fail_issue: + raise RuntimeError("untrusted reviewer detail") + return self.permit + + def assert_action_review_current( + self, + permit: CtpSimNowOperationalWindowPermit, + *, + request: CtpSimNowOperationalActionRequest, + ) -> object: + assert permit.binding == request + self.checked.append(permit) + if self.fail_check: + raise RuntimeError("untrusted reviewer detail") + return self.active_result + + +class _FakeQueryVerifier: + def __init__(self) -> None: + self.result: object = True + self.requests: list[CtpSimNowQueryRequest] = [] + self.observations: list[CtpSimNowSingleQueryObservation] = [] + + def verify_single_query( + self, + observation: CtpSimNowSingleQueryObservation, + *, + expected_request: CtpSimNowQueryRequest, + ) -> object: + self.observations.append(observation) + self.requests.append(expected_request) + return self.result + + +def _query_request(**changes: object) -> CtpSimNowQueryRequest: + values: dict[str, object] = { + "scope": _scope(), + "query_id": "query-11", + "query_kind": CtpSimNowQueryKind.OPEN_ORDERS, + "request_id": 11, + "filters_digest": _FILTERS, + } + values.update(changes) + return CtpSimNowQueryRequest(**values) # type: ignore[arg-type] + + +def _observation( + request: CtpSimNowQueryRequest, **changes: object +) -> CtpSimNowSingleQueryObservation: + values: dict[str, object] = { + "binding": request, + "b_is_last": True, + "response_error_code": 0, + "callback_count": 1, + "late_callback_count": 0, + "record_count": 0, + "records_digest": "6" * 64, + "source_evidence_digest": "7" * 64, + "started_at_utc": 1_790_000_000.0, + "terminal_at_utc": 1_790_000_001.0, + } + values.update(changes) + return CtpSimNowSingleQueryObservation(**values) # type: ignore[arg-type] + + +def test_simnow_action_permit_is_exact_and_explicitly_non_authorizing() -> None: + request = _action(action_kind="CANCEL") + permit = _permit(request) + reviewer = _FakeReviewer(permit) + + returned = require_simnow_operational_window_permit(reviewer, request, now_utc=1_790_000_001.0) + + assert returned is permit + assert reviewer.requests == [request] + assert reviewer.checked == [permit] + assert permit.request_digest == request.request_digest + assert permit.scope_digest == request.scope.scope_digest + assert permit.permit_digest == _permit(request).permit_digest + assert permit.account_writer_exclusive is False + assert permit.common_snapshot_verified is False + assert permit.cryptographic_signature_verified is False + assert permit.write_authorized is False + + +@pytest.mark.parametrize( + "field,value", + [ + ("environment", "production"), + ("mode", "live"), + ("preset", "managed_live_direct"), + ("account_fingerprint_sha256", "9" * 64), + ("config_digest", "9" * 64), + ("effective_digest", "9" * 64), + ("registration_digest", "9" * 64), + ("artifact_set_digest", "9" * 64), + ("session_id", "session-other"), + ("trading_day", "20260926"), + ("connection_generation", 8), + ("session_identity_digest", "9" * 64), + ("selected_front_pair", ("tcp://127.0.0.1:11002", "tcp://127.0.0.1:12002")), + ], +) +def test_scope_rejects_mode_account_config_artifact_session_day_or_pair_changes( + field: str, value: object +) -> None: + if field in ("environment", "mode", "preset"): + values = { + "window_id": "simnow-window-7", + "runtime_id": "iteration41.ctp.simnow-test", + "environment": "simnow", + "mode": "simulation", + "preset": "sandbox", + "account_fingerprint_sha256": _ACCOUNT, + "config_digest": _CONFIG, + "effective_digest": _EFFECTIVE, + "registration_digest": _REGISTRATION, + "artifact_set_digest": _ARTIFACTS, + "session_id": "session-7", + "trading_day": "20260925", + "connection_generation": 7, + "session_identity_digest": _SESSION, + "selected_front_pair": ("tcp://127.0.0.1:11001", "tcp://127.0.0.1:12001"), + "instrument_id": "rb2701", + "exchange_id": "SHFE", + "hedge_flag": "1", + "risk_limits": CtpSimNowOperationalRiskLimits(1, 1, 1, Decimal("5000")), + } + values[field] = value + with pytest.raises(CtpSimNowOperationalWindowError): + CtpSimNowOperationalWindowScope(**values) # type: ignore[arg-type] + return + + changed_scope = _scope(**{field: value}) + original = _action() + changed_request = replace(original, scope=changed_scope) + reviewer = _FakeReviewer(_permit(original)) + with pytest.raises(CtpSimNowOperationalWindowError, match="scope mismatch"): + require_simnow_operational_window_permit(reviewer, changed_request, now_utc=1_790_000_001.0) + + +@pytest.mark.parametrize( + "action_kind,changes", + [ + ("SUBMIT", {"requested_quantity": 2}), + ("SUBMIT", {"requested_notional": Decimal("5000.01")}), + ("SUBMIT", {"target_digest": _TARGET}), + ("CANCEL", {"target_digest": None}), + ("CANCEL", {"target_remaining_quantity": 2}), + ("CANCEL", {"requested_quantity": 1}), + ], +) +def test_action_request_enforces_risk_ceiling_and_exact_cancel_target( + action_kind: str, changes: dict[str, object] +) -> None: + with pytest.raises(CtpSimNowOperationalWindowError): + _action(action_kind=action_kind, **changes) + + +def test_risk_limits_are_single_order_one_lot_and_positive() -> None: + with pytest.raises(CtpSimNowOperationalWindowError): + CtpSimNowOperationalRiskLimits(2, 1, 1, Decimal("5000")) + with pytest.raises(CtpSimNowOperationalWindowError): + CtpSimNowOperationalRiskLimits(1, 1, 2, Decimal("5000")) + with pytest.raises(CtpSimNowOperationalWindowError): + CtpSimNowOperationalRiskLimits(1, 1, 1, Decimal("NaN")) + + +@pytest.mark.parametrize( + "now", + [1_789_999_999.0, 1_790_000_030.0], +) +def test_action_permit_expiry_is_enforced(now: float) -> None: + request = _action() + reviewer = _FakeReviewer(_permit(request)) + with pytest.raises(CtpSimNowOperationalWindowError, match="expired"): + require_simnow_operational_window_permit(reviewer, request, now_utc=now) + + +def test_action_permit_cannot_outlive_short_action_ttl() -> None: + with pytest.raises(CtpSimNowOperationalWindowError, match="expiry invalid"): + _permit(_action(), expires_at_utc=1_790_000_061.0) + + +def test_permit_digest_binds_window_expiry_and_revocation_epoch() -> None: + request = _action() + original = _permit(request) + + assert replace(original, revocation_epoch=4).permit_digest != original.permit_digest + assert replace(original, expires_at_utc=1_790_000_029.0).permit_digest != original.permit_digest + + +@pytest.mark.parametrize("active", [False, None, 1]) +def test_revocation_or_nonliteral_active_result_fails_closed(active: object) -> None: + request = _action() + reviewer = _FakeReviewer(_permit(request)) + reviewer.active_result = active + with pytest.raises(CtpSimNowOperationalWindowError, match="revoked"): + require_simnow_operational_window_permit(reviewer, request, now_utc=1_790_000_001.0) + + +def test_permit_rechecks_expiry_and_revocation_for_exact_action() -> None: + request = _action(action_kind="CANCEL") + permit = _permit(request) + reviewer = _FakeReviewer(permit) + require_active_simnow_operational_window_permit( + reviewer, permit, request, now_utc=1_790_000_002.0 + ) + + reviewer.active_result = False + with pytest.raises(CtpSimNowOperationalWindowError, match="revoked"): + require_active_simnow_operational_window_permit( + reviewer, permit, request, now_utc=1_790_000_003.0 + ) + with pytest.raises(CtpSimNowOperationalWindowError, match="scope mismatch"): + require_active_simnow_operational_window_permit( + reviewer, permit, replace(request, target_digest="8" * 64), now_utc=1_790_000_003.0 + ) + + +def test_revocation_verifier_errors_are_redacted() -> None: + request = _action() + reviewer = _FakeReviewer(_permit(request)) + reviewer.fail_check = True + with pytest.raises( + CtpSimNowOperationalWindowError, match="revocation check unavailable" + ) as caught: + require_simnow_operational_window_permit(reviewer, request, now_utc=1_790_000_001.0) + assert "untrusted reviewer detail" not in str(caught.value) + + +def test_reviewer_failures_are_redacted_and_wrong_contracts_reject() -> None: + request = _action() + reviewer = _FakeReviewer(_permit(request)) + reviewer.fail_issue = True + with pytest.raises(CtpSimNowOperationalWindowError, match="review unavailable") as caught: + require_simnow_operational_window_permit(reviewer, request, now_utc=1_790_000_001.0) + assert "untrusted reviewer detail" not in str(caught.value) + + strict_request = CtpF14ActionRequest( + runtime_id="runtime", + environment="simnow", + mode="simulation", + preset="sandbox", + account_fingerprint_sha256=_ACCOUNT, + config_digest=_CONFIG, + effective_digest=_EFFECTIVE, + registration_digest=_REGISTRATION, + artifact_set_digest=_ARTIFACTS, + session=CtpF14SessionBinding("session-7", "20260925", 7, _SESSION), + action_kind="SUBMIT", + action_id="action-52", + action_digest=_ACTION, + approval_digest=_APPROVAL, + ) + strict_claim = object.__new__(CtpF14ExternalAdmissionClaim) + reviewer = _FakeReviewer(strict_claim) + with pytest.raises(CtpSimNowOperationalWindowError, match="scope mismatch"): + require_simnow_operational_window_permit(reviewer, request, now_utc=1_790_000_001.0) + with pytest.raises(CtpF14AdmissionError, match="authority required"): + claim_f14_action(strict_claim, strict_request) # type: ignore[arg-type] + with pytest.raises(CtpF14AdmissionError, match="authority required"): + claim_f14_action(_permit(request), strict_request) # type: ignore[arg-type] + + +def test_one_terminal_query_observation_is_accepted_without_snapshot_claim() -> None: + request = _query_request() + observation = _observation(request) + verifier = _FakeQueryVerifier() + + require_simnow_single_query_observation(verifier, observation, expected_request=request) + + assert verifier.requests == [request] + assert verifier.observations == [observation] + assert observation.account_writer_exclusive is False + assert observation.common_snapshot_verified is False + assert observation.account_coverage_verified is False + + +@pytest.mark.parametrize( + "changes", + [ + {"b_is_last": False}, + {"response_error_code": 7}, + {"callback_count": 0}, + {"late_callback_count": 1}, + ], +) +def test_single_query_requires_one_clean_terminal_stream(changes: dict[str, object]) -> None: + request = _query_request() + observation = _observation(request, **changes) + with pytest.raises(CtpSimNowOperationalWindowError, match="terminal evidence incomplete"): + require_simnow_single_query_observation( + _FakeQueryVerifier(), observation, expected_request=request + ) + + +@pytest.mark.parametrize( + "field,value", + [ + ("query_kind", CtpSimNowQueryKind.TRADES), + ("request_id", 12), + ("filters_digest", "8" * 64), + ], +) +def test_single_query_observation_is_bound_to_exact_query_request( + field: str, value: object +) -> None: + expected = _query_request() + other = replace(expected, **{field: value}) + with pytest.raises(CtpSimNowOperationalWindowError, match="scope mismatch"): + require_simnow_single_query_observation( + _FakeQueryVerifier(), _observation(expected), expected_request=other + ) + + +@pytest.mark.parametrize( + "field,value", + [ + ("account_fingerprint_sha256", "8" * 64), + ("artifact_set_digest", "8" * 64), + ("trading_day", "20260926"), + ("connection_generation", 8), + ("selected_front_pair", ("tcp://127.0.0.1:11002", "tcp://127.0.0.1:12002")), + ], +) +def test_single_query_observation_is_bound_to_exact_account_session_and_fronts( + field: str, value: object +) -> None: + source_request = _query_request() + changed_scope = _scope(**{field: value}) + changed_request = replace(source_request, scope=changed_scope) + with pytest.raises(CtpSimNowOperationalWindowError, match="scope mismatch"): + require_simnow_single_query_observation( + _FakeQueryVerifier(), + _observation(source_request), + expected_request=changed_request, + ) + + +@pytest.mark.parametrize("result", [False, None, 1]) +def test_unverified_single_query_source_is_rejected(result: object) -> None: + request = _query_request() + verifier = _FakeQueryVerifier() + verifier.result = result + with pytest.raises(CtpSimNowOperationalWindowError, match="source unverified"): + require_simnow_single_query_observation( + verifier, _observation(request), expected_request=request + ) + + +def test_single_query_verifier_errors_are_redacted() -> None: + request = _query_request() + + class _FailingVerifier: + def verify_single_query(self, observation: object, *, expected_request: object) -> bool: + raise RuntimeError("untrusted verifier detail") + + with pytest.raises( + CtpSimNowOperationalWindowError, match="source verification unavailable" + ) as caught: + require_simnow_single_query_observation( + _FailingVerifier(), + _observation(request), + expected_request=request, # type: ignore[arg-type] + ) + assert "untrusted verifier detail" not in str(caught.value) + + +def test_several_single_streams_do_not_form_a_common_snapshot_contract() -> None: + first = _query_request( + query_id="query-1", query_kind=CtpSimNowQueryKind.ACCOUNT_FUNDS, request_id=1 + ) + second = _query_request( + query_id="query-2", query_kind=CtpSimNowQueryKind.OPEN_ORDERS, request_id=2 + ) + verifier = _FakeQueryVerifier() + + require_simnow_single_query_observation(verifier, _observation(first), expected_request=first) + require_simnow_single_query_observation(verifier, _observation(second), expected_request=second) + + assert _observation(first).common_snapshot_verified is False + assert _observation(second).account_writer_exclusive is False + with pytest.raises(CtpSimNowOperationalWindowError, match="scope mismatch"): + require_simnow_single_query_observation( + verifier, + (_observation(first), _observation(second)), # type: ignore[arg-type] + expected_request=first, + ) diff --git a/tests/unit/runtime/test_ctp_simnow_signed_review.py b/tests/unit/runtime/test_ctp_simnow_signed_review.py new file mode 100644 index 00000000..d495f05b --- /dev/null +++ b/tests/unit/runtime/test_ctp_simnow_signed_review.py @@ -0,0 +1,743 @@ +from __future__ import annotations + +import hashlib +import hmac +import multiprocessing +import os +import sqlite3 +import tempfile +from dataclasses import replace +from decimal import Decimal +from pathlib import Path +from typing import Callable + +import pytest + +from backtrader_runtime import ctp_simnow_signed_review as signed_review +from backtrader_runtime.ctp_simnow_operational_window import ( + CtpSimNowOperationalActionRequest, + CtpSimNowOperationalRiskLimits, + CtpSimNowOperationalWindowError, + CtpSimNowOperationalWindowPermit, + CtpSimNowOperationalWindowScope, + require_active_simnow_operational_window_permit, + require_simnow_operational_window_permit, +) +from backtrader_runtime.ctp_simnow_signed_review import ( + CtpSimNowOperationalReviewKeyPolicy, + CtpSimNowSignedOperationalReview, + HmacCtpSimNowOperationalWindowReviewer, + LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard, +) + + +_KEY_ID = "simnow-review-test-key" +_AUTHORITY = "simnow-test-review-authority" +_KEY = b"offline-test-only-secret-key-32bytes!!" +_NOW = 1_790_000_001.0 + + +def _claim_from_process(path: str, barrier: object, result_queue: object, permit_id: str) -> None: + try: + barrier.wait(timeout=10) # type: ignore[attr-defined] + result = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(Path(path)).claim_once( + permit_id, "a" * 64, "b" * 64 + ) + result_queue.put(("ok", result)) # type: ignore[attr-defined] + except BaseException as exc: + result_queue.put(("error", type(exc).__name__, str(exc), repr(exc.__cause__))) # type: ignore[attr-defined] + + +def _crash_after_permit_claim_insert(path: str) -> None: + connection = sqlite3.connect(path, timeout=2.0, isolation_level=None) + connection.execute("BEGIN IMMEDIATE") + connection.execute( + "INSERT INTO simnow_review_permit_claims (permit_id, request_digest) VALUES (?, ?)", + ("crash-permit", "c" * 64), + ) + os._exit(23) + + +def _scope(**changes: object) -> CtpSimNowOperationalWindowScope: + values: dict[str, object] = { + "window_id": "simnow-window-7", + "runtime_id": "iteration41.ctp.simnow-test", + "environment": "simnow", + "mode": "simulation", + "preset": "sandbox", + "account_fingerprint_sha256": "a" * 64, + "config_digest": "b" * 64, + "effective_digest": "c" * 64, + "registration_digest": "d" * 64, + "artifact_set_digest": "e" * 64, + "session_id": "session-7", + "trading_day": "20260925", + "connection_generation": 7, + "session_identity_digest": "f" * 64, + "selected_front_pair": ("tcp://127.0.0.1:11001", "tcp://127.0.0.1:12001"), + "instrument_id": "rb2701", + "exchange_id": "SHFE", + "hedge_flag": "1", + "risk_limits": CtpSimNowOperationalRiskLimits( + max_order_quantity=1, + max_gross_position_quantity=1, + max_live_test_orders=1, + max_order_notional=Decimal("5000"), + ), + } + values.update(changes) + return CtpSimNowOperationalWindowScope(**values) # type: ignore[arg-type] + + +def _request( + *, + scope: CtpSimNowOperationalWindowScope | None = None, + action_kind: str = "SUBMIT", + **changes: object, +) -> CtpSimNowOperationalActionRequest: + values: dict[str, object] = { + "scope": scope or _scope(), + "action_kind": action_kind, + "action_id": "action-52", + "action_digest": "1" * 64, + "approval_digest": "2" * 64, + "requested_quantity": 1 if action_kind == "SUBMIT" else None, + "requested_notional": Decimal("3450") if action_kind == "SUBMIT" else None, + "target_digest": "3" * 64 if action_kind == "CANCEL" else None, + "target_remaining_quantity": 1 if action_kind == "CANCEL" else None, + } + values.update(changes) + return CtpSimNowOperationalActionRequest(**values) # type: ignore[arg-type] + + +def _signed_review( + request: CtpSimNowOperationalActionRequest, + *, + permit_id: str = "permit-100", + issued_at_utc: float = _NOW - 1.0, + expires_at_utc: float = _NOW + 29.0, + revocation_epoch: int = 3, + review_authority_id: str = _AUTHORITY, + key_id: str = _KEY_ID, + key: bytes = _KEY, +) -> CtpSimNowSignedOperationalReview: + permit = CtpSimNowOperationalWindowPermit( + binding=request, + permit_id=permit_id, + review_authority_id=review_authority_id, + review_digest="4" * 64, + revocation_epoch=revocation_epoch, + issued_at_utc=issued_at_utc, + expires_at_utc=expires_at_utc, + ) + unsigned = CtpSimNowSignedOperationalReview( + permit=permit, key_id=key_id, signature_hex="0" * 64 + ) + signature = hmac.new(key, unsigned.signed_payload(), hashlib.sha256).hexdigest() + return replace(unsigned, signature_hex=signature) + + +def _policy( + request: CtpSimNowOperationalActionRequest, + **changes: object, +) -> CtpSimNowOperationalReviewKeyPolicy: + values: dict[str, object] = { + "key_id": _KEY_ID, + "review_authority_id": _AUTHORITY, + "key_bytes": _KEY, + "current_revocation_epoch": 3, + "allowed_account_fingerprints": frozenset((request.scope.account_fingerprint_sha256,)), + "allowed_window_ids": frozenset((request.scope.window_id,)), + "allowed_session_identity_digests": frozenset((request.scope.session_identity_digest,)), + "allowed_trading_days": frozenset((request.scope.trading_day,)), + "allowed_risk_limits_digests": frozenset((request.scope.risk_limits.digest,)), + "allowed_action_kinds": frozenset((request.action_kind,)), + } + values.update(changes) + return CtpSimNowOperationalReviewKeyPolicy(**values) # type: ignore[arg-type] + + +class _Clock: + def __init__(self, now: object = _NOW) -> None: + self.now = now + + def __call__(self) -> object: + return self.now + + +class _Source: + def __init__(self, *reviews: CtpSimNowSignedOperationalReview) -> None: + self.reviews = list(reviews) + self.calls = 0 + + def issue_action_review( + self, request: CtpSimNowOperationalActionRequest + ) -> CtpSimNowSignedOperationalReview: + del request + self.calls += 1 + return self.reviews[min(self.calls - 1, len(self.reviews) - 1)] + + +class _Resolver: + def __init__(self, *policies: CtpSimNowOperationalReviewKeyPolicy) -> None: + self.policies = {policy.key_id: policy for policy in policies} + + def resolve(self, key_id: str) -> CtpSimNowOperationalReviewKeyPolicy | None: + return self.policies.get(key_id) + + +class _ReplayGuard: + """Explicit in-memory fake; production must inject a durable atomic guard.""" + + def __init__(self) -> None: + self.permit_ids: set[str] = set() + self.action_keys: set[str] = set() + self.claims: list[tuple[str, str, str]] = [] + + def claim_once(self, permit_id: str, action_replay_key: str, request_digest: str) -> bool: + self.claims.append((permit_id, action_replay_key, request_digest)) + if permit_id in self.permit_ids or action_replay_key in self.action_keys: + return False + self.permit_ids.add(permit_id) + self.action_keys.add(action_replay_key) + return True + + +def _reviewer( + request: CtpSimNowOperationalActionRequest, + envelope: CtpSimNowSignedOperationalReview | None = None, + *, + clock: _Clock | None = None, + replay_guard: _ReplayGuard | None = None, + source: _Source | None = None, +) -> tuple[HmacCtpSimNowOperationalWindowReviewer, _Resolver, _ReplayGuard, _Clock]: + actual_clock = clock or _Clock() + actual_guard = replay_guard or _ReplayGuard() + actual_source = source or _Source(envelope or _signed_review(request)) + resolver = _Resolver(_policy(request)) + reviewer = HmacCtpSimNowOperationalWindowReviewer( + review_source=actual_source, + key_resolver=resolver, + trusted_utc=actual_clock, + replay_guard=actual_guard, + ) + return reviewer, resolver, actual_guard, actual_clock + + +def test_hmac_review_verifies_exact_scope_and_remains_non_authorizing() -> None: + request = _request(action_kind="CANCEL") + envelope = _signed_review(request) + reviewer, _resolver, replay_guard, _clock = _reviewer(request, envelope) + + permit = require_simnow_operational_window_permit(reviewer, request, now_utc=_NOW) + require_active_simnow_operational_window_permit(reviewer, permit, request, now_utc=_NOW + 1.0) + + assert permit.binding == request + assert permit.scope_digest == request.scope.scope_digest + assert permit.request_digest == request.request_digest + assert permit.cryptographic_signature_verified is False + assert permit.write_authorized is False + assert permit.account_writer_exclusive is False + assert len(replay_guard.claims) == 1 + + +def test_unsigned_or_tampered_review_is_rejected_before_permit_is_returned() -> None: + request = _request() + envelope = _signed_review(request) + altered = replace(envelope, signature_hex="0" * 64) + reviewer, _resolver, guard, _clock = _reviewer(request, altered) + + with pytest.raises(CtpSimNowOperationalWindowError, match="signature invalid"): + reviewer.issue_action_review(request) + assert guard.claims == [] + + +@pytest.mark.parametrize( + "scope_change", + [ + {"account_fingerprint_sha256": "9" * 64}, + {"window_id": "other-window"}, + {"session_id": "other-session"}, + {"trading_day": "20260926"}, + {"connection_generation": 8}, + {"session_identity_digest": "8" * 64}, + { + "risk_limits": CtpSimNowOperationalRiskLimits( + max_order_quantity=1, + max_gross_position_quantity=1, + max_live_test_orders=1, + max_order_notional=Decimal("4000"), + ) + }, + ], +) +def test_signed_envelope_cannot_be_replayed_for_a_different_account_window_or_scope( + scope_change: dict[str, object], +) -> None: + original = _request() + changed = _request(scope=_scope(**scope_change)) + reviewer, _resolver, guard, _clock = _reviewer(original, _signed_review(original)) + + with pytest.raises(CtpSimNowOperationalWindowError, match="scope mismatch"): + reviewer.issue_action_review(changed) + assert guard.claims == [] + + +def test_issuer_allowlist_rejects_other_account_action_session_day_and_risk() -> None: + request = _request() + denied_request = _request( + scope=_scope( + account_fingerprint_sha256="9" * 64, + window_id="unapproved-window", + session_identity_digest="8" * 64, + trading_day="20260926", + risk_limits=CtpSimNowOperationalRiskLimits( + max_order_quantity=1, + max_gross_position_quantity=1, + max_live_test_orders=1, + max_order_notional=Decimal("4200"), + ), + ), + action_kind="CANCEL", + action_id="other-action", + ) + signed = _signed_review(denied_request) + reviewer, _resolver, guard, _clock = _reviewer( + denied_request, + signed, + ) + reviewer._key_resolver.policies[_KEY_ID] = _policy(request) + + with pytest.raises(CtpSimNowOperationalWindowError, match="issuer policy rejected"): + reviewer.issue_action_review(denied_request) + assert guard.claims == [] + + +def test_key_id_and_issuer_identity_are_bound_to_the_resolved_policy() -> None: + request = _request() + envelope = _signed_review(request, review_authority_id="other-authority") + reviewer, _resolver, guard, _clock = _reviewer(request, envelope) + + with pytest.raises(CtpSimNowOperationalWindowError, match="issuer policy rejected"): + reviewer.issue_action_review(request) + assert guard.claims == [] + + +def test_unknown_or_revoked_key_and_changed_revocation_epoch_fail_closed() -> None: + request = _request() + envelope = _signed_review(request) + reviewer, resolver, _guard, _clock = _reviewer(request, envelope) + resolver.policies.clear() + with pytest.raises(CtpSimNowOperationalWindowError, match="key unavailable"): + reviewer.issue_action_review(request) + + reviewer, resolver, _guard, _clock = _reviewer(request, envelope) + resolver.policies[_KEY_ID] = replace(_policy(request), revoked=True) + with pytest.raises(CtpSimNowOperationalWindowError, match="key unavailable"): + reviewer.issue_action_review(request) + + reviewer, resolver, _guard, _clock = _reviewer(request, envelope) + permit = reviewer.issue_action_review(request) + resolver.policies[_KEY_ID] = replace(_policy(request), current_revocation_epoch=4) + with pytest.raises(CtpSimNowOperationalWindowError, match="issuer policy rejected"): + reviewer.assert_action_review_current(permit, request=request) + + +@pytest.mark.parametrize( + "now,issued,expires", + [ + (_NOW + 30.0, _NOW - 1.0, _NOW + 29.0), + (_NOW - 2.0, _NOW - 1.0, _NOW + 29.0), + ], +) +def test_expired_or_not_yet_valid_reviews_use_injected_trusted_utc( + now: float, issued: float, expires: float +) -> None: + request = _request() + envelope = _signed_review(request, issued_at_utc=issued, expires_at_utc=expires) + reviewer, _resolver, guard, _clock = _reviewer(request, envelope, clock=_Clock(now)) + + with pytest.raises(CtpSimNowOperationalWindowError, match="expired"): + reviewer.issue_action_review(request) + assert guard.claims == [] + + +def test_nonfinite_trusted_utc_and_policy_ttl_ceiling_fail_closed() -> None: + request = _request() + envelope = _signed_review(request) + reviewer, resolver, guard, clock = _reviewer(request, envelope) + clock.now = float("nan") + with pytest.raises(CtpSimNowOperationalWindowError, match="trusted utc unavailable"): + reviewer.issue_action_review(request) + assert guard.claims == [] + + reviewer, resolver, guard, _clock = _reviewer(request, envelope) + resolver.policies[_KEY_ID] = replace(_policy(request), max_ttl_seconds=5.0) + with pytest.raises(CtpSimNowOperationalWindowError, match="expired"): + reviewer.issue_action_review(request) + assert guard.claims == [] + + +def test_replay_guard_rejects_reused_permit_and_second_permit_for_same_action() -> None: + request = _request() + first = _signed_review(request, permit_id="permit-one") + second = _signed_review(request, permit_id="permit-two") + source = _Source(first, first, second) + reviewer, _resolver, guard, _clock = _reviewer(request, source=source) + + reviewer.issue_action_review(request) + with pytest.raises(CtpSimNowOperationalWindowError, match="replayed"): + reviewer.issue_action_review(request) + with pytest.raises(CtpSimNowOperationalWindowError, match="replayed"): + reviewer.issue_action_review(request) + assert len(guard.claims) == 2 + + +def test_replay_guard_failure_or_unavailable_guard_never_returns_a_permit() -> None: + request = _request() + envelope = _signed_review(request) + guard = _ReplayGuard() + guard.claim_once = lambda *_args: None # type: ignore[method-assign] + reviewer, _resolver, _guard, _clock = _reviewer(request, envelope, replay_guard=guard) + + with pytest.raises(CtpSimNowOperationalWindowError, match="replayed"): + reviewer.issue_action_review(request) + + +def test_policy_is_immutable_and_no_default_trust_material_is_created() -> None: + request = _request() + accounts = {request.scope.account_fingerprint_sha256} + policy = _policy(request, allowed_account_fingerprints=accounts) + accounts.add("9" * 64) + assert policy.allowed_account_fingerprints == frozenset(("a" * 64,)) + + reviewer = HmacCtpSimNowOperationalWindowReviewer( + review_source=_Source(_signed_review(request)), + key_resolver=_Resolver(), + trusted_utc=_Clock(), + replay_guard=_ReplayGuard(), + ) + with pytest.raises(CtpSimNowOperationalWindowError, match="key unavailable"): + reviewer.issue_action_review(request) + + +@pytest.fixture +def review_state_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + if os.name == "nt": + temporary = tempfile.TemporaryDirectory(prefix="codex-simnow-review-", dir=str(Path.home())) + state_dir = Path(temporary.name) + request.addfinalizer(temporary.cleanup) + signed_review._protect_windows_path_acl(state_dir, is_directory=True) + else: + state_dir = tmp_path / "private-review-state" + state_dir.mkdir() + state_dir.chmod(0o700) + return state_dir + + +def test_local_sqlite_guard_persists_both_replay_keys_across_instances( + review_state_dir: Path, +) -> None: + state_dir = review_state_dir + database = state_dir / "review-replay.sqlite3" + first = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert first.LOCAL_ONLY is True + assert first.NO_WRITE is True + assert first.claim_once("permit-persist", "1" * 64, "2" * 64) is True + + restarted = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert restarted.claim_once("permit-persist", "3" * 64, "4" * 64) is False + assert restarted.claim_once("permit-new", "1" * 64, "5" * 64) is False + assert restarted.claim_once("permit-new", "6" * 64, "7" * 64) is True + + +def test_reviewer_rejects_signed_review_replayed_after_guard_restart( + review_state_dir: Path, +) -> None: + request = _request() + envelope = _signed_review(request) + database = review_state_dir / "review-replay.sqlite3" + first = HmacCtpSimNowOperationalWindowReviewer( + review_source=_Source(envelope), + key_resolver=_Resolver(_policy(request)), + trusted_utc=_Clock(), + replay_guard=LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database), + ) + first.issue_action_review(request) + + restarted = HmacCtpSimNowOperationalWindowReviewer( + review_source=_Source(envelope), + key_resolver=_Resolver(_policy(request)), + trusted_utc=_Clock(), + replay_guard=LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database), + ) + with pytest.raises(CtpSimNowOperationalWindowError, match="replayed"): + restarted.issue_action_review(request) + + +def test_local_sqlite_guard_recovers_uncommitted_half_claim_after_process_crash( + review_state_dir: Path, +) -> None: + database = review_state_dir / "review-replay.sqlite3" + guard = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert guard.claim_once("seed-permit", "8" * 64, "9" * 64) is True + + context = multiprocessing.get_context("spawn") + process = context.Process(target=_crash_after_permit_claim_insert, args=(str(database),)) + process.start() + process.join(timeout=15) + if process.is_alive(): + process.terminate() + process.join(timeout=5) + pytest.fail("crash-recovery subprocess did not exit") + assert process.exitcode == 23 + + restarted = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert restarted.claim_once("crash-permit", "a" * 64, "b" * 64) is True + + +def test_local_sqlite_guard_serializes_cross_process_race(review_state_dir: Path) -> None: + database = review_state_dir / "review-replay.sqlite3" + context = multiprocessing.get_context("spawn") + barrier = context.Barrier(2) + result_queue = context.Queue() + processes = [ + context.Process( + target=_claim_from_process, + args=(str(database), barrier, result_queue, permit_id), + ) + for permit_id in ("race-permit-a", "race-permit-b") + ] + try: + for process in processes: + process.start() + results = [result_queue.get(timeout=15) for _ in processes] + for process in processes: + process.join(timeout=15) + assert all(not process.is_alive() and process.exitcode == 0 for process in processes) + assert len(results) == 2 + assert results.count(("ok", True)) == 1 + + duplicate_results = [result for result in results if result == ("ok", False)] + unavailable_results = [ + result + for result in results + if ( + len(result) == 4 + and result[:3] == ("error", "RuntimeError", "operational_review_replay_unavailable") + ) + ] + assert len(duplicate_results) + len(unavailable_results) == 1 + + durable_guard = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert durable_guard.claim_once("race-permit-durable-check", "a" * 64, "c" * 64) is False + finally: + for process in processes: + if process.is_alive(): + process.terminate() + process.join(timeout=5) + result_queue.close() + + +def test_local_sqlite_guard_rejects_unsafe_path_identity_and_permissions( + tmp_path: Path, review_state_dir: Path +) -> None: + state_dir = review_state_dir + target = state_dir / "real.sqlite3" + link = state_dir / "link.sqlite3" + if os.name != "nt": + link.symlink_to(target) + with pytest.raises(RuntimeError, match="operational_review_replay_path_invalid"): + LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(link).claim_once( + "permit-link", "a" * 64, "b" * 64 + ) + link.unlink() + + state_dir.chmod(0o755) + with pytest.raises(RuntimeError, match="operational_review_replay_permissions_invalid"): + LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(target).claim_once( + "permit-parent-mode", "a" * 64, "b" * 64 + ) + state_dir.chmod(0o700) + + guard = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(target) + assert guard.claim_once("permit-file-mode", "a" * 64, "b" * 64) is True + if os.name != "nt": + target.chmod(0o644) + with pytest.raises(RuntimeError, match="operational_review_replay_permissions_invalid"): + guard.claim_once("permit-insecure-file", "c" * 64, "d" * 64) + + if os.name == "nt": + shared_directory = tmp_path / "shared-directory" + shared_directory.mkdir() + shared_database = shared_directory / "review-replay.sqlite3" + with pytest.raises(RuntimeError): + LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(shared_database).claim_once( + "permit-shared", "e" * 64, "f" * 64 + ) + assert not shared_database.exists() + + +def test_windows_acl_policies_accept_only_private_state_and_safe_ancestors() -> None: + owner = "S-1-5-21-10-20-30-1001" + private_entries = ( + (0, 0, 0x001F01FF, owner), + (0, 0, 0x001F01FF, "S-1-5-18"), + (0, 0, 0x001F01FF, "S-1-5-32-544"), + ) + signed_review._validate_windows_private_acl(owner, owner, True, private_entries) + with pytest.raises(RuntimeError, match="windows_acl_invalid"): + signed_review._validate_windows_private_acl(owner, owner, False, private_entries) + with pytest.raises(RuntimeError, match="windows_acl_invalid"): + signed_review._validate_windows_private_acl( + owner, owner, True, private_entries + ((0, 0, 0x001F01FF, "S-1-1-0"),) + ) + + safe_ancestor_entries = ( + (0, 0, 0x001200A9, "S-1-5-11"), + (0, 0, 0x001F01FF, owner), + ) + signed_review._validate_windows_ancestor_acl(owner, owner, safe_ancestor_entries) + with pytest.raises(RuntimeError, match="ancestor_acl_invalid"): + signed_review._validate_windows_ancestor_acl( + owner, owner, safe_ancestor_entries + ((0, 0, 0x00000040, "S-1-5-11"),) + ) + with pytest.raises(RuntimeError, match="ancestor_acl_invalid"): + signed_review._validate_windows_ancestor_acl( + owner, owner, safe_ancestor_entries + ((0, 0, 0x40000000, "S-1-5-11"),) + ) + + inherited_sidecar_entries = ( + (0, 0x13, 0x001F01FF, owner), + (0, 0x13, 0x001F01FF, "S-1-5-18"), + (0, 0x13, 0x001F01FF, "S-1-5-32-544"), + ) + signed_review._validate_windows_sidecar_acl(owner, owner, inherited_sidecar_entries) + with pytest.raises(RuntimeError, match="sidecar_acl_invalid"): + signed_review._validate_windows_sidecar_acl( + owner, owner, inherited_sidecar_entries + ((0, 0x13, 0x001F01FF, "S-1-1-0"),) + ) + + +def test_windows_local_sqlite_path_rejects_unc_remote_and_unknown_volumes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + unc_path = Path(r"\\server\share\review-replay.sqlite3") + with pytest.raises(RuntimeError, match="operational_review_replay_local_volume_required"): + signed_review._require_windows_local_volume( + unc_path, + drive_type_getter=lambda _root: pytest.fail("UNC must reject before DriveTypeW"), + ) + + local_drive_path = Path(r"Z:\runtime\review-replay.sqlite3") + + def make_drive_type_getter( + volume_type: int, + ) -> tuple[list[str], Callable[[str], int]]: + seen_roots: list[str] = [] + + def get_drive_type(root: str) -> int: + seen_roots.append(root) + return volume_type + + return seen_roots, get_drive_type + + for volume_type in (0, 4): # DRIVE_UNKNOWN and DRIVE_REMOTE + seen_roots, get_drive_type = make_drive_type_getter(volume_type) + with pytest.raises(RuntimeError, match="operational_review_replay_local_volume_required"): + signed_review._require_windows_local_volume( + local_drive_path, drive_type_getter=get_drive_type + ) + assert seen_roots == ["Z:\\"] + + signed_review._require_windows_local_volume( + local_drive_path, drive_type_getter=lambda root: 3 if root == "Z:\\" else 0 + ) + if os.name == "nt": + monkeypatch.setattr(signed_review, "_windows_drive_type", lambda _root: 4) + with pytest.raises(ValueError, match="operational_review_replay_local_volume_required"): + LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(local_drive_path) + + +def test_local_sqlite_guard_rejects_changed_database_or_parent_identity( + review_state_dir: Path, +) -> None: + database = review_state_dir / "review-replay.sqlite3" + guard = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert guard.claim_once("permit-identity", "a" * 64, "b" * 64) is True + parent_identity, database_identity = guard._prepare_database_file() + + changed_database_identity = ( + parent_identity, + (database_identity[0], database_identity[1] + 1), + ) + with pytest.raises(RuntimeError, match="operational_review_replay_path_changed"): + guard._require_current_identity(changed_database_identity) + + changed_parent_identity = list(parent_identity) + path, device, inode = changed_parent_identity[-1] + changed_parent_identity[-1] = (path, device, inode + 1) + with pytest.raises(RuntimeError, match="operational_review_replay_path_changed"): + guard._require_current_identity((tuple(changed_parent_identity), database_identity)) + + +def test_local_sqlite_guard_fails_closed_on_locked_or_corrupt_database( + review_state_dir: Path, +) -> None: + state_dir = review_state_dir + database = state_dir / "review-replay.sqlite3" + guard = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert guard.claim_once("permit-unlocked", "a" * 64, "b" * 64) is True + + locker = sqlite3.connect(str(database), timeout=1.0, isolation_level=None) + try: + locker.execute("BEGIN EXCLUSIVE") + with pytest.raises(RuntimeError, match="operational_review_replay_unavailable"): + guard.claim_once("permit-locked", "c" * 64, "d" * 64) + finally: + locker.execute("ROLLBACK") + locker.close() + assert guard.claim_once("permit-after-lock", "e" * 64, "f" * 64) is True + + corrupt = state_dir / "corrupt.sqlite3" + corrupt.write_bytes(b"not a sqlite database") + if os.name != "nt": + corrupt.chmod(0o600) + else: + signed_review._protect_windows_path_acl(corrupt, is_directory=False) + with pytest.raises(RuntimeError, match="operational_review_replay_unavailable"): + LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(corrupt).claim_once( + "permit-corrupt", "1" * 64, "2" * 64 + ) + + +def test_local_sqlite_guard_rejects_unavailable_permissions_and_schema_corruption( + review_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state_dir = review_state_dir + database = state_dir / "review-replay.sqlite3" + guard = LocalOnlySqliteCtpSimNowOperationalReviewReplayGuard(database) + assert guard.claim_once("permit-schema", "a" * 64, "b" * 64) is True + + connection = sqlite3.connect(str(database)) + connection.execute("DELETE FROM simnow_review_replay_meta") + connection.commit() + connection.close() + with pytest.raises(RuntimeError, match="operational_review_replay_unavailable"): + guard.claim_once("permit-bad-schema", "c" * 64, "d" * 64) + + # A permission error from the SQLite open path is converted to an + # unavailable guard result; it can never be interpreted as a fresh claim. + original_connect = sqlite3.connect + + def denied_connect(*args: object, **kwargs: object) -> sqlite3.Connection: + raise PermissionError("injected") + + monkeypatch.setattr( + "backtrader_runtime.ctp_simnow_signed_review.sqlite3.connect", denied_connect + ) + with pytest.raises(RuntimeError, match="operational_review_replay_unavailable"): + guard.claim_once("permit-denied", "e" * 64, "f" * 64) + monkeypatch.setattr( + "backtrader_runtime.ctp_simnow_signed_review.sqlite3.connect", original_connect + ) diff --git a/tests/unit/runtime/test_framework_projection_source_events.py b/tests/unit/runtime/test_framework_projection_source_events.py new file mode 100644 index 00000000..f969de0c --- /dev/null +++ b/tests/unit/runtime/test_framework_projection_source_events.py @@ -0,0 +1,259 @@ +"""Local-only source-event receipts used by managed Broker projection.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from types import SimpleNamespace +from typing import Optional + +import pytest + +from backtrader_runtime.framework_projection import ( + FrameworkProjectionJournal, + FrameworkProjectionRecoveryError, +) + + +_SCOPE = "scope.fixture.local" +_INCARNATION = "a" * 32 +_SESSION = "session.fixture.1" +_FINGERPRINT = "b" * 64 + + +def _event( + sequence: int, + *, + intent_id: str = "intent.fixture", + event_id: Optional[str] = None, + quantity: str = "1", + average: Optional[str] = "100", + commission: Optional[str] = "0.05", +): + return SimpleNamespace( + sequence=sequence, + event_id=event_id or "event." + str(sequence), + intent_id=intent_id, + scope_key=_SCOPE, + event_type="provider_observation", + state=SimpleNamespace(value="PARTIALLY_FILLED"), + created_at_ns=sequence + 100, + journal_incarnation_id=_INCARNATION, + payload={ + "provider_order_id": "provider.fixture", + "filled_quantity": quantity, + "average_price": average, + "cumulative_commission": commission, + "reason_code": None, + "source": "provider", + }, + ) + + +def _claim(journal: FrameworkProjectionJournal, event, session: str = _SESSION): + return journal.claim_source_event( + event=event, + session_id=session, + expected_scope_key=_SCOPE, + canonical_fingerprint=_FINGERPRINT, + ) + + +def test_source_event_receipt_is_idempotent_and_restart_scoped(tmp_path) -> None: + journal = FrameworkProjectionJournal(tmp_path) + try: + first = _event(1) + claim = _claim(journal, first) + assert claim is not None + assert journal.validate_source_event(claim) + journal.complete_source_event(claim) + assert ( + journal.claim_source_event( + event=first, + session_id=_SESSION, + expected_scope_key=_SCOPE, + canonical_fingerprint=_FINGERPRINT, + ) + is None + ) + + second = _event(2, quantity="2", average="110", commission="0.11") + next_claim = _claim(journal, second) + assert next_claim is not None + journal.complete_source_event(next_claim) + + # A fresh framework session rebuilds its empty Broker from the same + # immutable event stream; the existing session cannot replay old seq. + restart_claim = _claim(journal, first, session="session.fixture.2") + assert restart_claim is not None + journal.complete_source_event(restart_claim) + with pytest.raises(FrameworkProjectionRecoveryError, match="backwards"): + _claim(journal, _event(1, event_id="different.event")) + finally: + journal.close() + + +def test_source_event_identity_payload_and_missing_fill_evidence_fail_closed(tmp_path) -> None: + journal = FrameworkProjectionJournal(tmp_path) + try: + first = _event(1) + claim = _claim(journal, first) + assert claim is not None + journal.complete_source_event(claim) + + changed = _event(1, event_id=first.event_id, average="101") + with pytest.raises(FrameworkProjectionRecoveryError, match="reused"): + _claim(journal, changed) + + with pytest.raises(FrameworkProjectionRecoveryError, match="lacks price"): + _claim( + journal, + _event(2, quantity="2", average=None, commission="0.11"), + ) + with pytest.raises(FrameworkProjectionRecoveryError, match="lacks price"): + _claim( + journal, + _event(2, quantity="2", average="110", commission=None), + ) + finally: + journal.close() + + +def test_source_event_completion_rolls_back_without_advancing_high_water(tmp_path) -> None: + journal = FrameworkProjectionJournal(tmp_path) + try: + claim = _claim(journal, _event(1)) + assert claim is not None + invalid = replace(claim, claim_token="c" * 32) + with pytest.raises(FrameworkProjectionRecoveryError, match="not active"): + journal.complete_source_event(invalid) + assert journal.validate_source_event(claim) + journal.complete_source_event(claim) + assert _claim(journal, _event(2)) is not None + finally: + journal.close() + + +def test_source_event_claim_race_has_one_winner_across_journal_handles(tmp_path) -> None: + first = FrameworkProjectionJournal(tmp_path) + second = FrameworkProjectionJournal(tmp_path) + event = _event(1) + try: + + def attempt(journal: FrameworkProjectionJournal): + try: + return _claim(journal, event) + except FrameworkProjectionRecoveryError: + return None + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(attempt, (first, second))) + assert sum(result is not None for result in results) == 1 + winner = next(result for result in results if result is not None) + assert first.validate_source_event(winner) or second.validate_source_event(winner) + finally: + first.close() + second.close() + + +def test_source_stream_high_water_is_scope_global_across_intents(tmp_path) -> None: + journal = FrameworkProjectionJournal(tmp_path) + try: + first = _claim(journal, _event(1, intent_id="intent.a")) + assert first is not None + journal.complete_source_event(first) + later = _claim(journal, _event(3, intent_id="intent.b")) + assert later is not None + journal.complete_source_event(later) + with pytest.raises(FrameworkProjectionRecoveryError, match="backwards"): + _claim(journal, _event(2, intent_id="intent.a")) + finally: + journal.close() + + +def test_fenced_source_session_rejects_future_events(tmp_path) -> None: + journal = FrameworkProjectionJournal(tmp_path) + try: + journal.fence_source_session( + scope_key=_SCOPE, + journal_incarnation_id=_INCARNATION, + session_id=_SESSION, + reason="fixture_failure", + ) + with pytest.raises(FrameworkProjectionRecoveryError, match="fenced"): + _claim(journal, _event(1)) + finally: + journal.close() + + +def test_validate_source_event_rejects_a_receipt_after_session_fence(tmp_path) -> None: + journal = FrameworkProjectionJournal(tmp_path) + try: + claim = _claim(journal, _event(1)) + assert claim is not None + journal.fence_source_session( + scope_key=_SCOPE, + journal_incarnation_id=_INCARNATION, + session_id=_SESSION, + reason="fixture_failure", + ) + assert journal.validate_source_event(claim) is False + finally: + journal.close() + + +def test_consumed_non_projection_event_persists_cursor_idempotently(tmp_path) -> None: + journal = FrameworkProjectionJournal(tmp_path) + lifecycle = SimpleNamespace( + sequence=1, + event_id="event.intent-recorded", + intent_id="intent.a", + scope_key=_SCOPE, + event_type="intent_recorded", + state="PENDING_ADMISSION", + created_at_ns=101, + journal_incarnation_id=_INCARNATION, + payload={"payload_sha256": "c" * 64}, + ) + try: + assert ( + journal.source_high_water( + scope_key=_SCOPE, + journal_incarnation_id=_INCARNATION, + session_id=_SESSION, + ) + == 0 + ) + assert ( + journal.advance_source_event( + event=lifecycle, + session_id=_SESSION, + expected_scope_key=_SCOPE, + ) + is True + ) + assert ( + journal.advance_source_event( + event=lifecycle, + session_id=_SESSION, + expected_scope_key=_SCOPE, + ) + is False + ) + assert ( + journal.source_high_water( + scope_key=_SCOPE, + journal_incarnation_id=_INCARNATION, + session_id=_SESSION, + ) + == 1 + ) + changed = SimpleNamespace(**{**vars(lifecycle), "payload": {"payload_sha256": "d" * 64}}) + with pytest.raises(FrameworkProjectionRecoveryError, match="reused"): + journal.advance_source_event( + event=changed, + session_id=_SESSION, + expected_scope_key=_SCOPE, + ) + finally: + journal.close() diff --git a/tests/unit/runtime/test_iteration41_evidence_bundle.py b/tests/unit/runtime/test_iteration41_evidence_bundle.py new file mode 100644 index 00000000..2acf257f --- /dev/null +++ b/tests/unit/runtime/test_iteration41_evidence_bundle.py @@ -0,0 +1,141 @@ +"""Local-only contracts for the Iteration 41 metadata evidence collector.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Tuple + +import pytest + +from backtrader_runtime import RuntimeRegistry +from backtrader_runtime import cli as runtime_cli +from backtrader_runtime.evidence_bundle import collect_iteration41_evidence + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _passing_specs() -> Tuple[Tuple[str, str, str], ...]: + return ( + ("metadata", "evidence/metadata.json", "json"), + ("junit", "evidence/results.xml", "junit"), + ) + + +def _passing_fixture(root: Path) -> None: + _write( + root / "evidence" / "metadata.json", + json.dumps( + { + "schema_version": 1, + "status": "PASS", + "evidence_kind": "local", + "secret_value": "must-not-appear-in-output", + } + ), + ) + _write( + root / "evidence" / "results.xml", + '', + ) + + +def test_collector_returns_only_redacted_structural_metadata_for_code_owned_inputs( + tmp_path: Path, +) -> None: + _passing_fixture(tmp_path) + + bundle = collect_iteration41_evidence(tmp_path, _passing_specs()) + + assert bundle["status"] == "PASS" + assert bundle["reason_code"] == "local_metadata_collected" + assert bundle["actual"] == { + "artifact_count": 2, + "pass_count": 2, + "not_run_count": 0, + "fail_count": 0, + } + assert bundle["network_attempt_count"] == 0 + assert bundle["external_provider_write_count"] == 0 + assert bundle["evidence_boundary"].endswith("NOT_LIVE_ADMISSION") + assert bundle["artifact_refs"][1]["summary"] == { + "testcases": 1, + "failures": 0, + "errors": 0, + "skipped": 0, + } + rendered = json.dumps(bundle) + assert "must-not-appear-in-output" not in rendered + assert "secret_value" not in rendered + + +def test_collector_marks_missing_code_owned_artifacts_not_run(tmp_path: Path) -> None: + bundle = collect_iteration41_evidence(tmp_path, _passing_specs()) + + assert bundle["status"] == "NOT_RUN" + assert bundle["reason_code"] == "required_local_artifact_missing" + assert [item["reason_code"] for item in bundle["artifact_refs"]] == [ + "required_artifact_missing", + "required_artifact_missing", + ] + + +@pytest.mark.parametrize( + "path,expected_reason", + ( + ("../outside.json", "artifact_path_not_code_owned"), + ("evidence\\outside.json", "artifact_path_not_code_owned"), + ), +) +def test_collector_rejects_non_code_owned_paths_without_reading_them( + tmp_path: Path, path: str, expected_reason: str +) -> None: + _write(tmp_path / "outside.json", "{}") + + bundle = collect_iteration41_evidence(tmp_path, (("bad", path, "json"),)) + + assert bundle["status"] == "FAIL" + assert bundle["artifact_refs"][0]["reason_code"] == expected_reason + + +def test_collector_rejects_invalid_or_failing_junit_metadata(tmp_path: Path) -> None: + _write(tmp_path / "evidence" / "metadata.json", "not-json") + _write( + tmp_path / "evidence" / "results.xml", + '', + ) + + bundle = collect_iteration41_evidence(tmp_path, _passing_specs()) + + assert bundle["status"] == "FAIL" + assert [item["reason_code"] for item in bundle["artifact_refs"]] == [ + "invalid_json_artifact", + "junit_reports_failures", + ] + + +def test_cli_collect_evidence_has_no_runtime_or_provider_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + expected = { + "status": "PASS", + "reason_code": "local_metadata_collected", + "evidence_boundary": "LOCAL_METADATA_COLLECTION_ONLY_NOT_LIVE_ADMISSION", + } + monkeypatch.setattr(runtime_cli, "collect_iteration41_evidence", lambda: expected) + stdout = io.StringIO() + + status = runtime_cli.main( + ["collect-evidence"], + registry=RuntimeRegistry(()), + environ={}, + stdout=stdout, + stderr=io.StringIO(), + ) + + assert status == 0 + assert json.loads(stdout.getvalue()) == expected diff --git a/tests/unit/runtime/test_iteration41_registered_offline_smoke.py b/tests/unit/runtime/test_iteration41_registered_offline_smoke.py new file mode 100644 index 00000000..830ff748 --- /dev/null +++ b/tests/unit/runtime/test_iteration41_registered_offline_smoke.py @@ -0,0 +1,55 @@ +"""Offline smoke selection must not read private or shadow runtime configs.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from scripts.ci import smoke_iteration41_registered_offline as smoke + + +def test_offline_smoke_skips_registered_private_read_and_shadow_before_config_load( + monkeypatch, capsys +): + registry = smoke.iteration41_runtime_registry() + private_registration = smoke.ITERATION41_013_3_CTP_PRIVATE_READONLY_REGISTRATION + private_007_registration = smoke.ITERATION41_007_CTP_PRIVATE_READONLY_REGISTRATION + shadow_registration = smoke.ITERATION41_010_OKX_SHADOW_REGISTRATION + + loaded = [] + dispatched = [] + + def fake_load_runtime_config(runtime_dir, *, registry): + runtime_path = Path(runtime_dir) + loaded.append(runtime_path) + registration = registry.require_runtime_dir(runtime_path) + preset = registration.allowed_presets[0] + mode = "backtest" if preset == "local_backtest" else "simulation" + return SimpleNamespace(mode=mode, preset=preset, secrets_ref="none") + + def fake_run(command, **kwargs): + dispatched.append(command) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(smoke, "iteration41_runtime_registry", lambda: registry) + monkeypatch.setattr(smoke, "load_runtime_config", fake_load_runtime_config) + monkeypatch.setattr(smoke.subprocess, "run", fake_run) + + assert smoke.main() == 0 + + eligible = tuple( + registration + for registration in registry.registrations + if registration.allowed_presets in (("replay",), ("local_backtest",)) + ) + eligible_paths = {registration.runtime_dir for registration in eligible} + assert set(loaded) == eligible_paths + assert private_registration.runtime_dir not in loaded + assert private_007_registration.runtime_dir not in loaded + assert shadow_registration.runtime_dir not in loaded + assert len(dispatched) == len(eligible) + report = capsys.readouterr().out + assert "{0}: SKIP_PRIVATE_READ_ONLY".format(private_registration.runtime_id) in report + assert "{0}: SKIP_PRIVATE_READ_ONLY".format(private_007_registration.runtime_id) in report + assert "{0}: SKIP_SHADOW".format(shadow_registration.runtime_id) in report + assert "offline smoke: 14 passed, 0 failed, 14 selected, 3 skipped" in report diff --git a/tests/unit/runtime/test_iteration41_review_evidence.py b/tests/unit/runtime/test_iteration41_review_evidence.py new file mode 100644 index 00000000..e07ac6ce --- /dev/null +++ b/tests/unit/runtime/test_iteration41_review_evidence.py @@ -0,0 +1,266 @@ +"""Config-v4 bindings for the Iteration 41 AI review-evidence profile.""" + +from __future__ import annotations + +import copy +import subprocess +import sys +from pathlib import Path + +import pytest + +from backtrader_runtime import ( + PRESET_POLICY_VIOLATION, + REVIEW_REQUIRED, + RegisteredRuntime, + RuntimeConfigError, + RuntimeRegistry, +) +from backtrader_runtime.review_evidence import ( + Iteration41ReviewEvidenceError, + evidence_sha256, + resolve_iteration41_review_evidence_bindings, + validate_iteration41_review_evidence, + validate_registered_iteration41_review_evidence, +) + + +NOW = 1_700_000_100.0 + + +def _write_config(runtime_dir: Path, *, scenario: str = "baseline") -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "config.yaml").write_text( + "config_schema_version: 4\n" + "strategy:\n" + " id: example.ai-review\n" + "runtime:\n" + " mode: simulation\n" + " preset: replay\n" + "parameters:\n" + " scenario: {0}\n" + "secrets_ref: none\n".format(scenario), + encoding="utf-8", + ) + + +def _registry(runtime_dir: Path) -> RuntimeRegistry: + return RuntimeRegistry( + ( + RegisteredRuntime( + runtime_dir=runtime_dir, + strategy_id="example.ai-review", + allowed_presets=("replay",), + allowed_parameter_keys=("scenario",), + ), + ) + ) + + +def _wire(bindings, *, review_status: str = REVIEW_REQUIRED) -> dict: + return { + "artifact_sha256": bindings.artifact_sha256, + "config_effective_digest": bindings.config_effective_digest, + "created_at": NOW - 10.0, + "evidence_id": "evidence-iteration41-config-v4", + "expires_at": NOW + 60.0, + "metadata": {"source": "offline-agent", "summary": "review queue only"}, + "producer": { + "commit": "iteration41", + "product": "backtrader-agent", + "version": "0.2.0", + "wheel_sha256": "a" * 64, + }, + "review_status": review_status, + "schema_version": "bt-api-deployment-evidence/v1", + "strategy_id": bindings.strategy_id, + "tenant_id": bindings.tenant_id, + } + + +def _context(tmp_path: Path): + runtime_dir = tmp_path / "registered-runtime" + _write_config(runtime_dir) + artifact = tmp_path / "strategy.py" + artifact.write_bytes(b"class Strategy:\n pass\n") + registry = _registry(runtime_dir) + bindings = resolve_iteration41_review_evidence_bindings( + strategy_dir=runtime_dir, + tenant_id="tenant-iteration41", + artifact_path=artifact, + registry=registry, + ) + return runtime_dir, artifact, registry, bindings + + +def test_registered_config_v4_scope_binds_artifact_tenant_and_review_only_evidence( + tmp_path: Path, +) -> None: + runtime_dir, artifact, registry, bindings = _context(tmp_path) + wire = _wire(bindings) + + observation = validate_registered_iteration41_review_evidence( + wire, + expected_evidence_sha256=evidence_sha256(wire), + strategy_dir=runtime_dir, + tenant_id="tenant-iteration41", + artifact_path=artifact, + registry=registry, + now=NOW, + ) + + assert observation.status == "REVIEW_REQUIRED" + assert observation.review_status == REVIEW_REQUIRED + assert observation.read_only is True + assert observation.deployment_authorized is False + assert observation.execution_authorized is False + assert observation.control_authorized is False + assert dict(bindings.as_consumer_bindings()) == { + "expected_tenant_id": "tenant-iteration41", + "expected_strategy_id": "example.ai-review", + "expected_artifact_sha256": bindings.artifact_sha256, + "expected_config_effective_digest": bindings.config_effective_digest, + } + + +def test_unregistered_config_cannot_supply_iteration41_evidence_bindings(tmp_path: Path) -> None: + runtime_dir = tmp_path / "not-registered" + _write_config(runtime_dir) + artifact = tmp_path / "strategy.py" + artifact.write_bytes(b"pass\n") + + with pytest.raises(RuntimeConfigError) as caught: + resolve_iteration41_review_evidence_bindings( + strategy_dir=runtime_dir, + tenant_id="tenant-iteration41", + artifact_path=artifact, + registry=RuntimeRegistry(()), + ) + + assert caught.value.code == PRESET_POLICY_VIOLATION + assert caught.value.reason == "runtime_not_registered" + + +@pytest.mark.parametrize( + "mutate,reason", + ( + (lambda wire: wire.update({"tenant_id": "other-tenant"}), "tenant_mismatch"), + (lambda wire: wire.update({"strategy_id": "other-strategy"}), "strategy_mismatch"), + (lambda wire: wire.update({"artifact_sha256": "b" * 64}), "artifact_mismatch"), + ( + lambda wire: wire.update({"config_effective_digest": "c" * 64}), + "config_effective_digest_mismatch", + ), + ( + lambda wire: wire.update({"review_status": "human_reviewed"}), + "review_status_not_review_required", + ), + ), +) +def test_iteration41_profile_rejects_wrong_scope_or_non_review_required_status( + tmp_path: Path, mutate, reason: str +) -> None: + _, _, _, bindings = _context(tmp_path) + wire = _wire(bindings) + mutate(wire) + + with pytest.raises(Iteration41ReviewEvidenceError) as caught: + validate_iteration41_review_evidence( + wire, + expected_evidence_sha256=evidence_sha256(wire), + bindings=bindings, + now=NOW, + ) + + assert caught.value.reason == reason + + +def test_tampering_expiry_and_authority_shaped_metadata_fail_closed(tmp_path: Path) -> None: + _, _, _, bindings = _context(tmp_path) + original = _wire(bindings) + trusted_digest = evidence_sha256(original) + + tampered = copy.deepcopy(original) + tampered["metadata"]["source"] = "changed" + with pytest.raises(Iteration41ReviewEvidenceError) as caught: + validate_iteration41_review_evidence( + tampered, + expected_evidence_sha256=trusted_digest, + bindings=bindings, + now=NOW, + ) + assert caught.value.reason == "evidence_digest_mismatch" + + expired = _wire(bindings) + expired["expires_at"] = NOW + with pytest.raises(Iteration41ReviewEvidenceError) as caught: + validate_iteration41_review_evidence( + expired, + expected_evidence_sha256=evidence_sha256(expired), + bindings=bindings, + now=NOW, + ) + assert caught.value.reason == "evidence_expired" + + authority = _wire(bindings) + authority["metadata"] = {"approval": "not-an-admission"} + with pytest.raises(Iteration41ReviewEvidenceError) as caught: + validate_iteration41_review_evidence( + authority, + expected_evidence_sha256=evidence_sha256(authority), + bindings=bindings, + now=NOW, + ) + assert caught.value.reason == "authority_shaped_metadata" + + +def test_config_or_artifact_change_requires_fresh_iteration41_evidence(tmp_path: Path) -> None: + runtime_dir, artifact, registry, old_bindings = _context(tmp_path) + old_wire = _wire(old_bindings) + old_digest = evidence_sha256(old_wire) + + _write_config(runtime_dir, scenario="changed") + artifact.write_bytes(b"class Strategy:\n revision = 2\n") + + with pytest.raises(Iteration41ReviewEvidenceError) as caught: + validate_registered_iteration41_review_evidence( + old_wire, + expected_evidence_sha256=old_digest, + strategy_dir=runtime_dir, + tenant_id="tenant-iteration41", + artifact_path=artifact, + registry=registry, + now=NOW, + ) + + # Artifact is checked first after the scope fields, so either changed + # binding remains a deterministic no-admission result. + assert caught.value.reason == "artifact_mismatch" + + fresh_bindings = resolve_iteration41_review_evidence_bindings( + strategy_dir=runtime_dir, + tenant_id="tenant-iteration41", + artifact_path=artifact, + registry=registry, + ) + assert fresh_bindings.config_effective_digest != old_bindings.config_effective_digest + assert fresh_bindings.artifact_sha256 != old_bindings.artifact_sha256 + + +def test_profile_module_has_no_backtrader_or_bt_api_execution_imports() -> None: + script = ( + "import sys; import backtrader_runtime.review_evidence; " + "blocked = ('backtrader', 'bt_api', 'bt_api_py', 'backtrader_agent', " + "'backtrader_skills', 'backtrader_mcp'); " + "assert not any(name == item or name.startswith(item + '.') " + "for item in blocked for name in sys.modules)" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=str(Path(__file__).resolve().parents[3]), + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr diff --git a/tests/unit/runtime/test_provider_deployment.py b/tests/unit/runtime/test_provider_deployment.py new file mode 100644 index 00000000..cfe62eed --- /dev/null +++ b/tests/unit/runtime/test_provider_deployment.py @@ -0,0 +1,588 @@ +"""Offline provider-deployment receipt binding contract coverage.""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path + +import pytest + +import backtrader_runtime.provider_deployment as provider_deployment +from backtrader_runtime.provider_deployment import ( + ACTIVE_RECEIPT_STATUS, + MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULE_NAME_LENGTH, + MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES, + PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION, + ProviderDeploymentReceipt, + ProviderDeploymentReceiptError, + ProviderDeploymentRegistration, + canonical_provider_deployment_receipt, + parse_provider_deployment_receipt, + validate_provider_deployment_receipt, +) + + +NOW = 1_700_000_100.0 + + +@pytest.fixture(autouse=True) +def receipt_clock(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep public validation on its real clock path in fixed-time tests.""" + + monkeypatch.setattr(provider_deployment.time, "time", lambda: NOW) + + +class _AcceptingOfflineVerifier: + """A test-only pure verifier; it has no trust material or provider access.""" + + def __init__(self) -> None: + self.calls = 0 + + def verify(self, receipt, canonical_payload: bytes) -> bool: + self.calls += 1 + assert canonical_payload == canonical_provider_deployment_receipt(receipt) + return True + + +class _MutatingOfflineVerifier: + """A hostile verifier used to ensure its input cannot corrupt validation.""" + + def verify(self, receipt, canonical_payload: bytes) -> bool: + del canonical_payload + object.__setattr__(receipt, "artifact_sha256", "e" * 64) + object.__setattr__(receipt, "required_capability_modules", ("bt_api_unreviewed",)) + return True + + +class _InvalidMutatingOfflineVerifier: + """A hostile verifier that makes its receipt impossible to canonicalize.""" + + def verify(self, receipt, canonical_payload: bytes) -> bool: + del canonical_payload + object.__setattr__(receipt, "artifact_sha256", object()) + return True + + +class _StatefulMapping(Mapping): + """A mapping whose iteration would be an unacceptable parser side effect.""" + + def __init__(self) -> None: + self.iterated = False + + def __getitem__(self, key): + del key + raise AssertionError("the parser must not read a custom Mapping") + + def __iter__(self): + self.iterated = True + raise AssertionError("the parser must not iterate a custom Mapping") + + def __len__(self) -> int: + return 0 + + +class _StatefulList(list): + """A list subclass whose iteration would be an unacceptable parser side effect.""" + + def __init__(self, values) -> None: + super().__init__(values) + self.iterated = False + + def __iter__(self): + self.iterated = True + raise AssertionError("the parser must not iterate a list subclass") + + +class _StatefulText(str): + """A text subclass whose length access would be a parser side effect.""" + + def __new__(cls, value: str): + instance = super().__new__(cls, value) + instance.touched = False + return instance + + def __len__(self) -> int: + self.touched = True + raise AssertionError("the parser must not inspect a str subclass") + + +class _StatefulBytes(bytes): + """A bytes subclass whose length access would be a parser side effect.""" + + def __new__(cls, value: bytes): + instance = super().__new__(cls, value) + instance.touched = False + return instance + + def __len__(self) -> int: + self.touched = True + raise AssertionError("the parser must not inspect a bytes subclass") + + +class _SideEffectReceipt(ProviderDeploymentReceipt): + """A receipt subclass whose virtual serializer must never be dispatched.""" + + serializer_called = False + + def as_wire(self): + type(self).serializer_called = True + raise AssertionError("the canonicalizer must not dispatch receipt subclasses") + + +class _SideEffectRegistration(ProviderDeploymentRegistration): + """A registration subclass whose attributes must not be read by validation.""" + + attributes_read = False + armed = False + + def __getattribute__(self, name): + if name == "runtime_id" and type(self).armed: + type(self).attributes_read = True + raise AssertionError("validation must not read registration subclasses") + return super().__getattribute__(name) + + +def _registration() -> ProviderDeploymentRegistration: + return ProviderDeploymentRegistration( + registration_id="iteration41.ctp.sandbox.demo-1", + runtime_id="example.iteration41.runtime", + strategy_id="example.iteration41.strategy", + provider="ctp", + environment="sandbox", + allowed_secrets_refs=("os_secret_store:iteration41.ctp.sandbox",), + account_fingerprint_sha256="a" * 64, + approval_receipt_digest="e" * 64, + artifact_sha256="b" * 64, + effective_config_digest="c" * 64, + capability_receipt_digest="d" * 64, + required_capability_modules=("bt_api_py", "bt_api_ctp"), + ) + + +def _wire(registration: ProviderDeploymentRegistration) -> dict: + return { + "account_fingerprint_sha256": registration.account_fingerprint_sha256, + "approval_receipt_digest": registration.approval_receipt_digest, + "artifact_sha256": registration.artifact_sha256, + "capability_receipt_digest": registration.capability_receipt_digest, + "created_at": NOW - 10.0, + "effective_config_digest": registration.effective_config_digest, + "environment": registration.environment, + "expires_at": NOW + 60.0, + "provider": registration.provider, + "receipt_id": "iteration41-receipt-1", + "registration_id": registration.registration_id, + "required_capability_modules": list(registration.required_capability_modules), + "revoked_at": None, + "runtime_id": registration.runtime_id, + "schema_version": PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION, + "secrets_ref": registration.allowed_secrets_refs[0], + "status": ACTIVE_RECEIPT_STATUS, + "strategy_id": registration.strategy_id, + } + + +def test_matching_receipt_is_only_a_non_authoritative_offline_observation() -> None: + registration = _registration() + verifier = _AcceptingOfflineVerifier() + + validation = validate_provider_deployment_receipt( + _wire(registration), registration=registration, verifier=verifier + ) + + assert verifier.calls == 1 + assert validation.status == "RECEIPT_BINDING_VALIDATED" + assert validation.receipt_binding_valid is True + assert validation.deployment_authorized is False + assert validation.execution_authorized is False + assert validation.secrets_resolved is False + assert validation.provider_preflight_started is False + assert validation.valid_until == NOW + 60.0 + assert validation.approval_receipt_digest == registration.approval_receipt_digest + assert validation.required_capability_modules == ("bt_api_ctp", "bt_api_py") + canonical = canonical_provider_deployment_receipt( + parse_provider_deployment_receipt(_wire(registration)) + ) + assert json.loads(canonical) == _wire(registration) + assert PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION == "bt-provider-deployment-receipt/v2" + public = validation.as_public_dict() + assert "secrets_ref" not in public + assert registration.allowed_secrets_refs[0] not in json.dumps(public) + assert registration.allowed_secrets_refs[0] not in repr(registration) + assert registration.allowed_secrets_refs[0] not in repr( + parse_provider_deployment_receipt(_wire(registration)) + ) + with pytest.raises(ValueError, match="cannot grant admission"): + replace(validation, deployment_authorized=True) + with pytest.raises(TypeError, match="not an admission decision"): + bool(validation) + + +def test_default_verifier_fails_closed_without_claiming_provider_trust() -> None: + registration = _registration() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt(_wire(registration), registration=registration) + + assert caught.value.reason == "receipt_untrusted" + + +def test_caller_cannot_revive_receipt_with_historical_time() -> None: + registration = _registration() + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(TypeError, match="unexpected keyword argument 'now'"): + validate_provider_deployment_receipt( + _wire(registration), + registration=registration, + verifier=verifier, + now=NOW - 1_000.0, # type: ignore[call-arg] + ) + + assert verifier.calls == 0 + + +def test_canonicalizer_rejects_receipt_subclasses_without_virtual_dispatch() -> None: + registration = _registration() + base_receipt = parse_provider_deployment_receipt(_wire(registration)) + receipt = _SideEffectReceipt(**base_receipt.__dict__) + _SideEffectReceipt.serializer_called = False + + with pytest.raises(TypeError, match="receipt must be a ProviderDeploymentReceipt"): + canonical_provider_deployment_receipt(receipt) + + assert _SideEffectReceipt.serializer_called is False + + +def test_validation_rejects_registration_subclasses_before_attribute_access() -> None: + base_registration = _registration() + registration = _SideEffectRegistration(**base_registration.__dict__) + _SideEffectRegistration.attributes_read = False + _SideEffectRegistration.armed = True + try: + with pytest.raises( + TypeError, match="registration must be a ProviderDeploymentRegistration" + ): + validate_provider_deployment_receipt( + _wire(base_registration), registration=registration + ) + finally: + _SideEffectRegistration.armed = False + + assert _SideEffectRegistration.attributes_read is False + + +@pytest.mark.parametrize( + "mutate,reason", + ( + ( + lambda wire: wire.update({"registration_id": "other.registration"}), + "registration_mismatch", + ), + (lambda wire: wire.update({"runtime_id": "other.runtime"}), "runtime_mismatch"), + (lambda wire: wire.update({"strategy_id": "other.strategy"}), "strategy_mismatch"), + (lambda wire: wire.update({"provider": "other-provider"}), "provider_mismatch"), + (lambda wire: wire.update({"environment": "production"}), "environment_mismatch"), + ( + lambda wire: wire.update({"secrets_ref": "os_secret_store:other-provider"}), + "secrets_ref_not_registered", + ), + ( + lambda wire: wire.update({"account_fingerprint_sha256": "e" * 64}), + "account_fingerprint_mismatch", + ), + ( + lambda wire: wire.update({"approval_receipt_digest": "f" * 64}), + "approval_receipt_digest_mismatch", + ), + (lambda wire: wire.update({"artifact_sha256": "e" * 64}), "artifact_mismatch"), + ( + lambda wire: wire.update({"effective_config_digest": "e" * 64}), + "effective_config_digest_mismatch", + ), + ( + lambda wire: wire.update({"capability_receipt_digest": "e" * 64}), + "capability_receipt_digest_mismatch", + ), + ( + lambda wire: wire.update({"required_capability_modules": ["bt_api_py"]}), + "required_capability_modules_mismatch", + ), + ), +) +def test_receipt_must_bind_every_registered_provider_deployment_value(mutate, reason: str) -> None: + registration = _registration() + wire = _wire(registration) + mutate(wire) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt(wire, registration=registration, verifier=verifier) + + assert caught.value.reason == reason + assert verifier.calls == 0 + + +@pytest.mark.parametrize( + "mutate,reason", + ( + (lambda wire: wire.update({"created_at": NOW + 1.0}), "receipt_not_yet_valid"), + (lambda wire: wire.update({"expires_at": NOW}), "receipt_expired"), + (lambda wire: wire.update({"status": "revoked"}), "receipt_revoked"), + (lambda wire: wire.update({"revoked_at": NOW - 1.0}), "receipt_revoked"), + (lambda wire: wire.update({"status": "suspended"}), "unsupported_receipt_status"), + ( + lambda wire: wire.update({"schema_version": "bt-provider-deployment-receipt/v1"}), + "unsupported_schema", + ), + (lambda wire: wire.update({"unexpected": "field"}), "invalid_wire"), + ( + lambda wire: wire.update({"secrets_ref": "runtime_secrets"}), + "invalid_secrets_ref", + ), + ( + lambda wire: wire.update({"required_capability_modules": ["bt_api_py", "bt_api_py"]}), + "invalid_capability_modules", + ), + ( + lambda wire: wire.update({"required_capability_modules": [[]]}), + "invalid_capability_modules", + ), + (lambda wire: wire.update({"approval_receipt_digest": "invalid"}), "invalid_digest"), + ), +) +def test_malformed_lifecycle_or_revoked_receipts_fail_before_the_verifier( + mutate, reason: str +) -> None: + registration = _registration() + wire = _wire(registration) + mutate(wire) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt(wire, registration=registration, verifier=verifier) + + assert caught.value.reason == reason + assert verifier.calls == 0 + + +def test_parser_rejects_duplicate_json_fields_before_any_verifier_can_run() -> None: + registration = _registration() + encoded = json.dumps(_wire(registration), sort_keys=True) + duplicate = encoded[:-1] + ',"receipt_id":"duplicate"}' + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt( + duplicate, registration=registration, verifier=verifier + ) + + assert caught.value.reason == "invalid_json" + assert verifier.calls == 0 + + +def test_parser_rejects_receipts_missing_the_preissued_approval_digest() -> None: + registration = _registration() + wire = _wire(registration) + del wire["approval_receipt_digest"] + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + parse_provider_deployment_receipt(wire) + + assert caught.value.reason == "invalid_wire" + + +@pytest.mark.parametrize( + "serialized,expected_reasons", + ( + ("[" * 2_000 + "]" * 2_000, ("invalid_json", "invalid_wire")), + (b"{" + b" " * MAX_PROVIDER_DEPLOYMENT_RECEIPT_BYTES + b"}", ("receipt_too_large",)), + ), + ids=("deeply_nested_json", "oversized_bytes"), +) +def test_parser_rejects_nested_or_oversized_receipts_before_the_verifier( + serialized: object, expected_reasons +) -> None: + registration = _registration() + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt( + serialized, registration=registration, verifier=verifier + ) + + assert caught.value.reason in expected_reasons + assert verifier.calls == 0 + + +def test_parser_rejects_stateful_mappings_without_executing_them() -> None: + mapping = _StatefulMapping() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + parse_provider_deployment_receipt(mapping) + + assert caught.value.reason == "invalid_wire" + assert mapping.iterated is False + + +def test_parser_rejects_stateful_json_container_subclasses_without_executing_them() -> None: + registration = _registration() + wire = _wire(registration) + modules = _StatefulList(wire["required_capability_modules"]) + wire["required_capability_modules"] = modules + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + parse_provider_deployment_receipt(wire) + + assert caught.value.reason == "invalid_capability_modules" + assert modules.iterated is False + + +@pytest.mark.parametrize( + "factory", + (lambda: _StatefulText("{}"), lambda: _StatefulBytes(b"{}")), + ids=("str_subclass", "bytes_subclass"), +) +def test_parser_rejects_stateful_serialized_subclasses_without_executing_them(factory) -> None: + serialized = factory() + with pytest.raises(ProviderDeploymentReceiptError) as caught: + parse_provider_deployment_receipt(serialized) + + assert caught.value.reason == "invalid_wire" + assert serialized.touched is False + + +def test_plain_dict_receipts_cannot_bypass_the_canonical_size_limit() -> None: + registration = _registration() + wire = _wire(registration) + wire["required_capability_modules"] = [ + "bt_api_" + "a" * (MAX_PROVIDER_DEPLOYMENT_CAPABILITY_MODULE_NAME_LENGTH + 1) + ] + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt(wire, registration=registration, verifier=verifier) + + assert caught.value.reason == "receipt_too_large" + assert verifier.calls == 0 + + +def test_parser_converts_a_json_recursion_error_to_a_redacted_rejection(monkeypatch) -> None: + def raise_recursion_error(*args, **kwargs): + del args, kwargs + raise RecursionError("synthetic parser depth failure") + + monkeypatch.setattr(provider_deployment.json, "loads", raise_recursion_error) + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + parse_provider_deployment_receipt("{}") + + assert caught.value.reason == "invalid_json" + + +def test_verifier_cannot_mutate_the_canonical_receipt_it_claims_to_trust() -> None: + registration = _registration() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt( + _wire(registration), + registration=registration, + verifier=_MutatingOfflineVerifier(), + ) + + assert caught.value.reason == "receipt_mutated_by_verifier" + + +def test_verifier_invalid_type_mutation_fails_closed_with_a_redacted_reason() -> None: + registration = _registration() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt( + _wire(registration), + registration=registration, + verifier=_InvalidMutatingOfflineVerifier(), + ) + + assert caught.value.reason == "receipt_mutated_by_verifier" + + +def test_validation_rechecks_expiry_after_the_verifier_returns(monkeypatch) -> None: + registration = _registration() + clock_values = iter((NOW, NOW + 60.0)) + monkeypatch.setattr(provider_deployment.time, "time", lambda: next(clock_values)) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_deployment_receipt( + _wire(registration), registration=registration, verifier=verifier + ) + + assert caught.value.reason == "receipt_expired" + assert verifier.calls == 1 + + +def test_registration_requires_only_opaque_secret_references_and_capability_modules() -> None: + values = dict(_registration().__dict__) + values["allowed_secrets_refs"] = ("runtime_secrets",) + with pytest.raises(ValueError, match="invalid allowed_secrets_refs"): + ProviderDeploymentRegistration(**values) + + values = dict(_registration().__dict__) + values["required_capability_modules"] = () + with pytest.raises(ValueError, match="invalid required_capability_modules"): + ProviderDeploymentRegistration(**values) + + +def test_registration_requires_a_preissued_approval_receipt_digest() -> None: + values = dict(_registration().__dict__) + values["approval_receipt_digest"] = None + + with pytest.raises(ValueError, match="invalid approval_receipt_digest"): + ProviderDeploymentRegistration(**values) + + +def test_validation_rechecks_mutated_code_owned_registration_digest() -> None: + registration = _registration() + object.__setattr__(registration, "approval_receipt_digest", "invalid") + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(ValueError, match="invalid approval_receipt_digest"): + validate_provider_deployment_receipt( + _wire(_registration()), registration=registration, verifier=verifier + ) + + assert verifier.calls == 0 + + +def test_receipt_parser_normalizes_capability_module_order_for_exact_scope_matching() -> None: + registration = _registration() + wire = _wire(registration) + wire["required_capability_modules"] = ["bt_api_py", "bt_api_ctp"] + + receipt = parse_provider_deployment_receipt(copy.deepcopy(wire)) + + assert receipt.required_capability_modules == ("bt_api_ctp", "bt_api_py") + + +def test_provider_deployment_module_imports_no_framework_or_provider_sdk() -> None: + script = ( + "import sys; import backtrader_runtime.provider_deployment; " + "blocked = ('backtrader', 'bt_api', 'bt_api_py', 'backtrader_agent', " + "'backtrader_skills', 'backtrader_mcp'); " + "assert not any(name == item or name.startswith(item + '.') " + "for item in blocked for name in sys.modules)" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=str(Path(__file__).resolve().parents[3]), + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr diff --git a/tests/unit/runtime/test_provider_preflight.py b/tests/unit/runtime/test_provider_preflight.py new file mode 100644 index 00000000..e546c19e --- /dev/null +++ b/tests/unit/runtime/test_provider_preflight.py @@ -0,0 +1,396 @@ +"""Focused, zero-I/O coverage for the sealed provider-preflight binding.""" + +from __future__ import annotations + +import json +import socket +import subprocess +import sys +from dataclasses import replace +from pathlib import Path + +import pytest + +import backtrader_runtime.provider_deployment as provider_deployment +from backtrader_runtime import ( + RegisteredRuntime, + RuntimeConfigError, + RuntimeRegistry, + load_runtime_config, + resolve_runtime_config, +) +from backtrader_runtime.policy import MANAGED_WRITE_CAPABILITIES +from backtrader_runtime.provider_deployment import ( + ACTIVE_RECEIPT_STATUS, + PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION, + ProviderDeploymentReceiptError, + ProviderDeploymentRegistration, +) +from backtrader_runtime.provider_preflight import ( + ProviderSessionPreflightRegistration, + validate_provider_session_preflight_binding, +) + + +NOW = 1_700_100_000.0 +SECRET_REF = "os_secret_store:iteration41.ctp.simnow" +CAPABILITY_MODULES = ( + "bt_api_ctp", + "bt_api_execution", + "bt_api_monitor", + "bt_api_py", + "bt_api_risk", +) + + +@pytest.fixture(autouse=True) +def receipt_clock(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise the production clock path while using fixed receipt vectors.""" + + monkeypatch.setattr(provider_deployment.time, "time", lambda: NOW) + + +class _AcceptingOfflineVerifier: + """Test-only verifier with no trust material, SDK access, or provider I/O.""" + + def __init__(self) -> None: + self.calls = 0 + + def verify(self, receipt, canonical_payload: bytes) -> bool: + self.calls += 1 + assert receipt.receipt_id == "provider-receipt-1" + assert canonical_payload + return True + + +def _write_config(runtime_dir: Path) -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "config.yaml").write_text( + """config_schema_version: 4 +strategy: + id: example.provider_preflight +runtime: + mode: simulation + preset: sandbox +parameters: {{}} +secrets_ref: {secret_ref} +""".format( + secret_ref=SECRET_REF + ), + encoding="utf-8", + ) + + +def _registry(runtime_dir: Path) -> RuntimeRegistry: + return RuntimeRegistry( + ( + RegisteredRuntime( + runtime_dir=runtime_dir, + runtime_id="example.provider_preflight.simnow", + strategy_id="example.provider_preflight", + allowed_presets=("sandbox",), + allowed_secrets_refs=(SECRET_REF,), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + sandbox_write_policy="receipt_required", + approval_receipt_digest="a" * 64, + capability_modules=CAPABILITY_MODULES, + ), + ), + registry_id="iteration41.provider-preflight-test", + ) + + +def _effective(runtime_dir: Path): + _write_config(runtime_dir) + registry = _registry(runtime_dir) + return ( + resolve_runtime_config(load_runtime_config(runtime_dir, registry=registry), registry), + registry, + ) + + +def _deployment(effective) -> ProviderDeploymentRegistration: + return ProviderDeploymentRegistration( + registration_id="iteration41.ctp.simnow-readonly-preflight", + runtime_id=effective.registration.runtime_id, + strategy_id=effective.strategy_id, + provider="ctp", + environment="simnow_set2", + allowed_secrets_refs=(SECRET_REF,), + account_fingerprint_sha256="b" * 64, + approval_receipt_digest=effective.registration.approval_receipt_digest, + artifact_sha256="c" * 64, + effective_config_digest=effective.effective_digest, + capability_receipt_digest="d" * 64, + required_capability_modules=tuple(sorted(CAPABILITY_MODULES)), + ) + + +def _profile(deployment: ProviderDeploymentRegistration) -> ProviderSessionPreflightRegistration: + return ProviderSessionPreflightRegistration( + deployment=deployment, + mode="simulation", + preset="sandbox", + account_access="sandbox_direct_provider", + ) + + +def _receipt(deployment: ProviderDeploymentRegistration) -> dict: + return { + "account_fingerprint_sha256": deployment.account_fingerprint_sha256, + "approval_receipt_digest": deployment.approval_receipt_digest, + "artifact_sha256": deployment.artifact_sha256, + "capability_receipt_digest": deployment.capability_receipt_digest, + "created_at": NOW - 10.0, + "effective_config_digest": deployment.effective_config_digest, + "environment": deployment.environment, + "expires_at": NOW + 60.0, + "provider": deployment.provider, + "receipt_id": "provider-receipt-1", + "registration_id": deployment.registration_id, + "required_capability_modules": list(deployment.required_capability_modules), + "revoked_at": None, + "runtime_id": deployment.runtime_id, + "schema_version": PROVIDER_DEPLOYMENT_RECEIPT_SCHEMA_VERSION, + "secrets_ref": SECRET_REF, + "status": ACTIVE_RECEIPT_STATUS, + "strategy_id": deployment.strategy_id, + } + + +def test_sandbox_receipt_binding_cannot_connect_or_authorize_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + effective, registry = _effective(tmp_path / "runtime") + deployment = _deployment(effective) + verifier = _AcceptingOfflineVerifier() + socket_attempts = [] + + def reject_socket(*args, **kwargs): + socket_attempts.append((args, kwargs)) + raise AssertionError("offline provider preflight binding must not open a socket") + + monkeypatch.setattr(socket, "socket", reject_socket) + binding = validate_provider_session_preflight_binding( + effective, + registry, + _profile(deployment), + _receipt(deployment), + verifier=verifier, + ) + + assert verifier.calls == 1 + assert binding.mode == "simulation" + assert binding.preset == "sandbox" + assert binding.preflight_binding_valid is True + assert binding.provider_preflight_started is False + assert binding.secrets_resolved is False + assert binding.session_connected is False + assert binding.execution_authorized is False + assert binding.external_writes_authorized is False + assert binding.valid_until == NOW + 60.0 + assert socket_attempts == [] + with pytest.raises(TypeError, match="not a session or execution authorization"): + bool(binding) + public = json.dumps(binding.as_public_dict(), sort_keys=True) + assert SECRET_REF not in public + + +def test_caller_cannot_override_receipt_clock(tmp_path: Path) -> None: + effective, registry = _effective(tmp_path / "runtime") + deployment = _deployment(effective) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(TypeError, match="unexpected keyword argument 'now'"): + validate_provider_session_preflight_binding( + effective, + registry, + _profile(deployment), + _receipt(deployment), + verifier=verifier, + now=NOW - 1_000.0, # type: ignore[call-arg] + ) + + assert verifier.calls == 0 + + +def test_default_receipt_verifier_stops_before_any_session_preflight(tmp_path: Path) -> None: + effective, registry = _effective(tmp_path / "runtime") + deployment = _deployment(effective) + + with pytest.raises(ProviderDeploymentReceiptError) as caught: + validate_provider_session_preflight_binding( + effective, + registry, + _profile(deployment), + _receipt(deployment), + ) + + assert caught.value.reason == "receipt_untrusted" + + +def test_binding_rejects_a_deployment_for_a_different_effective_config(tmp_path: Path) -> None: + effective, registry = _effective(tmp_path / "runtime") + deployment = replace(_deployment(effective), effective_config_digest="e" * 64) + + with pytest.raises(RuntimeConfigError) as caught: + validate_provider_session_preflight_binding( + effective, + registry, + _profile(deployment), + _receipt(deployment), + verifier=_AcceptingOfflineVerifier(), + ) + + assert getattr(caught.value, "reason", None) == "provider_effective_config_mismatch" + + +def test_binding_rejects_a_deployment_for_a_different_runtime_approval_digest( + tmp_path: Path, +) -> None: + effective, registry = _effective(tmp_path / "runtime") + deployment = replace(_deployment(effective), approval_receipt_digest="f" * 64) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(RuntimeConfigError) as caught: + validate_provider_session_preflight_binding( + effective, + registry, + _profile(deployment), + _receipt(deployment), + verifier=verifier, + ) + + assert getattr(caught.value, "reason", None) == "provider_approval_receipt_digest_mismatch" + assert verifier.calls == 0 + + +def test_binding_rejects_different_capability_modules_before_receipt_verification( + tmp_path: Path, +) -> None: + effective, registry = _effective(tmp_path / "runtime") + deployment = replace( + _deployment(effective), + required_capability_modules=("bt_api_ctp", "bt_api_py"), + ) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(RuntimeConfigError) as caught: + validate_provider_session_preflight_binding( + effective, + registry, + _profile(deployment), + _receipt(deployment), + verifier=verifier, + ) + + assert getattr(caught.value, "reason", None) == "provider_capability_modules_mismatch" + assert verifier.calls == 0 + + +@pytest.mark.parametrize( + "mode,preset,account_access", + ( + ("simulation", "replay", "sandbox_direct_provider"), + ("simulation", "sandbox", "direct_provider"), + ("live", "sandbox", "sandbox_direct_provider"), + ), +) +def test_preflight_registration_only_accepts_reviewed_managed_route_shapes( + tmp_path: Path, mode: str, preset: str, account_access: str +) -> None: + effective, _ = _effective(tmp_path / "runtime") + + with pytest.raises(ValueError): + ProviderSessionPreflightRegistration( + deployment=_deployment(effective), + mode=mode, + preset=preset, + account_access=account_access, + ) + + +@pytest.mark.parametrize( + "provider,environment", + ( + ("ctp", "sandbox"), + ("ctp", "production"), + ("ctp", "simnow_set0"), + ("ctp", "simnow_set01"), + ("okx", "simnow_set2"), + ("unreviewed", "demo"), + ), +) +def test_sandbox_preflight_requires_a_code_owned_nonproduction_environment( + tmp_path: Path, provider: str, environment: str +) -> None: + effective, _ = _effective(tmp_path / "runtime") + deployment = replace(_deployment(effective), provider=provider, environment=environment) + + with pytest.raises(ValueError, match="environment"): + _profile(deployment) + + +def test_ctp_simnow_environment_is_rechecked_at_binding_time(tmp_path: Path) -> None: + effective, registry = _effective(tmp_path / "runtime") + deployment = _deployment(effective) + registration = _profile(deployment) + object.__setattr__(deployment, "environment", "production") + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(RuntimeConfigError) as caught: + validate_provider_session_preflight_binding( + effective, + registry, + registration, + _receipt(_deployment(effective)), + verifier=verifier, + ) + + assert getattr(caught.value, "reason", None) == "provider_preflight_registration_invalid" + assert verifier.calls == 0 + + +def test_live_preflight_requires_the_exact_production_environment(tmp_path: Path) -> None: + effective, _ = _effective(tmp_path / "runtime") + deployment = replace(_deployment(effective), provider="ctp", environment="production-us") + + with pytest.raises(ValueError, match="exact production"): + ProviderSessionPreflightRegistration( + deployment=deployment, + mode="live", + preset="managed_live_direct", + account_access="direct_provider", + ) + + +def test_live_preflight_accepts_the_exact_production_environment(tmp_path: Path) -> None: + effective, _ = _effective(tmp_path / "runtime") + deployment = replace(_deployment(effective), environment="production") + + registration = ProviderSessionPreflightRegistration( + deployment=deployment, + mode="live", + preset="managed_live_direct", + account_access="direct_provider", + ) + + assert registration.deployment.environment == "production" + + +def test_preflight_module_imports_no_provider_or_backtrader_framework() -> None: + program = """ +import sys +import backtrader_runtime.provider_preflight +blocked = ('backtrader', 'bt_api', 'bt_api_py', 'bt_api_ctp', 'bt_api_execution', 'bt_api_risk', 'bt_api_monitor') +assert not any(name == item or name.startswith(item + '.') for item in blocked for name in sys.modules) +""" + result = subprocess.run( + [sys.executable, "-c", program], + cwd=Path(__file__).resolve().parents[3], + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/unit/runtime/test_runtime_live_dispatch_guard.py b/tests/unit/runtime/test_runtime_live_dispatch_guard.py new file mode 100644 index 00000000..d95a9ef5 --- /dev/null +++ b/tests/unit/runtime/test_runtime_live_dispatch_guard.py @@ -0,0 +1,76 @@ +"""A synthetic live registration cannot dispatch on confirmation alone.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from backtrader_runtime.cli import main +from backtrader_runtime.config import load_runtime_config +from backtrader_runtime.errors import RuntimeConfigError +from backtrader_runtime.policy import CAPABILITY_EXECUTION, CAPABILITY_MONITOR, CAPABILITY_RISK +from backtrader_runtime.registry import RegisteredRuntime, RuntimeRegistry, resolve_runtime_config +from backtrader_runtime.runner import dispatch_registered_runtime + + +def _live_registry(runtime_dir: Path) -> RuntimeRegistry: + runtime_dir.mkdir(parents=True) + (runtime_dir / "config.yaml").write_text( + """config_schema_version: 4 +strategy: + id: iteration41.ctp.live_guard +runtime: + mode: live + preset: managed_live_direct +parameters: {} +secrets_ref: runtime_secrets +""", + encoding="utf-8", + ) + return RuntimeRegistry( + ( + RegisteredRuntime( + runtime_dir=runtime_dir, + runtime_id="iteration41.ctp.live-guard", + strategy_id="iteration41.ctp.live_guard", + allowed_presets=("managed_live_direct",), + allowed_secrets_refs=("runtime_secrets",), + available_capabilities=( + CAPABILITY_EXECUTION, + CAPABILITY_RISK, + CAPABILITY_MONITOR, + ), + approval_receipt_digest="a" * 64, + runner_module="iteration41_fake_live_runner", + ), + ), + registry_id="test.live-dispatch-guard", + ) + + +def test_synthetic_live_registration_and_confirmation_cannot_import_a_runner( + tmp_path: Path, +) -> None: + runtime_dir = tmp_path / "live" + registry = _live_registry(runtime_dir) + effective = resolve_runtime_config( + load_runtime_config(runtime_dir, registry=registry), registry + ) + + with pytest.raises(RuntimeConfigError) as caught: + dispatch_registered_runtime(effective, registry) + assert caught.value.reason == "live_execution_admission_required" + + stdout, stderr = io.StringIO(), io.StringIO() + status = main( + ["run", "--strategy-dir", str(runtime_dir), "--confirm-live"], + registry=registry, + stdout=stdout, + stderr=stderr, + ) + assert status == 2 + assert stdout.getvalue() == "" + assert json.loads(stderr.getvalue())["reason"] == "live_execution_admission_required" diff --git a/tests/unit/runtime/test_runtime_operator_flow.py b/tests/unit/runtime/test_runtime_operator_flow.py new file mode 100644 index 00000000..3ea5172e --- /dev/null +++ b/tests/unit/runtime/test_runtime_operator_flow.py @@ -0,0 +1,536 @@ +"""Operator-path acceptance for the small configuration-first ``bt-runtime`` CLI. + +These tests intentionally use only temporary, code-built registries and an +in-memory runner. They exercise the user-facing command boundary without +claiming a provider, socket, account, or live deployment is available. +""" + +from __future__ import annotations + +import builtins +import importlib +import io +import json +import os +import sys +import types +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +from backtrader_runtime import ( + EffectiveRuntimeConfig, + RegisteredRuntime, + RuntimeRegistry, + RuntimeSet, + bootstrap_runtime_config, +) +from backtrader_runtime.cli import main +from backtrader_runtime.policy import MANAGED_WRITE_CAPABILITIES + + +_RUNNER_MODULE = "iteration41_operator_flow_local_runner" + + +def _payload(stream: io.StringIO) -> Dict[str, Any]: + return json.loads(stream.getvalue()) + + +def _write_config( + runtime_dir: Path, + *, + strategy_id: str, + mode: str = "simulation", + preset: str = "replay", + secrets_ref: str = "none", +) -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "config.yaml").write_text( + "config_schema_version: 4\n" + "strategy:\n" + " id: {0}\n" + "runtime:\n" + " mode: {1}\n" + " preset: {2}\n" + "parameters: {{}}\n" + "secrets_ref: {3}\n".format(strategy_id, mode, preset, secrets_ref), + encoding="utf-8", + ) + + +def _install_local_runner(monkeypatch: pytest.MonkeyPatch) -> List[Optional[Path]]: + """Install a no-I/O runner so ``run`` remains inside this unit test.""" + + calls: List[Optional[Path]] = [] + module = types.ModuleType(_RUNNER_MODULE) + + def run_runtime( + runtime_dir: Optional[Path], + *, + registry: RuntimeRegistry, + effective: EffectiveRuntimeConfig, + runtime_directory: Optional[object] = None, + ) -> Dict[str, Any]: + del registry, effective, runtime_directory + calls.append(runtime_dir) + return { + "status": "LOCAL_OPERATOR_FIXTURE_COMPLETED", + "external_network_requests": 0, + "external_write_requests": 0, + "evidence_boundary": "LOCAL_CLI_FIXTURE_ONLY", + } + + module.run_runtime = run_runtime + monkeypatch.setitem(sys.modules, _RUNNER_MODULE, module) + return calls + + +def _forbid_provider_or_socket_imports(monkeypatch: pytest.MonkeyPatch) -> List[str]: + """Fail if a CLI path attempts to load a provider/network implementation.""" + + attempted: List[str] = [] + original_import = builtins.__import__ + original_import_module = importlib.import_module + + def reject(name: str) -> None: + if ( + name == "socket" + or name == "backtrader" + or name.startswith(("bt_api_py.", "backtrader.")) + ): + attempted.append(name) + raise AssertionError("operator CLI acceptance must not import " + name) + + def guarded_import( + name: str, + globals_value: object = None, + locals_value: object = None, + fromlist: Tuple[str, ...] = (), + level: int = 0, + ) -> object: + reject(name) + return original_import(name, globals_value, locals_value, fromlist, level) + + def guarded_import_module(name: str, package: object = None) -> object: + reject(name) + return original_import_module(name, package) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + monkeypatch.setattr(importlib, "import_module", guarded_import_module) + return attempted + + +def _forbid_configured_runner_import(monkeypatch: pytest.MonkeyPatch) -> List[str]: + """Record any attempt to import this test's registered runner.""" + + attempted: List[str] = [] + original_import_module = importlib.import_module + + def guarded_import_module(name: str, package: object = None) -> object: + if name == _RUNNER_MODULE: + attempted.append(name) + raise AssertionError("offline doctor must not import its configured runner") + return original_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", guarded_import_module) + return attempted + + +def _replay_registration(runtime_dir: Path, strategy_id: str) -> RegisteredRuntime: + return RegisteredRuntime( + runtime_dir=runtime_dir, + strategy_id=strategy_id, + allowed_presets=("replay",), + runner_module=_RUNNER_MODULE, + ) + + +@pytest.mark.parametrize("middle_command", ("doctor", "validate")) +def test_first_run_needs_at_most_three_top_level_commands_without_mode_flags( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + middle_command: str, +) -> None: + runtime_dir = tmp_path / "replay-runtime" + runtime_dir.mkdir() + strategy_id = "operator.normal_replay" + registry = RuntimeRegistry((_replay_registration(runtime_dir, strategy_id),)) + runner_calls = _install_local_runner(monkeypatch) + forbidden_imports = _forbid_provider_or_socket_imports(monkeypatch) + + commands = ( + ["bootstrap", "--strategy-dir", str(runtime_dir)], + [middle_command, "--strategy-dir", str(runtime_dir)], + ["run", "--strategy-dir", str(runtime_dir)], + ) + results = [] + for command in commands: + stdout = io.StringIO() + stderr = io.StringIO() + status = main(command, registry=registry, environ={}, stdout=stdout, stderr=stderr) + results.append((status, _payload(stdout), stderr.getvalue())) + + assert len(commands) == 3 + assert [result[0] for result in results] == [0, 0, 0] + assert all(result[2] == "" for result in results) + assert results[0][1]["mode"] == "simulation" + assert results[0][1]["preset"] == "replay" + assert results[1][1]["status"] == ("diagnostic" if middle_command == "doctor" else "valid") + assert results[2][1]["status"] == "completed" + assert results[2][1]["report"]["result"]["external_network_requests"] == 0 + assert results[2][1]["report"]["result"]["external_write_requests"] == 0 + assert runner_calls == [None if os.name == "posix" else runtime_dir.resolve()] + assert forbidden_imports == [] + + +def test_existing_config_needs_only_doctor_then_run_and_keeps_the_route_offline( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_dir = tmp_path / "existing-replay-runtime" + strategy_id = "operator.existing_replay" + _write_config(runtime_dir, strategy_id=strategy_id) + registry = RuntimeRegistry((_replay_registration(runtime_dir, strategy_id),)) + runner_calls = _install_local_runner(monkeypatch) + forbidden_imports = _forbid_provider_or_socket_imports(monkeypatch) + + commands = ( + ["doctor", "--strategy-dir", str(runtime_dir)], + ["run", "--strategy-dir", str(runtime_dir)], + ) + payloads = [] + for command in commands: + stdout = io.StringIO() + status = main(command, registry=registry, environ={}, stdout=stdout, stderr=io.StringIO()) + assert status == 0 + payloads.append(_payload(stdout)) + + assert len(commands) == 2 + summary = payloads[0]["diagnostic"]["operator_summary"] + assert summary == { + "mode": "simulation", + "preset": "replay", + "destination": "local_runtime", + "environment": "offline", + "write_boundary": "zero_external_writes", + "pnl_source": "not_applicable_without_external_fills", + "required_capabilities": [], + "requires_approval": False, + } + assert payloads[0]["diagnostic"]["next_actions"][0]["command"] == [ + "bt-runtime", + "run", + "--strategy-dir", + str(runtime_dir), + ] + assert payloads[1]["report"]["result"]["status"] == "LOCAL_OPERATOR_FIXTURE_COMPLETED" + assert runner_calls == [None if os.name == "posix" else runtime_dir.resolve()] + assert forbidden_imports == [] + + +def test_doctor_distinguishes_normal_and_offline_managed_fake_replay_without_importing_runner( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + normal_dir = tmp_path / "normal-replay" + managed_dir = tmp_path / "managed-replay" + normal_id = "operator.normal_replay" + managed_id = "operator.offline_managed_replay" + _write_config(normal_dir, strategy_id=normal_id) + _write_config(managed_dir, strategy_id=managed_id) + registry = RuntimeRegistry( + ( + _replay_registration(normal_dir, normal_id), + RegisteredRuntime( + runtime_dir=managed_dir, + strategy_id=managed_id, + allowed_presets=("replay",), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + offline_managed_execution=True, + runner_module=_RUNNER_MODULE, + ), + ) + ) + forbidden_imports = _forbid_provider_or_socket_imports(monkeypatch) + + summaries = {} + for label, runtime_dir in (("normal", normal_dir), ("managed", managed_dir)): + stdout = io.StringIO() + status = main( + ["doctor", "--strategy-dir", str(runtime_dir)], + registry=registry, + environ={}, + stdout=stdout, + stderr=io.StringIO(), + ) + assert status == 0 + diagnostic = _payload(stdout)["diagnostic"] + assert diagnostic["offline"] is True + assert diagnostic["provider_preflight_started"] is False + assert diagnostic["next_actions"][0]["command"] == [ + "bt-runtime", + "run", + "--strategy-dir", + str(runtime_dir), + ] + summaries[label] = diagnostic["operator_summary"] + + assert summaries["normal"]["destination"] == "local_runtime" + assert summaries["normal"]["write_boundary"] == "zero_external_writes" + assert summaries["normal"]["pnl_source"] == "not_applicable_without_external_fills" + assert summaries["managed"] == { + "mode": "simulation", + "preset": "replay", + "destination": "offline_fake_provider_managed_execution", + "environment": "offline", + "write_boundary": "zero_external_writes", + "pnl_source": "not_applicable_without_external_fills", + "required_capabilities": list(MANAGED_WRITE_CAPABILITIES), + "requires_approval": False, + } + assert forbidden_imports == [] + + +def test_doctor_reports_unapproved_live_route_as_blocked_without_a_run_shortcut( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_dir = tmp_path / "unapproved-live" + strategy_id = "operator.unapproved_live" + _write_config( + runtime_dir, + strategy_id=strategy_id, + mode="live", + preset="managed_live_direct", + secrets_ref="runtime_secrets", + ) + registry = RuntimeRegistry( + ( + RegisteredRuntime( + runtime_dir=runtime_dir, + strategy_id=strategy_id, + allowed_presets=("managed_live_direct",), + allowed_secrets_refs=("runtime_secrets",), + available_capabilities=MANAGED_WRITE_CAPABILITIES, + runner_module=_RUNNER_MODULE, + ), + ) + ) + forbidden_imports = _forbid_provider_or_socket_imports(monkeypatch) + stderr = io.StringIO() + + status = main( + ["doctor", "--strategy-dir", str(runtime_dir)], + registry=registry, + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + payload = _payload(stderr) + assert payload["reason"] == "approval_receipt_missing" + diagnostic = payload["diagnostic"] + assert diagnostic["offline"] is True + assert diagnostic["provider_preflight_started"] is False + assert diagnostic["operator_summary"] == { + "mode": "live", + "preset": "managed_live_direct", + "destination": "managed_execution:direct_provider", + "environment": "production", + "write_boundary": "blocked_before_external_writes", + "pnl_source": "not_started_provider_reconciliation_required", + "required_capabilities": list(MANAGED_WRITE_CAPABILITIES), + "requires_approval": True, + "admission_status": "blocked", + } + assert diagnostic["blockers"] == [ + { + "field_path": "runtime.preset", + "reason": "approval_receipt_missing", + "message": "the registered write-capable profile has no trusted approval receipt", + } + ] + assert diagnostic["next_actions"][0]["action"] == "review_live_contract" + assert "--confirm-live" not in diagnostic["next_actions"][0]["note"] + assert "cannot bypass" in diagnostic["next_actions"][0]["note"] + assert "command" not in diagnostic["next_actions"][0] + assert forbidden_imports == [] + + +def test_doctor_aggregates_live_config_environment_and_contract_blockers_offline( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A validly parsed live config exposes independent local blockers together.""" + + runtime_dir = tmp_path / "live-blocker-aggregate" + strategy_id = "operator.live_blocker_aggregate" + _write_config( + runtime_dir, + strategy_id=strategy_id, + mode="live", + preset="managed_live_direct", + secrets_ref="runtime_secrets", + ) + registry = RuntimeRegistry( + ( + RegisteredRuntime( + runtime_dir=runtime_dir, + strategy_id=strategy_id, + allowed_presets=("managed_live_direct",), + runner_module=_RUNNER_MODULE, + ), + ) + ) + forbidden_imports = _forbid_provider_or_socket_imports(monkeypatch) + runner_imports = _forbid_configured_runner_import(monkeypatch) + stderr = io.StringIO() + + status = main( + ["doctor", "--strategy-dir", str(runtime_dir)], + registry=registry, + environ={ + "BT_RUNTIME_MODE": "simulation", + "BACKTRADER_RUNTIME_PRESET": "replay", + }, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + payload = _payload(stderr) + assert payload["reason"] == "secrets_ref_not_registered" + diagnostic = payload["diagnostic"] + assert diagnostic["offline"] is True + assert diagnostic["provider_preflight_started"] is False + assert diagnostic["operator_summary"]["mode"] == "live" + assert diagnostic["operator_summary"]["preset"] == "managed_live_direct" + assert diagnostic["operator_summary"]["admission_status"] == "blocked" + assert [(item["field_path"], item["reason"]) for item in diagnostic["blockers"]] == [ + ("secrets_ref", "secrets_ref_not_registered"), + ("runtime.preset", "approval_receipt_missing"), + ("runtime.preset", "required_capability_not_declared"), + ("environment.BT_RUNTIME_MODE", "environment_override_not_allowed"), + ("environment.BACKTRADER_RUNTIME_PRESET", "environment_override_not_allowed"), + ] + assert [item["action"] for item in diagnostic["next_actions"]] == [ + "review_secret_reference", + "review_live_contract", + "remove_environment_override", + "remove_environment_override", + ] + assert "runtime_secrets" not in json.dumps(payload).lower() + assert runner_imports == [] + assert forbidden_imports == [] + + +@pytest.mark.parametrize("override", ("--mode=live", "--preset=managed_live_direct")) +@pytest.mark.parametrize("command", ("validate", "doctor", "run")) +def test_cli_mode_and_preset_override_flags_are_rejected_before_runtime_loading( + tmp_path: Path, + command: str, + override: str, +) -> None: + runtime_dir = tmp_path / "override-flags" + strategy_id = "operator.override_flags" + _write_config(runtime_dir, strategy_id=strategy_id) + registry = RuntimeRegistry((_replay_registration(runtime_dir, strategy_id),)) + stderr = io.StringIO() + + status = main( + [command, "--strategy-dir", str(runtime_dir), override], + registry=registry, + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + assert _payload(stderr)["reason"] == "cli_override_not_allowed" + + +@pytest.mark.parametrize( + "environment_name", + ( + "BT_RUNTIME_MODE", + "BT_RUNTIME_PRESET", + "BACKTRADER_RUNTIME_MODE", + "BACKTRADER_RUNTIME_PRESET", + ), +) +def test_environment_mode_and_preset_overrides_are_rejected_after_static_validation( + tmp_path: Path, + environment_name: str, +) -> None: + runtime_dir = tmp_path / "override-environment" + strategy_id = "operator.override_environment" + _write_config(runtime_dir, strategy_id=strategy_id) + registry = RuntimeRegistry((_replay_registration(runtime_dir, strategy_id),)) + stderr = io.StringIO() + + status = main( + ["validate", "--strategy-dir", str(runtime_dir)], + registry=registry, + environ={environment_name: "attempted-override"}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejected = _payload(stderr) + assert rejected["reason"] == "environment_override_not_allowed" + assert rejected["field_path"] == "environment." + environment_name + + +def test_batch_bootstrap_conflict_writes_no_other_runtime_config(tmp_path: Path) -> None: + conflict_dir = tmp_path / "conflict" + matching_dir = tmp_path / "matching" + missing_dir = tmp_path / "missing" + for directory in (conflict_dir, matching_dir, missing_dir): + directory.mkdir() + conflict_id = "operator.batch_conflict" + matching_id = "operator.batch_matching" + missing_id = "operator.batch_missing" + registrations = ( + _replay_registration(conflict_dir, conflict_id), + _replay_registration(matching_dir, matching_id), + _replay_registration(missing_dir, missing_id), + ) + registry = RuntimeRegistry( + registrations, + runtime_sets=( + RuntimeSet( + name="operator-reviewed-set", + runtime_ids=(conflict_id, matching_id, missing_id), + ), + ), + ) + original_conflict = "operator-owned config must survive\n" + (conflict_dir / "config.yaml").write_text(original_conflict, encoding="utf-8") + # This byte-for-byte canonical config models a prior safe batch retry. It + # must remain untouched when another member makes preflight fail. + bootstrap_runtime_config(matching_dir, registry, "replay") + original_matching = (matching_dir / "config.yaml").read_bytes() + stderr = io.StringIO() + + status = main( + ["bootstrap", "--runtime-set", "operator-reviewed-set"], + registry=registry, + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + payload = _payload(stderr) + assert payload["status"] == "preflight_failed" + assert [item["status"] for item in payload["items"]] == [ + "conflict", + "already_matching", + "not_written", + ] + assert (conflict_dir / "config.yaml").read_text(encoding="utf-8") == original_conflict + assert (matching_dir / "config.yaml").read_bytes() == original_matching + assert not (missing_dir / "config.yaml").exists() diff --git a/tests/unit/runtime/test_runtime_operator_guidance.py b/tests/unit/runtime/test_runtime_operator_guidance.py new file mode 100644 index 00000000..441696e4 --- /dev/null +++ b/tests/unit/runtime/test_runtime_operator_guidance.py @@ -0,0 +1,464 @@ +"""Operator guidance contracts for the strict configuration-first CLI. + +These are deliberately local tests. A rejected command must provide one +safe next step without importing the code-owned runner, opening a socket, or +turning an override attempt into a different effective configuration. +""" + +from __future__ import annotations + +import builtins +import importlib +import io +import json +import socket +import sys +from pathlib import Path +from typing import List + +import pytest + +import backtrader_runtime +from backtrader_runtime import RegisteredRuntime, RuntimeRegistry +from backtrader_runtime.cli import _next_actions_for_error, main +from backtrader_runtime.errors import PRESET_POLICY_VIOLATION, RuntimeConfigError + + +_RUNNER_MODULE = "iteration41_operator_guidance_runner" + + +def test_runner_guidance_without_registry_stays_generic(tmp_path: Path) -> None: + error = RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "runner unavailable", + reason="runner_not_registered", + ) + + actions = _next_actions_for_error(error, tmp_path) + + assert actions[0]["action"] == "review_registration" + + +def _payload(stream: io.StringIO) -> dict: + return json.loads(stream.getvalue()) + + +def _registry(runtime_dir: Path, *, secrets: bool = False) -> RuntimeRegistry: + return RuntimeRegistry( + ( + RegisteredRuntime( + runtime_dir=runtime_dir, + strategy_id="operator.guidance", + allowed_presets=("replay",), + allowed_secrets_refs=("none", "runtime_secrets") if secrets else ("none",), + runner_module=_RUNNER_MODULE, + ), + ) + ) + + +def _write_config( + runtime_dir: Path, + *, + mode: str = "simulation", + preset: str = "replay", + secrets_ref: str = "none", +) -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "config.yaml").write_text( + "config_schema_version: 4\n" + "strategy:\n" + " id: operator.guidance\n" + "runtime:\n" + " mode: {0}\n" + " preset: {1}\n" + "parameters: {{}}\n" + "secrets_ref: {2}\n".format(mode, preset, secrets_ref), + encoding="utf-8", + ) + + +def _forbid_runner_import(monkeypatch: pytest.MonkeyPatch) -> List[str]: + attempted: List[str] = [] + original = importlib.import_module + + def guarded(name: str, package: object = None) -> object: + if name == _RUNNER_MODULE: + attempted.append(name) + raise AssertionError("a rejected command must not import its runner") + return original(name, package) + + monkeypatch.setattr(importlib, "import_module", guarded) + return attempted + + +def test_fresh_cli_module_keeps_doctor_runner_lazy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Offline doctor must not load the dispatch module merely by importing CLI.""" + + runtime_dir = tmp_path / "runner-lazy-doctor" + _write_config(runtime_dir) + attempted = [] + original_import = builtins.__import__ + + def guarded_import( + name: str, + globals_value: object = None, + locals_value: object = None, + fromlist: tuple = (), + level: int = 0, + ) -> object: + if name == "runner" and level == 1: + attempted.append(name) + raise AssertionError("doctor must not import backtrader_runtime.runner") + return original_import(name, globals_value, locals_value, fromlist, level) + + monkeypatch.delitem(sys.modules, "backtrader_runtime.cli", raising=False) + monkeypatch.delitem(sys.modules, "backtrader_runtime.runner", raising=False) + monkeypatch.delattr(backtrader_runtime, "cli", raising=False) + monkeypatch.delattr(backtrader_runtime, "runner", raising=False) + monkeypatch.setattr(builtins, "__import__", guarded_import) + fresh_cli = importlib.import_module("backtrader_runtime.cli") + stderr = io.StringIO() + + status = fresh_cli.main( + ["doctor", "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 0 + assert stderr.getvalue() == "" + assert attempted == [] + assert "backtrader_runtime.runner" not in sys.modules + + +@pytest.mark.parametrize("command", ("validate", "doctor", "run")) +def test_missing_config_has_the_same_safe_bootstrap_next_step_without_runner_import( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + command: str, +) -> None: + runtime_dir = tmp_path / "registered-runtime" + runtime_dir.mkdir() + attempted_imports = _forbid_runner_import(monkeypatch) + socket_attempts = [] + + def reject_socket(*args: object, **kwargs: object) -> object: + socket_attempts.append((args, kwargs)) + raise AssertionError("a rejected command must not create a socket") + + monkeypatch.setattr(socket, "socket", reject_socket) + stderr = io.StringIO() + + status = main( + [command, "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "missing_config" + assert rejection["diagnostic"] == { + "offline": True, + "provider_preflight_started": False, + "next_actions": [ + { + "action": "bootstrap", + "command": ["bt-runtime", "bootstrap", "--strategy-dir", str(runtime_dir)], + "note": "creates only the reviewed safe config; it never overwrites an existing file", + } + ], + } + assert attempted_imports == [] + assert socket_attempts == [] + + +def test_doctor_aggregates_missing_config_and_environment_override_blockers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A missing required config and ignored overrides are independently actionable.""" + + runtime_dir = tmp_path / "missing-config-and-overrides" + runtime_dir.mkdir() + attempted_imports = _forbid_runner_import(monkeypatch) + stderr = io.StringIO() + + status = main( + ["doctor", "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir), + environ={ + "BT_RUNTIME_MODE": "live", + "BACKTRADER_RUNTIME_PRESET": "managed_live_direct", + }, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "missing_config" + diagnostic = rejection["diagnostic"] + assert diagnostic["offline"] is True + assert diagnostic["provider_preflight_started"] is False + assert [(item["field_path"], item["reason"]) for item in diagnostic["blockers"]] == [ + ("config.yaml", "missing_config"), + ("environment.BT_RUNTIME_MODE", "environment_override_not_allowed"), + ("environment.BACKTRADER_RUNTIME_PRESET", "environment_override_not_allowed"), + ] + assert [item["action"] for item in diagnostic["next_actions"]] == [ + "bootstrap", + "remove_environment_override", + "remove_environment_override", + ] + assert not (runtime_dir / "config.yaml").exists() + assert attempted_imports == [] + + +def test_bootstrap_unregistered_directory_explains_that_cwd_cannot_be_a_route( + tmp_path: Path, +) -> None: + external_dir = tmp_path / "not-reviewed" + external_dir.mkdir() + stderr = io.StringIO() + + status = main( + ["bootstrap", "--strategy-dir", str(external_dir)], + registry=RuntimeRegistry(()), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "runtime_not_registered" + action = rejection["diagnostic"]["next_actions"] + assert action[0]["action"] == "review_registration" + assert "current directory" in action[0]["note"] + assert "AI output" in action[0]["note"] + assert "command" not in action[0] + + +def test_bootstrap_existing_config_preserves_it_and_suggests_only_offline_doctor( + tmp_path: Path, +) -> None: + runtime_dir = tmp_path / "existing-config" + _write_config(runtime_dir) + original = (runtime_dir / "config.yaml").read_bytes() + stderr = io.StringIO() + + status = main( + ["bootstrap", "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "config_exists" + action = rejection["diagnostic"]["next_actions"][0] + assert action["action"] == "review_existing_config" + assert action["command"] == ["bt-runtime", "doctor", "--strategy-dir", str(runtime_dir)] + assert (runtime_dir / "config.yaml").read_bytes() == original + + +@pytest.mark.parametrize("command", ("validate", "doctor", "run")) +def test_mode_preset_mismatch_gives_a_config_review_step_before_runner_import( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + command: str, +) -> None: + runtime_dir = tmp_path / "mode-mismatch" + _write_config(runtime_dir, mode="live", preset="replay") + attempted_imports = _forbid_runner_import(monkeypatch) + stderr = io.StringIO() + + status = main( + [command, "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "mode_preset_mismatch" + action = rejection["diagnostic"]["next_actions"][0] + assert action["action"] == "review_configuration" + assert action["field_path"] == "runtime.preset" + assert "override" in action["note"] + assert attempted_imports == [] + + +def test_validate_reports_secret_and_independent_environment_override_together( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_dir = tmp_path / "secret-and-override" + _write_config(runtime_dir, secrets_ref="runtime_secrets") + attempted_imports = _forbid_runner_import(monkeypatch) + stderr = io.StringIO() + + status = main( + ["validate", "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir, secrets=True), + environ={ + "BT_RUNTIME_MODE": "live", + "BACKTRADER_RUNTIME_PRESET": "managed_live_direct", + }, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "secrets_not_allowed_for_preset" + diagnostic = rejection["diagnostic"] + assert diagnostic["offline"] is True + assert diagnostic["provider_preflight_started"] is False + assert [(item["field_path"], item["reason"]) for item in diagnostic["blockers"]] == [ + ("secrets_ref", "secrets_not_allowed_for_preset"), + ("environment.BT_RUNTIME_MODE", "environment_override_not_allowed"), + ("environment.BACKTRADER_RUNTIME_PRESET", "environment_override_not_allowed"), + ] + assert [item["action"] for item in diagnostic["next_actions"]] == [ + "review_secret_reference", + "remove_environment_override", + "remove_environment_override", + ] + assert "runtime_secrets" not in json.dumps(rejection).lower() + assert attempted_imports == [] + + +def test_run_rejects_all_cli_override_flags_before_config_or_runner_loading( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_dir = tmp_path / "override" + _write_config(runtime_dir) + attempted_imports = _forbid_runner_import(monkeypatch) + stderr = io.StringIO() + + status = main( + [ + "run", + "--strategy-dir", + str(runtime_dir), + "--mode=live", + "--preset=managed_live_direct", + "--config=other.yaml", + ], + registry=_registry(runtime_dir), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "cli_override_not_allowed" + action = rejection["diagnostic"]["next_actions"][0] + assert action["action"] == "remove_cli_override" + assert action["rejected_arguments"] == ["--mode", "--preset", "--config"] + assert attempted_imports == [] + + +@pytest.mark.parametrize( + "arguments", + ( + ("validate",), + ("validate", "--ai-config", "proposal.yaml"), + ), +) +def test_cli_never_uses_cwd_or_ai_arguments_as_a_runtime(arguments: tuple) -> None: + stderr = io.StringIO() + + status = main( + list(arguments), + registry=RuntimeRegistry(()), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "cli_argument_not_allowed" + action = rejection["diagnostic"]["next_actions"][0] + assert action["action"] == "use_explicit_registered_runtime" + assert "CWD" in action["note"] + assert "AI-produced" in action["note"] + + +def test_run_failure_after_dispatch_does_not_claim_offline_execution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_dir = tmp_path / "public-run-failure" + _write_config(runtime_dir) + + def failed_dispatch(*_args: object) -> object: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "a bounded public market read failed", + field_path="runtime.runner", + reason="public_market_read_failed", + ) + + monkeypatch.setitem(main.__globals__, "dispatch_registered_runtime", failed_dispatch) + stderr = io.StringIO() + status = main( + ["run", "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + diagnostic = rejection["diagnostic"] + assert diagnostic["offline"] is False + assert diagnostic["provider_io_may_have_started"] is True + assert diagnostic["next_actions"][0]["action"] == "inspect_public_market_session" + + +def test_missing_managed_replay_capability_is_reported_before_provider_io( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_dir = tmp_path / "missing-local-capability" + _write_config(runtime_dir) + + def failed_dispatch(*_args: object) -> object: + raise RuntimeConfigError( + PRESET_POLICY_VIOLATION, + "reviewed local capability package is unavailable", + field_path="runtime.capabilities.bt_api_execution", + reason="capability_dependency_missing", + ) + + monkeypatch.setitem(main.__globals__, "dispatch_registered_runtime", failed_dispatch) + stderr = io.StringIO() + status = main( + ["run", "--strategy-dir", str(runtime_dir)], + registry=_registry(runtime_dir), + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + rejection = _payload(stderr) + assert rejection["reason"] == "capability_dependency_missing" + assert rejection["diagnostic"]["offline"] is True + assert rejection["diagnostic"]["provider_preflight_started"] is False + assert "provider_io_may_have_started" not in rejection["diagnostic"] diff --git a/tests/unit/runtime/test_runtime_package_import_boundary.py b/tests/unit/runtime/test_runtime_package_import_boundary.py new file mode 100644 index 00000000..262272e3 --- /dev/null +++ b/tests/unit/runtime/test_runtime_package_import_boundary.py @@ -0,0 +1,65 @@ +"""Fresh-process import boundaries for the Iteration 41 public package.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] + + +def test_package_import_leaves_dispatch_module_unloaded_until_explicitly_requested() -> None: + """An offline CLI import must not first load the runtime dispatcher.""" + + program = """ +import sys +import backtrader_runtime +import backtrader_runtime.cli +assert 'backtrader_runtime.runner' not in sys.modules +from backtrader_runtime import dispatch_registered_runtime +assert callable(dispatch_registered_runtime) +assert 'backtrader_runtime.runner' in sys.modules +""" + result = subprocess.run( + [sys.executable, "-c", program], + cwd=REPOSITORY_ROOT, + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_default_store_contract_definitions_import_without_managed_sdk() -> None: + """Evaluate the always-imported DTO module on every core CI interpreter. + + Loading this stdlib-only file directly keeps the minimal Python 3.8 lane + independent of optional framework integrations while exercising dataclass + construction (which an AST-only syntax check cannot verify). + """ + + program = """ +import importlib.util +import sys +from pathlib import Path +path = Path('backtrader/stores/managed_execution.py').resolve() +spec = importlib.util.spec_from_file_location('bt_store_contract_compat', path) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +assert module.CtpManagedDispatchBinding.__dataclass_params__.frozen +assert not any(name == 'bt_api_py' or name.startswith('bt_api_') for name in sys.modules) +if sys.version_info >= (3, 10): + assert 'command_id' in module.CtpManagedDispatchBinding.__slots__ +""" + result = subprocess.run( + [sys.executable, "-c", program], + cwd=REPOSITORY_ROOT, + capture_output=True, + check=False, + text=True, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/unit/runtime/test_runtime_profiles.py b/tests/unit/runtime/test_runtime_profiles.py new file mode 100644 index 00000000..f0b53766 --- /dev/null +++ b/tests/unit/runtime/test_runtime_profiles.py @@ -0,0 +1,995 @@ +"""Synthetic checks for exact mode/preset profiles on one runtime registration.""" + +from __future__ import annotations + +import io +import json +import sys +import types +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from backtrader_runtime.cli import main +from backtrader_runtime.config import load_runtime_config +from backtrader_runtime.errors import RuntimeConfigError +from backtrader_runtime.policy import MANAGED_WRITE_CAPABILITIES +from backtrader_runtime.registry import ( + RegisteredRuntime, + RuntimeProfile, + RuntimeRegistry, + UnavailableModeProfile, + bootstrap_runtime_config, + resolve_runtime_config, + require_effective_runtime_config_seal, + select_bootstrap_preset, +) + + +STRATEGY_ID = "example.synthetic_profiles" +APPROVAL_DIGEST = "a" * 64 + + +def _profile( + mode: str, + preset: str, + *, + parameter_keys: tuple[str, ...], + secret_refs: tuple[str, ...], + capabilities: tuple[str, ...] = (), + approval: str | None = None, + runner_module: str | None = None, + capability_modules: tuple[str, ...] = (), +) -> RuntimeProfile: + return RuntimeProfile( + mode=mode, + preset=preset, + allowed_parameter_keys=parameter_keys, + allowed_secrets_refs=secret_refs, + available_capabilities=capabilities, + approval_receipt_digest=approval, + runner_module=runner_module, + runner_entrypoint="run_runtime", + capability_modules=capability_modules, + offline_managed_execution=False, + sandbox_write_policy="deny", + ) + + +def _sandbox_profile() -> RuntimeProfile: + return _profile( + "simulation", + "sandbox", + parameter_keys=("sandbox_case",), + secret_refs=("none",), + runner_module="tests.synthetic_profiles.sandbox_runner", + ) + + +def _sandbox_private_profile() -> RuntimeProfile: + return _profile( + "simulation", + "sandbox", + parameter_keys=(), + secret_refs=("config_yaml",), + ) + + +def _replay_profile( + *, + runner_module: str | None = "tests.synthetic_profiles.replay_runner", + capabilities: tuple[str, ...] = (), + capability_modules: tuple[str, ...] = (), +) -> RuntimeProfile: + return _profile( + "simulation", + "replay", + parameter_keys=("replay_case",), + secret_refs=("none",), + capabilities=capabilities, + runner_module=runner_module, + capability_modules=capability_modules, + ) + + +def _live_profile( + *, + capabilities: tuple[str, ...] = MANAGED_WRITE_CAPABILITIES, + approval: str | None = APPROVAL_DIGEST, +) -> RuntimeProfile: + return _profile( + "live", + "managed_live_direct", + parameter_keys=("live_case",), + secret_refs=("runtime_secrets",), + capabilities=capabilities, + approval=approval, + runner_module="tests.synthetic_profiles.live_writer", + capability_modules=("bt_api_ctp",), + ) + + +def _registry( + runtime_dir: Path, + profiles: tuple[RuntimeProfile, ...], + *, + unavailable: tuple[UnavailableModeProfile, ...] = (), +) -> RuntimeRegistry: + registration = RegisteredRuntime( + runtime_dir=runtime_dir, + strategy_id=STRATEGY_ID, + runtime_id="synthetic.profiles", + allowed_presets=(), + profiles=profiles, + unavailable_mode_profiles=unavailable, + ) + return RuntimeRegistry((registration,), registry_id="test.synthetic.profiles") + + +def _write_config( + runtime_dir: Path, + *, + mode: str, + preset: str, + parameter_key: str, + secrets_ref: str, +) -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + (runtime_dir / "config.yaml").write_text( + "config_schema_version: 4\n" + "strategy:\n" + " id: {strategy_id}\n" + "runtime:\n" + " mode: {mode}\n" + " preset: {preset}\n" + "parameters:\n" + " {parameter_key}: sample\n" + "secrets_ref: {secrets_ref}\n".format( + strategy_id=STRATEGY_ID, + mode=mode, + preset=preset, + parameter_key=parameter_key, + secrets_ref=secrets_ref, + ), + encoding="utf-8", + ) + + +def test_two_profiles_resolve_independently_from_one_registration_and_file( + tmp_path: Path, +) -> None: + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + registration = registry.require_runtime_dir(tmp_path) + + _write_config( + tmp_path, + mode="simulation", + preset="sandbox", + parameter_key="sandbox_case", + secrets_ref="none", + ) + sandbox = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + + assert sandbox.registration is registration + assert sandbox.profile is registration.profile_for("simulation", "sandbox") + assert sandbox.profile is not None + assert sandbox.profile.runner_module == "tests.synthetic_profiles.sandbox_runner" + assert sandbox.profile.allowed_secrets_refs == ("none",) + assert sandbox.required_capabilities == () + assert sandbox.requires_approval is False + assert sandbox.allows_production_writes is False + assert sandbox.as_public_dict()["profile"] == {"mode": "simulation", "preset": "sandbox"} + + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + live = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + + assert live.registration is registration + assert live.profile is registration.profile_for("live", "managed_live_direct") + assert live.profile is not None + assert live.profile.runner_module == "tests.synthetic_profiles.live_writer" + assert live.profile.allowed_secrets_refs == ("runtime_secrets",) + assert live.profile.available_capabilities == MANAGED_WRITE_CAPABILITIES + assert live.required_capabilities == MANAGED_WRITE_CAPABILITIES + assert live.requires_approval is True + assert live.allows_production_writes is True + assert live.as_public_dict()["profile_dispatch_available"] is False + assert live.as_public_dict()["order_route"] is None + assert live.as_public_dict()["account_access"] is None + assert live.as_public_dict()["allows_external_writes"] is False + assert live.as_public_dict()["allows_production_writes"] is False + assert live.effective_digest != sandbox.effective_digest + + # Legacy fields stay closed; selecting a profile never overlays them. + assert registration.allowed_presets == () + assert registration.available_capabilities == () + assert registration.approval_receipt_digest is None + assert registration.runner_module is None + assert registration.capability_modules == () + + +def test_offline_replay_profile_dispatches_the_sealed_selected_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from backtrader_runtime.runner import resolve_runner_effective_config + + module_name = "tests.synthetic_profiles.replay_runner" + calls = [] + module = types.ModuleType(module_name) + + def run_runtime(runtime_dir, *, registry, effective, runtime_directory=None): + resolved = resolve_runner_effective_config(runtime_dir, registry, effective=effective) + calls.append( + ( + resolved.profile.mode, + resolved.profile.preset, + resolved.profile.runner_module, + resolved.profile.digest, + ) + ) + return {"status": "SYNTHETIC_REPLAY_PASS"} + + module.run_runtime = run_runtime # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_name, module) + registry = _registry(tmp_path, (_replay_profile(),)) + _write_config( + tmp_path, + mode="simulation", + preset="replay", + parameter_key="replay_case", + secrets_ref="none", + ) + effective = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + + assert effective.profile_dispatch_available is True + assert effective.as_public_dict()["profile_dispatch_available"] is True + stdout, stderr = io.StringIO(), io.StringIO() + status = main( + ["run", "--strategy-dir", str(tmp_path)], + registry=registry, + environ={}, + stdout=stdout, + stderr=stderr, + ) + + assert status == 0, stderr.getvalue() + assert json.loads(stdout.getvalue())["status"] == "completed" + assert calls == [ + ( + "simulation", + "replay", + module_name, + effective.profile.digest, + ) + ] + + +@pytest.mark.parametrize( + ("profile", "mode", "preset", "parameter_key", "secrets_ref", "expected_reason"), + ( + (_sandbox_profile(), "simulation", "sandbox", "sandbox_case", "none", "profile_dispatch_unavailable"), + ( + _replay_profile(runner_module=None), + "simulation", + "replay", + "replay_case", + "none", + "profile_dispatch_unavailable", + ), + ( + _replay_profile(capabilities=("execution",)), + "simulation", + "replay", + "replay_case", + "none", + "profile_dispatch_unavailable", + ), + ( + _replay_profile(capability_modules=("bt_api_execution",)), + "simulation", + "replay", + "replay_case", + "none", + "profile_dispatch_unavailable", + ), + ( + _live_profile(), + "live", + "managed_live_direct", + "live_case", + "runtime_secrets", + "live_execution_admission_required", + ), + ), +) +def test_ineligible_profile_dispatch_stops_before_runner_import( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + profile: RuntimeProfile, + mode: str, + preset: str, + parameter_key: str, + secrets_ref: str, + expected_reason: str, +) -> None: + from backtrader_runtime import runner + + registry = _registry(tmp_path, (profile,)) + _write_config( + tmp_path, + mode=mode, + preset=preset, + parameter_key=parameter_key, + secrets_ref=secrets_ref, + ) + effective = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + monkeypatch.setattr( + runner, + "_loaded_registered_runner", + lambda *args, **kwargs: pytest.fail("ineligible profile runner was imported"), + ) + + if expected_reason == "profile_dispatch_unavailable": + assert effective.profile_dispatch_available is False + with pytest.raises(RuntimeConfigError) as caught: + runner.dispatch_registered_runtime(effective, registry) + + assert caught.value.reason == expected_reason + + +def test_profile_config_cannot_select_an_unregistered_mode_preset( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + registry = _registry(tmp_path, (_replay_profile(),)) + _write_config( + tmp_path, + mode="backtest", + preset="local_backtest", + parameter_key="replay_case", + secrets_ref="none", + ) + monkeypatch.setattr( + "backtrader_runtime.runner._loaded_registered_runner", + lambda *args, **kwargs: pytest.fail("unregistered profile runner was imported"), + ) + + with pytest.raises(RuntimeConfigError) as caught: + resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + + assert caught.value.reason == "profile_not_registered" + + +def test_profile_dispatch_rejects_a_forged_effective_before_runner_import( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from dataclasses import replace + + from backtrader_runtime import runner + + imported = [] + attacker_name = "tests.synthetic_profiles.attacker" + attacker = types.ModuleType(attacker_name) + attacker.run_runtime = lambda *args, **kwargs: imported.append(True) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, attacker_name, attacker) + registered_profile = _replay_profile() + registry = _registry(tmp_path, (registered_profile,)) + _write_config( + tmp_path, + mode="simulation", + preset="replay", + parameter_key="replay_case", + secrets_ref="none", + ) + effective = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + forged = replace(effective, profile=replace(registered_profile, runner_module=attacker_name)) + monkeypatch.setattr( + runner, + "_loaded_registered_runner", + lambda *args, **kwargs: pytest.fail("forged profile runner was imported"), + ) + + with pytest.raises(RuntimeConfigError) as caught: + runner.dispatch_registered_runtime(forged, registry) + + assert caught.value.reason == "effective_config_mismatch" + assert imported == [] + + +def test_profile_secrets_parameters_approval_and_capabilities_do_not_inherit( + tmp_path: Path, +) -> None: + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + + _write_config( + tmp_path, + mode="simulation", + preset="sandbox", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + with pytest.raises(RuntimeConfigError) as sandbox_rejected: + resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + assert sandbox_rejected.value.reason == "parameter_not_registered" + + _write_config( + tmp_path, + mode="simulation", + preset="sandbox", + parameter_key="sandbox_case", + secrets_ref="runtime_secrets", + ) + with pytest.raises(RuntimeConfigError) as sandbox_secret_rejected: + resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + assert sandbox_secret_rejected.value.reason == "secrets_ref_not_registered" + + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="none", + ) + with pytest.raises(RuntimeConfigError) as live_rejected: + resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + assert live_rejected.value.reason == "secrets_ref_not_registered" + + no_approval_registry = _registry( + tmp_path, + (_sandbox_profile(), _live_profile(approval=None)), + ) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + with pytest.raises(RuntimeConfigError) as approval_rejected: + resolve_runtime_config( + load_runtime_config(tmp_path, registry=no_approval_registry), no_approval_registry + ) + assert approval_rejected.value.reason == "approval_receipt_missing" + + no_capabilities_registry = _registry( + tmp_path, + (_sandbox_profile(), _live_profile(capabilities=())), + ) + with pytest.raises(RuntimeConfigError) as capabilities_rejected: + resolve_runtime_config( + load_runtime_config(tmp_path, registry=no_capabilities_registry), + no_capabilities_registry, + ) + assert capabilities_rejected.value.reason == "required_capability_not_declared" + + +def test_unavailable_gate_cannot_be_shadowed_by_an_available_profile(tmp_path: Path) -> None: + live = _live_profile() + unavailable = UnavailableModeProfile( + mode="live", + preset="managed_live_direct", + reason="live_profile_not_enabled", + ) + with pytest.raises(ValueError, match="cannot also be an available profile"): + RegisteredRuntime( + runtime_dir=tmp_path, + strategy_id=STRATEGY_ID, + allowed_presets=(), + profiles=(_sandbox_profile(), live), + unavailable_mode_profiles=(unavailable,), + ) + + registry = _registry( + tmp_path, + (_sandbox_profile(),), + unavailable=(unavailable,), + ) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + with pytest.raises(RuntimeConfigError) as rejected: + resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + assert rejected.value.reason == "live_profile_not_enabled" + + +def test_profile_cli_keeps_unavailable_live_before_any_dispatch( + tmp_path: Path, monkeypatch +) -> None: + unavailable = UnavailableModeProfile( + mode="live", + preset="managed_live_direct", + reason="live_profile_not_enabled", + ) + registry = _registry( + tmp_path, + (_sandbox_profile(),), + unavailable=(unavailable,), + ) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + calls = [] + monkeypatch.setattr( + "backtrader_runtime.cli.dispatch_registered_runtime", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + stdout, stderr = io.StringIO(), io.StringIO() + + status = main( + ["run", "--strategy-dir", str(tmp_path), "--confirm-live"], + registry=registry, + environ={}, + stdout=stdout, + stderr=stderr, + ) + + assert status == 2 + assert stdout.getvalue() == "" + assert '"reason": "live_profile_not_enabled"' in stderr.getvalue() + assert calls == [] + + +def test_doctor_reports_profile_scoped_live_approval_and_capability_gaps(tmp_path: Path) -> None: + registry = _registry( + tmp_path, + (_sandbox_profile(), _live_profile(approval=None, capabilities=())), + ) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + stderr = io.StringIO() + + status = main( + ["doctor", "--strategy-dir", str(tmp_path)], + registry=registry, + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + payload = json.loads(stderr.getvalue()) + blocker_reasons = {blocker["reason"] for blocker in payload["diagnostic"]["blockers"]} + assert blocker_reasons >= {"approval_receipt_missing", "required_capability_not_declared"} + assert payload["diagnostic"]["operator_summary"]["profile_dispatch_available"] is False + assert payload["diagnostic"]["operator_summary"]["admission_status"] == "blocked" + assert payload["diagnostic"]["profile_dispatch_unavailable_reason"] == ( + "profile_dispatch_unavailable" + ) + + +def test_validate_and_doctor_do_not_expose_profile_route_or_write_authority( + tmp_path: Path, +) -> None: + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + stdout, stderr = io.StringIO(), io.StringIO() + + status = main( + ["validate", "--strategy-dir", str(tmp_path)], + registry=registry, + environ={}, + stdout=stdout, + stderr=stderr, + ) + + assert status == 0 + validated = json.loads(stdout.getvalue()) + assert validated["profile_dispatch_available"] is False + assert validated["profile_dispatch_unavailable_reason"] == "profile_dispatch_unavailable" + assert validated["order_route"] is None + assert validated["account_access"] is None + assert validated["allows_network"] is False + assert validated["allows_external_writes"] is False + assert validated["allows_production_writes"] is False + + stdout, stderr = io.StringIO(), io.StringIO() + status = main( + ["doctor", "--strategy-dir", str(tmp_path)], + registry=registry, + environ={}, + stdout=stdout, + stderr=stderr, + ) + assert status == 0 + diagnostic = json.loads(stdout.getvalue())["diagnostic"] + assert diagnostic["next_actions"] == [ + { + "action": "review_profile_dispatch", + "field_path": "runtime.preset", + "note": "profile validation is offline; profile-scoped dispatch is not enabled", + } + ] + operator = diagnostic["operator_summary"] + assert operator["destination"] == "profile_dispatch_unavailable" + assert operator["write_boundary"] == "blocked_before_external_writes" + assert operator["admission_status"] == "blocked" + assert operator["profile_dispatch_available"] is False + + +def test_effective_profile_seal_detects_profile_mutation(tmp_path: Path) -> None: + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + effective = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + assert effective.profile is not None + object.__setattr__(effective.profile, "runner_module", "tests.synthetic_profiles.mutated") + + with pytest.raises(RuntimeConfigError) as rejected: + require_effective_runtime_config_seal(effective, registry) + + assert rejected.value.reason == "effective_config_mismatch" + + +def test_registry_snapshot_detects_profile_mutation_before_resolution(tmp_path: Path) -> None: + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + loaded = load_runtime_config(tmp_path, registry=registry) + registration = registry.require_runtime_dir(tmp_path) + live_profile = registration.profile_for("live", "managed_live_direct") + assert live_profile is not None + object.__setattr__(live_profile, "approval_receipt_digest", None) + + with pytest.raises(RuntimeConfigError) as rejected: + resolve_runtime_config(loaded, registry) + + assert rejected.value.reason == "runtime_registration_identity_changed" + + +def test_profile_scoped_credentials_reject_before_secret_source_access( + tmp_path: Path, monkeypatch +) -> None: + from backtrader_runtime import credential_resolver + + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + effective = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + reads = [] + monkeypatch.setattr( + credential_resolver, + "_read_runtime_secrets_text", + lambda *args, **kwargs: reads.append((args, kwargs)), + ) + + with pytest.raises(credential_resolver.CredentialResolutionError) as rejected: + credential_resolver.resolve_runtime_credentials(effective, registry, object()) + + assert rejected.value.reason == "profile_scoped_credentials_unavailable" + assert reads == [] + + +def test_profile_registration_requires_exact_mode_preset_match(tmp_path: Path) -> None: + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="simulation", + preset="paper", + parameter_key="sandbox_case", + secrets_ref="none", + ) + + with pytest.raises(RuntimeConfigError) as rejected: + resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + + assert rejected.value.reason == "profile_not_registered" + + +def test_profiles_preserve_registered_runtime_positional_bootstrap_argument( + tmp_path: Path, +) -> None: + registration = RegisteredRuntime( + tmp_path, + STRATEGY_ID, + ("replay",), + ("bootstrap_case",), + ("none",), + (), + False, + "deny", + None, + "synthetic.positional", + None, + "run_runtime", + (), + (), + (("bootstrap_case", "preserved"),), + ) + + assert registration.bootstrap_parameters == (("bootstrap_case", "preserved"),) + assert registration.profiles == () + + +def test_profile_managed_execution_binding_rejects_before_store_or_provider_access( + tmp_path: Path, +) -> None: + from backtrader_runtime.managed_execution import ( + ManagedExecutionBindingError, + bind_managed_execution, + ) + + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + effective = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + calls = [] + + class _Store: + @property + def _sdk_mode(self): + calls.append("sdk_mode") + return False + + def _is_ctp_session_provider(self): + calls.append("provider_route_check") + return False + + def attach_managed_execution_adapter(self, adapter): + calls.append("attach") + + class _Runtime: + @property + def contract(self): + calls.append("contract") + return SimpleNamespace( + strategy_id=effective.strategy_id, + mode=effective.mode, + preset=effective.preset, + environment=effective.policy.environment, + order_route=effective.order_route, + effective_digest=effective.effective_digest, + required_capabilities=effective.required_capabilities, + ) + + @property + def scope(self): + calls.append("scope") + return SimpleNamespace( + strategy_id=effective.strategy_id, + environment=effective.policy.environment, + ) + + def submit(self, intent, dispatch): + calls.append("provider_submit") + + with pytest.raises(ManagedExecutionBindingError, match="profile_dispatch_unavailable"): + bind_managed_execution(_Store(), effective, _Runtime()) + + assert calls == [] + + # The dataclass seal is not a reason to trust profile=None at this binder + # boundary: hostile in-process code can mutate frozen objects directly. + object.__setattr__(effective, "profile", None) + with pytest.raises(ManagedExecutionBindingError, match="profile_dispatch_unavailable"): + bind_managed_execution(_Store(), effective, _Runtime()) + + assert calls == [] + + +def test_profile_registration_stays_closed_in_legacy_ctp_production_admission( + tmp_path: Path, +) -> None: + from decimal import Decimal + + from backtrader_runtime.ctp_production_execution_admission import ( + CtpProductionExecutionAdmissionError, + CtpProductionExecutionRegistration, + require_ctp_production_execution_config_binding, + ) + + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + loaded = load_runtime_config(tmp_path, registry=registry) + effective = resolve_runtime_config(loaded, registry) + registration = registry.require_runtime_dir(tmp_path) + admission = CtpProductionExecutionRegistration( + runtime_registration=registration, + environment="production", + account_binding_sha256=None, + md_front=None, + td_front=None, + instrument_id=None, + exchange_id=None, + hedge_flag=None, + approval_receipt_id="ctp-production:receipt.synthetic", + approval_receipt_sha256="c" * 64, + artifact_id="ctp-production:artifact.synthetic", + artifact_sha256="d" * 64, + allowed_sides=("BUY",), + allowed_offsets=("OPEN",), + quantity_step=1, + max_order_quantity=10, + max_gross_position=10, + min_price=Decimal("1"), + max_price=Decimal("100"), + price_tick=Decimal("1"), + max_order_notional=Decimal("1000"), + scope_binding_mode="sealed_config", + ) + + with pytest.raises(CtpProductionExecutionAdmissionError) as rejected: + require_ctp_production_execution_config_binding( + loaded, + registry, + admission, + effective_runtime=effective, + ) + + assert rejected.value.reason == "runtime_contract_mismatch" + + +def test_profile_style_private_config_is_protected_before_yaml_parse( + tmp_path: Path, monkeypatch +) -> None: + from backtrader_runtime import config as runtime_config + + registry = _registry(tmp_path, (_sandbox_private_profile(), _live_profile())) + _write_config( + tmp_path, + mode="simulation", + preset="sandbox", + parameter_key="unknown_for_private_profile", + secrets_ref="none", + ) + private_security_flags = [] + security_checks = [] + original_read_config_text = runtime_config._read_config_text + + def capture_private_config_security(*args, **kwargs): + private_security_flags.append(kwargs.get("require_private_config_security")) + return original_read_config_text(*args, **kwargs) + + monkeypatch.setattr(runtime_config, "_read_config_text", capture_private_config_security) + monkeypatch.setattr( + runtime_config, + "_require_private_config_security", + lambda *args, **kwargs: security_checks.append(kwargs), + ) + + loaded = load_runtime_config(tmp_path, registry=registry) + with pytest.raises(RuntimeConfigError) as rejected: + resolve_runtime_config(loaded, registry) + + assert private_security_flags == [True] + assert security_checks + assert rejected.value.reason == "parameter_not_registered" + + +def test_profile_registration_cannot_use_legacy_bootstrap_or_readonly_binding( + tmp_path: Path, +) -> None: + from backtrader_runtime.ctp_simnow_operator import CtpSimNowConfigReadOnlyBinding + + registration = RegisteredRuntime( + runtime_dir=tmp_path, + strategy_id=STRATEGY_ID, + runtime_id="synthetic.profiles", + allowed_presets=(), + profiles=(_sandbox_private_profile(), _live_profile()), + ) + registry = RuntimeRegistry((registration,), registry_id="test.synthetic.profiles") + + with pytest.raises(RuntimeConfigError) as bootstrap_rejected: + select_bootstrap_preset(registration) + assert bootstrap_rejected.value.reason == "bootstrap_safe_preset_unavailable" + + with pytest.raises(RuntimeConfigError) as config_rejected: + bootstrap_runtime_config(tmp_path, registry, "sandbox") + assert config_rejected.value.reason == "preset_not_registered" + assert not (tmp_path / "config.yaml").exists() + + with pytest.raises(ValueError, match="exact sandbox-only runtime"): + RuntimeRegistry( + (registration,), + ctp_simnow_readonly_bindings=( + CtpSimNowConfigReadOnlyBinding(runtime_id="synthetic.profiles"), + ), + registry_id="test.synthetic.profiles.readonly", + ) + + +def test_confirmed_synthetic_live_profile_stops_before_runner_import( + tmp_path: Path, monkeypatch +) -> None: + from backtrader_runtime import cli as runtime_cli + from backtrader_runtime import runner + + registry = _registry(tmp_path, (_sandbox_profile(), _live_profile())) + _write_config( + tmp_path, + mode="live", + preset="managed_live_direct", + parameter_key="live_case", + secrets_ref="runtime_secrets", + ) + dispatches = [] + monkeypatch.setattr( + runtime_cli, + "dispatch_registered_runtime", + lambda *args, **kwargs: dispatches.append((args, kwargs)), + ) + stderr = io.StringIO() + + status = main( + ["run", "--strategy-dir", str(tmp_path), "--confirm-live"], + registry=registry, + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + + assert status == 2 + assert '"reason": "profile_dispatch_unavailable"' in stderr.getvalue() + assert dispatches == [] + + stderr = io.StringIO() + status = main( + ["preflight", "--strategy-dir", str(tmp_path)], + registry=registry, + environ={}, + stdout=io.StringIO(), + stderr=stderr, + ) + assert status == 2 + assert '"reason": "profile_dispatch_unavailable"' in stderr.getvalue() + assert dispatches == [] + + effective = resolve_runtime_config(load_runtime_config(tmp_path, registry=registry), registry) + monkeypatch.setattr( + runner, + "_loaded_registered_runner", + lambda *args, **kwargs: pytest.fail("profile runner was imported"), + ) + with pytest.raises(RuntimeConfigError) as direct_dispatch_rejected: + runner.dispatch_registered_runtime(effective, registry) + assert direct_dispatch_rejected.value.reason == "live_execution_admission_required" diff --git a/tests/unit/runtime/test_test_execution_profile.py b/tests/unit/runtime/test_test_execution_profile.py new file mode 100644 index 00000000..2ae86e66 --- /dev/null +++ b/tests/unit/runtime/test_test_execution_profile.py @@ -0,0 +1,359 @@ +"""Offline, fail-closed contract tests for a future sandbox test profile.""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys +from dataclasses import replace +from pathlib import Path + +import pytest + +import backtrader_runtime.test_execution_profile as test_execution_profile +from backtrader_runtime.test_execution_profile import ( + MAX_TEST_EXECUTION_PROFILE_BYTES, + TEST_EXECUTION_PROFILE_PRECHECKED, + TEST_EXECUTION_PROFILE_SCHEMA_VERSION, + TestExecutionPreflightContext as _TestExecutionPreflightContext, + TestExecutionProfileError as _TestExecutionProfileError, + canonical_test_execution_profile, + parse_test_execution_profile, + test_execution_profile_sha256 as _test_execution_profile_sha256, + validate_test_execution_profile, +) + + +NOW = 1_700_000_100.0 + + +@pytest.fixture(autouse=True) +def profile_clock(monkeypatch: pytest.MonkeyPatch) -> None: + """Exercise public validation with a fixed actual-clock lookup.""" + + monkeypatch.setattr(test_execution_profile.time, "time", lambda: NOW) + + +class _AcceptingOfflineVerifier: + """A test-only verifier with no credential, account, or provider access.""" + + def __init__(self) -> None: + self.calls = 0 + + def verify(self, profile, canonical_payload: bytes) -> bool: + self.calls += 1 + assert canonical_payload == canonical_test_execution_profile(profile) + return True + + +class _MutatingOfflineVerifier: + """A hostile verifier used to ensure a profile cannot be changed in place.""" + + def verify(self, profile, canonical_payload: bytes) -> bool: + del canonical_payload + object.__setattr__(profile, "max_external_writes", 99) + return True + + +class _StatefulMapping(dict): + """A mapping whose iteration must not run at the parsing boundary.""" + + def __init__(self) -> None: + super().__init__() + self.iterated = False + + def __iter__(self): + self.iterated = True + raise AssertionError("the parser must not execute mapping subclasses") + + +def _wire() -> dict: + return { + "account_fingerprint_sha256": "a" * 64, + "allowed_instruments": ["IF2406", "rb2610"], + "approval_receipt_digest": "e" * 64, + "artifact_sha256": "b" * 64, + "capability_receipt_digest": "c" * 64, + "cleanup_required": True, + "created_at": NOW - 10.0, + "effective_config_digest": "d" * 64, + "environment": "sandbox", + "expires_at": NOW + 60.0, + "max_external_writes": 2, + "max_quantity": "3.000", + "profile_id": "iteration41.ctp.simnow-test-1", + "provider": "ctp", + "reconciliation_required": True, + "schema_version": TEST_EXECUTION_PROFILE_SCHEMA_VERSION, + "valid_from": NOW - 5.0, + } + + +def _context(**changes) -> _TestExecutionPreflightContext: + values = { + "provider": "ctp", + "environment": "sandbox", + "account_fingerprint_sha256": "a" * 64, + "approval_receipt_digest": "e" * 64, + "effective_config_digest": "d" * 64, + "artifact_sha256": "b" * 64, + "capability_receipt_digest": "c" * 64, + "instrument": "IF2406", + "quantity": "2.5", + "requested_external_writes": 1, + "cleanup_ready": True, + "reconciliation_ready": True, + } + values.update(changes) + return _TestExecutionPreflightContext(**values) + + +def test_matching_profile_only_returns_a_non_authoritative_offline_observation() -> None: + wire = _wire() + verifier = _AcceptingOfflineVerifier() + + observation = validate_test_execution_profile(wire, context=_context(), verifier=verifier) + + assert verifier.calls == 1 + assert observation.status == TEST_EXECUTION_PROFILE_PRECHECKED + assert observation.profile_binding_valid is True + assert observation.profile_verifier_accepted is True + assert observation.preflight_authorized is False + assert observation.execution_authorized is False + assert observation.provider_preflight_started is False + assert observation.provider_connected is False + assert observation.external_writes_started is False + assert observation.approval_receipt_digest == wire["approval_receipt_digest"] + assert observation.valid_until == NOW + 60.0 + public = observation.as_public_dict() + assert "account_fingerprint_sha256" not in public + assert wire["account_fingerprint_sha256"] not in json.dumps(public) + profile = parse_test_execution_profile(wire) + assert wire["account_fingerprint_sha256"] not in repr(profile) + with pytest.raises(ValueError, match="cannot grant authority"): + replace(observation, execution_authorized=True) + with pytest.raises(TypeError, match="not an admission decision"): + bool(observation) + + +def test_default_verifier_rejects_every_profile_before_any_provider_activity() -> None: + with pytest.raises(_TestExecutionProfileError) as caught: + validate_test_execution_profile(_wire(), context=_context()) + + assert caught.value.reason == "profile_untrusted" + + +def test_caller_cannot_revive_profile_with_historical_time() -> None: + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(TypeError, match="unexpected keyword argument 'now'"): + validate_test_execution_profile( + _wire(), + context=_context(), + verifier=verifier, + now=NOW - 1_000.0, # type: ignore[call-arg] + ) + + assert verifier.calls == 0 + + +def test_profile_canonicalization_normalizes_instrument_order_and_quantity() -> None: + first_wire = _wire() + second_wire = _wire() + second_wire["allowed_instruments"] = list(reversed(first_wire["allowed_instruments"])) + second_wire["max_quantity"] = "3" + + first = parse_test_execution_profile(first_wire) + second = parse_test_execution_profile(second_wire) + + assert first.allowed_instruments == ("IF2406", "rb2610") + assert first.max_quantity == "3" + assert canonical_test_execution_profile(first) == canonical_test_execution_profile(second) + assert _test_execution_profile_sha256(first) == _test_execution_profile_sha256(second) + assert TEST_EXECUTION_PROFILE_SCHEMA_VERSION == "bt-test-execution-profile/v2" + + +@pytest.mark.parametrize( + "mutate,reason", + ( + ( + lambda wire: wire.update({"environment": "production"}), + "production_environment_forbidden", + ), + (lambda wire: wire.update({"environment": "development"}), "environment_not_sandbox"), + (lambda wire: wire.update({"approval_receipt_digest": "invalid"}), "invalid_digest"), + (lambda wire: wire.update({"valid_from": NOW + 1.0}), "profile_not_yet_valid"), + (lambda wire: wire.update({"expires_at": NOW}), "profile_expired"), + (lambda wire: wire.update({"cleanup_required": False}), "required_safety_control_missing"), + ( + lambda wire: wire.update({"reconciliation_required": False}), + "required_safety_control_missing", + ), + ( + lambda wire: wire.update({"schema_version": "bt-test-execution-profile/v1"}), + "unsupported_schema", + ), + (lambda wire: wire.update({"unexpected": "field"}), "invalid_wire"), + ), +) +def test_malformed_or_non_sandbox_profiles_fail_before_the_verifier(mutate, reason: str) -> None: + wire = _wire() + mutate(wire) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(_TestExecutionProfileError) as caught: + validate_test_execution_profile(wire, context=_context(), verifier=verifier) + + assert caught.value.reason == reason + assert verifier.calls == 0 + + +@pytest.mark.parametrize( + "context_change,reason", + ( + ({"provider": "other"}, "provider_mismatch"), + ({"environment": "simnow"}, "environment_mismatch"), + ({"account_fingerprint_sha256": "e" * 64}, "account_fingerprint_mismatch"), + ({"effective_config_digest": "e" * 64}, "effective_config_digest_mismatch"), + ({"artifact_sha256": "e" * 64}, "artifact_mismatch"), + ({"capability_receipt_digest": "e" * 64}, "capability_receipt_digest_mismatch"), + ({"approval_receipt_digest": "f" * 64}, "approval_receipt_digest_mismatch"), + ({"instrument": "cu2406"}, "instrument_not_allowed"), + ({"quantity": "3.1"}, "quantity_limit_exceeded"), + ({"requested_external_writes": 3}, "external_write_limit_exceeded"), + ({"cleanup_ready": False}, "cleanup_not_ready"), + ({"reconciliation_ready": False}, "reconciliation_not_ready"), + ), +) +def test_context_must_remain_exactly_bound_and_within_profile_limits( + context_change, reason: str +) -> None: + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(_TestExecutionProfileError) as caught: + validate_test_execution_profile( + _wire(), context=_context(**context_change), verifier=verifier + ) + + assert caught.value.reason == reason + assert verifier.calls == 0 + + +def test_profile_parser_rejects_missing_and_duplicate_wire_fields() -> None: + missing = _wire() + del missing["max_quantity"] + + with pytest.raises(_TestExecutionProfileError) as caught: + parse_test_execution_profile(missing) + + assert caught.value.reason == "invalid_wire" + encoded = json.dumps(_wire(), sort_keys=True) + duplicate = encoded[:-1] + ',"profile_id":"duplicate"}' + with pytest.raises(_TestExecutionProfileError) as caught: + parse_test_execution_profile(duplicate) + + assert caught.value.reason == "invalid_json" + + +def test_profile_wire_requires_a_preissued_approval_receipt_digest() -> None: + missing = _wire() + del missing["approval_receipt_digest"] + + with pytest.raises(_TestExecutionProfileError) as caught: + parse_test_execution_profile(missing) + + assert caught.value.reason == "invalid_wire" + + with pytest.raises(_TestExecutionProfileError) as caught: + _context(approval_receipt_digest=None) + + assert caught.value.reason == "invalid_digest" + + +def test_profile_parser_rejects_stateful_mappings_without_running_them() -> None: + mapping = _StatefulMapping() + + with pytest.raises(_TestExecutionProfileError) as caught: + parse_test_execution_profile(mapping) + + assert caught.value.reason == "invalid_wire" + assert mapping.iterated is False + + +@pytest.mark.parametrize( + "serialized,reason", + ( + ("[" * 2_000 + "]" * 2_000, "invalid_json"), + (b"{" + b" " * MAX_TEST_EXECUTION_PROFILE_BYTES + b"}", "profile_too_large"), + ), + ids=("deeply_nested_json", "oversized_bytes"), +) +def test_profile_parser_rejects_nested_or_oversized_serialized_wires( + serialized, reason: str +) -> None: + with pytest.raises(_TestExecutionProfileError) as caught: + parse_test_execution_profile(serialized) + + assert caught.value.reason == reason + + +def test_profile_parser_rejects_deep_json_even_with_raised_recursion_limit() -> None: + original_limit = sys.getrecursionlimit() + try: + sys.setrecursionlimit(max(original_limit, 10_000)) + with pytest.raises(_TestExecutionProfileError) as caught: + parse_test_execution_profile("[" * 2_000 + "]" * 2_000) + finally: + sys.setrecursionlimit(original_limit) + assert caught.value.reason == "invalid_json" + + +def test_verifier_cannot_mutate_the_canonical_profile_it_claims_to_trust() -> None: + with pytest.raises(_TestExecutionProfileError) as caught: + validate_test_execution_profile( + _wire(), context=_context(), verifier=_MutatingOfflineVerifier() + ) + + assert caught.value.reason == "profile_mutated_by_verifier" + + +def test_validation_rechecks_expiry_after_the_verifier_returns(monkeypatch) -> None: + clock_values = iter((NOW, NOW + 60.0)) + monkeypatch.setattr(test_execution_profile.time, "time", lambda: next(clock_values)) + verifier = _AcceptingOfflineVerifier() + + with pytest.raises(_TestExecutionProfileError) as caught: + validate_test_execution_profile(_wire(), context=_context(), verifier=verifier) + + assert caught.value.reason == "profile_expired" + assert verifier.calls == 1 + + +def test_profile_module_imports_no_framework_or_provider_sdk() -> None: + script = ( + "import sys; import backtrader_runtime.test_execution_profile; " + "blocked = ('backtrader', 'bt_api', 'bt_api_py', 'backtrader_agent', " + "'backtrader_skills', 'backtrader_mcp'); " + "assert not any(name == item or name.startswith(item + '.') " + "for item in blocked for name in sys.modules)" + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=str(Path(__file__).resolve().parents[3]), + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_context_does_not_accept_production_environment() -> None: + values = dict(_context().__dict__) + values["environment"] = "production" + + with pytest.raises(_TestExecutionProfileError) as caught: + _TestExecutionPreflightContext(**copy.deepcopy(values)) + + assert caught.value.reason == "production_environment_forbidden" diff --git a/tests/unit/stores/test_btapistore_iteration21.py b/tests/unit/stores/test_btapistore_iteration21.py index 463d4b39..3843146d 100644 --- a/tests/unit/stores/test_btapistore_iteration21.py +++ b/tests/unit/stores/test_btapistore_iteration21.py @@ -1316,9 +1316,7 @@ def configure_execution(self, config): }, ) - result = store.run_bounded_read_only_metadata_probe( - datanames=(SYMBOL,), timeout_seconds=0.5 - ) + result = store.run_bounded_read_only_metadata_probe(datanames=(SYMBOL,), timeout_seconds=0.5) api = MetadataProbeTypedSdk.instances[-1] assert api.execution_configurations == [ @@ -3401,6 +3399,11 @@ def get_account_risk_snapshot(self, *, initialize_baseline=False): time.sleep(0.08) return account_risk_payload(self) + def get_execution_summary(self): + summary = super().get_execution_summary() + summary["evidence_errors"] = [] + return summary + api = SlowRiskSdk() store = make_store( api, diff --git a/tests/unit/test_cross_exchange_mode_matrix.py b/tests/unit/test_cross_exchange_mode_matrix.py index cf3906cc..c632c682 100644 --- a/tests/unit/test_cross_exchange_mode_matrix.py +++ b/tests/unit/test_cross_exchange_mode_matrix.py @@ -97,6 +97,19 @@ def _candidate_for(runner): return manifest, candidate +def _candidate_with_current_qualification_artifact_hash(runner, candidate): + """Return a test-only candidate copy bound to the current artifact bytes.""" + binding = candidate["qualification_artifact"] + artifact_path = runner.HERE / binding["path"] + return { + **candidate, + "qualification_artifact": { + **binding, + "sha256": runner._file_sha256(artifact_path, "qualification artifact"), + }, + } + + def _passing_store_health(): """Return a store-health payload reporting a clean shutdown.""" return { @@ -130,6 +143,7 @@ def _operator_demo_live_rules(runner): def test_012_1_calibration_loader_remains_strict_without_operator_demo_override(): runner = RUNNERS[0] _manifest, candidate = _candidate_for(runner) + candidate = _candidate_with_current_qualification_artifact_hash(runner, candidate) with pytest.raises(runner.RunnerConfigurationError, match="not bound to this config"): runner._load_model_qualification( @@ -140,9 +154,34 @@ def test_012_1_calibration_loader_remains_strict_without_operator_demo_override( ) +def test_012_1_qualification_artifact_hash_mismatch_fails_closed(): + runner = RUNNERS[0] + _manifest, candidate = _candidate_for(runner) + artifact_path = runner.HERE / candidate["qualification_artifact"]["path"] + artifact_sha256 = runner._file_sha256(artifact_path, "qualification artifact") + wrong_sha256 = ("0" if artifact_sha256[0] != "0" else "1") + artifact_sha256[1:] + candidate = { + **candidate, + "qualification_artifact": { + **candidate["qualification_artifact"], + "sha256": wrong_sha256, + }, + } + + assert wrong_sha256 != artifact_sha256 + with pytest.raises(runner.RunnerConfigurationError, match="fingerprint mismatch"): + runner._load_model_qualification( + candidate, + runner.replay_rules(), + runner.risk_from_config(runner.load_config()), + runner.DEFAULT_CONFIG, + ) + + def test_012_1_operator_demo_override_loads_original_training_artifact_with_mismatch_evidence(): runner = RUNNERS[0] _manifest, candidate = _candidate_for(runner) + candidate = _candidate_with_current_qualification_artifact_hash(runner, candidate) rules = _operator_demo_live_rules(runner) risk = runner.risk_from_config(runner.load_config()) artifact_path = runner.HERE / candidate["qualification_artifact"]["path"] @@ -174,6 +213,7 @@ def test_012_1_operator_demo_override_loads_original_training_artifact_with_mism def test_012_1_operator_demo_override_still_requires_the_bound_artifact_hash(): runner = RUNNERS[0] _manifest, candidate = _candidate_for(runner) + candidate = _candidate_with_current_qualification_artifact_hash(runner, candidate) tampered_candidate = { **candidate, "qualification_artifact": { diff --git a/tests/unit/test_cross_exchange_pair_examples.py b/tests/unit/test_cross_exchange_pair_examples.py index 7399e473..d1c418b1 100644 --- a/tests/unit/test_cross_exchange_pair_examples.py +++ b/tests/unit/test_cross_exchange_pair_examples.py @@ -40,15 +40,19 @@ def _install_test_only_trusted_formula_candidate_binding(monkeypatch, runner): helper never mutates any manifest or enables a network/approval path. """ - manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + local_path = Path(runner.MANIFEST_PATH).resolve() + assert local_path != Path(runner.REPO_CANONICAL_MANIFEST).resolve() + manifest = json.loads(local_path.read_text(encoding="utf-8")) + assert manifest["manifest_status"] == "RESEARCH_REJECTED_OPERATOR_DEMO_SIMULATION_ONLY" candidate = next( row for row in manifest["candidates"] if row["strategy_id"] == runner.STRATEGY_ID ) - canonical_path = Path(runner.MANIFEST_PATH).resolve() + assert candidate["research_status"] == "RESEARCH_REJECTED" + assert candidate["demo_approval"]["status"] == "NOT_APPROVED" def load_test_only_candidate(path=runner.MANIFEST_PATH): - assert Path(path).resolve() == canonical_path - return manifest, candidate, canonical_path + assert Path(path).resolve() == local_path + return manifest, candidate, local_path def unexpected_store(*_args, **_kwargs): pytest.fail("formula fixture must never construct a Store") diff --git a/tests/unit/test_ctp_sa_admission_receipt_tool.py b/tests/unit/test_ctp_sa_admission_receipt_tool.py index 0978a62c..d49b7779 100644 --- a/tests/unit/test_ctp_sa_admission_receipt_tool.py +++ b/tests/unit/test_ctp_sa_admission_receipt_tool.py @@ -14,7 +14,6 @@ import pytest import yaml - REPO = Path(__file__).resolve().parents[2] EXAMPLE = REPO / "examples" / "013_3_sa_midfreq_simnow" PACKAGE = "iter22_admission_receipt_tool_example" @@ -114,9 +113,25 @@ def _facts(config: dict, *, purpose: str = "natural_signal") -> dict: } +def _install_offline_test_identity(monkeypatch: pytest.MonkeyPatch) -> None: + """Use a stable, explicitly test-only identity without an installed SDK. + + The receipt tool must bind the dependency identity it is given. These + offline tests do not claim that a real CTP SDK is installed or verified; + production keeps the real identity collector and its fail-closed behavior. + """ + test_identity = { + "offline_test_fixture": tool._sha256_json( + {"identity_kind": "test-only", "sdk_provenance": "not-present"} + ) + } + monkeypatch.setattr(runner, "dependency_identity_hashes", lambda: dict(test_identity)) + + def test_build_request_is_offline_and_binds_full_runtime_evidence(tmp_path, monkeypatch): config = tool.load_config(_write_admitted_config(tmp_path)) facts = _facts(config) + _install_offline_test_identity(monkeypatch) monkeypatch.setattr( runner, "BtApiStore", @@ -139,6 +154,17 @@ def test_build_request_is_offline_and_binds_full_runtime_evidence(tmp_path, monk assert binding["facts_sha256"] == tool._sha256_json(request["facts"]) +def test_build_request_fails_closed_when_current_identity_is_unavailable(tmp_path, monkeypatch): + config = tool.load_config(_write_admitted_config(tmp_path)) + + def unavailable_identity(): + raise RuntimeError("test identity source unavailable") + + monkeypatch.setattr(runner, "dependency_identity_hashes", unavailable_identity) + with pytest.raises(tool.ReceiptToolError, match="current_source_identity_unavailable"): + tool.build_request(_facts(config), config) + + def test_natural_signal_request_rejects_non_admitted_config(): config = tool.load_config(EXAMPLE / "config.yaml") @@ -173,6 +199,7 @@ def test_request_rejects_stale_calendar_artifact_and_long_or_future_validity(tmp def test_cli_requires_explicit_signing_trust_root_and_validates_runner_contract( tmp_path, monkeypatch, capsys ): + _install_offline_test_identity(monkeypatch) config_path = _write_admitted_config(tmp_path) config = tool.load_config(config_path) facts_path = tmp_path / "facts.json" @@ -250,6 +277,7 @@ def test_cli_requires_explicit_signing_trust_root_and_validates_runner_contract( def test_signing_without_existing_trust_root_does_not_publish_receipt( tmp_path, monkeypatch, capsys ): + _install_offline_test_identity(monkeypatch) config_path = _write_admitted_config(tmp_path) config = tool.load_config(config_path) facts_path = tmp_path / "facts.json" diff --git a/tests/unit/test_examples_self_contained.py b/tests/unit/test_examples_self_contained.py index 7093efc1..d41c206b 100644 --- a/tests/unit/test_examples_self_contained.py +++ b/tests/unit/test_examples_self_contained.py @@ -22,16 +22,62 @@ import ast import importlib.util +import pkgutil import subprocess import sys +import sysconfig from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] EXAMPLES = ROOT / "examples" -STDLIB = set(sys.stdlib_module_names) NON_EXAMPLE_DIRS = frozenset({"logs", "output", "state"}) +# ``sys.stdlib_module_names`` lists names for every supported platform. The +# fallback scan only sees the current platform, while examples import these +# process-lock modules conditionally for Windows and POSIX respectively. +PLATFORM_STDLIB_MODULES = frozenset({"fcntl", "msvcrt"}) + + +def _stdlib_module_names() -> set[str]: + """Return top-level stdlib modules on Python versions 3.8 and newer. + + ``sys.stdlib_module_names`` was added in Python 3.10. On 3.8/3.9, scan + the interpreter's standard-library and extension-module directories + without traversing site-packages. + """ + + names = getattr(sys, "stdlib_module_names", None) + if names is not None: + return set(names) + + discovered = set(sys.builtin_module_names) + paths = { + sysconfig.get_path("stdlib"), + sysconfig.get_path("platstdlib"), + sysconfig.get_config_var("DESTSHARED"), + } + for path in sorted(path for path in paths if path): + discovered.update(name for _finder, name, _ispkg in pkgutil.iter_modules([path])) + return discovered | PLATFORM_STDLIB_MODULES + + +STDLIB = _stdlib_module_names() + +# These runtime dependencies are optional for the repository's ordinary CI +# environment and documented by their owning example folders. Keep exceptions +# folder-scoped so they cannot hide undeclared imports in other examples. +OPTIONAL_EXAMPLE_MODULES = { + "010_live_examples": frozenset({"ccxt"}), + "017_fnn_embedding": frozenset({"torch"}), +} + +# This module is in the standard library from Python 3.9 onward. The 013_3 +# example catches ImportError and falls back to the declared core dependency +# pytz on Python 3.8, so only that folder may use the compatibility import. +COMPATIBILITY_STDLIB_MODULES = { + "013_3_sa_midfreq_simnow": frozenset({"zoneinfo"}), +} # Byte-identical copies of shared support code, keyed by the canonical source. VENDORED_COPIES = { @@ -108,14 +154,48 @@ def _is_installed(name: str) -> bool: @pytest.mark.parametrize("folder", _example_folders(), ids=lambda path: path.name) def test_example_folder_is_self_contained(folder: Path) -> None: + assert _example_import_violations(folder) == [] + + +def _example_import_violations(folder: Path) -> list[str]: + """Return imports that are neither local, installed, nor declared compatible.""" + local = _local_modules(folder) + optional = OPTIONAL_EXAMPLE_MODULES.get(folder.name, frozenset()) + compatibility_stdlib = COMPATIBILITY_STDLIB_MODULES.get(folder.name, frozenset()) violations = [] for path in sorted(folder.rglob("*.py")): for name in sorted(_imported_top_levels(path)): - if name in local or _is_installed(name): + if ( + name in local + or name in optional + or name in compatibility_stdlib + or _is_installed(name) + ): continue - violations.append(f"{path.relative_to(EXAMPLES)} imports {name!r}") - assert violations == [] + violations.append(f"{path.relative_to(EXAMPLES).as_posix()} imports {name!r}") + return violations + + +def test_zoneinfo_compatibility_import_is_folder_scoped(tmp_path: Path, monkeypatch) -> None: + """The Python 3.8 fallback must not excuse a missing import elsewhere.""" + + original_is_installed = _is_installed + compatibility_folder = EXAMPLES / "013_3_sa_midfreq_simnow" + monkeypatch.setattr( + sys.modules[__name__], + "_is_installed", + lambda name: False if name == "zoneinfo" else original_is_installed(name), + ) + assert _example_import_violations(compatibility_folder) == [] + + folder = tmp_path / "unrelated_example" + folder.mkdir() + (folder / "example.py").write_text("import zoneinfo\n", encoding="utf-8") + monkeypatch.setattr(sys.modules[__name__], "EXAMPLES", tmp_path) + monkeypatch.setattr(sys.modules[__name__], "_is_installed", lambda _name: False) + + assert _example_import_violations(folder) == ["unrelated_example/example.py imports 'zoneinfo'"] @pytest.mark.parametrize("copy", sorted(VENDORED_COPIES), ids=lambda path: path.name) diff --git a/tests/unit/test_fnn_embedding_example.py b/tests/unit/test_fnn_embedding_example.py index 5af7fbbb..fbdef7c1 100644 --- a/tests/unit/test_fnn_embedding_example.py +++ b/tests/unit/test_fnn_embedding_example.py @@ -18,7 +18,6 @@ REPO = Path(__file__).resolve().parents[2] strategy_mod = importlib.import_module("examples.017_fnn_embedding.fnn_embedding_strategy") -autoenc_mod = importlib.import_module("examples.017_fnn_embedding.fnn_autoencoder") DECISION_LONG = strategy_mod.DECISION_LONG DECISION_SHORT = strategy_mod.DECISION_SHORT @@ -35,9 +34,6 @@ EmbeddingLibrary = strategy_mod.EmbeddingLibrary validate_ohlcv = strategy_mod.DataValidationError -train_autoencoder = autoenc_mod.train_autoencoder - - # --------------------------------------------------------------------------- # Synthetic frame helpers (load_mt5_csv compatible) # --------------------------------------------------------------------------- @@ -181,20 +177,25 @@ def test_zero_std_feature_zeroed(self): class TestAutoencoder: - def test_training_deterministic(self): + @pytest.fixture(scope="class") + def autoenc_mod(self): + pytest.importorskip("torch", reason="PyTorch is optional for FNN example tests") + return importlib.import_module("examples.017_fnn_embedding.fnn_autoencoder") + + def test_training_deterministic(self, autoenc_mod): rng = np.random.default_rng(3) X = rng.normal(0.0, 1.0, size=(500, 12)).astype(np.float32) - state1, meta1 = train_autoencoder( + state1, meta1 = autoenc_mod.train_autoencoder( X, embed_dim=4, epochs=15, lr=1e-3, batch=64, patience=5, seed=42 ) - state2, meta2 = train_autoencoder( + state2, meta2 = autoenc_mod.train_autoencoder( X, embed_dim=4, epochs=15, lr=1e-3, batch=64, patience=5, seed=42 ) for key in state1: assert np.array_equal(state1[key].numpy(), state2[key].numpy()) assert meta1["final_loss"] == pytest.approx(meta2["final_loss"], abs=0.0) - def test_forward_consistent(self): + def test_forward_consistent(self, autoenc_mod): autoenc_mod.make_deterministic(42) model = autoenc_mod.Autoencoder(embed_dim=4) x = np.random.default_rng(1).normal(size=(3, 12)).astype(np.float32)