diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 627af89..569d798 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,24 @@ name: CI on: pull_request: - branches: [dev] + branches: [dev, main] push: - branches: [dev] + branches: [dev, main] + workflow_dispatch: permissions: contents: read jobs: - tests: - name: Python tests + package: + name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: - name: Checkout uses: actions/checkout@v4 @@ -21,18 +27,62 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true - name: Install dependencies + # The shared suite exercises Optuna, reporting, and plotting APIs. + # Keep the heavyweight optional Nautilus validation extra out of this + # matrix; it has its own gate and creates avoidable RSS pressure. + run: uv sync --extra optimization --extra reports --extra viz --dev + + - name: Test + run: >- + .venv/bin/python -m pytest -q + --ignore=tests/test_real.py + --ignore=tests/test_real_endpoints.py + --ignore=tests/native_event + --ignore=tests/test_phase47a_grid_adapter.py + --ignore=tests/test_phase47c_grid_parity.py + --ignore=tests/test_phase47d_grid_optimizer.py + + - name: Build package run: | - python -m pip install -U pip - python -m pip install numpy pandas numba matplotlib seaborn pytest + rm -rf dist + uv build + + - name: Validate distribution metadata + run: uv run twine check dist/* - - name: Run core tests - env: - PYTHONPATH: ${{ github.workspace }}/.. + - name: Check repository visibility and release artifacts run: | - pytest tests \ - --ignore=tests/test_real.py \ - --ignore=tests/test_real_endpoints.py \ - --ignore=tests/test_phase5_nautilus_adapter.py + git ls-files --error-unmatch upgrade/implement.md + python "$GITHUB_WORKSPACE/tools/scan_public_secrets.py" --root "$GITHUB_WORKSPACE" + python "$GITHUB_WORKSPACE/tools/check_release_artifacts.py" --dist "$GITHUB_WORKSPACE/dist" + + - name: Clean wheel install smoke + shell: bash + run: | + python -m venv /tmp/quantbt-wheel-smoke + /tmp/quantbt-wheel-smoke/bin/python -m pip install --upgrade pip + /tmp/quantbt-wheel-smoke/bin/python -m pip install dist/quantbt_engine-*.whl + /tmp/quantbt-wheel-smoke/bin/python -m pip check + cd /tmp + /tmp/quantbt-wheel-smoke/bin/python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" + + - name: Clean sdist install smoke + shell: bash + run: | + python -m venv /tmp/quantbt-sdist-smoke + /tmp/quantbt-sdist-smoke/bin/python -m pip install --upgrade pip + /tmp/quantbt-sdist-smoke/bin/python -m pip install dist/quantbt_engine-*.tar.gz + /tmp/quantbt-sdist-smoke/bin/python -m pip check + cd /tmp + /tmp/quantbt-sdist-smoke/bin/python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" + + - name: Pool Alpha import compatibility smoke + run: uv run python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..1580222 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,122 @@ +name: Native Event API 0.4 Gate + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + native-event-api-04: + name: Native Event API 0.4 / CPython ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install core test environment + # The native gate needs the optimization dependencies used by the + # shared test suite, but not the optional Nautilus/report/viz extras. + # Keeping those out avoids importing a large third-party stack in + # every CPython matrix job. + run: uv sync --extra optimization --dev + + - name: Rust format, lint, and tests + working-directory: rust/native_event + run: | + cargo fmt --check + cargo clippy -- -D warnings + cargo test + + - name: Build core wheel from this ref + run: uv build --out-dir dist/core + + - name: Build native wheel + uses: PyO3/maturin-action@v1 + with: + command: build + args: >- + --release + --manifest-path rust/native_event/Cargo.toml + --interpreter python${{ matrix.python-version }} + --out dist/native + manylinux: "2014" + + - name: Clean combined core and native wheel install smoke + shell: bash + run: | + python -m venv /tmp/quantbt-native-combined-smoke + /tmp/quantbt-native-combined-smoke/bin/python -m pip install --upgrade pip + /tmp/quantbt-native-combined-smoke/bin/python -m pip install dist/core/quantbt_engine-*.whl dist/native/quantbt_native-*.whl + /tmp/quantbt-native-combined-smoke/bin/python -m pip check + cd /tmp + /tmp/quantbt-native-combined-smoke/bin/python - <<'PY' + from quantbt import QuantBTEndpoint + import _quantbt_native + + assert _quantbt_native.api_version() == "0.4" + required = { + "native_event_v2_full_contract", + "native_event_v2_multisymbol", + "native_event_v2_funding", + "native_event_v2_liquidation", + "native_event_v2_cancel_all_oco", + "native_event_v2_tif_expiry", + "native_event_v2_relationships", + "native_event_v2_quantity_preflight", + } + capabilities = _quantbt_native.capabilities() + missing = { + key for key in required if not capabilities.get(key, False) + } + assert not missing, sorted(missing) + print(QuantBTEndpoint) + print("Native Event API 0.4 capabilities: PASS") + PY + + - name: Install native wheel into core test environment + run: uv pip install --python .venv/bin/python --no-deps dist/native/quantbt_native-*.whl + + - name: Native Event full suite + run: .venv/bin/python -m pytest -q tests/native_event + + - name: API 0.4 full-contract closure tests + env: + QUANTBT_NATIVE_BACKEND: rust + run: | + .venv/bin/python -m pytest -q \ + tests/native_event/test_phase48e1_closure.py \ + tests/native_event/contract/test_phase47b_full_contract.py \ + tests/native_event/test_reactive_callback_contract.py + + - name: Prepared score RSS and parity gate + run: | + .venv/bin/python benchmarks/run_phase45b_native_event_score_rss.py --rows 1000 --repeats 25 --json-out /tmp/phase45b-score-rss.json + .venv/bin/python -c "import json; p=json.load(open('/tmp/phase45b-score-rss.json')); assert p['parity']; assert p['score_faster_than_audit']; assert p['score_rss_not_higher_than_audit']" + + - name: Rust RSS benchmark smoke + env: + QUANTBT_NATIVE_BACKEND: rust + run: .venv/bin/python benchmarks/native_event/benchmark_reactive_session.py --backend rust diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml new file mode 100644 index 0000000..d2e43ab --- /dev/null +++ b/.github/workflows/publish-testpypi.yml @@ -0,0 +1,161 @@ +name: Publish quantbt-engine to TestPyPI + +on: + workflow_dispatch: + inputs: + ref: + description: "Release tag containing the RC version, for example v1.0.7rc1" + required: true + type: string + push: + tags: + - "v*rc*" + +permissions: + contents: read + +jobs: + build: + name: Build and test release candidate + runs-on: ubuntu-latest + + steps: + - name: Checkout release candidate + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref_name }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install core development environment + run: uv sync --extra optimization --extra reports --extra viz --dev + + - name: Check RC version against tag + env: + GITHUB_REF_NAME: ${{ inputs.ref || github.ref_name }} + run: uv run python tools/check_release_version.py + + - name: Run regression + run: >- + .venv/bin/python -m pytest -q + --ignore=tests/test_real.py + --ignore=tests/test_real_endpoints.py + --ignore=tests/native_event + --ignore=tests/test_phase47a_grid_adapter.py + --ignore=tests/test_phase47c_grid_parity.py + --ignore=tests/test_phase47d_grid_optimizer.py + + - name: Clean build directory + run: rm -rf dist release-manifest.json + + - name: Build distributions + run: uv build --out-dir dist + + - name: Validate distribution metadata + run: uv run twine check --strict dist/* + + - name: Inspect release surfaces + run: | + git ls-files --error-unmatch upgrade/implement.md + test -n "$(find dist -maxdepth 1 -type f -name 'quantbt_engine-*.tar.gz' -print -quit)" + test -n "$(find dist -maxdepth 1 -type f -name 'quantbt_engine-*-py3-none-any.whl' -print -quit)" + if find dist -maxdepth 1 -type f \( -iname '*quantbt_native*' -o -iname '*manylinux*' \) | grep -q .; then + echo "ERROR: native artifact found in core TestPyPI release" + exit 1 + fi + python "$GITHUB_WORKSPACE/tools/scan_public_secrets.py" --root "$GITHUB_WORKSPACE" + python "$GITHUB_WORKSPACE/tools/check_release_artifacts.py" --dist "$GITHUB_WORKSPACE/dist" + + - name: Clean wheel install smoke + shell: bash + run: | + python -m venv /tmp/quantbt-testpypi-wheel-smoke + /tmp/quantbt-testpypi-wheel-smoke/bin/python -m pip install --upgrade pip + /tmp/quantbt-testpypi-wheel-smoke/bin/python -m pip install dist/quantbt_engine-*.whl + /tmp/quantbt-testpypi-wheel-smoke/bin/python -m pip check + cd /tmp + /tmp/quantbt-testpypi-wheel-smoke/bin/python - <<'PY' + import pathlib + import quantbt + + path = pathlib.Path(quantbt.__file__).resolve() + assert "site-packages" in path.parts, path + print(path) + PY + + - name: Clean sdist install smoke + shell: bash + run: | + python -m venv /tmp/quantbt-testpypi-sdist-smoke + /tmp/quantbt-testpypi-sdist-smoke/bin/python -m pip install --upgrade pip + /tmp/quantbt-testpypi-sdist-smoke/bin/python -m pip install dist/quantbt_engine-*.tar.gz + /tmp/quantbt-testpypi-sdist-smoke/bin/python -m pip check + cd /tmp + /tmp/quantbt-testpypi-sdist-smoke/bin/python - <<'PY' + import pathlib + import quantbt + + path = pathlib.Path(quantbt.__file__).resolve() + assert "site-packages" in path.parts, path + print(path) + PY + + - name: Create release manifest + env: + GITHUB_REF_NAME: ${{ inputs.ref || github.ref_name }} + run: >- + uv run python tools/create_release_manifest.py + --dist dist + --output release-manifest.json + --require-clean + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: testpypi-dist + path: dist/* + if-no-files-found: error + + - name: Upload release manifest + uses: actions/upload-artifact@v4 + with: + name: testpypi-release-manifest + path: release-manifest.json + if-no-files-found: error + + publish: + name: Publish release candidate to TestPyPI + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + permissions: + contents: read + id-token: write + + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: testpypi-dist + path: dist + + - name: Download release manifest + uses: actions/download-artifact@v4 + with: + name: testpypi-release-manifest + path: release-evidence + + - name: Publish with TestPyPI trusted publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ff04e7d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,173 @@ +name: Publish quantbt-engine + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + test: + name: Test Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --extra optimization --extra reports --extra viz --dev + + - name: Check release version + if: matrix.python-version == '3.12' + run: uv run python tools/check_release_version.py + + - name: Test + run: >- + .venv/bin/python -m pytest -q + --ignore=tests/test_real.py + --ignore=tests/test_real_endpoints.py + --ignore=tests/native_event + --ignore=tests/test_phase47a_grid_adapter.py + --ignore=tests/test_phase47c_grid_parity.py + --ignore=tests/test_phase47d_grid_optimizer.py + + - name: Build + run: uv build + + build: + name: Build distribution + needs: test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install build dependencies + run: uv sync --dev + + - name: Check release version + run: uv run python tools/check_release_version.py + + - name: Build package + run: | + rm -rf dist release-manifest.json + uv build + + - name: Validate distribution metadata + run: uv run twine check --strict dist/* + + - name: Inspect release surfaces + run: | + git ls-files --error-unmatch upgrade/implement.md + test -n "$(find dist -maxdepth 1 -type f -name 'quantbt_engine-*.tar.gz' -print -quit)" + test -n "$(find dist -maxdepth 1 -type f -name 'quantbt_engine-*-py3-none-any.whl' -print -quit)" + if find dist -maxdepth 1 -type f \( -iname '*quantbt_native*' -o -iname '*manylinux*' \) | grep -q .; then + echo "ERROR: native artifact found in core PyPI release" + exit 1 + fi + python "$GITHUB_WORKSPACE/tools/scan_public_secrets.py" --root "$GITHUB_WORKSPACE" + python "$GITHUB_WORKSPACE/tools/check_release_artifacts.py" --dist "$GITHUB_WORKSPACE/dist" + + - name: Create release manifest + run: >- + uv run python tools/create_release_manifest.py + --dist dist + --output release-manifest.json + --require-clean + + - name: Clean wheel install smoke + shell: bash + run: | + python -m venv /tmp/quantbt-wheel-smoke + /tmp/quantbt-wheel-smoke/bin/python -m pip install --upgrade pip + /tmp/quantbt-wheel-smoke/bin/python -m pip install dist/quantbt_engine-*.whl + /tmp/quantbt-wheel-smoke/bin/python -m pip check + cd /tmp + /tmp/quantbt-wheel-smoke/bin/python - <<'PY' + import pathlib + import quantbt + + path = pathlib.Path(quantbt.__file__).resolve() + assert "site-packages" in path.parts, path + print(path) + PY + + - name: Clean sdist install smoke + shell: bash + run: | + python -m venv /tmp/quantbt-sdist-smoke + /tmp/quantbt-sdist-smoke/bin/python -m pip install --upgrade pip + /tmp/quantbt-sdist-smoke/bin/python -m pip install dist/quantbt_engine-*.tar.gz + /tmp/quantbt-sdist-smoke/bin/python -m pip check + cd /tmp + /tmp/quantbt-sdist-smoke/bin/python - <<'PY' + import pathlib + import quantbt + + path = pathlib.Path(quantbt.__file__).resolve() + assert "site-packages" in path.parts, path + print(path) + PY + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: python-dist + path: dist/* + if-no-files-found: error + + - name: Upload release manifest + uses: actions/upload-artifact@v4 + with: + name: pypi-release-manifest + path: release-manifest.json + if-no-files-found: error + + publish: + name: Publish to PyPI + needs: build + if: ${{ !github.event.release.prerelease && !github.event.release.draft }} + runs-on: ubuntu-latest + environment: + name: pypi + permissions: + contents: read + id-token: write + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-dist + path: dist + + - name: Publish with PyPI trusted publishing + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index e98db2f..5da223b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,94 @@ +# Python bytecode and test/tool caches __pycache__/ *.py[cod] *$py.class +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.hypothesis/ +.coverage +.coverage.* +htmlcov/ -*.so - +# Python packaging and native build output dist/ build/ *.egg-info/ *.egg +pip-wheel-metadata/ +*.so +*.pyd +*.dylib +rust/**/target/ +.maturin/ -*.ipynb_checkpoints/ -.ipynb_checkpoints/ - -.env -.venv -env/ +# Virtual environments +.venv/ venv/ +env/ ENV/ -*.log - +# Notebook, editor and OS files +*.ipynb_checkpoints/ +.ipynb_checkpoints/ .vscode/ .idea/ *.swp *.swo *~ - .DS_Store Thumbs.db -upgrade/ -benchmarks/ +# Secrets and machine-local configuration +.env +.env.* +!.env.example +.pypirc +**/.pypirc +secrets/ +credentials/ +credentials*.json +*_credentials.json +secrets*.json +*_secrets.json +*.pem +*.key +*.p12 +*.pfx +*.jks +id_rsa +id_rsa.* +id_ed25519 +id_ed25519.* + +# Private/local data and databases +data/raw/ +data/private/ +data/local/ +datasets/private/ +downloads/private/ +*.sqlite +*.sqlite3 +*.db + +# Logs, profiling traces and local benchmark output +*.log +*.prof +*.lprof +*.memray +*.flamegraph.svg +release-manifest*.json +artifacts/local/ +artifacts/tmp/ +benchmarks/**/local/ +benchmarks/**/tmp/ +benchmarks/**/.cache/ +benchmarks/**/profiles/ + +# Private planning only; public plans remain visible and trackable. +upgrade/private/ +upgrade/local/ +upgrade/drafts/ + +# Local research sandbox is intentionally outside the public package. .local_arbitrage_sandboxes/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..12ec628 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to `quantbt-engine` are documented here. + +## [1.0.7] - Unreleased + +This is the first independently installable core package release line. + +### Release candidate + +- `1.0.7rc1` prepared for TestPyPI on 2026-08-03. +- Python 3.11-3.13 package validation is required before final publication. +- `backend="auto"` remains Python. +- `backend="rust"` remains explicit and experimental. +- `quantbt-native` is not included in this core release. +- Native crate and Python metadata remain aligned to API version `0.4.0`. + +### Added + +- Stable `from quantbt import QuantBTEndpoint` import contract. +- NumPy/Numba native vectorized, event-driven, portfolio, arbitrage, options, + intrabar, and walk-forward research routes. +- Optional extras for optimization, reports, visualization, and NautilusTrader + validation. +- Prepared service contexts and report levels for repeated research workloads. +- Explicit Python/Rust native-event selector contract with Python as the + release default and Rust as a capability-gated experimental backend. +- Wheel, sdist, clean-install, source-sync, parity, and RSS release gates. + +### Release policy + +- The core `quantbt-engine` distribution is the release candidate for PyPI. +- `quantbt-native` is not part of this core release and is not exposed through + a non-empty `native` extra until its wheel matrix and incremental RSS gates + pass. +- `native_backend="auto"` remains Python; explicit Rust requests never + silently fall back to Python. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b8bd7f9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,21 @@ +include README.md +include LICENSE +include CHANGELOG.md +include pyproject.toml + +recursive-include src/quantbt *.py py.typed + +global-exclude *.pem +global-exclude *.key +global-exclude .env +global-exclude .env.* +global-exclude .pypirc + +prune upgrade/private +prune upgrade/local +prune upgrade/drafts +prune benchmarks +prune artifacts +prune data/raw +prune data/private +prune data/local diff --git a/README.md b/README.md index 9c8352a..db303cf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # QuantBT -![Python](https://img.shields.io/badge/python-3.10%2B-blue) +![Python](https://img.shields.io/badge/python-3.11%2B-blue) ![Numba](https://img.shields.io/badge/core-numba-00A86B) ![Backtesting](https://img.shields.io/badge/backtesting-vectorized%20%7C%20event--driven-black) ![Nautilus](https://img.shields.io/badge/nautilus-optional-6f42c1) @@ -51,6 +51,48 @@ historical reproduction. - Domain-agnostic Optuna optimization adapters for prepared signal, intrabar, portfolio, and generic endpoint workflows. +## Event-Driven Quick Start + +New event-driven integrations should use the stable facade. Choose an input +mode, a retention profile, and a public backend; the facade keeps matching, +fills, fees, slippage, margin, funding, and PnL in the existing native-event +engine. + +```python +from quantbt import QuantBTEndpoint + +bt = QuantBTEndpoint.event_driven( + input_mode="strategy", # strategy | orders + profile="research", # research | optimize | audit + backend="auto", # auto | python | rust + initial_capital=20_000, + leverage=5, + fee_rate=0.0005, # one-way fee per fill + slippage_bps=2.0, + use_funding=False, +) + +result = bt.simulate(data=df, strategy=strategy, symbols=["BTCUSDT"]) +bt.show_metrics() +``` + +Use `profile="research"` for compact notebook results, `"optimize"` for +scalar parameter-search results, and `"audit"` for replay-certified fills and +event artifacts. For an upstream order planner, switch to +`input_mode="orders"` and pass `order_commands=[...]`. The default `auto` +backend follows the release policy; `rust` is an explicit request for the +optional capability-gated native wheel. + +The strategy owns signal generation and look-ahead control. QuantBT owns the +causal order lifecycle and accounting. Advanced users can still call +`native_event_strategy(...)` or `native_event_lifecycle(...)` directly when a +custom low-level execution/report combination is required. + +See [`docs/endpoint.md`](docs/endpoint.md#stable-event-driven-facade) for the +full strategy protocol, profile matrix, explicit-order example, conflict +rules, and migration guidance. The broader endpoint map is in +[`docs/README.md`](docs/README.md). + ## Performance Philosophy QuantBT is built for research loops where speed matters as much as accounting @@ -163,6 +205,227 @@ selection semantics inside `walkforward.py`. Read `benchmarks/results/optimization_overhead.md` for signal, intrabar, portfolio, arbitrage/grid/options fallback examples and benchmark details. +### Phase 46F package and dual-backend release evidence + +The core distribution is packaged as `quantbt-engine` and imports as +`quantbt`. Its release gate is independent from the optional experimental Rust +wheel: + +| Release artifact | Current status | Backend policy | +|---|---|---| +| `quantbt-engine==1.0.7` wheel/sdist | release-ready after local/TestPyPI approval | Python canonical; all existing endpoints remain available | +| `quantbt-native` PyO3 wheel | experimental, not published | explicit `native_backend="rust"` only | +| `quantbt-engine[native]` | intentionally empty | no dependency is advertised before native certification | + +The committed Phase 46F rerun compares the same prepared static tape and keeps +Python/Rust accounting parity at 100%: + +| Workload | Python median | Rust median | Python throughput | Rust throughput | Peak RSS | Parity | +|---|---:|---:|---:|---:|---:|---| +| Low churn, 2,000 bars | 20.33 ms | 0.109 ms | 98,385 bars/s | 18.30M bars/s | 181.97 MB | pass | +| High churn, 2,000 bars | 36.16 ms | 0.140 ms | 55,308 bars/s | 14.33M bars/s | 181.94 MB | pass | +| Prepared RSS reduction | - | - | - | - | -26.1% / -7.6%; absolute budget pass | gate fail | + +These are score-kernel measurements, not claims about full facade/report +runtime. The table reports raw median time and bars/second from five warmed +repetitions so the result is readable without an internal speedup convention. +The earlier Phase 45F end-to-end reference is retained in the JSON evidence +for historical comparison. The evidence files are +[`phase46e_release_gate.json`](benchmarks/native_event/phase46e_release_gate.json), +[`phase46f_release_gate.json`](benchmarks/native_event/phase46f_release_gate.json), +[`phase46d1_score_rss.json`](benchmarks/native_event/phase46d1_score_rss.json), +and [`phase45f_release_gate.json`](benchmarks/native_event/phase45f_release_gate.json). + +Phase 47C Grid integration evidence uses the external read-only Grid alpha on +the same deterministic 2,000-bar tape in both long-only and long-short modes: + +| Mode | Python scalar median | Rust scalar median | Python peak RSS | Rust peak RSS | Fingerprint parity | +|---|---:|---:|---:|---:|---| +| Long-only | 1.138 s | 1.245 s | 265.6 MB | 273.2 MB | pass | +| Long-short | 1.846 s | 1.985 s | 291.1 MB | 293.4 MB | pass | + +These are full reactive facade measurements, not pure Rust kernel claims. Rust +is currently slightly slower on this Grid integration but produces the same +command/fill/accounting fingerprint and is explicit fail-fast; `auto` remains +Python. The benchmark runner, five-run RSS slope gate, and scalar/audit +fingerprint contract are documented in +[`docs/grid_native_event_phase47c.md`](docs/grid_native_event_phase47c.md), +with raw JSON under `benchmarks/native_event/results/phase47c/`. The RSS +figures are the current Grid facade evidence; they are not compared directly +to the older ~180 MB core-process profile without a like-for-like baseline. + +### Phase 47D Grid optimizer evidence + +Phase 47D profiles the real prepared Grid optimizer path by separating alpha +preparation, strategy construction, engine score, and public report work. The +safe patch removes per-bar Grid diagnostics and diagnostic alias columns only +from scalar trials, while public/audit defaults remain unchanged. On the same +2,000-bar deterministic tape: + +| Grid mode | Python scalar | Rust scalar | Python throughput | Rust throughput | Peak RSS Python/Rust | Parity | +|---|---:|---:|---:|---:|---:|---| +| Long-only | 0.850 s | 1.086 s | 2,354 bars/s | 1,842 bars/s | 265.4 / 271.2 MB | pass | +| Long-short | 1.412 s | 1.831 s | 1,416 bars/s | 1,092 bars/s | 291.0 / 293.6 MB | pass | + +The apples-to-apples prepared scalar profile measured `0.813s` in the local +five-repeat profile. The timing breakdown shows the reactive engine callback +at about `97.9%` and alpha preparation at about `2.2%`, so an indicator cache +was deliberately not added. This evidence does not claim that Rust is faster +for the Python reactive Grid facade; Rust remains explicit experimental and +`auto` remains Python. See +[`docs/grid_native_event_phase47c.md`](docs/grid_native_event_phase47c.md) +for the scalar retention contract, RSS interpretation, and remaining debt. +Raw Phase 47D artifacts are kept under +`benchmarks/native_event/results/phase47d/`. + +### Phase 48F final release handoff + +The core `quantbt-engine` 1.0.7 artifact gate is now implemented locally and +in the release workflows: exact version/ref validation, wheel and sdist +`twine check`, archive allowlist and secret scan, clean import plus `pip check`, +and a SHA256 release manifest. The TestPyPI workflow is manual and OIDC +protected; it must be run with an unused matching RC version/tag and reviewed +before production PyPI publication. See +[`docs/testpypi_release_checklist.md`](docs/testpypi_release_checklist.md). +`quantbt-native` is intentionally excluded from this core upload, `auto` +remains Python, and explicit Rust remains capability-gated. + +### Phase 48C stable event-driven facade evidence + +The stable `QuantBTEndpoint.event_driven()` facade was benchmarked on the same +deterministic **2,000-bar** single-symbol baseline as the direct native-event +strategy constructor. Each route ran in a fresh process with five measured +repetitions. The Grid workload is reported separately because indicator +preparation and reactive state-machine work are part of its runtime. + +| Common route | Median runtime | Throughput | Peak RSS | Fills | Final Equity | Parity | +|---|---:|---:|---:|---:|---:|---| +| `native_event_strategy` | 161.20 ms | 12,407 bars/s | 184.2 MB | 109 | 19,998.269072 | baseline | +| `event_driven(profile="research")` | 154.54 ms | 12,942 bars/s | 183.4 MB | 109 | 19,998.269072 | pass | + +Separate reactive Grid benchmark on 2,000 bars: + +| Grid route | Median runtime | Throughput | Peak RSS | Fills | Final Equity | Parity | +|---|---:|---:|---:|---:|---:|---| +| direct `native_event_strategy` | 1.4187 s | 1,410 bars/s | 274.5 MB | 839 | 28,972.788456 | baseline | +| `event_driven(profile="audit")` | 1.3986 s | 1,430 bars/s | 274.5 MB | 839 | 28,972.788456 | pass | + +Both comparisons have identical accounting fingerprints, including equity, +positions, fees, funding, margin, lifecycle counters, fills, and liquidation +state. The facade adds no second execution loop; the small runtime difference +is measurement noise and configuration resolution. Reproduce with +`benchmarks/benchmark_phase48c_event_driven.py`; raw evidence is in +[`phase48c_event_driven_facade.md`](benchmarks/phase48c_event_driven_facade.md) +and [`phase48c_event_driven_facade.json`](benchmarks/phase48c_event_driven_facade.json). + +The release workflow is documented in +[`docs/release_packaging.md`](docs/release_packaging.md): build and inspect +wheel/sdist, run clean-install and `pip check`, publish an RC to TestPyPI with +OIDC, then publish the final core package through the protected PyPI +environment. The exact handoff fields and artifact-hash procedure are in +[`docs/testpypi_release_checklist.md`](docs/testpypi_release_checklist.md). +No long-lived token is required. Native optimization remains an +open, domain-preserving roadmap for portfolio, arbitrage, options, vectorized, +intrabar, and Nautilus adapter workloads; each future route needs its own +parity and RSS certification. + +### Pre-48E apples-to-apples native event evidence + +The native-event headline below uses one deterministic **2,000-bar**, +single-symbol tape, a fresh process per route, the same compiled command tape, +separate score/audit runs, and seven measured warm repetitions. Runtime is in +seconds, throughput is bars per second, and RSS is peak resident memory. Every +Python/Rust score and audit fingerprint passed accounting and lifecycle parity +(equity, positions, fees, funding, margin, fills, and event counters). + +| Workload | Route | Runtime s | Throughput | Peak RSS MB | Parity | +|---|---|---:|---:|---:|---| +| Common low churn | Python score | 0.087736 | 22,796 bars/s | 183.2 | pass | +| Common low churn | Rust score | 0.188448 | 10,613 bars/s | 185.7 | pass | +| Common low churn | Python audit | 0.087327 | 22,902 bars/s | 240.8 | pass | +| Common low churn | Rust audit | 0.176075 | 11,359 bars/s | 243.9 | pass | +| Common high churn | Python score | 0.086609 | 23,092 bars/s | 182.9 | pass | +| Common high churn | Rust score | 0.188299 | 10,621 bars/s | 186.1 | pass | +| Common high churn | Python audit | 0.119269 | 16,769 bars/s | 241.2 | pass | +| Common high churn | Rust audit | 0.198521 | 10,074 bars/s | 243.1 | pass | + +The safe Python patch improved the common low-churn score from `0.148483s` +to `0.087736s` on the frozen pre-patch baseline, without skipping any domain +accounting or quantity preflight when constraints are enabled. Explicit order +and Rust full-tape results are also recorded, but they are kept as route-level +evidence rather than used to imply that every reactive strategy is faster in +Rust. Reactive Grid has a separate workload and remains outside this common +native-event headline. + +Reproduce the gate with +[`benchmark_pre48e.py`](benchmarks/native_event/benchmark_pre48e.py). Read the +full before/after table and parity fingerprints in +[`pre48e/report.md`](benchmarks/native_event/results/pre48e/report.md); the +raw JSON artifacts are versioned beside it. + +### Phase 48E native-event boundary evidence + +The Phase 48E rerun keeps the same 2,000-bar tape, seven warm repetitions, +fresh-process routes, separate score/audit profiles, and `atol <= 1e-12` +accounting parity. The full raw result is in +[`phase48e/after.md`](benchmarks/native_event/results/phase48e/after.md). +The common rows are the comparable native-event/event-driven workload; the +explicit rows are a separate compiled-tape workload and must not be read as a +claim that Rust is faster for every Python callback strategy. + +| Workload | Route | Runtime s | Throughput | Peak RSS MB | Parity | +|---|---|---:|---:|---:|---| +| Common low churn | Python score | 0.094448 | 21,176 bars/s | 182.0 | pass | +| Common low churn | Rust score | 0.179506 | 11,142 bars/s | 183.9 | pass | +| Common low churn | Python audit | 0.093893 | 21,301 bars/s | 239.0 | pass | +| Common low churn | Rust audit | 0.178550 | 11,201 bars/s | 242.6 | pass | +| Common high churn | Python score | 0.107369 | 18,627 bars/s | 183.5 | pass | +| Common high churn | Rust score | 0.188549 | 10,607 bars/s | 185.2 | pass | +| Common high churn | Python audit | 0.106375 | 18,801 bars/s | 241.1 | pass | +| Common high churn | Rust audit | 0.208654 | 9,585 bars/s | 241.3 | pass | + +Phase 48E also reduced the static explicit Rust score to `0.000302s` +(`6,614,704 bars/s`) on the low-churn tape and `0.000392s` +(`5,103,342 bars/s`) on the high-churn tape. Those numbers benefit from the +scalar Rust output contract and prepared command-tape reuse, so they are +reported separately from callback execution. Rust and Python fingerprints, +fees, positions, fills, events, rejection counters, and final equity passed. +`backend="auto"` remains Python and `[native]` remains empty until the public +`quantbt-native` wheel matrix is clean-install certified. + +### Phase 48E.1 native production-closure evidence + +The Phase 48E.1 rerun uses the same isolated 2,000-bar tape, fresh subprocesses, +seven warm runs, separate score/audit routes, and exact Python/Rust fingerprints. +The complete report is [`phase48e1/after.md`](benchmarks/native_event/results/phase48e1/after.md). +This table separates the explicit prepared-tape path from the generic callback +facade; it is not a universal Rust speed claim. + +| Workload | Route | Runtime s | Throughput | Peak RSS MB | Parity | +|---|---|---:|---:|---:|---| +| Common low churn | Python score | 0.085853 | 23,296 bars/s | 182.2 | pass | +| Common low churn | Rust score | 0.218293 | 9,162 bars/s | 185.7 | pass | +| Common low churn | Python audit | 0.095110 | 21,028 bars/s | 239.4 | pass | +| Common low churn | Rust audit | 0.230769 | 8,667 bars/s | 242.1 | pass | +| Common high churn | Python score | 0.091562 | 21,843 bars/s | 182.1 | pass | +| Common high churn | Rust score | 0.222166 | 9,002 bars/s | 185.1 | pass | +| Common high churn | Python audit | 0.104712 | 19,100 bars/s | 239.6 | pass | +| Common high churn | Rust audit | 0.237654 | 8,416 bars/s | 241.4 | pass | +| Explicit low churn | Python score | 0.023777 | 84,114 bars/s | 180.4 | pass | +| Explicit low churn | Rust score | 0.000289 | 6,921,851 bars/s | 181.6 | pass | +| Explicit low churn | Python audit | 0.007385 | 270,814 bars/s | 237.7 | pass | +| Explicit low churn | Rust audit | 0.004357 | 459,060 bars/s | 182.0 | pass | +| Explicit high churn | Python score | 0.021689 | 92,214 bars/s | 180.2 | pass | +| Explicit high churn | Rust score | 0.000366 | 5,461,021 bars/s | 181.9 | pass | +| Explicit high churn | Python audit | 0.013703 | 145,952 bars/s | 239.6 | pass | +| Explicit high churn | Rust audit | 0.006469 | 309,174 bars/s | 183.1 | pass | + +Phase 48E.1 also locks typed API 0.4 step results, count-only score sinks, +reusable SoA audit buffers, separate command/lifecycle/fill reports, compact +validated Rust order state, and reset/compaction parity. `auto` remains Python; +the native extra remains empty until the CPython 3.11/3.12/3.13 manylinux +clean-install workflow passes. + Ecosystem positioning: | Tool | Core strength | Runtime model | QuantBT role beside it | @@ -323,24 +586,45 @@ fills, positions, account state, and performance report. ## Install -Minimal research stack: +Install the released core package: + +```bash +pip install quantbt-engine==1.0.7 +``` + +Optional reports and third-party validation: ```bash -pip install numpy pandas numba matplotlib seaborn +pip install "quantbt-engine[reports,validation]==1.0.7" ``` -Workspace or Poetry environment: +Development from this repository: ```bash -poetry install +uv sync --extra optimization --extra reports --extra viz --dev +.venv/bin/python -m pytest -q --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py --ignore=tests/native_event ``` -Optional validation and reporting: +For core-only package/build validation, use the smaller dependency boundary +used by the native gate: ```bash -poetry add nautilus-trader quantstats +uv sync --dev +uv build +uv run twine check dist/* ``` +Pool Alpha and notebooks can continue using an editable checkout while a +feature is under development: + +```bash +pip install -e /root/bobby/pool_alpha/quantbt +``` + +After the release is approved, downstream services should use +`pip install quantbt-engine==1.0.7` and keep the unchanged import +`from quantbt import QuantBTEndpoint`. + ## Quick Start ```python @@ -534,7 +818,8 @@ Key examples: ## Development ```bash -PYTHONPATH=/path/to/pool_alpha poetry run pytest -q quantbt/tests +uv sync --extra optimization --extra reports --extra viz --dev +.venv/bin/python -m pytest -q --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py --ignore=tests/native_event ``` Contribution workflow: diff --git a/__init__.py b/__init__.py index ed57c24..a908b36 100644 --- a/__init__.py +++ b/__init__.py @@ -45,95 +45,148 @@ tearsheet(result) """ +from importlib import import_module + + +_LAZY_EXPORTS = { + **{ + name: (".optimization", name) + for name in ( + "CONSTRAINTS_USER_ATTR", + "ArbitrageGenericEvaluator", + "ArbitrageTrialOutput", + "CandidateSelector", + "GenericEndpointEvaluator", + "GridDCAGenericEvaluator", + "GridDCATrialOutput", + "JsonlOptimizationLogger", + "MissingOptimizationMetricError", + "MultiSeedOptimization", + "ObjectiveResult", + "OptionPackageGenericEvaluator", + "OptionTrialOutput", + "OptimizationConfig", + "OptimizationResult", + "OptimizationTrialRecord", + "OptunaOptimizer", + "PreparedIntrabarEvaluator", + "PreparedNativeEventStrategyEvaluator", + "PreparedPortfolioEvaluator", + "PreparedSignalEvaluator", + "ReportMetricObjective", + "RobustSelectionConfig", + "SamplerConfig", + "SearchSpaceInfo", + "SelectedCandidate", + "SharpeObjective", + "SingleObjectiveEarlyStopping", + "TrialEvaluator", + "build_grid_search_space", + "build_sampler", + "constraints_feasible", + "constraints_from_trial", + "max_drawdown_constraint", + "max_margin_utilization_constraint", + "max_rejection_rate_constraint", + "max_turnover_constraint", + "metric_from_result", + "metrics_from_result", + "min_trades_constraint", + "result_full_report", + "search_space_info", + "set_trial_constraints", + "stable_params_key", + "suggest_parameter", + "suggest_params", + ) + }, + **{ + name: (".walkforward", name) + for name in ( + "DuplicatePruner", + "EarlyStoppingCallback", + "WalkForwardBenchmarkSnapshot", + "WalkForwardCompatibilityEntry", + "WalkForwardConfig", + "WalkForwardEngine", + "WalkForwardFold", + "WalkForwardResult", + "WalkForwardTrialRecord", + "benchmark_walkforward_kernels", + "logging_callback", + "score_strategy_output", + "select_full_sample_robust_record", + "select_is_plateau_robust_record", + "select_is_only_robust_record", + "select_flat_minima_record", + "stationary_bootstrap_sharpes", + "synthetic_walkforward_sharpes", + "stitch_oos_outputs", + "strategy_return_series", + "trade_frequency_penalty", + "validate_param_ranges", + "volatility_regime_labels", + "validate_walkforward_strategy_output", + "walkforward_support_matrix", + ) + }, + "quick_plot": (".viz", "quick_plot"), + "tearsheet": (".viz", "tearsheet"), + "apply_theme": (".viz", "apply_theme"), + **{ + name: (".reporting", name) + for name in ( + "build_arbitrage_domain_audit", + "build_native_nautilus_parity_report", + "build_nautilus_certification_profile", + "build_nautilus_depth_execution_report", + "build_nautilus_depth_parity_summary", + "build_nautilus_pct_equity_diagnostic", + "build_portfolio_domain_audit", + "build_portfolio_nautilus_position_report", + "build_portfolio_nautilus_validation_report", + "compare_native_arbitrage_results", + "export_nautilus_report_bundle", + "NautilusToleranceProfile", + "summarize_native_nautilus_parity_report", + "write_nautilus_certification_artifacts", + ) + }, +} + + +def __getattr__(name: str): + """Resolve optional/heavy public exports on first use.""" + + try: + module_name, attribute_name = _LAZY_EXPORTS[name] + except KeyError as exc: # pragma: no cover - normal Python attribute error + raise AttributeError(name) from exc + value = getattr(import_module(module_name, __name__), attribute_name) + globals()[name] = value + return value + + +def __dir__(): + return sorted(set(globals()) | set(_LAZY_EXPORTS)) + + from .backtester import BacktestEngine from .portfolio import MultiSymbolPortfolio from .endpoint import ( EndpointConfig, + NativeEventProfile, PreparedIntrabarRunner, PreparedNativeEventStrategyRunner, QuantBTEndpoint, QuantBTPreparedContext, format_metrics_report, ) -from .walkforward import ( - DuplicatePruner, - EarlyStoppingCallback, - WalkForwardBenchmarkSnapshot, - WalkForwardCompatibilityEntry, - WalkForwardConfig, - WalkForwardEngine, - WalkForwardFold, - WalkForwardResult, - WalkForwardTrialRecord, - benchmark_walkforward_kernels, - logging_callback, - score_strategy_output, - select_full_sample_robust_record, - select_is_plateau_robust_record, - select_is_only_robust_record, - select_flat_minima_record, - stationary_bootstrap_sharpes, - synthetic_walkforward_sharpes, - stitch_oos_outputs, - strategy_return_series, - trade_frequency_penalty, - validate_param_ranges, - volatility_regime_labels, - validate_walkforward_strategy_output, - walkforward_support_matrix, -) -from .optimization import ( - CONSTRAINTS_USER_ATTR, - ArbitrageGenericEvaluator, - ArbitrageTrialOutput, - CandidateSelector, - GenericEndpointEvaluator, - GridDCAGenericEvaluator, - GridDCATrialOutput, - JsonlOptimizationLogger, - MissingOptimizationMetricError, - MultiSeedOptimization, - ObjectiveResult, - OptionPackageGenericEvaluator, - OptionTrialOutput, - OptimizationConfig, - OptimizationResult, - OptimizationTrialRecord, - OptunaOptimizer, - PreparedIntrabarEvaluator, - PreparedNativeEventStrategyEvaluator, - PreparedPortfolioEvaluator, - PreparedSignalEvaluator, - ReportMetricObjective, - RobustSelectionConfig, - SamplerConfig, - SearchSpaceInfo, - SelectedCandidate, - SharpeObjective, - SingleObjectiveEarlyStopping, - TrialEvaluator, - build_grid_search_space, - build_sampler, - constraints_feasible, - constraints_from_trial, - max_drawdown_constraint, - max_margin_utilization_constraint, - max_rejection_rate_constraint, - max_turnover_constraint, - metric_from_result, - metrics_from_result, - min_trades_constraint, - result_full_report, - search_space_info, - set_trial_constraints, - stable_params_key, - suggest_parameter, - suggest_params, -) from .engines import BacktestEngineV2, EventDrivenBacktestEngine, OptionBacktestEngine, PortfolioBacktestEngine from .backends import ( NativeEventBackend, NativeEventConfig, + NativeEventScoreRequirements, NativeOptionBackend, NativeOptionConfig, NativePortfolioBackend, @@ -141,10 +194,21 @@ NativeVectorizedBackend, NativeVectorizedConfig, OptionSettlementEvent, + RustBatchedAuditResult, + RustBatchedChunkResult, + RustBatchedRunner, + RustBatchedScoreResult, + RustBatchedSession, ) from .adapters.nautilus import NautilusBacktestEngine from .core.types import BacktestResult -from .core.results import BacktestResultV2, NativeAccountingArrays, NativeEventScoreResult, OptionBacktestResult +from .core.results import ( + BacktestResultV2, + NativeAccountingArrays, + NativeEventScalarScoreResult, + NativeEventScoreResult, + OptionBacktestResult, +) from .core.execution_contract import ( EXECUTION_CONTRACT_REGISTRY, AmbiguityPolicy, @@ -195,6 +259,20 @@ classify_alpha_source, scan_alpha_directory, ) +from .core.native_event_capabilities import ( + NATIVE_EVENT_CAPABILITY_MATRIX, + NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + capability_matrix_fingerprint, + native_event_capability_matrix, + normalize_native_event_capabilities, + validate_native_event_capability_matrix, +) +from .core.native_event_parity import ( + DEFAULT_NUMERIC_ATOL, + NativeEventParityCertificate, + NativeEventParityError, + assert_native_event_full_parity, +) from .core.orders import ( BasketIntent, Fill, @@ -207,6 +285,8 @@ ) from .core.reactive import ( NativeActiveOrderSnapshot, + NativeCommandBatch, + NativeEventStrategy, NativeEventStrategyError, NativeEventStrategyProtocol, NativeFillEvent, @@ -418,25 +498,8 @@ option_run_manifest, ) -from .viz import quick_plot, tearsheet, apply_theme -from .reporting import ( - build_arbitrage_domain_audit, - build_native_nautilus_parity_report, - build_nautilus_certification_profile, - build_nautilus_depth_execution_report, - build_nautilus_depth_parity_summary, - build_nautilus_pct_equity_diagnostic, - build_portfolio_domain_audit, - build_portfolio_nautilus_position_report, - build_portfolio_nautilus_validation_report, - compare_native_arbitrage_results, - export_nautilus_report_bundle, - NautilusToleranceProfile, - summarize_native_nautilus_parity_report, - write_nautilus_certification_artifacts, -) -__version__ = "0.1.0" +__version__ = "1.0.7rc1" __author__ = "quantbt" __all__ = [ @@ -449,10 +512,17 @@ "NautilusBacktestEngine", "NativeEventBackend", "NativeEventConfig", + "NativeEventScoreRequirements", "NativeAccountingArrays", + "NativeEventProfile", "NativeActiveOrderSnapshot", + "NativeCommandBatch", "NativeEventScoreResult", + "NativeEventScalarScoreResult", + "NativeEventParityCertificate", + "NativeEventParityError", "NativeEventStrategyError", + "NativeEventStrategy", "NativeEventStrategyProtocol", "NativeFillEvent", "NativeOrderEvent", @@ -645,6 +715,9 @@ "BracketOrderSpec", "AccountConfig", "AlphaExecutionClassification", + "DEFAULT_NUMERIC_ATOL", + "NATIVE_EVENT_CAPABILITY_MATRIX", + "NATIVE_EVENT_CAPABILITY_MATRIX_VERSION", "AmbiguityPolicy", "ArbExecutionPolicy", "ArbitrageLeg", @@ -754,6 +827,11 @@ "portfolio_capability_matrix", "quantize_signed_quantity", "round_down_to_step", + "assert_native_event_full_parity", + "capability_matrix_fingerprint", + "native_event_capability_matrix", + "normalize_native_event_capabilities", + "validate_native_event_capability_matrix", "run_fill_replay_kernel", "run_intrabar_kernel", "run_intrabar_session_kernel", diff --git a/backends/__init__.py b/backends/__init__.py index 04066a7..15a96bc 100644 --- a/backends/__init__.py +++ b/backends/__init__.py @@ -1,11 +1,19 @@ -from .native_event import NativeEventBackend, NativeEventConfig +from .native_event import NativeEventBackend, NativeEventConfig, NativeEventScoreRequirements from .native_option import NativeOptionBackend, NativeOptionConfig, OptionSettlementEvent from .native_portfolio import NativePortfolioBackend, NativePortfolioConfig from .native_vectorized import NativeVectorizedBackend, NativeVectorizedConfig +from ._native_event_rust import ( + RustBatchedAuditResult, + RustBatchedChunkResult, + RustBatchedRunner, + RustBatchedScoreResult, + RustBatchedSession, +) __all__ = [ "NativeEventBackend", "NativeEventConfig", + "NativeEventScoreRequirements", "NativeOptionBackend", "NativeOptionConfig", "NativePortfolioBackend", @@ -13,4 +21,9 @@ "NativeVectorizedBackend", "NativeVectorizedConfig", "OptionSettlementEvent", + "RustBatchedAuditResult", + "RustBatchedChunkResult", + "RustBatchedRunner", + "RustBatchedScoreResult", + "RustBatchedSession", ] diff --git a/backends/_native_event_rust.py b/backends/_native_event_rust.py new file mode 100644 index 0000000..d610106 --- /dev/null +++ b/backends/_native_event_rust.py @@ -0,0 +1,2168 @@ +"""Optional PyO3 adapter for the certified native-event Rust slices. + +Python remains the full-featured reactive implementation. Rust is explicit and +capability-gated for the certified single-symbol batched tape contract; audit +buffers are adapted back to the common Python result surface outside the score +hot path. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +import importlib +import os +from types import ModuleType +from typing import Callable, Mapping, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.event import ORDER_STATUS_PENDING +from ..core.constraints import quantize_signed_quantity +from ..core.order_compiler import CompiledOrderCommandArrays, command_tape_fingerprint +from ..core.orders import OrderAction, OrderActivationPolicy, OrderCommand +from ..core.reactive import NativeActiveOrderSnapshot, NativeFillEvent, NativeOrderEvent, NativeStrategyContext +from ..core.schema import OrderSide, OrderType, TimeInForce +from ..core.native_event_capabilities import normalize_native_event_capabilities + + +RUST_NATIVE_API_VERSION = "0.4" +_VALID_BACKENDS = frozenset({"auto", "python", "rust", "replay_certified"}) +_R1_ACTION_PLACE = 0 +_R1_ACTION_CANCEL = 1 +_R2_ACTION_AMEND = 2 +_R2_ACTION_REPLACE = 3 +_R1_ORDER_MARKET = 0 +_R1_ORDER_LIMIT = 1 +_R2_ORDER_STOP_MARKET = 2 +_R2_ORDER_STOP_LIMIT = 3 +_R1_CODE_WIDTH = 8 +_R1_VALUE_WIDTH = 3 +_R2_FLAG_REDUCE_ONLY = 1 +_R2_MUTATE_QTY = 1 +_R2_MUTATE_PRICE = 2 +_R2_MUTATE_TRIGGER = 4 +_FULL_CODE_WIDTH = 16 +_FULL_VALUE_WIDTH = 3 +_FULL_OUTPUT_POSITIONS = 1 +_FULL_OUTPUT_FILLS = 2 +_FULL_OUTPUT_EVENTS = 4 +_FULL_OUTPUT_ACTIVE_ORDERS = 8 + + +def _step_value(payload, key: str, default=None): + """Read a legacy dict or the API 0.4 typed Rust step result.""" + + if isinstance(payload, Mapping): + return payload.get(key, default) + return getattr(payload, key, default) + + +def _step_has(payload, key: str) -> bool: + if isinstance(payload, Mapping): + return key in payload + return hasattr(payload, key) + + +class NativeEventRustBackendError(RuntimeError): + """Raised when an explicitly requested Rust backend cannot be used.""" + + +@dataclass(frozen=True) +class NativeEventRustExtensionStatus: + """Import and compatibility state of the optional ``_quantbt_native`` wheel.""" + + available: bool + compatible: bool + executable: bool + version: Optional[str] + api_version: Optional[str] + capabilities: Mapping[str, bool] + reason: Optional[str] = None + canonical_capabilities: Mapping[str, bool] = field(default_factory=dict) + + +@dataclass(frozen=True) +class NativeEventBackendSelection: + """Internal backend decision without changing the public endpoint API.""" + + requested: str + resolved: str + extension: NativeEventRustExtensionStatus + + +@dataclass(frozen=True) +class RustCommandBatch: + """Contiguous R1 command buffers plus the Python-side identity table.""" + + codes: np.ndarray + values: np.ndarray + expiry: np.ndarray + commands: tuple[OrderCommand, ...] + + +@dataclass(frozen=True, slots=True) +class RustBatchedScoreResult: + """Scalar result returned by one Rust full-tape call.""" + + final_equity: float + final_position: float + total_fee: float + total_turnover: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + bars: int + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RustBatchedAuditResult: + """Contiguous SoA audit buffers returned by one Rust full-tape call.""" + + equity: np.ndarray + positions: np.ndarray + fees: np.ndarray + turnover: np.ndarray + initial_margin: np.ndarray + maintenance_margin: np.ndarray + fill_bar: np.ndarray + fill_order_id: np.ndarray + fill_side: np.ndarray + fill_qty: np.ndarray + fill_price: np.ndarray + fill_fee: np.ndarray + event_bar: np.ndarray + event_kind: np.ndarray + event_status: np.ndarray + event_order_id: np.ndarray + event_target_id: np.ndarray + total_fee: float + total_turnover: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + metadata: Mapping[str, object] = field(default_factory=dict) + id_values: tuple[str, ...] = () + + @property + def final_equity(self) -> float: + """Final equity without materializing a second result object.""" + + return float(self.equity[-1]) if len(self.equity) else 0.0 + + @property + def final_position(self) -> float: + """Final single-symbol position from the audit path.""" + + return float(self.positions[-1]) if len(self.positions) else 0.0 + + def to_backtest_result( + self, + *, + datetime_index: pd.DatetimeIndex, + closes: pd.Series | pd.DataFrame, + symbol: str, + initial_capital: float, + leverage: float = 1.0, + metadata: Optional[Mapping[str, object]] = None, + include_fills: bool = True, + ): + """Adapt a Rust audit into the common :class:`BacktestResultV2`. + + The Rust boundary intentionally returns typed scalar/SoA data rather + than Python domain objects. This adapter is the single report + boundary: it creates the same equity, position, fee, margin, + ``fills_report`` and ``order_report`` surfaces used by native-event + Python results. It is an audit/report operation, not part of the + batched score hot path. + """ + + from ..core.results import BacktestResultV2 + from ..core.orders import Fill + from ..core.schema import OrderSide + + idx = pd.DatetimeIndex(datetime_index) + if len(idx) != len(self.equity): + raise ValueError("datetime_index length must match Rust audit equity path") + if isinstance(closes, pd.DataFrame): + if symbol in closes.columns: + close_series = closes[symbol] + elif f"Close_{symbol}" in closes.columns: + close_series = closes[f"Close_{symbol}"] + elif len(closes.columns) == 1: + close_series = closes.iloc[:, 0] + else: + raise KeyError(f"close data does not contain symbol={symbol!r}") + else: + close_series = closes + close_series = pd.Series(close_series, index=idx, dtype=float) + equity = pd.Series(np.asarray(self.equity, dtype=np.float64), index=idx, name="equity") + positions = pd.DataFrame( + {f"Position_{symbol}": np.asarray(self.positions, dtype=np.float64)}, + index=idx, + ) + fees = pd.Series(np.asarray(self.fees, dtype=np.float64), index=idx, name="fees") + funding = pd.Series(0.0, index=idx, name="funding") + margin = pd.DataFrame( + { + "initial_margin": np.asarray(self.initial_margin, dtype=np.float64), + "maintenance_margin": np.asarray(self.maintenance_margin, dtype=np.float64), + }, + index=idx, + ) + diagnostics = pd.DataFrame( + { + "turnover": np.asarray(self.turnover, dtype=np.float64), + "rejected_orders": np.bincount( + np.asarray(self.event_bar, dtype=np.int64)[ + np.asarray(self.event_kind, dtype=np.int64) == 3 + ], + minlength=len(idx), + ), + "canceled_orders": np.bincount( + np.asarray(self.event_bar, dtype=np.int64)[ + np.asarray(self.event_kind, dtype=np.int64) == 1 + ], + minlength=len(idx), + ), + }, + index=idx, + ) + + id_values = tuple(self.id_values or self.metadata.get("id_values", ())) + + def order_id(code: int) -> Optional[str]: + return id_values[int(code)] if 0 <= int(code) < len(id_values) else None + + fills_report = pd.DataFrame( + { + "bar": np.asarray(self.fill_bar, dtype=np.int64), + "timestamp": [idx[int(bar)] for bar in self.fill_bar], + "order_id": [order_id(code) for code in self.fill_order_id], + "side": ["BUY" if int(side) > 0 else "SELL" for side in self.fill_side], + "qty": np.asarray(self.fill_qty, dtype=np.float64), + "price": np.asarray(self.fill_price, dtype=np.float64), + "fee": np.asarray(self.fill_fee, dtype=np.float64), + "symbol": symbol, + } + ) + order_report = pd.DataFrame( + { + "bar": np.asarray(self.event_bar, dtype=np.int64), + "timestamp": [idx[int(bar)] for bar in self.event_bar], + "event_kind": np.asarray(self.event_kind, dtype=np.int64), + "event_status": np.asarray(self.event_status, dtype=np.int64), + "order_id": [order_id(code) for code in self.event_order_id], + "target_order_id": [order_id(code) for code in self.event_target_id], + "symbol": symbol, + } + ) + fill_objects = () + if include_fills: + fill_objects = tuple( + Fill( + timestamp=idx[int(bar)], + symbol=symbol, + side=OrderSide.BUY if int(side) > 0 else OrderSide.SELL, + qty=float(qty), + price=float(price), + fee=float(fee), + order_id=order_id(order_code), + metadata={"backend": "rust_batched", "bar": int(bar)}, + ) + for bar, order_code, side, qty, price, fee in zip( + self.fill_bar, + self.fill_order_id, + self.fill_side, + self.fill_qty, + self.fill_price, + self.fill_fee, + ) + ) + result_metadata = { + "backend": "native_event", + "engine": "event_v2_rust_batched_audit", + "report_level": "audit", + "native_event_backend_requested": "rust", + "native_event_backend_resolved": "rust", + "fills_report": fills_report, + "order_report": order_report, + "command_report": order_report, + "id_values": id_values, + "lifecycle_counters": { + "fill_count": int(self.fill_count), + "event_count": int(self.event_count), + "rejected_count": int(self.rejected_count), + "canceled_count": int(self.canceled_count), + }, + "rust_audit_adapter": "RustBatchedAuditResult.to_backtest_result", + } + if metadata: + result_metadata.update(dict(metadata)) + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0), + positions=positions, + closes=pd.DataFrame({f"Close_{symbol}": close_series.to_numpy()}, index=idx), + symbols=[symbol], + initial_capital=float(initial_capital), + leverage=float(leverage), + liquidated=False, + orders=(), + fills=fill_objects, + fees=fees, + funding=funding, + margin=margin, + diagnostics=diagnostics, + metadata=result_metadata, + ) + + +@dataclass(frozen=True, slots=True) +class RustFullAuditResult: + """Full-contract Rust SoA result, including multi-symbol/funding state.""" + + equity: np.ndarray + positions: np.ndarray + fees: np.ndarray + turnover: np.ndarray + funding: np.ndarray + initial_margin: np.ndarray + maintenance_margin: np.ndarray + fill_bar: np.ndarray + fill_order_id: np.ndarray + fill_symbol: np.ndarray + fill_side: np.ndarray + fill_qty: np.ndarray + fill_price: np.ndarray + fill_fee: np.ndarray + event_bar: np.ndarray + event_kind: np.ndarray + event_status: np.ndarray + event_order_id: np.ndarray + event_target_id: np.ndarray + event_symbol: np.ndarray + event_reject_code: np.ndarray + total_fee: float + total_turnover: float + total_funding: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + liquidated: bool + liquidation_bar: int + liquidation_reason: int + id_values: tuple[str, ...] = () + command_report: Optional[pd.DataFrame] = None + command_metadata: Mapping[str, Mapping[str, object]] = field(default_factory=dict) + + @property + def final_equity(self) -> float: + return float(self.equity[-1]) if len(self.equity) else 0.0 + + def to_backtest_result( + self, + *, + datetime_index: pd.DatetimeIndex, + closes: pd.DataFrame, + symbols: Sequence[str], + initial_capital: float, + leverage: float, + metadata: Optional[Mapping[str, object]] = None, + ): + """Materialize the common result surface outside the Rust hot path.""" + from ..core.results import BacktestResultV2 + from ..core.orders import Fill + from ..core.schema import OrderSide + + idx = pd.DatetimeIndex(datetime_index) + equity = pd.Series(self.equity, index=idx, name="equity") + positions = pd.DataFrame( + {f"Position_{symbol}": self.positions[:, col] for col, symbol in enumerate(symbols)}, + index=idx, + ) + close_frame = pd.DataFrame( + {f"Close_{symbol}": closes[symbol].to_numpy(dtype=np.float64) for symbol in symbols}, + index=idx, + ) + + def order_id(code: int) -> Optional[str]: + return self.id_values[int(code)] if 0 <= int(code) < len(self.id_values) else None + + fill_meta = [ + self.command_metadata.get(order_id(code) or "", {}) for code in self.fill_order_id + ] + fills_report = pd.DataFrame({ + "bar": self.fill_bar, + "timestamp": [idx[int(bar)] for bar in self.fill_bar], + "order_id": [order_id(code) for code in self.fill_order_id], + "symbol": [symbols[int(code)] for code in self.fill_symbol], + "side": ["BUY" if int(side) > 0 else "SELL" for side in self.fill_side], + "qty": self.fill_qty, + "price": self.fill_price, + "fee": self.fill_fee, + "tag": [meta.get("tag") for meta in fill_meta], + "campaign_id": [meta.get("campaign_id") for meta in fill_meta], + "cycle_id": [meta.get("cycle_id") for meta in fill_meta], + "level_id": [meta.get("level_id") for meta in fill_meta], + }) + order_report = pd.DataFrame({ + "bar": self.event_bar, + "timestamp": [idx[int(bar)] for bar in self.event_bar], + "event_kind": self.event_kind, + "event_status": self.event_status, + "order_id": [order_id(code) for code in self.event_order_id], + "target_order_id": [order_id(code) for code in self.event_target_id], + "symbol": [None if int(code) < 0 else symbols[int(code)] for code in self.event_symbol], + "reject_code": self.event_reject_code, + }) + fills = tuple( + Fill( + timestamp=idx[int(bar)], symbol=symbols[int(symbol)], + side=OrderSide.BUY if int(side) > 0 else OrderSide.SELL, + qty=float(qty), price=float(price), fee=float(fee), order_id=order_id(order_code), + metadata={"backend": "rust_full_contract", "bar": int(bar)}, + ) + for bar, order_code, symbol, side, qty, price, fee in zip( + self.fill_bar, self.fill_order_id, self.fill_symbol, self.fill_side, + self.fill_qty, self.fill_price, self.fill_fee, + ) + ) + diagnostics = pd.DataFrame({ + "turnover": self.turnover, + "rejected_orders": np.bincount(self.event_bar[self.event_kind == 7], minlength=len(idx)), + "canceled_orders": np.bincount(self.event_bar[self.event_kind == 1], minlength=len(idx)), + }, index=idx) + result_metadata = { + "backend": "native_event", + "engine": "event_v2_rust_full_contract", + "report_level": "audit", + "native_event_backend_requested": "rust", + "native_event_backend_resolved": "rust", + "fills_report": fills_report, + "order_report": order_report, + "command_report": ( + self.command_report.copy(deep=False) + if self.command_report is not None + else pd.DataFrame() + ), + "id_values": self.id_values, + "liquidation_reason": int(self.liquidation_reason), + "lifecycle_counters": { + "fill_count": int(self.fill_count), "event_count": int(self.event_count), + "rejected_count": int(self.rejected_count), "canceled_count": int(self.canceled_count), + }, + "rust_contract": "native_event_v2_full_contract", + } + if metadata: + result_metadata.update(dict(metadata)) + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0), + positions=positions, + closes=close_frame, + symbols=list(symbols), + initial_capital=float(initial_capital), + leverage=float(leverage), + liquidated=bool(self.liquidated), + liquidation_bar=int(self.liquidation_bar), + orders=(), fills=fills, + fees=pd.Series(self.fees, index=idx, name="fees"), + funding=pd.Series(self.funding, index=idx, name="funding"), + margin=pd.DataFrame({"initial_margin": self.initial_margin, "maintenance_margin": self.maintenance_margin}, index=idx), + diagnostics=diagnostics, + metadata=result_metadata, + ) + + +@dataclass(frozen=True, slots=True) +class RustBatchedChunkResult: + """Sparse result for one stateful ``run_until`` continuation chunk. + + The arrays contain only fills/order events observed in the chunk. No + dense equity or position path is materialized; the caller can request a + full audit separately when it needs bar-by-bar diagnostics. + """ + + start_bar: int + stop_bar: int + final_equity: float + final_position: float + total_fee: float + total_turnover: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + liquidation_seen: bool + wake_bar: np.ndarray + wake_kind: np.ndarray + fill_bar: np.ndarray + fill_order_id: np.ndarray + fill_side: np.ndarray + fill_qty: np.ndarray + fill_price: np.ndarray + fill_fee: np.ndarray + event_bar: np.ndarray + event_kind: np.ndarray + event_status: np.ndarray + event_order_id: np.ndarray + event_target_id: np.ndarray + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass +class RustCommandBuffer: + """Capacity-managed primitive buffers reused across Rust callback bars.""" + + codes: np.ndarray = field(default_factory=lambda: np.empty((0, _R1_CODE_WIDTH), dtype=np.int64)) + values: np.ndarray = field(default_factory=lambda: np.empty((0, _R1_VALUE_WIDTH), dtype=np.float64)) + expiry: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64)) + + def reserve(self, size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + if size > len(self.codes): + capacity = max(int(size), max(8, len(self.codes) * 2)) + self.codes = np.empty((capacity, _R1_CODE_WIDTH), dtype=np.int64) + self.values = np.empty((capacity, _R1_VALUE_WIDTH), dtype=np.float64) + self.expiry = np.empty(capacity, dtype=np.int64) + return self.codes[:size], self.values[:size], self.expiry[:size] + + +@dataclass +class RustFullCommandBuffer: + """Capacity-managed buffers for the API 0.4 full command ABI. + + The public compiler remains the source of truth for command meaning and + ordering. This object only owns reusable contiguous storage so repeated + static or reactive runs do not allocate a new ``(n, 16)``/``(n, 3)`` pair + for every call. + """ + + codes: np.ndarray = field(default_factory=lambda: np.empty((0, _FULL_CODE_WIDTH), dtype=np.int64)) + values: np.ndarray = field(default_factory=lambda: np.empty((0, _FULL_VALUE_WIDTH), dtype=np.float64)) + expiry: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64)) + growth_count: int = 0 + commands_compiled: int = 0 + + @property + def capacity(self) -> int: + """Number of command rows currently reserved.""" + + return int(len(self.codes)) + + def reserve(self, size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + size = int(size) + if size < 0: + raise ValueError("command buffer size must be >= 0") + if size > self.capacity: + capacity = max(size, max(8, self.capacity * 2)) + self.codes = np.empty((capacity, _FULL_CODE_WIDTH), dtype=np.int64) + self.values = np.empty((capacity, _FULL_VALUE_WIDTH), dtype=np.float64) + self.expiry = np.empty(capacity, dtype=np.int64) + self.growth_count += 1 + self.commands_compiled += size + codes = self.codes[:size] + values = self.values[:size] + expiry = self.expiry[:size] + codes.fill(-1) + values.fill(0.0) + expiry.fill(-1) + return codes, values, expiry + + def clear(self) -> None: + """Release storage and reset counters for explicit cache cleanup.""" + + self.codes = np.empty((0, _FULL_CODE_WIDTH), dtype=np.int64) + self.values = np.empty((0, _FULL_VALUE_WIDTH), dtype=np.float64) + self.expiry = np.empty(0, dtype=np.int64) + self.growth_count = 0 + self.commands_compiled = 0 + + +@dataclass(frozen=True) +class _RustPendingOrder: + order_id: Optional[str] + side: OrderSide + order_type: OrderType + qty: float + price: float + trigger_price: float + reduce_only: bool + + +def _empty_status(reason: str) -> NativeEventRustExtensionStatus: + return NativeEventRustExtensionStatus( + available=False, + compatible=False, + executable=False, + version=None, + api_version=None, + capabilities={}, + reason=reason, + canonical_capabilities={}, + ) + + +def _load_extension() -> Optional[ModuleType]: + return importlib.import_module("_quantbt_native") + + +def _read_native_value(module: ModuleType, name: str) -> Optional[object]: + value = getattr(module, name, None) + return value() if callable(value) else value + + +def probe_native_event_rust_extension( + module: Optional[ModuleType] = None, + *, + module_loader: Optional[Callable[[], Optional[ModuleType]]] = None, +) -> NativeEventRustExtensionStatus: + """Return extension compatibility without enabling a Rust execution path. + + ``module`` and ``module_loader`` are test seams. Runtime callers should + leave both unset so the optional extension is imported normally. + """ + if module is None: + loader = _load_extension if module_loader is None else module_loader + try: + module = loader() + except (ImportError, OSError) as exc: + return _empty_status(f"unable to import _quantbt_native: {exc}") + if module is None: + return _empty_status("quantbt-native is not installed; install a compatible native wheel first") + + try: + version_value = _read_native_value(module, "version") + version = str(version_value if version_value is not None else getattr(module, "__version__", "")) or None + api_value = _read_native_value(module, "api_version") + api_version = str(api_value) if api_value is not None else None + raw_capabilities = _read_native_value(module, "capabilities") + except Exception as exc: # pragma: no cover - protects optional binary imports. + return _empty_status(f"failed to query _quantbt_native metadata: {exc}") + + if not isinstance(raw_capabilities, Mapping): + raw_capabilities = {} + capabilities = {str(name): bool(enabled) for name, enabled in raw_capabilities.items()} + canonical_capabilities = normalize_native_event_capabilities(capabilities) + # 0.3 remains readable for the legacy R1/R2 classes. Full V2 capability + # is gated independently by the explicit 0.4 capability keys below. + compatible = api_version in {"0.3", RUST_NATIVE_API_VERSION} + if not compatible: + return NativeEventRustExtensionStatus( + available=True, + compatible=False, + executable=False, + version=version, + api_version=api_version, + capabilities=capabilities, + reason=( + "_quantbt_native API version mismatch: " + f"expected {RUST_NATIVE_API_VERSION!r}, received {api_version!r}" + ), + canonical_capabilities=canonical_capabilities, + ) + + executable = bool(capabilities.get("reactive_session", False)) + reason = None if executable else "_quantbt_native does not advertise the required R1 reactive_session capability" + return NativeEventRustExtensionStatus( + available=True, + compatible=True, + executable=executable, + version=version, + api_version=api_version, + capabilities=capabilities, + reason=reason, + canonical_capabilities=canonical_capabilities, + ) + + +def resolve_native_event_backend( + requested: Optional[str] = None, + *, + extension_status: Optional[NativeEventRustExtensionStatus] = None, +) -> NativeEventBackendSelection: + """Resolve the native-event selector under the release rollout policy. + + ``auto`` intentionally resolves to Python for the first dual-backend + release, even with the wheel installed. ``rust`` is explicit and fails + loudly unless the installed extension advertises the required capability. + """ + selected = str(requested or os.getenv("QUANTBT_NATIVE_BACKEND", "auto")).lower().strip() + if selected not in _VALID_BACKENDS: + valid = ", ".join(sorted(_VALID_BACKENDS)) + raise ValueError(f"QUANTBT_NATIVE_BACKEND must be one of: {valid}") + + status = extension_status + if selected == "rust": + status = status or probe_native_event_rust_extension() + if not status.available or not status.compatible or not status.executable: + detail = status.reason or "unknown native extension state" + raise NativeEventRustBackendError(f"native-event backend='rust' is unavailable: {detail}") + return NativeEventBackendSelection(requested=selected, resolved="rust", extension=status) + + # R0 rollout contract: never auto-enable a just-built extension. + status = status or _empty_status("Rust extension was not queried because the Python backend was selected") + resolved = "replay_certified" if selected == "replay_certified" else "python" + return NativeEventBackendSelection(requested=selected, resolved=resolved, extension=status) + + +def _require_r1_extension() -> ModuleType: + module = _load_extension() + status = probe_native_event_rust_extension(module=module) + if not status.available or not status.compatible or not status.executable: + detail = status.reason or "unknown native extension state" + raise NativeEventRustBackendError(f"native-event Rust R1 is unavailable: {detail}") + if not hasattr(module, "ReactiveSessionCore"): + raise NativeEventRustBackendError("_quantbt_native is compatible but lacks ReactiveSessionCore") + return module + + +def validate_rust_r1_support( + *, + symbols: Sequence[str], + constraints, + use_funding: bool, + maintenance_ratio: float, +) -> None: + """Reject every feature outside the R2 single-symbol surface. + + The historic function name remains an internal compatibility alias for + callers introduced with R1. R2 adds lifecycle commands and quantity + filters, but funding/liquidation and multi-symbol remain Python-only. + """ + if len(symbols) != 1: + raise NativeEventRustBackendError("Rust R1 supports exactly one symbol; use backend='python' for multi-symbol") + if use_funding: + raise NativeEventRustBackendError("Rust R2 does not support funding; use backend='python'") + if float(maintenance_ratio) != 0.0: + raise NativeEventRustBackendError( + "Rust R2 does not support liquidation semantics; set maintenance_ratio=0.0 or use backend='python'" + ) + + +def compile_rust_r1_command_batch( + commands: Sequence[OrderCommand], + *, + symbol: str, + intern_id: Callable[[Optional[str]], int], + buffer: Optional[RustCommandBuffer] = None, +) -> RustCommandBatch: + """Compile the R2 lifecycle subset into contiguous primitive buffers. + + Field layout is stable from R1: ``[action, side, type, flags, order_id, + target_id, mutate_mask, sequence]`` and ``[qty, price, trigger]``. This + lets the optional extension evolve without adding Python object work to the + bar loop. + """ + command_tuple = tuple(commands) + if buffer is None: + codes = np.full((len(command_tuple), _R1_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((len(command_tuple), _R1_VALUE_WIDTH), dtype=np.float64) + expiry = np.full(len(command_tuple), -1, dtype=np.int64) + else: + codes, values, expiry = buffer.reserve(len(command_tuple)) + codes.fill(-1) + values.fill(0.0) + expiry.fill(-1) + + for sequence, command in enumerate(command_tuple): + codes[sequence, 7] = sequence + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if command.symbol != symbol: + raise NativeEventRustBackendError(f"Rust R2 command symbol must be {symbol!r}") + if command.side not in (OrderSide.BUY, OrderSide.SELL): + raise NativeEventRustBackendError("Rust R2 PLACE/REPLACE requires BUY or SELL") + if command.order_type not in ( + OrderType.MARKET, + OrderType.LIMIT, + OrderType.STOP_MARKET, + OrderType.STOP_LIMIT, + ): + raise NativeEventRustBackendError("Rust R2 supports MARKET, LIMIT, STOP_MARKET, and STOP_LIMIT only") + if command.tif is not TimeInForce.GTC: + raise NativeEventRustBackendError("Rust R2 supports GTC only") + if command.parent_order_id or command.oco_group_id or command.group_id: + raise NativeEventRustBackendError("Rust R2 does not support parent, group, or OCO orders") + if command.activation_policy is not OrderActivationPolicy.IMMEDIATE: + raise NativeEventRustBackendError("Rust R2 supports immediate order activation only") + if command.expires_at is not None or command.trigger_price is not None: + if command.expires_at is not None: + raise NativeEventRustBackendError("Rust R2 does not support expiry; use backend='python'") + codes[sequence, 0] = _R1_ACTION_PLACE if command.action is OrderAction.PLACE else _R2_ACTION_REPLACE + codes[sequence, 1] = command.side.sign + codes[sequence, 2] = { + OrderType.MARKET: _R1_ORDER_MARKET, + OrderType.LIMIT: _R1_ORDER_LIMIT, + OrderType.STOP_MARKET: _R2_ORDER_STOP_MARKET, + OrderType.STOP_LIMIT: _R2_ORDER_STOP_LIMIT, + }[command.order_type] + codes[sequence, 3] = _R2_FLAG_REDUCE_ONLY if command.reduce_only else 0 + codes[sequence, 4] = intern_id(command.order_id) + codes[sequence, 5] = intern_id(command.target_order_id) + values[sequence, 0] = float(command.qty or 0.0) + values[sequence, 1] = float(command.price or 0.0) + values[sequence, 2] = float(command.trigger_price or 0.0) + elif command.action is OrderAction.CANCEL: + codes[sequence, 0] = _R1_ACTION_CANCEL + codes[sequence, 5] = intern_id(command.target_order_id) + elif command.action is OrderAction.AMEND: + codes[sequence, 0] = _R2_ACTION_AMEND + codes[sequence, 5] = intern_id(command.target_order_id) + mask = 0 + if command.qty is not None: + mask |= _R2_MUTATE_QTY + values[sequence, 0] = float(command.qty) + if command.price is not None: + mask |= _R2_MUTATE_PRICE + values[sequence, 1] = float(command.price) + if command.trigger_price is not None: + mask |= _R2_MUTATE_TRIGGER + values[sequence, 2] = float(command.trigger_price) + codes[sequence, 6] = mask + else: + raise NativeEventRustBackendError("Rust R2 supports PLACE, CANCEL, AMEND, and REPLACE commands only") + return RustCommandBatch(codes=codes, values=values, expiry=expiry, commands=command_tuple) + + +def compile_rust_batched_tape( + compiled_commands: CompiledOrderCommandArrays, + *, + symbol: str, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Convert the canonical command compiler output to the Rust tape ABI. + + The conversion is deliberately performed once per static tape, not once + per bar or per trial. The canonical compiler remains the source of truth + for bar ordering and dense order identifiers. + """ + if tuple(compiled_commands.symbols) != (symbol,): + raise NativeEventRustBackendError("Rust batched tape supports exactly one symbol") + commands = tuple(command for _, command in compiled_commands.sorted_commands) + n = len(commands) + codes = np.full((n, _R1_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((n, _R1_VALUE_WIDTH), dtype=np.float64) + expiry = np.ascontiguousarray(compiled_commands.command_expires_bar, dtype=np.int64) + if n: + codes[:, 0] = np.asarray(compiled_commands.command_action, dtype=np.int64) + codes[:, 1] = np.asarray(compiled_commands.command_side, dtype=np.int64) + codes[:, 2] = np.asarray(compiled_commands.command_type, dtype=np.int64) + codes[:, 3] = np.asarray(compiled_commands.command_reduce_only, dtype=np.int64) + codes[:, 4] = np.asarray(compiled_commands.command_order_id, dtype=np.int64) + codes[:, 5] = np.asarray(compiled_commands.command_target_order_id, dtype=np.int64) + values[:, 0] = np.asarray(compiled_commands.command_qty, dtype=np.float64) + values[:, 1] = np.asarray(compiled_commands.command_price, dtype=np.float64) + values[:, 2] = np.asarray(compiled_commands.command_trigger_price, dtype=np.float64) + codes[:, 7] = np.arange(n, dtype=np.int64) + + for row, command in enumerate(commands): + if command.symbol not in (None, symbol): + raise NativeEventRustBackendError(f"Rust batched command symbol must be {symbol!r}") + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if command.tif is not TimeInForce.GTC: + raise NativeEventRustBackendError("Rust batched tape supports GTC only") + if command.parent_order_id or command.group_id or command.oco_group_id: + raise NativeEventRustBackendError("Rust batched tape does not support parent, group, or OCO orders") + if command.activation_policy is not OrderActivationPolicy.IMMEDIATE: + raise NativeEventRustBackendError("Rust batched tape supports immediate activation only") + if command.expires_at is not None: + raise NativeEventRustBackendError("Rust batched tape does not support expiry") + if command.action is OrderAction.REPLACE: + # CompiledOrderCommandArrays uses the canonical compiler + # codes (REPLACE=2, AMEND=3), while the stable reactive R2 + # ABI uses AMEND=2, REPLACE=3. + codes[row, 0] = _R2_ACTION_REPLACE + elif command.action is OrderAction.CANCEL: + if command.tif is not TimeInForce.GTC: + raise NativeEventRustBackendError("Rust batched tape supports GTC only") + elif command.action is OrderAction.AMEND: + codes[row, 0] = _R2_ACTION_AMEND + mask = 0 + if command.qty is not None: + mask |= _R2_MUTATE_QTY + if command.price is not None: + mask |= _R2_MUTATE_PRICE + if command.trigger_price is not None: + mask |= _R2_MUTATE_TRIGGER + codes[row, 6] = mask + else: + raise NativeEventRustBackendError("Rust batched tape supports PLACE, CANCEL, AMEND, and REPLACE only") + if command.expires_at is not None or int(expiry[row]) != -1: + raise NativeEventRustBackendError("Rust batched tape does not support expiry") + + return ( + np.ascontiguousarray(compiled_commands.command_ptr, dtype=np.int64), + np.ascontiguousarray(codes, dtype=np.int64), + np.ascontiguousarray(values, dtype=np.float64), + np.ascontiguousarray(expiry, dtype=np.int64), + ) + + +def compile_rust_full_tape( + compiled_commands: CompiledOrderCommandArrays, + *, + buffer: Optional[RustFullCommandBuffer] = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Compile the complete V2 command schema into the Rust 0.4 ABI. + + Code layout is intentionally explicit and integer-only for relationship + fields. The compiler's stable row order remains authoritative. + """ + commands = tuple(command for _, command in compiled_commands.sorted_commands) + n = len(commands) + if buffer is None: + codes = np.full((n, _FULL_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((n, _FULL_VALUE_WIDTH), dtype=np.float64) + expiry = np.full(n, -1, dtype=np.int64) + else: + codes, values, expiry = buffer.reserve(n) + if n: + expiry[:] = np.asarray(compiled_commands.command_expires_bar, dtype=np.int64) + if n: + codes[:, 0] = np.asarray(compiled_commands.command_action, dtype=np.int64) + codes[:, 1] = np.asarray(compiled_commands.command_symbol, dtype=np.int64) + codes[:, 2] = np.asarray(compiled_commands.command_side, dtype=np.int64) + codes[:, 3] = np.asarray(compiled_commands.command_type, dtype=np.int64) + codes[:, 4] = np.asarray(compiled_commands.command_tif, dtype=np.int64) + codes[:, 5] = np.asarray(compiled_commands.command_reduce_only, dtype=np.int64) + codes[:, 6] = np.asarray(compiled_commands.command_order_id, dtype=np.int64) + codes[:, 7] = np.asarray(compiled_commands.command_target_order_id, dtype=np.int64) + codes[:, 8] = np.asarray(compiled_commands.command_parent_order_id, dtype=np.int64) + codes[:, 9] = np.asarray(compiled_commands.command_group_id, dtype=np.int64) + codes[:, 10] = np.asarray(compiled_commands.command_oco_group_id, dtype=np.int64) + codes[:, 11] = np.asarray(compiled_commands.command_activation, dtype=np.int64) + codes[:, 12] = np.arange(n, dtype=np.int64) + values[:, 0] = np.asarray(compiled_commands.command_qty, dtype=np.float64) + values[:, 1] = np.asarray(compiled_commands.command_price, dtype=np.float64) + values[:, 2] = np.asarray(compiled_commands.command_trigger_price, dtype=np.float64) + for row, command in enumerate(commands): + if command.action.value not in {"place", "cancel", "cancel_all", "amend", "replace"}: + raise NativeEventRustBackendError(f"unsupported full-contract action={command.action!r}") + if command.expires_at is not None and int(expiry[row]) < 0: + raise NativeEventRustBackendError("compiled full tape lost command expiry") + return ( + np.ascontiguousarray(compiled_commands.command_ptr, dtype=np.int64), + codes, + values, + expiry, + ) + + +def compile_rust_full_reactive_batch( + commands: Sequence[OrderCommand], + *, + symbols: Sequence[str], + intern_id: Callable[[Optional[str]], int], + idx: pd.DatetimeIndex, + buffer: Optional[RustFullCommandBuffer] = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Compile one callback batch for the full ABI without Python objects.""" + rows = tuple(commands) + if buffer is None: + codes = np.full((len(rows), _FULL_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((len(rows), _FULL_VALUE_WIDTH), dtype=np.float64) + expiry = np.full(len(rows), -1, dtype=np.int64) + else: + codes, values, expiry = buffer.reserve(len(rows)) + symbol_to_code = {symbol: col for col, symbol in enumerate(symbols)} + order_type = {OrderType.MARKET: 0, OrderType.LIMIT: 1, OrderType.STOP_MARKET: 2, OrderType.STOP_LIMIT: 3} + tif = {TimeInForce.GTC: 0, TimeInForce.IOC: 1, TimeInForce.FOK: 2, TimeInForce.GTD: 3} + action = {OrderAction.PLACE: 0, OrderAction.CANCEL: 1, OrderAction.REPLACE: 2, OrderAction.AMEND: 3, OrderAction.CANCEL_ALL: 4} + activation = { + OrderActivationPolicy.IMMEDIATE: 0, + OrderActivationPolicy.ON_PARENT_FIRST_FILL: 1, + OrderActivationPolicy.ON_PARENT_FULL_FILL: 2, + } + for row, command in enumerate(rows): + codes[row, 0] = action[command.action] + codes[row, 1] = -1 if command.symbol is None else symbol_to_code[command.symbol] + codes[row, 2] = 0 if command.side is None else int(command.side.sign) + codes[row, 3] = -1 if command.order_type is None else order_type[command.order_type] + codes[row, 4] = tif[command.tif] + codes[row, 5] = 1 if command.reduce_only else 0 + codes[row, 6] = intern_id(command.order_id) + codes[row, 7] = intern_id(command.target_order_id) + codes[row, 8] = intern_id(command.parent_order_id) + codes[row, 9] = intern_id(command.group_id) + codes[row, 10] = intern_id(command.oco_group_id) + codes[row, 11] = activation[command.activation_policy] + codes[row, 12] = row + values[row, 0] = 0.0 if command.qty is None else float(command.qty) + values[row, 1] = 0.0 if command.price is None else float(command.price) + values[row, 2] = 0.0 if command.trigger_price is None else float(command.trigger_price) + if command.expires_at is not None: + ts = pd.Timestamp(command.expires_at) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + expiry[row] = int(np.searchsorted(idx.asi8, ts.value, side="left")) + return np.ascontiguousarray(codes), np.ascontiguousarray(values), np.ascontiguousarray(expiry) + + +def _command_tape_fingerprint(compiled_commands: CompiledOrderCommandArrays) -> str: + """Return the compile-time identity of an immutable primitive tape.""" + + stored = getattr(compiled_commands, "tape_fingerprint", "") + return stored or command_tape_fingerprint(compiled_commands) + + +def _build_rust_command_intent_report( + compiled_commands: CompiledOrderCommandArrays, +) -> pd.DataFrame: + """Build the command-intent surface independently from lifecycle events. + + Rust owns execution lifecycle rows. The immutable compiler tape owns the + requested command semantics, so this report is deliberately an intent + table rather than an alias of ``order_report``. + """ + + rows = [] + for sorted_index, (original_index, command) in enumerate(compiled_commands.sorted_commands): + metadata = dict(command.metadata) + rows.append( + { + "original_index": int(original_index), + "sorted_index": int(sorted_index), + "timestamp": command.timestamp, + "action": command.action.value, + "symbol": command.symbol, + "side": None if command.side is None else command.side.value, + "order_type": None if command.order_type is None else command.order_type.value, + "order_id": command.order_id, + "target_order_id": command.target_order_id, + "parent_order_id": command.parent_order_id, + "group_id": command.group_id, + "oco_group_id": command.oco_group_id, + "qty": None if command.qty is None else float(command.qty), + "price": None if command.price is None else float(command.price), + "trigger_price": None if command.trigger_price is None else float(command.trigger_price), + "tif": command.tif.value, + "reduce_only": bool(command.reduce_only), + "activation_policy": command.activation_policy.value, + "expires_at": command.expires_at, + "tag": command.tag, + "tag_prefix": command.tag_prefix, + "campaign_id": metadata.get("campaign_id"), + "cycle_id": metadata.get("cycle_id"), + "level_id": metadata.get("level_id"), + "report_kind": "command_intent", + } + ) + return pd.DataFrame(rows) + + +def _payload_value(payload, key: str): + """Read both the R2 dict boundary and the R2.1 typed score boundary.""" + + if isinstance(payload, Mapping): + return payload[key] + return getattr(payload, key) + + +class RustFullRunner: + """Prepared full-contract Rust tape runner for explicit Rust execution.""" + + def __init__( + self, + *, + idx: pd.DatetimeIndex, + symbols: Sequence[str], + market_arrays, + contract_sizes: np.ndarray, + leverages: np.ndarray, + fee_rates: np.ndarray, + initial_capital: float, + maintenance_ratio: float, + slippage: float, + use_funding: bool, + opens_arr: Optional[np.ndarray] = None, + volumes_arr: Optional[np.ndarray] = None, + prepared_market_core=None, + max_tape_cache_bytes: int = 64 * 1024 * 1024, + ) -> None: + self.idx = pd.DatetimeIndex(idx) + self.symbols = tuple(symbols) + self.contract_sizes = np.ascontiguousarray(contract_sizes, dtype=np.float64) + self.leverages = np.ascontiguousarray(leverages, dtype=np.float64) + self.fee_rates = np.ascontiguousarray(fee_rates, dtype=np.float64) + self.initial_capital = float(initial_capital) + self.maintenance_ratio = float(maintenance_ratio) + self.slippage = float(slippage) + self.use_funding = bool(use_funding) + if int(max_tape_cache_bytes) < 0: + raise ValueError("max_tape_cache_bytes must be >= 0") + self.max_tape_cache_bytes = int(max_tape_cache_bytes) + if len(self.symbols) == 0 or market_arrays.closes.shape[1] != len(self.symbols): + raise NativeEventRustBackendError("full Rust runner symbols do not match prepared market arrays") + self._module = _require_r1_extension() + status = probe_native_event_rust_extension(module=self._module) + required = { + "native_event_v2_full_contract", "native_event_v2_multisymbol", + "native_event_v2_funding", "native_event_v2_liquidation", + "native_event_v2_cancel_all_oco", "native_event_v2_tif_expiry", + "native_event_v2_relationships", "native_event_v2_quantity_preflight", + } + missing = sorted(name for name in required if not status.capabilities.get(name, False)) + if missing: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel lacks Rust full-contract capabilities: " + ", ".join(missing) + ) + self.prepared_market_core = prepared_market_core + if self.prepared_market_core is None: + shape = market_arrays.closes.shape + zeros = np.zeros(shape, dtype=np.float64) + opens = zeros if opens_arr is None else np.ascontiguousarray(opens_arr, dtype=np.float64) + volumes = zeros if volumes_arr is None else np.ascontiguousarray(volumes_arr, dtype=np.float64) + self.prepared_market_core = self._module.FullPreparedMarketCore( + np.ascontiguousarray(self.idx.asi8, dtype=np.int64), + opens, + np.ascontiguousarray(market_arrays.highs, dtype=np.float64), + np.ascontiguousarray(market_arrays.lows, dtype=np.float64), + np.ascontiguousarray(market_arrays.closes, dtype=np.float64), + volumes, + np.ascontiguousarray(market_arrays.funding, dtype=np.float64), + np.ascontiguousarray(market_arrays.is_funding_bar, dtype=np.bool_), + ) + self._command_buffer = RustFullCommandBuffer() + self._cached_tape_fingerprint: Optional[str] = None + self._cached_tape_arrays: Optional[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None + self._cached_tape_bytes = 0 + self._session = None + + def _new_session(self): + if self._session is None: + self._session = self._module.FullReactiveSessionCore.from_prepared( + self.prepared_market_core, + self.contract_sizes, + self.leverages, + self.fee_rates, + self.initial_capital, + self.maintenance_ratio, + self.slippage, + self.use_funding, + ) + else: + self._session.reset() + return self._session + + def _tape_arrays(self, compiled_commands: CompiledOrderCommandArrays): + fingerprint = getattr(compiled_commands, "tape_fingerprint", "") or _command_tape_fingerprint( + compiled_commands + ) + if fingerprint == self._cached_tape_fingerprint and self._cached_tape_arrays is not None: + return self._cached_tape_arrays + arrays = compile_rust_full_tape(compiled_commands, buffer=self._command_buffer) + byte_size = sum(int(array.nbytes) for array in arrays) + if byte_size <= self.max_tape_cache_bytes: + self._cached_tape_fingerprint = fingerprint + self._cached_tape_arrays = arrays + self._cached_tape_bytes = byte_size + else: + self.clear_tape_cache() + return arrays + + @property + def tape_cache_bytes(self) -> int: + """Resident bytes held by the bounded full-contract tape cache.""" + + return int(self._cached_tape_bytes) + + def clear_tape_cache(self) -> None: + """Release compiled tape arrays while retaining prepared market state.""" + + self._cached_tape_fingerprint = None + self._cached_tape_arrays = None + self._cached_tape_bytes = 0 + self._command_buffer.clear() + + def clear_caches(self) -> None: + """Release runner-local tape/session caches without mutating market data.""" + + self.clear_tape_cache() + self._session = None + + def cache_info(self) -> Mapping[str, int]: + """Return observable bounded-cache and command-buffer counters.""" + + info = { + "tape_cache_bytes": self.tape_cache_bytes, + "tape_cache_entries": int(self._cached_tape_arrays is not None), + "command_buffer_capacity": self._command_buffer.capacity, + "command_buffer_growth_count": self._command_buffer.growth_count, + "commands_compiled": self._command_buffer.commands_compiled, + } + if self._session is not None and hasattr(self._session, "order_arena_counters"): + slots, capacity, compactions, removed = self._session.order_arena_counters() + info.update( + { + "order_arena_slots": int(slots), + "order_arena_capacity": int(capacity), + "order_compactions": int(compactions), + "terminal_orders_removed": int(removed), + } + ) + if self._session is not None and hasattr(self._session, "step_buffer_capacities"): + fills, events, active = self._session.step_buffer_capacities() + info.update( + { + "step_fill_buffer_capacity": int(fills), + "step_event_buffer_capacity": int(events), + "step_active_order_buffer_capacity": int(active), + } + ) + if self._session is not None and hasattr(self._session, "margin_recompute_count"): + info["margin_recompute_count"] = int(self._session.margin_recompute_count()) + return info + + def run_tape_score(self, compiled_commands: CompiledOrderCommandArrays) -> Mapping[str, object]: + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + return self._new_session().run_tape_score(ptr, codes, values, expiry) + + def run_tape_audit(self, compiled_commands: CompiledOrderCommandArrays) -> RustFullAuditResult: + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + payload = self._new_session().run_tape_audit(ptr, codes, values, expiry) + keys = ( + "equity", "positions", "fees", "turnover", "funding", "initial_margin", "maintenance_margin", + "fill_bar", "fill_order_id", "fill_symbol", "fill_side", "fill_qty", "fill_price", "fill_fee", + "event_bar", "event_kind", "event_status", "event_order_id", "event_target_id", "event_symbol", "event_reject_code", + ) + arrays = {key: np.ascontiguousarray(np.asarray(payload[key])) for key in keys} + arrays["positions"] = np.asarray(arrays["positions"], dtype=np.float64).reshape(len(self.idx), len(self.symbols)) + return RustFullAuditResult( + **arrays, + total_fee=float(payload["total_fee"]), total_turnover=float(payload["total_turnover"]), + total_funding=float(payload["total_funding"]), fill_count=int(payload["fill_count"]), + event_count=int(payload["event_count"]), rejected_count=int(payload["rejected_count"]), + canceled_count=int(payload["canceled_count"]), max_initial_margin=float(payload["max_initial_margin"]), + max_maintenance_margin=float(payload["max_maintenance_margin"]), liquidated=bool(payload["liquidated"]), + liquidation_bar=int(payload["liquidation_bar"]), liquidation_reason=int(payload["liquidation_reason"]), + id_values=tuple(compiled_commands.id_values), + command_report=_build_rust_command_intent_report(compiled_commands), + command_metadata={ + command.order_id: dict(command.metadata) + for _, command in compiled_commands.sorted_commands + if command.order_id + }, + ) + + +class RustBatchedRunner: + """Single-symbol Rust full-tape runner with prepared-market reuse. + + This is an explicit experimental backend. It accepts a precompiled + static command tape and never invokes arbitrary Python strategy callbacks. + Unsupported funding, liquidation, quantity constraints, TIF and package + semantics fail before crossing the Rust boundary. + """ + + def __init__( + self, + *, + idx: pd.DatetimeIndex, + symbols: Sequence[str], + market_arrays, + contract_size: float = 1.0, + leverage: float = 1.0, + fee_rate: float = 0.0, + initial_capital: float = 1_000.0, + maintenance_ratio: float = 0.0, + slippage: float = 0.0, + use_funding: bool = False, + prepared_market_core=None, + max_tape_cache_bytes: int = 64 * 1024 * 1024, + ) -> None: + if len(symbols) != 1: + raise NativeEventRustBackendError("Rust batched runner supports exactly one symbol") + if use_funding: + raise NativeEventRustBackendError("Rust batched runner does not support funding") + if float(maintenance_ratio) != 0.0: + raise NativeEventRustBackendError("Rust batched runner does not support liquidation") + if float(contract_size) <= 0.0 or float(leverage) <= 0.0: + raise ValueError("contract_size and leverage must be > 0") + if float(fee_rate) < 0.0 or float(slippage) < 0.0: + raise ValueError("fee_rate and slippage must be >= 0") + if int(max_tape_cache_bytes) < 0: + raise ValueError("max_tape_cache_bytes must be >= 0") + self.idx = pd.DatetimeIndex(idx) + self.symbols = tuple(symbols) + self.contract_size = float(contract_size) + self.leverage = float(leverage) + self.fee_rate = float(fee_rate) + self.initial_capital = float(initial_capital) + self.maintenance_ratio = float(maintenance_ratio) + self.slippage = float(slippage) + self.max_tape_cache_bytes = int(max_tape_cache_bytes) + self._module = _require_r1_extension() + status = probe_native_event_rust_extension(module=self._module) + required = ( + "rust_batched_tape", + "rust_batched_tape_score", + "rust_batched_tape_audit", + "rust_batched_tape_sparse", + ) + missing = [name for name in required if not status.capabilities.get(name, False)] + if missing: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel lacks Rust batched capabilities: " + ", ".join(missing) + ) + self.prepared_market_core = prepared_market_core + self._cached_tape_fingerprint: Optional[str] = None + self._cached_tape_arrays: Optional[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None + self._cached_tape_bytes = 0 + if self.prepared_market_core is None: + close = np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64) + self.prepared_market_core = self._module.PreparedMarketCore( + np.ascontiguousarray(self.idx.asi8, dtype=np.int64), + close, + np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64), + close, + np.zeros(len(self.idx), dtype=np.float64), + np.zeros(len(self.idx), dtype=np.float64), + np.zeros(len(self.idx), dtype=np.bool_), + ) + + def open_sparse_session( + self, + compiled_commands: Optional[CompiledOrderCommandArrays] = None, + ) -> "RustBatchedSession": + """Open a stateful sparse session over one compiled command tape. + + ``run_until`` keeps the Rust lifecycle state between calls. The + tape is compiled once and the session only returns sparse fills/events + plus scalar accounting, so strategy services do not pay for a dense + per-bar result path on every chunk. + """ + return RustBatchedSession(self, compiled_commands) + + # A descriptive alias for callers that use the shorter session wording. + new_sparse_session = open_sparse_session + + def _new_session(self): + return self._module.ReactiveSessionCore.from_prepared( + self.prepared_market_core, + self.contract_size, + self.leverage, + self.fee_rate, + self.initial_capital, + self.maintenance_ratio, + self.slippage, + False, + ) + + def _tape_arrays(self, compiled_commands: CompiledOrderCommandArrays): + fingerprint = getattr(compiled_commands, "tape_fingerprint", "") or _command_tape_fingerprint( + compiled_commands + ) + if fingerprint == self._cached_tape_fingerprint and self._cached_tape_arrays is not None: + return self._cached_tape_arrays + arrays = compile_rust_batched_tape(compiled_commands, symbol=self.symbols[0]) + byte_size = sum(int(array.nbytes) for array in arrays) + if byte_size <= self.max_tape_cache_bytes: + self._cached_tape_fingerprint = fingerprint + self._cached_tape_arrays = arrays + self._cached_tape_bytes = byte_size + else: + self.clear_tape_cache() + return arrays + + @property + def tape_cache_bytes(self) -> int: + """Current resident size of the bounded primitive tape cache.""" + + return int(self._cached_tape_bytes) + + def clear_tape_cache(self) -> None: + """Release cached command arrays and their fingerprint immediately.""" + + self._cached_tape_fingerprint = None + self._cached_tape_arrays = None + self._cached_tape_bytes = 0 + + def run_tape_score(self, compiled_commands: CompiledOrderCommandArrays) -> RustBatchedScoreResult: + """Run a complete static tape through one PyO3 call and return scalars.""" + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + payload = self._new_session().run_tape_score(ptr, codes, values, expiry) + return RustBatchedScoreResult( + final_equity=float(_payload_value(payload, "final_equity")), + final_position=float(_payload_value(payload, "final_position")), + total_fee=float(_payload_value(payload, "total_fee")), + total_turnover=float(_payload_value(payload, "total_turnover")), + fill_count=int(_payload_value(payload, "fill_count")), + event_count=int(_payload_value(payload, "event_count")), + rejected_count=int(_payload_value(payload, "rejected_count")), + canceled_count=int(_payload_value(payload, "canceled_count")), + max_initial_margin=float(_payload_value(payload, "max_initial_margin")), + max_maintenance_margin=float(_payload_value(payload, "max_maintenance_margin")), + bars=int(_payload_value(payload, "bars")), + metadata={"backend": "rust_batched", "mode": "score", "pycalls": 1}, + ) + + def run_tape_audit(self, compiled_commands: CompiledOrderCommandArrays) -> RustBatchedAuditResult: + """Run a complete tape and return contiguous struct-of-arrays audit data.""" + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + payload = self._new_session().run_tape_audit(ptr, codes, values, expiry) + arrays = {key: np.ascontiguousarray(np.asarray(payload[key])) for key in ( + "equity", "positions", "fees", "turnover", "initial_margin", "maintenance_margin", + "fill_bar", "fill_order_id", "fill_side", "fill_qty", "fill_price", "fill_fee", + "event_bar", "event_kind", "event_status", "event_order_id", "event_target_id", + )} + return RustBatchedAuditResult( + **arrays, + total_fee=float(payload["total_fee"]), + total_turnover=float(payload["total_turnover"]), + fill_count=int(payload["fill_count"]), + event_count=int(payload["event_count"]), + rejected_count=int(payload["rejected_count"]), + canceled_count=int(payload["canceled_count"]), + max_initial_margin=float(payload["max_initial_margin"]), + max_maintenance_margin=float(payload["max_maintenance_margin"]), + metadata={"backend": "rust_batched", "mode": "audit", "pycalls": 1}, + id_values=tuple(compiled_commands.id_values), + ) + + +class RustBatchedSession: + """Stateful single-symbol sparse continuation over a static tape.""" + + def __init__( + self, + runner: RustBatchedRunner, + compiled_commands: Optional[CompiledOrderCommandArrays] = None, + ) -> None: + self.runner = runner + self.compiled_commands = compiled_commands + self._core = runner._new_session() + self._tape_arrays_cache = ( + None if compiled_commands is None else runner._tape_arrays(compiled_commands) + ) + self.next_bar = 0 + + @staticmethod + def _arrays(payload: Mapping[str, object]) -> dict[str, np.ndarray]: + return { + key: np.ascontiguousarray(np.asarray(payload[key])) + for key in ( + "wake_bar", + "wake_kind", + "fill_bar", + "fill_order_id", + "fill_side", + "fill_qty", + "fill_price", + "fill_fee", + "event_bar", + "event_kind", + "event_status", + "event_order_id", + "event_target_id", + ) + } + + def run_until( + self, + stop_bar: int, + command_batch: Optional[CompiledOrderCommandArrays] = None, + *, + wake_on_fill: bool = True, + wake_on_order_event: bool = True, + wake_on_liquidation: bool = True, + ) -> RustBatchedChunkResult: + """Advance through ``stop_bar`` without crossing Python per bar. + + The first call starts at bar zero and later calls continue at the bar + after the previous chunk. ``command_batch`` is optional after a tape + was supplied to :meth:`open_sparse_session`; replacing the tape + mid-session is rejected to avoid an accounting mismatch. + """ + if command_batch is not None: + if self.compiled_commands is not None and command_batch is not self.compiled_commands: + raise NativeEventRustBackendError("cannot replace the command tape during a sparse session") + self.compiled_commands = command_batch + if self.compiled_commands is None: + raise NativeEventRustBackendError("run_until requires a compiled command tape") + stop = int(stop_bar) + if stop < self.next_bar: + raise ValueError("run_until stop_bar must advance beyond the previous chunk") + if self._tape_arrays_cache is None: + self._tape_arrays_cache = self.runner._tape_arrays(self.compiled_commands) + ptr, codes, values, expiry = self._tape_arrays_cache + payload = self._core.run_until( + stop, + ptr, + codes, + values, + expiry, + bool(wake_on_fill), + bool(wake_on_order_event), + bool(wake_on_liquidation), + ) + arrays = self._arrays(payload) + self.next_bar = stop + 1 + return RustBatchedChunkResult( + start_bar=int(payload["start_bar"]), + stop_bar=int(payload["stop_bar"]), + final_equity=float(payload["final_equity"]), + final_position=float(payload["final_position"]), + total_fee=float(payload["total_fee"]), + total_turnover=float(payload["total_turnover"]), + fill_count=int(payload["fill_count"]), + event_count=int(payload["event_count"]), + rejected_count=int(payload["rejected_count"]), + canceled_count=int(payload["canceled_count"]), + max_initial_margin=float(payload["max_initial_margin"]), + max_maintenance_margin=float(payload["max_maintenance_margin"]), + liquidation_seen=bool(payload["liquidation_seen"]), + **arrays, + metadata={ + "backend": "rust_batched", + "mode": "sparse", + "pycalls": 1, + "dense_paths_materialized": False, + "wake_on_fill": bool(wake_on_fill), + "wake_on_order_event": bool(wake_on_order_event), + "wake_on_liquidation": bool(wake_on_liquidation), + }, + ) + + def reset(self) -> None: + """Reset lifecycle/accounting while retaining Rust buffer capacity.""" + + self._core.reset() + self.next_bar = 0 + + +class RustReactiveSessionAdapter: + """R2 bridge: Python callbacks around one Rust state transition per bar.""" + + def __init__( + self, + *, + idx: pd.DatetimeIndex, + symbols: Sequence[str], + market_arrays, + opens_arr: np.ndarray, + volumes_arr: np.ndarray, + constraints, + contract_sizes: np.ndarray, + leverages: np.ndarray, + fee_rates: np.ndarray, + initial_capital: float, + maintenance_ratio: float, + slippage: float, + use_funding: bool, + retain_terminal_orders: bool = True, + score_requirements=None, + prepared_market_core=None, + ) -> None: + self._module = _require_r1_extension() + extension_status = probe_native_event_rust_extension(module=self._module) + self._full_contract = bool(extension_status.capabilities.get("native_event_v2_full_contract", False)) + if not self._full_contract: + validate_rust_r1_support( + symbols=symbols, + constraints=constraints, + use_funding=use_funding, + maintenance_ratio=maintenance_ratio, + ) + self.idx = idx + self.symbols = list(symbols) + self.symbols_tuple = tuple(symbols) + self.market_arrays = market_arrays + self.opens_arr = opens_arr + self.volumes_arr = volumes_arr + self.constraints = constraints + self.contract_sizes = np.asarray(contract_sizes, dtype=np.float64) + self.leverages = np.asarray(leverages, dtype=np.float64) + self.fee_rates = np.asarray(fee_rates, dtype=np.float64) + self.initial_capital = float(initial_capital) + self.maintenance_ratio = float(maintenance_ratio) + self.slippage = float(slippage) + self.use_funding = bool(use_funding) + self.retain_terminal_orders = bool(retain_terminal_orders) + self.score_requirements = score_requirements + self.scalar_score = bool( + score_requirements is not None + and score_requirements.need_trade_stats + and not score_requirements.need_equity_path + and not score_requirements.need_position_path + and not score_requirements.need_fee_path + and not score_requirements.need_funding_path + and not score_requirements.need_margin_path + ) + self.retain_fill_ledger = bool(score_requirements is None or score_requirements.need_fill_ledger) + self.retain_event_ledger = bool(score_requirements is None or score_requirements.need_event_ledger) + self.emit_context_fills = bool( + score_requirements is None or score_requirements.need_context_fills + ) + self.emit_context_events = bool( + score_requirements is None or score_requirements.need_context_events + ) + self.emit_context_active_orders = bool( + score_requirements is None or score_requirements.need_context_active_orders + ) + self.emit_context_positions = bool( + score_requirements is None or score_requirements.need_context_positions + ) + self.emit_context_margin = bool( + score_requirements is None or score_requirements.need_context_margin + ) + self.compact_score_state = bool( + score_requirements is not None + and not score_requirements.need_context_fills + and not score_requirements.need_context_events + and not score_requirements.need_context_active_orders + and not score_requirements.need_context_positions + and not score_requirements.need_context_margin + and not score_requirements.need_fill_ledger + and not score_requirements.need_event_ledger + and not score_requirements.need_terminal_orders + ) + self._r2_capable = bool(extension_status.capabilities.get("r2_stop_amend_replace_reduce_only_constraints", False)) + self._prepared_market_core_capable = bool(extension_status.capabilities.get("prepared_market_core", False)) + if self.constraints.enabled and not self._r2_capable: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel is R1-only and cannot apply quantity constraints; rebuild/install R2 or use backend='python'" + ) + self._id_to_code: dict[str, int] = {} + self._id_values: list[str] = [] + self._commands_by_id: dict[str, OrderCommand] = {} + self._command_buffer = RustCommandBuffer() + self._full_command_buffer = RustFullCommandBuffer() + self.execution_counters = { + "bars_processed": 0, + "bars_with_commands": 0, + "contexts_materialized": 0, + "timestamp_objects_materialized": 0, + "commands_compiled": 0, + "command_buffer_growths": 0, + "bytes_copied_to_rust": 0, + "active_snapshot_materializations": 0, + "empty_command_batches_skipped": 0, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 0, + "commands_retimed": 0, + "commands_quantized": 0, + } + self.scheduled: dict[int, list[OrderCommand]] = {} + self.pending: list[_RustPendingOrder] = [] + self.orders: list[_RustPendingOrder] = [] + self.fills: list[NativeFillEvent] = [] + self.events: list[NativeOrderEvent] = [] + self.fill_count = 0 + self.event_count = 0 + self.rejected_count = 0 + self.canceled_count = 0 + self.total_fee = 0.0 + self.total_funding = 0.0 + self.total_turnover = 0.0 + self.fills_by_bar: dict[int, list[NativeFillEvent]] = {} + self.events_by_bar: dict[int, list[NativeOrderEvent]] = {} + self.current_pos = np.zeros(len(self.symbols), dtype=np.float64) + self.equity = float(initial_capital) + self.liquidated = False + self.liquidation_bar = -1 + self.liquidation_reason = 0 + self.last_initial_margin = 0.0 + self.last_maintenance_margin = 0.0 + self.processed_bar = -1 + n_bars = len(idx) + self.equity_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.pos_path = None if self.scalar_score else np.zeros((n_bars, len(self.symbols)), dtype=np.float64) + self.fee_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.turnover_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.funding_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.initial_margin_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.maintenance_margin_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.rejected_bar = None if self.scalar_score else np.zeros(n_bars, dtype=np.int64) + self.canceled_bar = None if self.scalar_score else np.zeros(n_bars, dtype=np.int64) + self.empty_fills: tuple[NativeFillEvent, ...] = () + self.empty_events: tuple[NativeOrderEvent, ...] = () + self.empty_active_orders: tuple[NativeActiveOrderSnapshot, ...] = () + self._active_snapshot_cache: tuple[NativeActiveOrderSnapshot, ...] = () + if self.scalar_score: + # Import lazily to avoid the native_event <-> Rust adapter import + # cycle. The class is shared with Python scalar scoring so metric + # definitions remain identical across backends. + from .native_event import _OnlineScoreState + + self.online_score = _OnlineScoreState(self.initial_capital, len(self.symbols)) + else: + self.online_score = None + self.prepared_market_core = prepared_market_core + if self._full_contract and hasattr(self._module, "FullPreparedMarketCore"): + if self.prepared_market_core is None: + self.prepared_market_core = self._module.FullPreparedMarketCore( + np.ascontiguousarray(idx.asi8, dtype=np.int64), + np.ascontiguousarray(opens_arr, dtype=np.float64), + np.ascontiguousarray(market_arrays.highs, dtype=np.float64), + np.ascontiguousarray(market_arrays.lows, dtype=np.float64), + np.ascontiguousarray(market_arrays.closes, dtype=np.float64), + np.ascontiguousarray(volumes_arr, dtype=np.float64), + np.ascontiguousarray(market_arrays.funding, dtype=np.float64), + np.ascontiguousarray(market_arrays.is_funding_bar, dtype=np.bool_), + ) + self._core = self._module.FullReactiveSessionCore.from_prepared( + self.prepared_market_core, + np.ascontiguousarray(self.contract_sizes, dtype=np.float64), + np.ascontiguousarray(self.leverages, dtype=np.float64), + np.ascontiguousarray(self.fee_rates, dtype=np.float64), + float(initial_capital), float(maintenance_ratio), float(slippage), bool(use_funding), + ) + # Accounting and the live position vector are always required by + # the Python adapter. Other projections are requested only when + # the strategy/ledger can observe them. + output_mask = _FULL_OUTPUT_POSITIONS + if self.retain_fill_ledger or self.emit_context_fills: + output_mask |= _FULL_OUTPUT_FILLS + if self.retain_event_ledger or self.emit_context_events: + output_mask |= _FULL_OUTPUT_EVENTS + if self.emit_context_active_orders: + output_mask |= _FULL_OUTPUT_ACTIVE_ORDERS + self._core.set_output_mask(output_mask) + elif self._prepared_market_core_capable and hasattr(self._module, "PreparedMarketCore"): + if self.prepared_market_core is None: + self.prepared_market_core = self._module.PreparedMarketCore( + np.ascontiguousarray(idx.asi8, dtype=np.int64), + np.ascontiguousarray(opens_arr[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64), + np.ascontiguousarray(volumes_arr[:, 0], dtype=np.float64), + np.zeros(n_bars, dtype=np.float64), + np.zeros(n_bars, dtype=np.bool_), + ) + self._core = self._module.ReactiveSessionCore.from_prepared( + self.prepared_market_core, + float(self.contract_sizes[0]), + float(self.leverages[0]), + float(self.fee_rates[0]), + float(initial_capital), + float(maintenance_ratio), + float(slippage), + False, + ) + else: + self.prepared_market_core = None + self._core = self._module.ReactiveSessionCore( + np.ascontiguousarray(idx.asi8, dtype=np.int64), + np.ascontiguousarray(opens_arr[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64), + np.ascontiguousarray(volumes_arr[:, 0], dtype=np.float64), + np.zeros(n_bars, dtype=np.float64), + np.zeros(n_bars, dtype=np.bool_), + float(self.contract_sizes[0]), + float(self.leverages[0]), + float(self.fee_rates[0]), + float(initial_capital), + float(maintenance_ratio), + float(slippage), + False, + ) + self.size_helper = self._size_order + + def _intern_id(self, value: Optional[str]) -> int: + if value is None: + return -1 + if value not in self._id_to_code: + self._id_to_code[value] = len(self._id_values) + self._id_values.append(value) + return self._id_to_code[value] + + def _id_from_code(self, value: int) -> Optional[str]: + return self._id_values[value] if 0 <= int(value) < len(self._id_values) else None + + def _size_order(self, symbol: str, notional: float, price: float, side: OrderSide = OrderSide.BUY) -> float: + if symbol not in self.symbols: + raise ValueError(f"unknown symbol={symbol!r}") + if price <= 0.0: + raise ValueError("price must be > 0") + column = self.symbols.index(symbol) + return abs(float(notional) / (float(price) * float(self.contract_sizes[column]))) + + def _quantize_r2_commands(self, bar: int, commands: Sequence[OrderCommand]) -> tuple[OrderCommand, ...]: + """Apply the canonical quantity filter at the same bar as replay preflight. + + Reactive commands cannot be preflighted before a strategy emits them. + The static replay performs the equivalent filtering over the emitted + tape; this method makes explicit Rust follow that exact exchange-rule + contract without changing the command tape or endpoint API. + """ + if not self.constraints.enabled: + self.execution_counters["constraint_preflight_skipped"] += int(bool(commands)) + return tuple(commands) + self.execution_counters["constraint_preflight_calls"] += int(bool(commands)) + out: list[OrderCommand] = [] + for command in commands: + if command.action not in (OrderAction.PLACE, OrderAction.REPLACE) or command.qty is None: + out.append(command) + continue + try: + column = self.symbols.index(command.symbol) + except ValueError as exc: + raise NativeEventRustBackendError( + f"quantity preflight received unknown symbol={command.symbol!r}" + ) from exc + close = float(self.market_arrays.closes[int(bar), column]) + price = float(command.price) if command.price is not None else close + signed = command.signed_qty + quantity = abs( + quantize_signed_quantity( + signed, + price, + float(self.contract_sizes[column]), + float(self.constraints.qty_step[column]), + float(self.constraints.min_qty[column]), + float(self.constraints.min_notional[column]), + ) + ) + if quantity <= 0.0: + continue + if abs(quantity - float(command.qty)) > 1e-12: + out.append(replace(command, qty=quantity)) + else: + out.append(command) + self.execution_counters["commands_quantized"] += len(commands) + return tuple(out) + + @staticmethod + def _commands_require_r2(commands: Sequence[OrderCommand]) -> bool: + return any( + command.action in (OrderAction.AMEND, OrderAction.REPLACE) + or command.reduce_only + or command.order_type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT) + for command in commands + ) + + def _require_r2_for_commands(self, commands: Sequence[OrderCommand]) -> None: + if self._commands_require_r2(commands) and not self._r2_capable: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel is R1-only and cannot execute R2 lifecycle commands; rebuild/install R2 or use backend='python'" + ) + + def schedule(self, bar: int, commands: Sequence[OrderCommand]) -> None: + if commands and int(bar) < len(self.idx): + self.scheduled.setdefault(int(bar), []).extend(commands) + + def release_bar_payload(self, bar: int) -> None: + self.fills_by_bar.pop(int(bar), None) + self.events_by_bar.pop(int(bar), None) + + def process_bar(self, bar: int) -> None: + if bar <= self.processed_bar: + return + for current_bar in range(self.processed_bar + 1, int(bar) + 1): + commands = self._quantize_r2_commands(current_bar, self.scheduled.pop(current_bar, ())) + self._require_r2_for_commands(commands) + if self._full_contract: + full_codes, full_values, full_expiry = compile_rust_full_reactive_batch( + commands, + symbols=self.symbols, + intern_id=self._intern_id, + idx=self.idx, + buffer=self._full_command_buffer, + ) + batch = None + else: + batch = compile_rust_r1_command_batch( + commands, + symbol=self.symbols[0], + intern_id=self._intern_id, + buffer=self._command_buffer, + ) + if self._full_contract: + for command in commands: + if command.order_id: + self._commands_by_id[command.order_id] = command + step_method = getattr(self._core, "step_typed", self._core.step) + payload = step_method(current_bar, full_codes, full_values, full_expiry) + else: + for command in batch.commands: + if command.order_id: + self._commands_by_id[command.order_id] = command + payload = self._core.step(current_bar, batch.codes, batch.values, batch.expiry) + self._consume_step(current_bar, payload) + self.processed_bar = current_bar + self.execution_counters["bars_processed"] += 1 + self.execution_counters["commands_compiled"] += len(commands) + self.execution_counters["command_buffer_growths"] = self._full_command_buffer.growth_count + self.execution_counters["bytes_copied_to_rust"] += int( + full_codes.nbytes + full_values.nbytes + full_expiry.nbytes + if self._full_contract + else batch.codes.nbytes + batch.values.nbytes + batch.expiry.nbytes + ) + + def _consume_step(self, bar: int, payload) -> None: + self.equity = float(_step_value(payload, "equity", 0.0)) + if self._full_contract: + positions = _step_value(payload, "positions") + if positions is not None: + self.current_pos[:] = np.asarray(positions, dtype=np.float64) + else: + self.current_pos[0] = float(_step_value(payload, "position", 0.0)) + fee = float(_step_value(payload, "fee", 0.0)) + turnover = float(_step_value(payload, "turnover", 0.0)) + funding = float(_step_value(payload, "funding", 0.0)) if self._full_contract else 0.0 + initial_margin = float(_step_value(payload, "initial_margin", 0.0)) + maintenance_margin = float(_step_value(payload, "maintenance_margin", 0.0)) + self.last_initial_margin = initial_margin + self.last_maintenance_margin = maintenance_margin + self.total_fee += fee + self.total_turnover += turnover + self.total_funding += funding + if self.equity_path is not None: + self.equity_path[bar] = self.equity + if self.pos_path is not None: + self.pos_path[bar, :] = self.current_pos + if self.fee_path is not None: + self.fee_path[bar] = fee + if self.turnover_path is not None: + self.turnover_path[bar] = turnover + if self.funding_path is not None: + self.funding_path[bar] = funding + if self.initial_margin_path is not None: + self.initial_margin_path[bar] = initial_margin + if self.maintenance_margin_path is not None: + self.maintenance_margin_path[bar] = maintenance_margin + self.liquidated = bool(_step_value(payload, "liquidated", False)) + self.liquidation_bar = int(_step_value(payload, "liquidation_bar", -1)) + self.liquidation_reason = int(_step_value(payload, "liquidation_reason", 0)) + if self.online_score is not None: + self.online_score.observe( + self.idx.asi8[bar], + self.equity, + self.current_pos, + initial_margin, + maintenance_margin, + ) + reported_fill_count = _step_has(payload, "fill_count") + reported_event_counts = _step_has(payload, "event_count") + if reported_fill_count: + self.fill_count += int(_step_value(payload, "fill_count", 0)) + if reported_event_counts: + self.event_count += int(_step_value(payload, "event_count", 0)) + rejected = int(_step_value(payload, "rejected_count", 0)) + canceled = int(_step_value(payload, "canceled_count", 0)) + self.rejected_count += rejected + self.canceled_count += canceled + if self.rejected_bar is not None: + self.rejected_bar[bar] += rejected + if self.canceled_bar is not None: + self.canceled_bar[bar] += canceled + fills = [] + for fill_row in (_step_value(payload, "fills") or ()): + if self._full_contract: + order_code, symbol_code, side_sign, qty, price, fee = fill_row + symbol = self.symbols[int(symbol_code)] + else: + order_code, side_sign, qty, price, fee = fill_row + symbol = self.symbols[0] + order_id = self._id_from_code(int(order_code)) + command = self._commands_by_id.get(order_id or "") + fill = NativeFillEvent( + timestamp=self.idx[bar], + symbol=symbol, + side=OrderSide.BUY if int(side_sign) > 0 else OrderSide.SELL, + qty=float(qty), + price=float(price), + fee=float(fee), + order_id=order_id, + tag=None if command is None else command.tag, + metadata={} if command is None else dict(command.metadata), + ) + fills.append(fill) + if not reported_fill_count: + self.fill_count += 1 + if self.retain_fill_ledger: + self.fills.append(fill) + if fills: + self.fills_by_bar[bar] = fills + events = [] + for event_row in (_step_value(payload, "events") or ()): + if self._full_contract: + event_kind, status, order_code, target_code, symbol_code = event_row[:5] + reject_code = int(event_row[5]) if len(event_row) > 5 else 0 + event_symbol = None if int(symbol_code) < 0 else self.symbols[int(symbol_code)] + else: + event_kind, status, order_code, target_code = event_row + reject_code = 0 + event_symbol = None + name = ({0: "place", 1: "cancel", 2: "replace", 3: "amend", 4: "fill", 5: "expire", 6: "activate", 7: "reject"} if self._full_contract else {0: "place", 1: "cancel", 2: "fill", 3: "reject", 4: "amend", 5: "replace"}).get( + int(event_kind), "reject" + ) + event = NativeOrderEvent( + timestamp=self.idx[bar], + bar=bar, + event_name=name, + status=int(status), + order_id=self._id_from_code(int(order_code)), + target_order_id=self._id_from_code(int(target_code)), + metadata={"reject_code": reject_code}, + ) + events.append(event) + if not reported_event_counts: + self.event_count += 1 + if name == "reject": + if self.rejected_bar is not None: + self.rejected_bar[bar] += 1 + self.rejected_count += 1 + if name == "cancel": + if self.canceled_bar is not None: + self.canceled_bar[bar] += 1 + self.canceled_count += 1 + if self.retain_event_ledger: + self.events.append(event) + if events: + self.events_by_bar[bar] = events + pending = [] + snapshots = [] + for active_row in (_step_value(payload, "active_orders") or ()): + if self._full_contract: + order_code, symbol_code, side_sign, order_type, qty, price, trigger_price, tif, flags, parent, group, oco, activation, waiting_parent = active_row + active_symbol = self.symbols[int(symbol_code)] + parent_order_id = self._id_from_code(int(parent)) + group_id = self._id_from_code(int(group)) + oco_group_id = self._id_from_code(int(oco)) + else: + order_code, side_sign, order_type, qty, price, trigger_price, flags = active_row + active_symbol = self.symbols[0] + parent_order_id = None + group_id = None + oco_group_id = None + order_id = self._id_from_code(int(order_code)) + command = self._commands_by_id.get(order_id or "") + side = OrderSide.BUY if int(side_sign) > 0 else OrderSide.SELL + kind = { + _R1_ORDER_MARKET: OrderType.MARKET, + _R1_ORDER_LIMIT: OrderType.LIMIT, + _R2_ORDER_STOP_MARKET: OrderType.STOP_MARKET, + _R2_ORDER_STOP_LIMIT: OrderType.STOP_LIMIT, + }.get(int(order_type), OrderType.MARKET) + reduce_only = bool(int(flags) & _R2_FLAG_REDUCE_ONLY) + pending.append( + _RustPendingOrder( + order_id=order_id, + side=side, + order_type=kind, + qty=float(qty), + price=float(price), + trigger_price=float(trigger_price), + reduce_only=reduce_only, + ) + ) + snapshots.append( + NativeActiveOrderSnapshot( + order_id=order_id, + symbol=active_symbol, + side=side.value, + order_type=kind.value, + status=ORDER_STATUS_PENDING, + remaining_qty=float(qty), + price=float(price), + trigger_price=float(trigger_price), + reduce_only=reduce_only, + parent_order_id=parent_order_id, + group_id=group_id, + oco_group_id=oco_group_id, + tag=None if command is None else command.tag, + campaign_id=None if command is None else command.metadata.get("campaign_id"), + cycle_id=None if command is None else command.metadata.get("cycle_id"), + level_id=None if command is None else command.metadata.get("level_id"), + ) + ) + self.pending = pending + if self.emit_context_active_orders: + self._active_snapshot_cache = tuple(snapshots) + self.execution_counters["active_snapshot_materializations"] += 1 + else: + self._active_snapshot_cache = self.empty_active_orders + + @staticmethod + def _is_pending(state: _RustPendingOrder) -> bool: + return True + + def context(self, bar: int) -> NativeStrategyContext: + self.process_bar(bar) + self.execution_counters["contexts_materialized"] += 1 + initial_margin = ( + float(self.initial_margin_path[int(bar)]) + if self.initial_margin_path is not None + else float(self.last_initial_margin) + ) + maintenance_margin = ( + float(self.maintenance_margin_path[int(bar)]) + if self.maintenance_margin_path is not None + else float(self.last_maintenance_margin) + ) + return NativeStrategyContext( + bar_index=int(bar), + timestamp=self.idx[int(bar)], + open=self.opens_arr[int(bar)], + high=self.market_arrays.highs[int(bar)], + low=self.market_arrays.lows[int(bar)], + close=self.market_arrays.closes[int(bar)], + volume=self.volumes_arr[int(bar)], + equity=float(self.equity), + available_equity=float(self.equity - initial_margin), + initial_margin=initial_margin if self.emit_context_margin else 0.0, + maintenance_margin=maintenance_margin if self.emit_context_margin else 0.0, + positions=( + {symbol: float(self.current_pos[col]) for col, symbol in enumerate(self.symbols)} + if self.emit_context_positions else {} + ), + fills_this_bar=( + tuple(self.fills_by_bar.get(int(bar), ())) + if self.emit_context_fills else self.empty_fills + ), + order_events_this_bar=( + tuple(self.events_by_bar.get(int(bar), ())) + if self.emit_context_events else self.empty_events + ), + active_orders=( + self._active_snapshot_cache + if self.emit_context_active_orders else self.empty_active_orders + ), + liquidated=bool(self.liquidated), + symbols=self.symbols_tuple, + size_order=self.size_helper, + ) + + +__all__ = [ + "NativeEventBackendSelection", + "NativeEventRustBackendError", + "NativeEventRustExtensionStatus", + "RUST_NATIVE_API_VERSION", + "RustCommandBatch", + "RustCommandBuffer", + "RustFullCommandBuffer", + "RustBatchedAuditResult", + "RustFullAuditResult", + "RustBatchedChunkResult", + "RustBatchedRunner", + "RustFullRunner", + "RustBatchedScoreResult", + "RustBatchedSession", + "RustReactiveSessionAdapter", + "compile_rust_batched_tape", + "compile_rust_full_tape", + "compile_rust_full_reactive_batch", + "compile_rust_r1_command_batch", + "probe_native_event_rust_extension", + "resolve_native_event_backend", + "validate_rust_r1_support", +] diff --git a/backends/native_event.py b/backends/native_event.py index d71f1cb..b7ab01d 100644 --- a/backends/native_event.py +++ b/backends/native_event.py @@ -7,8 +7,9 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field, replace +import math from pathlib import Path -from typing import Dict, List, Optional, Sequence, Union +from typing import Dict, List, Mapping, Optional, Sequence, Union import numpy as np import pandas as pd @@ -87,7 +88,12 @@ prepare_funding, validate_datetime, ) -from ..core.results import BacktestResultV2 +from ..core.results import ( + BacktestResultV2, + NativeAccountingArrays, + NativeEventScalarScoreResult, + NativeEventScoreResult, +) from ..core.reactive import ( NativeActiveOrderSnapshot, NativeEventStrategyError, @@ -106,6 +112,14 @@ TimeInForce, InstrumentSpec, ) +from ._native_event_rust import ( + NativeEventBackendSelection, + NativeEventRustBackendError, + RustBatchedRunner, + RustFullRunner, + RustReactiveSessionAdapter, + resolve_native_event_backend, +) def _event_type_name(event_type: int) -> str: @@ -131,6 +145,7 @@ class NativeEventConfig: audit_sink: str = "memory" audit_sink_path: Optional[str] = None reactive_kernel_mode: str = "replay_certified" + native_backend: Optional[str] = None def __post_init__(self) -> None: if isinstance(self.fee_rate, dict): @@ -141,6 +156,13 @@ def __post_init__(self) -> None: object.__setattr__(self, "report_level", _normalize_native_event_report_level(self.report_level)) object.__setattr__(self, "audit_sink", _normalize_native_event_audit_sink(self.audit_sink)) object.__setattr__(self, "reactive_kernel_mode", _normalize_reactive_kernel_mode(self.reactive_kernel_mode)) + if self.native_backend is not None: + selected = str(self.native_backend).lower().strip() + if selected not in {"python", "rust", "auto", "replay_certified"}: + raise ValueError( + "native_backend must be one of: auto, python, replay_certified, rust" + ) + object.__setattr__(self, "native_backend", selected) @dataclass(frozen=True) @@ -159,6 +181,103 @@ class NativeEventArtifactPlan: materialize_active_orders: bool +@dataclass(frozen=True, slots=True) +class NativeEventScoreRequirements: + """Internal retention contract for direct prepared-score execution. + + The public ``PreparedNativeEventStrategyRunner.score`` compatibility + contract exposes accounting arrays. Prepared optimization uses + ``scalar_score_contract()`` instead, which relies on online metrics and + keeps only live reactive state. Context flags are separate from ledger + retention: a strategy may consume current-bar fills without retaining the + complete fill history. + """ + + need_equity_path: bool = False + need_position_path: bool = False + need_fee_path: bool = False + need_funding_path: bool = False + need_margin_path: bool = False + need_turnover_path: bool = False + need_rejection_path: bool = False + need_cancellation_path: bool = False + need_trade_stats: bool = True + need_fill_ledger: bool = False + need_event_ledger: bool = False + need_terminal_orders: bool = False + need_context_fills: bool = True + need_context_events: bool = True + need_context_active_orders: bool = True + need_context_positions: bool = True + need_context_margin: bool = True + need_command_tape: bool = False + + @classmethod + def public_score_contract(cls) -> "NativeEventScoreRequirements": + """Return the compatible array set required by ``NativeEventScoreResult``.""" + return cls( + need_equity_path=True, + need_position_path=True, + need_fee_path=True, + need_funding_path=True, + need_margin_path=True, + need_trade_stats=False, + ) + + @classmethod + def scalar_score_contract(cls) -> "NativeEventScoreRequirements": + """Return the low-retention contract used by prepared optimization.""" + return cls( + need_equity_path=False, + need_position_path=False, + need_fee_path=False, + need_funding_path=False, + need_margin_path=False, + need_turnover_path=False, + need_rejection_path=False, + need_cancellation_path=False, + need_trade_stats=True, + need_fill_ledger=False, + need_event_ledger=False, + need_terminal_orders=False, + need_context_fills=True, + need_context_events=True, + need_context_active_orders=True, + need_context_positions=True, + need_context_margin=True, + need_command_tape=False, + ) + + @classmethod + def from_strategy( + cls, + strategy, + *, + base: Optional["NativeEventScoreRequirements"] = None, + ) -> "NativeEventScoreRequirements": + """Apply an optional strategy context declaration to a base contract.""" + requirements = base or cls.scalar_score_contract() + declaration = getattr(strategy, "native_context_requirements", None) + if declaration is None: + return requirements + if not isinstance(declaration, Mapping): + raise TypeError("native_context_requirements must be a mapping") + aliases = { + "fills": "need_context_fills", + "events": "need_context_events", + "active_orders": "need_context_active_orders", + "positions": "need_context_positions", + "margin": "need_context_margin", + } + valid = set(aliases) | set(aliases.values()) + updates = {} + for key, value in declaration.items(): + if key not in valid: + raise ValueError(f"unsupported native context requirement: {key!r}") + updates[aliases.get(key, key)] = bool(value) + return replace(requirements, **updates) + + @dataclass(frozen=True) class CompactFillLedger: bar: np.ndarray @@ -254,10 +373,10 @@ def _native_event_artifact_plan(report_level: str) -> NativeEventArtifactPlan: keep_funding_path=True, keep_margin_path=True, keep_fill_ledger=False, - keep_command_terminal_state=True, + keep_command_terminal_state=False, keep_event_ledger=False, keep_command_tape=False, - materialize_pandas=True, + materialize_pandas=False, materialize_python_objects=False, materialize_active_orders=False, ) @@ -307,7 +426,7 @@ def _native_event_artifact_plan(report_level: str) -> NativeEventArtifactPlan: ) -@dataclass +@dataclass(slots=True) class _ReactiveOrderState: command: OrderCommand command_index: int @@ -321,6 +440,290 @@ class _ReactiveOrderState: reject_code: int = 0 +def _compact_score_command(command: OrderCommand) -> OrderCommand: + """Drop non-execution metadata from a score-only pending order. + + Static score runs do not expose fills, events, active-order snapshots, or + terminal order objects. Parent/OCO/group/tag fields remain because they + affect lifecycle matching; strategy metadata is deliberately not retained + on the hot state. Public command objects and audit runs are untouched. + """ + + if not command.metadata: + return command + return replace(command, metadata={}) + + +class _OnlineScoreState: + """Streaming equivalent of the array-first performance metric helpers.""" + + __slots__ = ( + "initial_capital", "n_symbols", "trading_days", "prev_equity", "first_equity", + "last_equity", "peak", "max_drawdown", "drawdown_sum", "drawdown_count", + "bar_count", "bar_mean", "bar_m2", "bar_downside_sq", "bar_downside_count", "bar_gain", "bar_loss", + "bar_win_sum", "bar_win_count", "bar_loss_sum", "bar_loss_count", "daily_day", + "daily_close", "last_daily_close", "daily_points", "daily_mean", "daily_m2", + "daily_downside_sq", "daily_downside_count", "daily_gain", "daily_loss", "daily_win_sum", "daily_win_count", + "daily_loss_sum", "daily_loss_count", "daily_peak", "daily_dd_run", "daily_dd_runs", + "prev_positions", "trade_count", "long_total", "short_total", "long_wins", + "short_wins", "last_timestamp_ns", "last_observed_bar", "max_initial_margin", "max_maintenance_margin", + ) + + def __init__(self, initial_capital: float, n_symbols: int, trading_days: int = 365) -> None: + self.initial_capital = float(initial_capital) + self.n_symbols = int(n_symbols) + self.trading_days = int(trading_days) + self.prev_equity = None + self.first_equity = None + self.last_equity = float(initial_capital) + self.peak = -np.inf + self.max_drawdown = 0.0 + self.drawdown_sum = 0.0 + self.drawdown_count = 0 + self.bar_count = 0 + self.bar_mean = 0.0 + self.bar_m2 = 0.0 + self.bar_downside_sq = 0.0 + self.bar_downside_count = 0 + self.bar_gain = 0.0 + self.bar_loss = 0.0 + self.bar_win_sum = 0.0 + self.bar_win_count = 0 + self.bar_loss_sum = 0.0 + self.bar_loss_count = 0 + self.daily_day = None + self.daily_close = None + self.last_daily_close = None + self.daily_points = 0 + self.daily_mean = 0.0 + self.daily_m2 = 0.0 + self.daily_downside_sq = 0.0 + self.daily_downside_count = 0 + self.daily_gain = 0.0 + self.daily_loss = 0.0 + self.daily_win_sum = 0.0 + self.daily_win_count = 0 + self.daily_loss_sum = 0.0 + self.daily_loss_count = 0 + self.daily_peak = -np.inf + self.daily_dd_run = 0 + self.daily_dd_runs: List[int] = [] + self.prev_positions = np.zeros(self.n_symbols, dtype=np.float64) + self.trade_count = self.n_symbols + self.long_total = np.zeros(self.n_symbols, dtype=np.int64) + self.short_total = np.zeros(self.n_symbols, dtype=np.int64) + self.long_wins = np.zeros(self.n_symbols, dtype=np.int64) + self.short_wins = np.zeros(self.n_symbols, dtype=np.int64) + self.last_timestamp_ns = None + self.last_observed_bar = -1 + self.max_initial_margin = 0.0 + self.max_maintenance_margin = 0.0 + + @staticmethod + def _update_moments(value: float, count: int, mean: float, m2: float) -> tuple[int, float, float]: + count += 1 + delta = value - mean + mean += delta / count + m2 += delta * (value - mean) + return count, mean, m2 + + def _observe_return(self, value: float, *, daily: bool) -> None: + if not np.isfinite(value): + return + if daily: + if value > 0.0: + self.daily_gain += float(value) + self.daily_win_sum += float(value) + self.daily_win_count += 1 + elif value < 0.0: + self.daily_loss += float(-value) + self.daily_loss_sum += float(value) + self.daily_loss_count += 1 + if value < 0.0: + self.daily_downside_sq += float(value * value) + self.daily_downside_count += 1 + self.daily_points, self.daily_mean, self.daily_m2 = self._update_moments( + float(value), self.daily_points - 1, self.daily_mean, self.daily_m2 + ) + else: + if value > 0.0: + self.bar_gain += float(value) + self.bar_win_sum += float(value) + self.bar_win_count += 1 + elif value < 0.0: + self.bar_loss += float(-value) + self.bar_loss_sum += float(value) + self.bar_loss_count += 1 + if value < 0.0: + self.bar_downside_sq += float(value * value) + self.bar_downside_count += 1 + self.bar_count, self.bar_mean, self.bar_m2 = self._update_moments( + float(value), self.bar_count, self.bar_mean, self.bar_m2 + ) + + def _close_day(self) -> None: + if self.daily_close is None: + return + close = float(self.daily_close) + if self.last_daily_close is not None: + base = float(self.last_daily_close) + daily_return = (close - base) / base if base != 0.0 else 0.0 + self._observe_return(float(daily_return), daily=True) + self.last_daily_close = close + self.daily_points += 1 + self.daily_peak = max(self.daily_peak, close) + in_drawdown = self.daily_peak != close + if in_drawdown: + self.daily_dd_run += 1 + elif self.daily_dd_run > 0: + self.daily_dd_runs.append(self.daily_dd_run) + self.daily_dd_run = 0 + + def observe( + self, + timestamp, + equity: float, + positions: np.ndarray, + initial_margin: float, + maintenance_margin: float, + ) -> None: + """Consume one canonical post-bar accounting observation.""" + value = float(equity) + if self.first_equity is None: + self.first_equity = value + if self.prev_equity is None or self.prev_equity == 0.0: + bar_return = 0.0 + else: + bar_return = value / float(self.prev_equity) - 1.0 + if math.isfinite(float(bar_return)): + bar_return = float(bar_return) + self.bar_count += 1 + delta = bar_return - self.bar_mean + self.bar_mean += delta / self.bar_count + self.bar_m2 += delta * (bar_return - self.bar_mean) + if bar_return > 0.0: + self.bar_gain += bar_return + self.bar_win_sum += bar_return + self.bar_win_count += 1 + elif bar_return < 0.0: + self.bar_loss += -bar_return + self.bar_loss_sum += bar_return + self.bar_loss_count += 1 + self.bar_downside_sq += bar_return * bar_return + self.bar_downside_count += 1 + + self.peak = max(self.peak, value) + drawdown = (self.peak - value) / self.peak if self.peak != 0.0 else 0.0 + self.max_drawdown = max(self.max_drawdown, float(drawdown)) + if drawdown > 0.0: + self.drawdown_sum += float(drawdown) + self.drawdown_count += 1 + + current = positions + for j in range(self.n_symbols): + position = float(current[j]) + if self.bar_count > 1 and position != self.prev_positions[j]: + self.trade_count += 1 + if position > 0.0: + self.long_total[j] += 1 + if bar_return > 0.0: + self.long_wins[j] += 1 + elif position < 0.0: + self.short_total[j] += 1 + if bar_return > 0.0: + self.short_wins[j] += 1 + self.prev_positions[j] = position + self.prev_equity = value + self.last_equity = value + self.last_timestamp_ns = int(timestamp) if isinstance(timestamp, (int, np.integer)) else int(pd.Timestamp(timestamp).value) + self.max_initial_margin = max(self.max_initial_margin, float(initial_margin)) + self.max_maintenance_margin = max(self.max_maintenance_margin, float(maintenance_margin)) + + day = self.last_timestamp_ns // 86_400_000_000_000 + if self.daily_day is not None and day != self.daily_day: + self._close_day() + self.daily_day = day + self.daily_close = value + + def finish(self, timestamps: pd.DatetimeIndex) -> Dict[str, float]: + self._close_day() + if self.daily_dd_run > 0: + self.daily_dd_runs.append(self.daily_dd_run) + self.daily_dd_run = 0 + + use_daily = self.daily_points >= 2 + count = self.daily_points - 1 if use_daily else self.bar_count + mean = self.daily_mean if use_daily else self.bar_mean + m2 = self.daily_m2 if use_daily else self.bar_m2 + downside_sq = self.daily_downside_sq if use_daily else self.bar_downside_sq + downside_count = self.daily_downside_count if use_daily else self.bar_downside_count + gain = self.daily_gain if use_daily else self.bar_gain + loss = self.daily_loss if use_daily else self.bar_loss + win_sum = self.daily_win_sum if use_daily else self.bar_win_sum + win_count = self.daily_win_count if use_daily else self.bar_win_count + loss_sum = self.daily_loss_sum if use_daily else self.bar_loss_sum + loss_count = self.daily_loss_count if use_daily else self.bar_loss_count + + if use_daily: + periods = float(self.trading_days) + else: + ns = np.asarray(timestamps.view("int64"), dtype=np.int64) + deltas = np.diff(ns).astype(np.float64) / 1_000_000_000.0 + deltas = deltas[deltas > 0.0] + median_seconds = float(np.median(deltas)) if len(deltas) else 0.0 + periods = 365.25 * 24.0 * 60.0 * 60.0 / median_seconds if median_seconds > 0.0 else float(self.trading_days) + + std = float(np.sqrt(m2 / (count - 1))) if count >= 2 and m2 > 0.0 else 0.0 + sharpe_value = float(mean / std * np.sqrt(periods)) if std > 0.0 else 0.0 + downside = float(np.sqrt(downside_sq / downside_count)) if downside_count > 0 else 0.0 + sortino_value = float(mean / downside * np.sqrt(periods)) if downside > 0.0 else (np.inf if mean > 0.0 else 0.0) + omega_value = float(gain / loss) if loss > 0.0 else np.inf + pf_value = omega_value + elapsed_days = 0.0 + if len(timestamps) >= 2: + elapsed_days = (timestamps[-1] - timestamps[0]).total_seconds() / 86_400.0 + years = elapsed_days / 365.25 if elapsed_days > 0.0 else 0.0 + total_ret = (self.last_equity - self.initial_capital) / self.initial_capital + if 0.0 < elapsed_days < 1.0: + cagr_value = total_ret + elif years <= 0.0: + cagr_value = 0.0 + elif self.first_equity is None or self.last_equity / self.first_equity <= 0.0: + cagr_value = -1.0 + else: + annual_log = np.log(self.last_equity / self.first_equity) / years + cagr_value = float(np.expm1(np.clip(annual_log, -50.0, 50.0))) + long_hr = np.divide(self.long_wins, self.long_total, out=np.zeros_like(self.long_wins, dtype=np.float64), where=self.long_total != 0) * 100.0 + short_hr = np.divide(self.short_wins, self.short_total, out=np.zeros_like(self.short_wins, dtype=np.float64), where=self.short_total != 0) * 100.0 + avg_win = win_sum / win_count * 100.0 if win_count else 0.0 + avg_loss = loss_sum / loss_count * 100.0 if loss_count else 0.0 + hit_rate = (float(np.mean(long_hr)) + float(np.mean(short_hr))) / 200.0 + avg_dd = self.drawdown_sum / self.drawdown_count if self.drawdown_count else 0.0 + max_duration = max(self.daily_dd_runs) if self.daily_dd_runs else 0 + avg_duration = float(np.mean(self.daily_dd_runs)) if self.daily_dd_runs else 0.0 + return { + "initial_capital": float(self.initial_capital), + "final_equity": float(self.last_equity), + "total_return_pct": float(total_ret * 100.0), + "cagr_pct": float(cagr_value * 100.0), + "sharpe": sharpe_value, + "sortino": sortino_value, + "calmar": float(cagr_value / self.max_drawdown) if self.max_drawdown > 0.0 else 0.0, + "omega": omega_value, + "max_drawdown_pct": float(self.max_drawdown * 100.0), + "avg_drawdown_pct": float(avg_dd * 100.0), + "max_dd_duration_days": int(max_duration), + "avg_dd_duration_days": int(avg_duration), + "profit_factor": pf_value, + "long_hitrate_pct": float(np.mean(long_hr)), + "short_hitrate_pct": float(np.mean(short_hr)), + "avg_win_pct": float(avg_win), + "avg_loss_pct": float(avg_loss), + "expectancy_pct": float(hit_rate * avg_win + (1.0 - hit_rate) * avg_loss), + "num_trades": int(self.trade_count), + } + + class _NativeEventReactiveSession: """ Lightweight per-bar state used only to feed reactive strategy callbacks. @@ -346,14 +749,21 @@ def __init__( maintenance_ratio: float, slippage: float, use_funding: bool, + retain_terminal_orders: bool = True, + score_requirements: Optional[NativeEventScoreRequirements] = None, ) -> None: self.idx = idx self.symbols = symbols + self.symbols_tuple = tuple(symbols) + self.n_symbols = len(symbols) self.symbol_to_col = {symbol: j for j, symbol in enumerate(symbols)} self.market_arrays = market_arrays self.opens_arr = opens_arr self.volumes_arr = volumes_arr self.constraints = constraints + # Quantity policy is immutable for a session. Cache the decision once + # so score/research loops do not scan every constraint array per bar. + self.constraints_enabled = bool(constraints.enabled) self.contract_sizes = contract_sizes self.leverages = leverages self.fee_rates = fee_rates @@ -361,6 +771,40 @@ def __init__( self.maintenance_ratio = float(maintenance_ratio) self.slippage = float(slippage) self.use_funding = bool(use_funding) + self.retain_terminal_orders = bool(retain_terminal_orders) + self.score_requirements = score_requirements + self.retain_fill_ledger = bool( + score_requirements is None or score_requirements.need_fill_ledger + ) + self.retain_event_ledger = bool( + score_requirements is None or score_requirements.need_event_ledger + ) + self.emit_context_fills = bool( + score_requirements is None or score_requirements.need_context_fills + ) + self.emit_context_events = bool( + score_requirements is None or score_requirements.need_context_events + ) + self.emit_context_active_orders = bool( + score_requirements is None or score_requirements.need_context_active_orders + ) + self.emit_context_positions = bool( + score_requirements is None or score_requirements.need_context_positions + ) + self.emit_context_margin = bool( + score_requirements is None or score_requirements.need_context_margin + ) + self.compact_score_state = bool( + score_requirements is not None + and not score_requirements.need_context_fills + and not score_requirements.need_context_events + and not score_requirements.need_context_active_orders + and not score_requirements.need_context_positions + and not score_requirements.need_context_margin + and not score_requirements.need_fill_ledger + and not score_requirements.need_event_ledger + and not score_requirements.need_terminal_orders + ) self.current_pos = np.zeros(len(symbols), dtype=np.float64) self.equity = float(initial_capital) @@ -374,20 +818,63 @@ def __init__( self.scheduled: Dict[int, List[OrderCommand]] = {} self.fills_by_bar: Dict[int, List[NativeFillEvent]] = {} self.events_by_bar: Dict[int, List[NativeOrderEvent]] = {} + self.fills: List[NativeFillEvent] = [] + self.events: List[NativeOrderEvent] = [] + self.fill_count = 0 + self.event_count = 0 + self.rejected_count = 0 + self.canceled_count = 0 + self.expired_count = 0 + self.total_fee = 0.0 + self.total_funding = 0.0 + self.total_turnover = 0.0 + self.children_by_parent_id: Dict[str, List[_ReactiveOrderState]] = {} + self.members_by_oco_group: Dict[str, List[_ReactiveOrderState]] = {} + self.expiry_by_bar: Dict[int, List[_ReactiveOrderState]] = {} self.processed_bar = -1 self.last_initial_margin = 0.0 self.last_maintenance_margin = 0.0 + self.margin_bar = -1 + self.margin_dirty = True + self.size_helper = NativeEventBackend._reactive_size_helper( + symbols=self.symbols, + constraints=self.constraints, + contract_sizes=self.contract_sizes, + ) + self.empty_fills: tuple[NativeFillEvent, ...] = () + self.empty_events: tuple[NativeOrderEvent, ...] = () + self.empty_active_orders: tuple[NativeActiveOrderSnapshot, ...] = () + self._active_snapshot_cache: tuple[NativeActiveOrderSnapshot, ...] = self.empty_active_orders + self._active_snapshot_dirty = True + self.execution_counters = { + "bars_processed": 0, + "bars_with_commands": 0, + "contexts_materialized": 0, + "timestamp_objects_materialized": 0, + "active_snapshot_materializations": 0, + "empty_command_batches_skipped": 0, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 0, + "commands_retimed": 0, + "commands_quantized": 0, + } n_bars = len(idx) n_syms = len(symbols) - self.equity_path = np.zeros(n_bars, dtype=np.float64) - self.pos_path = np.zeros((n_bars, n_syms), dtype=np.float64) - self.fee_path = np.zeros(n_bars, dtype=np.float64) - self.turnover_path = np.zeros(n_bars, dtype=np.float64) - self.funding_path = np.zeros(n_bars, dtype=np.float64) - self.initial_margin_path = np.zeros(n_bars, dtype=np.float64) - self.maintenance_margin_path = np.zeros(n_bars, dtype=np.float64) - self.rejected_bar = np.zeros(n_bars, dtype=np.int64) - self.canceled_bar = np.zeros(n_bars, dtype=np.int64) + requirements = score_requirements + self.equity_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_equity_path else None + self.pos_path = np.zeros((n_bars, n_syms), dtype=np.float64) if requirements is None or requirements.need_position_path else None + self.fee_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_fee_path else None + self.turnover_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_turnover_path else None + self.funding_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_funding_path else None + self.initial_margin_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_margin_path else None + self.maintenance_margin_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_margin_path else None + self.rejected_bar = np.zeros(n_bars, dtype=np.int64) if requirements is None or requirements.need_rejection_path else None + self.canceled_bar = np.zeros(n_bars, dtype=np.int64) if requirements is None or requirements.need_cancellation_path else None + self.online_score = ( + _OnlineScoreState(self.initial_capital, n_syms) + if requirements is not None and requirements.need_trade_stats + else None + ) self._record_bar(0) def schedule(self, bar: int, commands: Sequence[OrderCommand]) -> None: @@ -395,43 +882,59 @@ def schedule(self, bar: int, commands: Sequence[OrderCommand]) -> None: return self.scheduled.setdefault(int(bar), []).extend(commands) + def release_bar_payload(self, bar: int) -> None: + self.fills_by_bar.pop(int(bar), None) + self.events_by_bar.pop(int(bar), None) + def process_bar(self, bar: int) -> None: if bar <= self.processed_bar: return for i in range(self.processed_bar + 1, int(bar) + 1): self._process_single_bar(i) self.processed_bar = i + self.execution_counters["bars_processed"] += 1 def context(self, bar: int) -> NativeStrategyContext: self.process_bar(bar) - init_margin, maint_margin = self._close_margin(bar) - self.last_initial_margin = init_margin - self.last_maintenance_margin = maint_margin - positions = {symbol: float(self.current_pos[j]) for j, symbol in enumerate(self.symbols)} - size_helper = NativeEventBackend._reactive_size_helper( - symbols=self.symbols, - constraints=self.constraints, - contract_sizes=self.contract_sizes, - ) + self.execution_counters["contexts_materialized"] += 1 + self.execution_counters["timestamp_objects_materialized"] += 1 + init_margin, maint_margin = self._refresh_close_margin(bar) + if self.emit_context_positions and self.n_symbols == 1: + positions = {self.symbols[0]: float(self.current_pos[0])} + elif self.emit_context_positions: + positions = {symbol: float(self.current_pos[j]) for j, symbol in enumerate(self.symbols)} + else: + positions = {} + if self.emit_context_fills: + fills_this_bar = tuple(self.fills_by_bar.get(int(bar), self.empty_fills)) + else: + fills_this_bar = self.empty_fills + if self.emit_context_events: + events_this_bar = tuple(self.events_by_bar.get(int(bar), self.empty_events)) + else: + events_this_bar = self.empty_events + if not self.emit_context_margin: + init_margin = 0.0 + maint_margin = 0.0 return NativeStrategyContext( bar_index=int(bar), timestamp=self.idx[int(bar)], - open=np.ascontiguousarray(self.opens_arr[int(bar)].copy()), - high=np.ascontiguousarray(self.market_arrays.highs[int(bar)].copy()), - low=np.ascontiguousarray(self.market_arrays.lows[int(bar)].copy()), - close=np.ascontiguousarray(self.market_arrays.closes[int(bar)].copy()), - volume=np.ascontiguousarray(self.volumes_arr[int(bar)].copy()), + open=self.opens_arr[int(bar)], + high=self.market_arrays.highs[int(bar)], + low=self.market_arrays.lows[int(bar)], + close=self.market_arrays.closes[int(bar)], + volume=self.volumes_arr[int(bar)], equity=float(self.equity), available_equity=float(self.equity - init_margin), initial_margin=float(init_margin), maintenance_margin=float(maint_margin), positions=positions, - fills_this_bar=tuple(self.fills_by_bar.get(int(bar), ())), - order_events_this_bar=tuple(self.events_by_bar.get(int(bar), ())), - active_orders=tuple(self._active_snapshots()), + fills_this_bar=fills_this_bar, + order_events_this_bar=events_this_bar, + active_orders=self._active_snapshots() if self.emit_context_active_orders else self.empty_active_orders, liquidated=bool(self.liquidated), - symbols=tuple(self.symbols), - size_order=size_helper, + symbols=self.symbols_tuple, + size_order=self.size_helper, ) def _process_single_bar(self, bar: int) -> None: @@ -463,20 +966,22 @@ def _process_single_bar(self, bar: int) -> None: * self.market_arrays.funding[bar, s] ) self.equity -= funding_cost - self.funding_path[bar] += funding_cost + self.total_funding += float(funding_cost) + if self.funding_path is not None: + self.funding_path[bar] += funding_cost if bar > 0: - _, close_mm = self._close_margin(bar) + _, close_mm = self._refresh_close_margin(bar) if close_mm > 0.0 and self.equity <= close_mm: self._liquidate(bar, LIQ_AFTER_FUNDING) self._record_bar(bar) return self._expire_orders(bar) - for command in self.scheduled.get(bar, ()): + for command in self.scheduled.pop(bar, ()): self._apply_command(bar, command) self._match_orders(bar) self._compact_pending() - _, close_mm = self._close_margin(bar) + _, close_mm = self._refresh_close_margin(bar) if close_mm > 0.0 and self.equity <= close_mm: self._liquidate(bar, LIQ_AFTER_ORDER) self._record_bar(bar) @@ -484,13 +989,24 @@ def _process_single_bar(self, bar: int) -> None: def _record_bar(self, bar: int) -> None: if bar < 0 or bar >= len(self.idx): return - init_margin, maint_margin = self._close_margin(bar) - self.equity_path[bar] = float(self.equity) - self.pos_path[bar, :] = self.current_pos - self.initial_margin_path[bar] = float(init_margin) - self.maintenance_margin_path[bar] = float(maint_margin) - self.last_initial_margin = float(init_margin) - self.last_maintenance_margin = float(maint_margin) + init_margin, maint_margin = self._refresh_close_margin(bar) + if self.equity_path is not None: + self.equity_path[bar] = float(self.equity) + if self.pos_path is not None: + self.pos_path[bar, :] = self.current_pos + if self.initial_margin_path is not None: + self.initial_margin_path[bar] = float(init_margin) + if self.maintenance_margin_path is not None: + self.maintenance_margin_path[bar] = float(maint_margin) + if self.online_score is not None and self.online_score.last_observed_bar != int(bar): + self.online_score.observe( + self.idx.asi8[bar], + self.equity, + self.current_pos, + init_margin, + maint_margin, + ) + self.online_score.last_observed_bar = int(bar) def _apply_command(self, bar: int, command: OrderCommand) -> None: action = command.action @@ -502,9 +1018,9 @@ def _apply_command(self, bar: int, command: OrderCommand) -> None: self._event(bar, command, "reject", ORDER_STATUS_REJECTED, target_order_id=command.target_order_id) else: self._cancel_state(bar, target, "replace", ORDER_STATUS_CANCELED, command) - self._place_order(bar, command, "replace") - if command.target_order_id: - self.id_to_order[command.target_order_id] = self.orders[-1] + replacement = self._place_order(bar, command, "replace") + if command.target_order_id and replacement is not None: + self.id_to_order[command.target_order_id] = replacement elif action is OrderAction.CANCEL: target = self._lookup_pending(command.target_order_id) if target is None: @@ -524,19 +1040,21 @@ def _apply_command(self, bar: int, command: OrderCommand) -> None: target.working_trigger = float(command.trigger_price) self._event(bar, command, "amend", ORDER_STATUS_FILLED, target_order_id=command.target_order_id) elif action is OrderAction.CANCEL_ALL: - for target in tuple(self.pending): + targets = self.pending if self._cancel_all_unfiltered(command) else tuple(self.pending) + for target in targets: if self._is_pending(target) and self._cancel_all_matches(command, target.command): self._cancel_state(bar, target, "cancel", ORDER_STATUS_CANCELED, command) self._event(bar, command, "cancel", ORDER_STATUS_FILLED) else: self._event(bar, command, "reject", ORDER_STATUS_REJECTED) - def _place_order(self, bar: int, command: OrderCommand, event_name: str) -> None: + def _place_order(self, bar: int, command: OrderCommand, event_name: str) -> Optional[_ReactiveOrderState]: if command.symbol is None or command.symbol not in self.symbol_to_col: self._event(bar, command, "reject", ORDER_STATUS_REJECTED) - return + return None + stored_command = _compact_score_command(command) if self.compact_score_state else command state = _ReactiveOrderState( - command=command, + command=stored_command, command_index=self.command_seq, symbol_col=self.symbol_to_col[command.symbol], active=command.activation_policy is OrderActivationPolicy.IMMEDIATE, @@ -546,11 +1064,22 @@ def _place_order(self, bar: int, command: OrderCommand, event_name: str) -> None working_trigger=0.0 if command.trigger_price is None else float(command.trigger_price), ) self.command_seq += 1 - self.orders.append(state) self.pending.append(state) + if self.retain_terminal_orders: + self.orders.append(state) if command.order_id: self.id_to_order[command.order_id] = state + if command.parent_order_id: + self.children_by_parent_id.setdefault(command.parent_order_id, []).append(state) + if command.oco_group_id: + self.members_by_oco_group.setdefault(command.oco_group_id, []).append(state) + if command.expires_at is not None: + expiry_bar = max(self._expiry_bar(command.expires_at), int(bar) + 1) + if 0 <= expiry_bar < len(self.idx): + self.expiry_by_bar.setdefault(expiry_bar, []).append(state) + self._active_snapshot_dirty = True self._event(bar, command, event_name, ORDER_STATUS_PENDING) + return state def _match_orders(self, bar: int) -> None: for state in tuple(self.pending): @@ -592,37 +1121,46 @@ def _match_orders(self, bar: int) -> None: required, cur_im = self._margin_required(bar, state.symbol_col, delta, float(exec_price), fee_cost) if required > self.equity - cur_im: state.status = ORDER_STATUS_REJECTED - state.active = False - state.waiting_parent = False state.reject_code = REJECT_INSUFFICIENT_MARGIN self._event(bar, command, "reject", ORDER_STATUS_REJECTED) + self._terminalize_state(state) continue self.equity += delta * (close - float(exec_price)) * cs - fee_cost self.current_pos[state.symbol_col] += delta - self.fee_path[bar] += fee_cost - self.turnover_path[bar] += trade_notional + self.margin_dirty = True + if self.fee_path is not None: + self.fee_path[bar] += fee_cost + if self.turnover_path is not None: + self.turnover_path[bar] += trade_notional + self.total_fee += float(fee_cost) + self.total_turnover += float(trade_notional) state.status = ORDER_STATUS_FILLED - state.active = False - state.waiting_parent = False - fill = NativeFillEvent( - timestamp=self.idx[bar], - symbol=command.symbol or self.symbols[state.symbol_col], - side=command.side, - qty=float(qty), - price=float(exec_price), - fee=float(fee_cost), - order_id=command.order_id, - tag=command.tag, - campaign_id=command.metadata.get("campaign_id"), - cycle_id=command.metadata.get("cycle_id"), - level_id=command.metadata.get("level_id"), - parent_order_id=command.parent_order_id, - oco_group_id=command.oco_group_id, - metadata=dict(command.metadata), - ) - self.fills_by_bar.setdefault(bar, []).append(fill) + fill = None + if self.emit_context_fills or self.retain_fill_ledger: + fill = NativeFillEvent( + timestamp=self.idx[bar], + symbol=command.symbol or self.symbols[state.symbol_col], + side=command.side, + qty=float(qty), + price=float(exec_price), + fee=float(fee_cost), + order_id=command.order_id, + tag=command.tag, + campaign_id=command.metadata.get("campaign_id"), + cycle_id=command.metadata.get("cycle_id"), + level_id=command.metadata.get("level_id"), + parent_order_id=command.parent_order_id, + oco_group_id=command.oco_group_id, + metadata=dict(command.metadata), + ) + if self.emit_context_fills: + self.fills_by_bar.setdefault(bar, []).append(fill) + self.fill_count += 1 + if self.retain_fill_ledger and fill is not None: + self.fills.append(fill) self._event(bar, command, "fill", ORDER_STATUS_FILLED) + self._terminalize_state(state) self._activate_children(bar, state) self._cancel_oco_siblings(bar, state) @@ -630,7 +1168,8 @@ def _activate_children(self, bar: int, parent: _ReactiveOrderState) -> None: parent_id = parent.command.order_id if not parent_id: return - for child in tuple(self.pending): + children = self.children_by_parent_id.get(parent_id, ()) + for child in tuple(children): if child.waiting_parent and child.command.parent_order_id == parent_id: if child.command.activation_policy in ( OrderActivationPolicy.ON_PARENT_FIRST_FILL, @@ -638,30 +1177,31 @@ def _activate_children(self, bar: int, parent: _ReactiveOrderState) -> None: ): child.waiting_parent = False child.active = True + self._active_snapshot_dirty = True self._event(bar, child.command, "activate", ORDER_STATUS_PENDING, related_order_id=parent_id) + self.children_by_parent_id[parent_id] = [child for child in children if self._is_pending(child)] + if not self.children_by_parent_id[parent_id]: + self.children_by_parent_id.pop(parent_id, None) def _cancel_oco_siblings(self, bar: int, filled: _ReactiveOrderState) -> None: group = filled.command.oco_group_id if not group: return - for sibling in tuple(self.pending): + siblings = self.members_by_oco_group.get(group, ()) + for sibling in tuple(siblings): if sibling is filled: continue if self._is_pending(sibling) and sibling.command.oco_group_id == group: self._cancel_state(bar, sibling, "cancel", ORDER_STATUS_CANCELED, filled.command) + self.members_by_oco_group[group] = [sibling for sibling in siblings if self._is_pending(sibling)] + if not self.members_by_oco_group[group]: + self.members_by_oco_group.pop(group, None) def _expire_orders(self, bar: int) -> None: - ts = self.idx[bar] - for state in tuple(self.pending): + for state in tuple(self.expiry_by_bar.pop(int(bar), ())): if not self._is_pending(state) or state.command.expires_at is None: continue - exp = pd.Timestamp(state.command.expires_at) - if exp.tz is None: - exp = exp.tz_localize("UTC") - else: - exp = exp.tz_convert("UTC") - if ts.value >= exp.value: - self._cancel_state(bar, state, "expire", ORDER_STATUS_CANCELED, state.command) + self._cancel_state(bar, state, "expire", ORDER_STATUS_CANCELED, state.command) def _cancel_state( self, @@ -674,7 +1214,9 @@ def _cancel_state( state.active = False state.waiting_parent = False state.status = ORDER_STATUS_CANCELED - self.canceled_bar[bar] += 1 + self.canceled_count += 1 + if self.canceled_bar is not None: + self.canceled_bar[bar] += 1 self._event( bar, command, @@ -683,6 +1225,7 @@ def _cancel_state( target_order_id=state.command.order_id, related_order_id=state.command.order_id, ) + self._terminalize_state(state) def _event( self, @@ -695,9 +1238,14 @@ def _event( related_order_id: Optional[str] = None, ) -> None: if event_name == "reject": - self.rejected_bar[bar] += 1 - self.events_by_bar.setdefault(bar, []).append( - NativeOrderEvent( + self.rejected_count += 1 + if self.rejected_bar is not None: + self.rejected_bar[bar] += 1 + if event_name == "expire": + self.expired_count += 1 + event = None + if self.emit_context_events or self.retain_event_ledger: + event = NativeOrderEvent( timestamp=self.idx[bar], bar=int(bar), event_name=event_name, @@ -713,7 +1261,11 @@ def _event( original_index=-1, related_original_index=-1, ) - ) + if self.emit_context_events and event is not None: + self.events_by_bar.setdefault(bar, []).append(event) + self.event_count += 1 + if self.retain_event_ledger and event is not None: + self.events.append(event) def _lookup_pending(self, order_id: Optional[str]) -> Optional[_ReactiveOrderState]: if not order_id: @@ -727,7 +1279,44 @@ def _lookup_pending(self, order_id: Optional[str]) -> Optional[_ReactiveOrderSta def _is_pending(state: _ReactiveOrderState) -> bool: return state.status == ORDER_STATUS_PENDING and (state.active or state.waiting_parent) - def _active_snapshots(self) -> List[NativeActiveOrderSnapshot]: + def _terminalize_state(self, state: _ReactiveOrderState) -> None: + state.active = False + state.waiting_parent = False + order_id = state.command.order_id + if order_id and self.id_to_order.get(order_id) is state: + self.id_to_order.pop(order_id, None) + parent_id = state.command.parent_order_id + if parent_id and parent_id in self.children_by_parent_id: + children = [child for child in self.children_by_parent_id[parent_id] if child is not state and self._is_pending(child)] + if children: + self.children_by_parent_id[parent_id] = children + else: + self.children_by_parent_id.pop(parent_id, None) + group = state.command.oco_group_id + if group and group in self.members_by_oco_group: + members = [member for member in self.members_by_oco_group[group] if member is not state and self._is_pending(member)] + if members: + self.members_by_oco_group[group] = members + else: + self.members_by_oco_group.pop(group, None) + self._active_snapshot_dirty = True + + def _expiry_bar(self, expires_at) -> int: + exp = pd.Timestamp(expires_at) + if exp.tz is None: + exp = exp.tz_localize("UTC") + else: + exp = exp.tz_convert("UTC") + return int(self.idx.searchsorted(exp, side="left")) + + def _active_snapshots(self) -> tuple[NativeActiveOrderSnapshot, ...]: + if not self.pending: + self._active_snapshot_cache = self.empty_active_orders + self._active_snapshot_dirty = False + return self.empty_active_orders + if not self._active_snapshot_dirty: + return self._active_snapshot_cache + self.execution_counters["active_snapshot_materializations"] += 1 out: List[NativeActiveOrderSnapshot] = [] for state in self.pending: if not self._is_pending(state): @@ -753,9 +1342,14 @@ def _active_snapshots(self) -> List[NativeActiveOrderSnapshot]: level_id=command.metadata.get("level_id"), ) ) - return out - - def _close_margin(self, bar: int) -> tuple[float, float]: + self._active_snapshot_cache = tuple(out) if out else self.empty_active_orders + self._active_snapshot_dirty = False + return self._active_snapshot_cache + + def _refresh_close_margin(self, bar: int) -> tuple[float, float]: + bar = int(bar) + if not self.margin_dirty and self.margin_bar == bar: + return self.last_initial_margin, self.last_maintenance_margin init_margin = 0.0 maint_margin = 0.0 for s in range(len(self.symbols)): @@ -764,10 +1358,17 @@ def _close_margin(self, bar: int) -> tuple[float, float]: notional = abs(p) * self.market_arrays.closes[bar, s] * self.contract_sizes[s] init_margin += notional / self.leverages[s] maint_margin += notional * self.maintenance_ratio - return float(init_margin), float(maint_margin) + self.last_initial_margin = float(init_margin) + self.last_maintenance_margin = float(maint_margin) + self.margin_bar = bar + self.margin_dirty = False + return self.last_initial_margin, self.last_maintenance_margin + + def _close_margin(self, bar: int) -> tuple[float, float]: + return self._refresh_close_margin(bar) def _margin_required(self, bar: int, sym: int, delta: float, exec_price: float, fee_cost: float) -> tuple[float, float]: - cur_im, _ = self._close_margin(bar) + cur_im, _ = self._refresh_close_margin(bar) close = float(self.market_arrays.closes[bar, sym]) old_im = abs(self.current_pos[sym]) * close * self.contract_sizes[sym] / self.leverages[sym] new_im = abs(self.current_pos[sym] + delta) * exec_price * self.contract_sizes[sym] / self.leverages[sym] @@ -795,6 +1396,8 @@ def _liquidate(self, bar: int, reason: int) -> None: self.liquidation_reason = int(reason) self.equity = 0.0 self.current_pos[:] = 0.0 + self.margin_dirty = True + self._active_snapshot_dirty = True def _touched_price( self, @@ -825,6 +1428,20 @@ def _touched_price( return True, float(price) return False, float(close) + @staticmethod + def _cancel_all_unfiltered(command: OrderCommand) -> bool: + return ( + command.symbol is None + and command.side is None + and command.order_type is None + and command.parent_order_id is None + and command.group_id is None + and command.oco_group_id is None + and command.tag is None + and command.tag_prefix is None + and not command.metadata + ) + @staticmethod def _cancel_all_matches(cancel_command: OrderCommand, target: OrderCommand) -> bool: if cancel_command.symbol is not None and cancel_command.symbol != target.symbol: @@ -852,6 +1469,7 @@ def _compact_pending(self) -> None: if not self.pending: return self.pending = [state for state in self.pending if self._is_pending(state)] + self._active_snapshot_dirty = True class NativeEventBackend: @@ -865,6 +1483,49 @@ class NativeEventBackend: def __init__(self, config: NativeEventConfig): self.config = config + # Phase 46E: selection is explicit and capability-gated. ``auto`` + # remains Python for the release; direct Rust is limited to the + # certified single-symbol batched tape path. + self._backend_selection = resolve_native_event_backend(requested=config.native_backend) + # Keys use object identity in addition to the immutable market + # signature: open/volume are callback-visible and are not part of the + # OHLC/funding signature. Reuse is therefore safe only for the exact + # prepared arrays owned by one prepared runner. + self._rust_prepared_market_cores: Dict[tuple, object] = {} + + def _create_reactive_session( + self, + *, + backend_selection: NativeEventBackendSelection, + **kwargs, + ) -> _NativeEventReactiveSession | RustReactiveSessionAdapter: + """Create the selected reactive session without changing endpoint APIs. + + Rust's per-bar adapter remains a correctness/debug path. Unsupported + execution semantics fail explicitly under backend='rust' rather than + silently switching domain behavior. + """ + if backend_selection.resolved == "rust": + market_arrays = kwargs["market_arrays"] + key = (market_arrays.signature, id(kwargs["opens_arr"]), id(kwargs["volumes_arr"])) + kwargs["prepared_market_core"] = self._rust_prepared_market_cores.get(key) + session = RustReactiveSessionAdapter(**kwargs) + prepared_core = getattr(session, "prepared_market_core", None) + if prepared_core is not None: + self._rust_prepared_market_cores.setdefault(key, prepared_core) + return session + return _NativeEventReactiveSession(**kwargs) + + def _backend_selection_metadata(self) -> dict: + selection = self._backend_selection + return { + "native_event_backend_requested": selection.requested, + "native_event_backend_resolved": selection.resolved, + "native_event_rust_available": bool(selection.extension.available), + "native_event_rust_compatible": bool(selection.extension.compatible), + "native_event_rust_capabilities": dict(selection.extension.capabilities), + "native_event_rust_canonical_capabilities": dict(selection.extension.canonical_capabilities), + } def prepare_market_arrays( self, @@ -898,6 +1559,66 @@ def prepare_market_arrays( funding_dict=funding_dict, ) + def prepare_rust_batched_runner( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + *, + symbols: Optional[Sequence[str]] = None, + contract_size: float = 1.0, + leverage: Optional[float] = None, + fee_rate: Optional[float] = None, + initial_capital: Optional[float] = None, + maintenance_ratio: Optional[float] = None, + slippage: Optional[float] = None, + prepared_market_core=None, + ) -> RustFullRunner: + """Prepare the explicit experimental Rust full-tape runner. + + This helper does not change endpoint defaults and never accepts a + Python strategy callback. Callers compile a static ``OrderCommand`` + tape once and pass it to ``run_tape_score`` or ``run_tape_audit``. + The selected Rust 0.4 full-contract capability set is checked before + crossing the boundary. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) if symbols is not None else list(closes.keys()) + market_arrays = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate if self.config.use_funding else 0.0, + symbols=symbol_list, + ) + configured_fee = self.config.fee_rate + if isinstance(configured_fee, dict): + configured_fee = configured_fee.get(symbol_list[0], 0.0) + return RustFullRunner( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + contract_sizes=self._per_symbol_array(contract_size, symbol_list, default=1.0), + leverages=self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ), + fee_rates=self._per_symbol_array(configured_fee if fee_rate is None else fee_rate, symbol_list, default=0.0), + initial_capital=float( + self.config.account.initial_capital if initial_capital is None else initial_capital + ), + maintenance_ratio=float( + self.config.account.maintenance_ratio if maintenance_ratio is None else maintenance_ratio + ), + slippage=float(self.config.execution.slippage_rate if slippage is None else slippage), + use_funding=bool(self.config.use_funding), + prepared_market_core=prepared_market_core, + ) + @staticmethod def compile_orders( datetime_index: Union[pd.DatetimeIndex, pd.Series], @@ -964,6 +1685,7 @@ def run_order_commands( report_level: Optional[str] = None, audit_sink: Optional[str] = None, audit_sink_path: Optional[str] = None, + _force_python_backend: bool = False, ) -> BacktestResultV2: """ Execute Phase 30B lifecycle `OrderCommand` tapes through event v2. @@ -1005,6 +1727,22 @@ def run_order_commands( min_qty=min_qty, min_notional=min_notional, ) + if self._backend_selection.resolved == "rust" and not _force_python_backend: + status = self._backend_selection.extension + required = { + "native_event_v2_full_contract", + "native_event_v2_multisymbol", + "native_event_v2_funding", + "native_event_v2_liquidation", + "native_event_v2_cancel_all_oco", + "native_event_v2_tif_expiry", + "native_event_v2_relationships", + } + missing = sorted(name for name in required if not status.capabilities.get(name, False)) + if missing: + raise NativeEventRustBackendError( + "native_backend='rust' requires full-contract capabilities: " + ", ".join(missing) + ) effective_commands, quantity_preflight = self._apply_command_quantity_constraints( idx=idx, commands=commands, @@ -1031,6 +1769,45 @@ def run_order_commands( ): raise ValueError("compiled commands do not match prepared market arrays") + if self._backend_selection.resolved == "rust" and not _force_python_backend: + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + configured_fee = self.config.fee_rate if fee_rate is None else fee_rate + fee_rates = self._per_symbol_array(configured_fee, symbol_list, default=0.0) + runner = RustFullRunner( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + contract_sizes=contract_sizes, + leverages=leverages, + fee_rates=fee_rates, + initial_capital=float(self.config.account.initial_capital), + maintenance_ratio=float(self.config.account.maintenance_ratio), + slippage=float(self.config.execution.slippage_rate), + use_funding=bool(self.config.use_funding), + ) + audit = runner.run_tape_audit(compiled_commands) + result = audit.to_backtest_result( + datetime_index=idx, + closes=pd.DataFrame({symbol: market_arrays.closes[:, col] for col, symbol in enumerate(symbol_list)}, index=idx), + symbols=symbol_list, + initial_capital=float(self.config.account.initial_capital), + leverage=float(np.mean(leverages)), + metadata={ + **self._backend_selection_metadata(), + "quantity_preflight": quantity_preflight, + "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list), + "slippage_bps": self.config.execution.slippage_bps, + "rust_contract": "native_event_v2_full_contract", + "use_funding": bool(self.config.use_funding), + }, + ) + return result + leverages = self._per_symbol_array( self.config.account.leverage if leverage is None else leverage, symbol_list, @@ -1217,6 +1994,7 @@ def run_order_commands( metadata = { "backend": "native_event", "engine": "event_v2_lifecycle", + **self._backend_selection_metadata(), "report_level": level, "report_level_requested": str(requested_report_level), "artifact_plan": asdict(plan), @@ -1294,7 +2072,10 @@ def run_strategy( market_arrays: Optional[PreparedMarketArrays] = None, opens_arr: Optional[np.ndarray] = None, volumes_arr: Optional[np.ndarray] = None, - ) -> BacktestResultV2: + _score_requirements: Optional[NativeEventScoreRequirements] = None, + _return_score: bool = False, + _trading_days: int = 365, + ) -> Union[BacktestResultV2, NativeEventScoreResult]: """ Run a reactive strategy against native-event v2 lifecycle semantics. @@ -1310,12 +2091,23 @@ def run_strategy( execution_mode = str(execution_mode).lower().strip() if execution_mode not in {"fast", "audit"}: raise ValueError("execution_mode must be 'fast' or 'audit'") + backend_selection = self._backend_selection kernel_mode = _normalize_reactive_kernel_mode( self.config.reactive_kernel_mode if reactive_kernel_mode is None else reactive_kernel_mode ) + if backend_selection.resolved == "replay_certified": + kernel_mode = "replay_certified" requested_report_level = self.config.report_level if report_level is None else report_level level = _normalize_native_event_report_level(requested_report_level) plan = _native_event_artifact_plan(level) + if _return_score: + if level != "score": + raise ValueError("internal direct score execution requires report_level='score'") + if kernel_mode != "single_pass" or execution_mode != "fast": + raise ValueError("internal direct score execution requires fast single_pass mode") + score_requirements = _score_requirements or NativeEventScoreRequirements.public_score_contract() + else: + score_requirements = None idx = validate_datetime(datetime_index) symbol_list = list(symbols) if symbols is not None else list(closes.keys()) @@ -1342,6 +2134,8 @@ def run_strategy( volumes_arr = np.ascontiguousarray(volumes_arr, dtype=np.float64) if opens_arr.shape != market_arrays.closes.shape or volumes_arr.shape != market_arrays.closes.shape: raise ValueError("prepared opens/volumes arrays must match market array shape") + opens_arr.setflags(write=False) + volumes_arr.setflags(write=False) contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) constraints = build_quantity_constraints( @@ -1363,7 +2157,8 @@ def run_strategy( symbol_list, default=0.0, ) - session = _NativeEventReactiveSession( + session = self._create_reactive_session( + backend_selection=backend_selection, idx=idx, symbols=symbol_list, market_arrays=market_arrays, @@ -1377,64 +2172,185 @@ def run_strategy( maintenance_ratio=self.config.account.maintenance_ratio, slippage=self.config.execution.slippage_rate, use_funding=bool(self.config.use_funding), + retain_terminal_orders=level != "score", + score_requirements=score_requirements, ) - + execution_counters = getattr(session, "execution_counters", None) + if execution_counters is None: + execution_counters = {} + constraints_enabled = bool(getattr(session, "constraints_enabled", constraints.enabled)) + if getattr(session, "online_score", None) is not None: + session.online_score.trading_days = int(_trading_days) + + # Keep execution and audit tape distinct: next-bar semantics prohibit + # executing a final-close command, while audit still needs to preserve + # that strategy intent for replayability and review. emitted: list[OrderCommand] = [] + emitted_audit_tape: list[OrderCommand] = [] emitted_order_ids: set[str] = set() + emitted_command_count = 0 + emitted_executable_command_count = 0 callback_count = 0 ignored_commands_after_end = 0 + + def record_scheduled(commands: Sequence[OrderCommand]) -> None: + nonlocal emitted_command_count, emitted_executable_command_count + count = len(commands) + emitted_command_count += count + emitted_executable_command_count += count + if not _return_score: + emitted.extend(commands) + emitted_audit_tape.extend(commands) + + def record_outside_tape(commands: Sequence[OrderCommand]) -> None: + nonlocal emitted_command_count + emitted_command_count += len(commands) + if not _return_score: + emitted_audit_tape.extend(commands) + initial_context = session.context(0) last_context = initial_context + def quantize_reactive_schedule(commands: Sequence[OrderCommand]) -> tuple[OrderCommand, ...]: + if not commands: + if execution_counters: + execution_counters["empty_command_batches_skipped"] += 1 + return () + if not constraints_enabled: + if execution_counters: + execution_counters["constraint_preflight_skipped"] += 1 + return tuple(commands) + if execution_counters: + execution_counters["constraint_preflight_calls"] += 1 + effective, _ = self._apply_command_quantity_constraints( + idx=idx, + commands=commands, + closes=market_arrays.closes, + symbol_list=symbol_list, + contract_sizes=contract_sizes, + constraints=constraints, + ) + if execution_counters: + execution_counters["commands_quantized"] += len(commands) + return effective + + def schedule_reactive_batch( + commands: Sequence[OrderCommand], + effective_bar: int, + ) -> tuple[tuple[OrderCommand, ...], int]: + if not commands: + if execution_counters: + execution_counters["empty_command_batches_skipped"] += 1 + return (), 0 + if execution_counters: + execution_counters["bars_with_commands"] += 1 + execution_counters["commands_retimed"] += 1 + scheduled, ignored = self._retime_reactive_commands( + commands=commands, + effective_bar=effective_bar, + idx=idx, + emitted_order_ids=emitted_order_ids, + ) + if scheduled: + record_scheduled(scheduled) + session.schedule(effective_bar, quantize_reactive_schedule(scheduled)) + return scheduled, ignored + initial_commands = self._expand_scoped_cancel_all_commands( self._call_strategy_callback(strategy, "initialize", initial_context), initial_context, ) - scheduled, ignored = self._retime_reactive_commands( - commands=initial_commands, - effective_bar=1, - idx=idx, - emitted_order_ids=emitted_order_ids, - ) - emitted.extend(scheduled) - session.schedule(1, scheduled) + scheduled, ignored = schedule_reactive_batch(initial_commands, 1) ignored_commands_after_end += ignored + if ignored: + record_outside_tape( + self._record_reactive_commands_outside_tape( + commands=initial_commands, + effective_bar=1, + emitted_order_ids=emitted_order_ids, + ) + ) for bar in range(len(idx)): context = session.context(bar) last_context = context callback_count += 1 if context.liquidated: + session.release_bar_payload(bar) break commands = self._expand_scoped_cancel_all_commands( self._call_strategy_callback(strategy, "on_bar_close", context), context, ) - scheduled, ignored = self._retime_reactive_commands( - commands=commands, - effective_bar=bar + 1, - idx=idx, - emitted_order_ids=emitted_order_ids, - ) - emitted.extend(scheduled) - session.schedule(bar + 1, scheduled) + session.release_bar_payload(bar) + scheduled, ignored = schedule_reactive_batch(commands, bar + 1) ignored_commands_after_end += ignored + if ignored: + record_outside_tape( + self._record_reactive_commands_outside_tape( + commands=commands, + effective_bar=bar + 1, + emitted_order_ids=emitted_order_ids, + ) + ) if last_context is not None and not last_context.liquidated: final_commands = self._expand_scoped_cancel_all_commands( self._call_strategy_callback(strategy, "finalize", last_context), last_context, ) - scheduled, ignored = self._retime_reactive_commands( - commands=final_commands, - effective_bar=len(idx), - idx=idx, - emitted_order_ids=emitted_order_ids, - ) - emitted.extend(scheduled) + if final_commands: + if execution_counters: + execution_counters["bars_with_commands"] += 1 + execution_counters["commands_retimed"] += 1 + scheduled, ignored = self._retime_reactive_commands( + commands=final_commands, + effective_bar=len(idx), + idx=idx, + emitted_order_ids=emitted_order_ids, + ) + else: + scheduled, ignored = (), 0 + record_scheduled(scheduled) ignored_commands_after_end += ignored + if ignored: + record_outside_tape( + self._record_reactive_commands_outside_tape( + commands=final_commands, + effective_bar=len(idx), + emitted_order_ids=emitted_order_ids, + ) + ) replay_required = kernel_mode == "replay_certified" or level in {"standard", "audit"} or execution_mode == "audit" + if _return_score: + return self._reactive_session_score_result( + session=session, + symbol_list=symbol_list, + leverages=leverages, + requirements=score_requirements, + trading_days=_trading_days, + metadata={ + "backend": "native_event", + "engine": "event_v2_reactive_score", + "report_level": "score", + "artifact_plan": asdict(plan), + "score_requirements": asdict(score_requirements), + "reactive_execution_mode": execution_mode, + "reactive_kernel_mode": kernel_mode, + "command_effective_phase": "next_bar", + "emitted_command_count": int(emitted_command_count), + "emitted_executable_command_count": int(emitted_executable_command_count), + "ignored_commands_after_end": int(ignored_commands_after_end), + "strategy_callback_count": int(callback_count), + "static_replay_available": False, + "reactive_static_replay_count": 0, + "reactive_session_liquidated": bool(session.liquidated), + "reactive_session_liquidation_bar": int(session.liquidation_bar), + "execution_counters": dict(getattr(session, "execution_counters", {})), + **self._backend_selection_metadata(), + }, + ) replay_result = None if replay_required: replay_result = self.run_order_commands( @@ -1458,6 +2374,7 @@ def run_strategy( report_level=level, audit_sink=audit_sink, audit_sink_path=audit_sink_path, + _force_python_backend=True, ) if kernel_mode == "replay_certified": final_result = replay_result @@ -1480,12 +2397,14 @@ def run_strategy( final_result.metadata.update( { "engine": engine_name, + **self._backend_selection_metadata(), "reactive_execution_mode": execution_mode, "reactive_kernel_mode": kernel_mode, "command_effective_phase": "next_bar", - "emitted_command_tape": tuple(emitted) if plan.keep_command_tape else (), + "emitted_command_tape": tuple(emitted_audit_tape) if plan.keep_command_tape else (), "emitted_command_tape_retained": bool(plan.keep_command_tape), - "emitted_command_count": len(emitted), + "emitted_command_count": int(emitted_command_count), + "emitted_executable_command_count": int(emitted_executable_command_count), "ignored_commands_after_end": int(ignored_commands_after_end), "strategy_callback_count": int(callback_count), "static_replay_available": bool(replay_result is not None), @@ -1496,6 +2415,9 @@ def run_strategy( "reactive_session_liquidation_bar": int(session.liquidation_bar), } ) + if backend_selection.resolved == "rust": + final_result.metadata["rust_r1_session_fills"] = tuple(session.fills) if plan.materialize_python_objects else () + final_result.metadata["rust_r1_session_events"] = tuple(session.events) if plan.keep_event_ledger else () if execution_mode == "audit" and replay_result is not None: replay_last_pos = { symbol: float(replay_result.positions[f"Position_{symbol}"].iloc[-1]) @@ -1511,6 +2433,231 @@ def run_strategy( } return final_result + def run_strategy_score( + self, + *args, + trading_days: int = 365, + score_requirements: Optional[NativeEventScoreRequirements] = None, + **kwargs, + ) -> Union[NativeEventScoreResult, NativeEventScalarScoreResult]: + """Execute a prepared reactive score without pandas/result materialization. + + This is an internal prepared-runner path. Public ``run_strategy`` keeps + returning ``BacktestResultV2`` for every report level, including + ``score``; callers that need an audit trace must use that public path. + """ + kwargs.update( + { + "reactive_kernel_mode": "single_pass", + "report_level": "score", + "audit_sink": "none", + "_score_requirements": score_requirements, + "_return_score": True, + "_trading_days": int(trading_days), + } + ) + result = self.run_strategy(*args, **kwargs) + if not isinstance(result, (NativeEventScoreResult, NativeEventScalarScoreResult)): # pragma: no cover + raise TypeError("native-event direct score did not return a native-event score result") + return result + + def run_compiled_tape_score( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + compiled_commands: CompiledOrderCommandArrays, + *, + market_arrays: PreparedMarketArrays, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + initial_capital: Optional[float] = None, + maintenance_ratio: Optional[float] = None, + slippage: Optional[float] = None, + use_funding: Optional[bool] = None, + trading_days: int = 365, + ) -> NativeEventScalarScoreResult: + """Run a prepared static command tape and retain scalar state only. + + This is the Python-side apples-to-apples score contract for the Rust + batched runner. It accepts already prepared market arrays and compiled + commands, schedules the existing lifecycle commands without pandas + reports or full ledgers, and returns the same scalar accounting fields + as :class:`RustBatchedScoreResult` via the result properties and + metadata. + + The method is intentionally internal-facing: quantity preflight and + capability validation must happen before compiling the tape. It does + not change the public endpoint default or the audit ``run_orders`` + contract. + """ + if market_arrays is None: + raise ValueError("run_compiled_tape_score requires prepared market_arrays") + idx = validate_datetime(datetime_index) + symbol_list = list(compiled_commands.symbols) + if not symbol_list: + raise ValueError("compiled command tape must contain at least one symbol") + if market_arrays.signature != self._market_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + if compiled_commands.index_signature != market_arrays.signature: + raise ValueError("compiled commands do not match prepared market arrays") + + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + configured_fee = self.config.fee_rate if fee_rate is None else fee_rate + fee_rates = self._per_symbol_array(configured_fee, symbol_list, default=0.0) + initial = float(self.config.account.initial_capital if initial_capital is None else initial_capital) + maint = float( + self.config.account.maintenance_ratio if maintenance_ratio is None else maintenance_ratio + ) + slip = float(self.config.execution.slippage_rate if slippage is None else slippage) + funding_enabled = bool(self.config.use_funding if use_funding is None else use_funding) + if initial <= 0.0 or maint < 0.0 or slip < 0.0 or np.any(contract_sizes <= 0.0) or np.any(leverages <= 0.0): + raise ValueError("invalid scalar score account or execution configuration") + + if self._backend_selection.resolved == "rust": + runner = RustFullRunner( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + contract_sizes=contract_sizes, + leverages=leverages, + fee_rates=fee_rates, + initial_capital=initial, + maintenance_ratio=maint, + slippage=slip, + use_funding=funding_enabled, + ) + # ``run_compiled_tape_score`` is the legacy/public score facade + # and promises dense accounting arrays for metric computation. + # Keep the Rust runner's scalar score ABI minimal, but use its + # typed audit projection here rather than manufacturing missing + # paths or changing the public result contract. + audit = runner.run_tape_audit(compiled_commands) + equity = np.ascontiguousarray(np.asarray(audit.equity, dtype=np.float64)) + positions = np.ascontiguousarray(np.asarray(audit.positions, dtype=np.float64)) + returns = np.zeros_like(equity) + if len(equity) > 1: + with np.errstate(divide="ignore", invalid="ignore"): + returns[1:] = equity[1:] / equity[:-1] - 1.0 + returns[~np.isfinite(returns)] = 0.0 + from ..metrics.performance import compute_performance_metrics + + metrics = compute_performance_metrics( + timestamps=idx, + equity=equity, + returns=returns, + positions=positions, + symbols=tuple(symbol_list), + initial_capital=initial, + liquidated=bool(audit.liquidated), + trading_days=int(trading_days), + ) + metadata = { + "backend": "native_event", + "engine": "event_v2_compiled_tape_score_facade_rust_full", + "report_level": "score", + "score_pandas_materialized": False, + "score_full_ledgers_materialized": False, + "compiled_tape_commands": int(compiled_commands.n_commands), + "compiled_tape_symbols": tuple(symbol_list), + "use_funding": funding_enabled, + "total_fee": float(audit.total_fee), + "total_funding": float(audit.total_funding), + "total_turnover": float(audit.total_turnover), + "lifecycle_counters": { + "fill_count": int(audit.fill_count), + "event_count": int(audit.event_count), + "rejected_count": int(audit.rejected_count), + "canceled_count": int(audit.canceled_count), + }, + "trading_days": int(trading_days), + "rust_contract": "native_event_v2_full_contract", + } + metrics.update({ + "total_fee": float(audit.total_fee), + "total_funding": float(audit.total_funding), + "total_turnover": float(audit.total_turnover), + "max_initial_margin": float(audit.max_initial_margin), + "max_maintenance_margin": float(audit.max_maintenance_margin), + }) + return NativeEventScalarScoreResult( + final_equity=float(audit.equity[-1]), + final_positions=np.asarray(audit.positions[-1], dtype=np.float64), + fill_count=int(audit.fill_count), + rejection_count=int(audit.rejected_count), + cancellation_count=int(audit.canceled_count), + liquidated=bool(audit.liquidated), + liquidation_bar=int(audit.liquidation_bar), + metrics=metrics, + metadata=metadata, + ) + + requirements = NativeEventScoreRequirements( + need_trade_stats=True, + need_context_fills=False, + need_context_events=False, + need_context_active_orders=False, + need_context_positions=False, + need_context_margin=False, + ) + opens_arr = np.ascontiguousarray(market_arrays.closes, dtype=np.float64) + volumes_arr = np.zeros_like(opens_arr, dtype=np.float64) + session = _NativeEventReactiveSession( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + opens_arr=opens_arr, + volumes_arr=volumes_arr, + constraints=build_quantity_constraints(symbol_list), + contract_sizes=contract_sizes, + leverages=leverages, + fee_rates=fee_rates, + initial_capital=initial, + maintenance_ratio=maint, + slippage=slip, + use_funding=funding_enabled, + retain_terminal_orders=False, + score_requirements=requirements, + ) + session.online_score.trading_days = int(trading_days) + + for bar in range(len(idx)): + start = int(compiled_commands.command_ptr[bar]) + stop = int(compiled_commands.command_ptr[bar + 1]) + if stop > start: + session.schedule( + bar, + tuple(compiled_commands.sorted_commands[row][1] for row in range(start, stop)), + ) + session.process_bar(len(idx) - 1) + result = self._reactive_session_score_result( + session=session, + symbol_list=symbol_list, + leverages=leverages, + requirements=requirements, + trading_days=int(trading_days), + metadata={ + "backend": "native_event", + "engine": "event_v2_compiled_tape_scalar_python", + "report_level": "score", + "score_pandas_materialized": False, + "score_full_ledgers_materialized": False, + "compiled_tape_commands": int(compiled_commands.n_commands), + "compiled_tape_symbols": tuple(symbol_list), + "use_funding": funding_enabled, + "total_fee": float(session.total_fee), + "total_funding": float(session.total_funding), + "total_turnover": float(session.total_turnover), + }, + ) + if not isinstance(result, NativeEventScalarScoreResult): # pragma: no cover + raise TypeError("compiled tape scalar path unexpectedly retained dense accounting") + return result + def run_orders( self, datetime_index: Union[pd.DatetimeIndex, pd.Series], @@ -1851,6 +2998,121 @@ def _apply_command_quantity_constraints( out.append(command) return tuple(out), {"changed_count": changed, "dropped_count": len(dropped), "dropped_orders": dropped} + @staticmethod + def _reactive_session_score_result( + *, + session, + symbol_list: List[str], + leverages: np.ndarray, + requirements: NativeEventScoreRequirements, + trading_days: int, + metadata: Dict[str, object], + ) -> NativeEventScoreResult: + """Build direct score arrays from session state without pandas objects.""" + required = { + "equity_path": session.equity_path, + "pos_path": session.pos_path, + "fee_path": session.fee_path, + "funding_path": session.funding_path, + "initial_margin_path": session.initial_margin_path, + "maintenance_margin_path": session.maintenance_margin_path, + } + counters = { + "fill_count": int(session.fill_count), + "event_count": int(session.event_count), + "rejected_count": int(session.rejected_count), + "canceled_count": int(session.canceled_count), + "filled_command_count": int(session.fill_count), + "pending_command_count": int(sum(1 for state in session.pending if session._is_pending(state))), + "expired_event_count": int(getattr(session, "expired_count", 0)), + } + score_metadata = { + **metadata, + "lifecycle_counters": counters, + "execution_counters": dict(getattr(session, "execution_counters", {})), + "score_direct_arrays": True, + "score_pandas_materialized": False, + "score_full_ledgers_materialized": False, + "score_requirements": asdict(requirements), + "score_primitive_order_state": bool(getattr(session, "compact_score_state", False)), + "trading_days": int(trading_days), + } + all_paths = all(value is not None for value in required.values()) + if all_paths: + equity = required["equity_path"] + returns = np.zeros_like(equity) + if len(equity) > 1: + with np.errstate(divide="ignore", invalid="ignore"): + returns[1:] = equity[1:] / equity[:-1] - 1.0 + returns[~np.isfinite(returns)] = 0.0 + accounting = NativeAccountingArrays( + timestamps=np.ascontiguousarray(session.idx.asi8, dtype=np.int64), + equity=equity, + returns=returns, + positions=required["pos_path"], + fees=required["fee_path"], + funding=required["funding_path"], + initial_margin=required["initial_margin_path"], + maintenance_margin=required["maintenance_margin_path"], + symbols=tuple(symbol_list), + initial_capital=float(session.initial_capital), + leverage=float(np.mean(leverages)), + liquidated=bool(session.liquidated), + liquidation_bar=int(session.liquidation_bar), + ) + from ..metrics.performance import compute_performance_metrics + + metrics = compute_performance_metrics( + timestamps=session.idx, + equity=accounting.equity, + returns=accounting.returns, + positions=accounting.positions, + symbols=accounting.symbols, + initial_capital=accounting.initial_capital, + liquidated=bool(session.liquidated), + trading_days=int(trading_days), + ) + return NativeEventScoreResult( + accounting=accounting, + final_positions=accounting.positions[-1].copy(), + fill_count=counters["fill_count"], + rejection_count=counters["rejected_count"], + cancellation_count=counters["canceled_count"], + liquidated=bool(session.liquidated), + liquidation_bar=int(session.liquidation_bar), + metrics=metrics, + metadata=score_metadata, + ) + + online = getattr(session, "online_score", None) + if online is None: + raise RuntimeError("scalar native-event score requires online metric state") + metrics = online.finish(session.idx) + metrics["liquidated"] = bool(session.liquidated) + metrics["total_fee"] = float(getattr(session, "total_fee", 0.0)) + metrics["total_funding"] = float(getattr(session, "total_funding", 0.0)) + metrics["total_turnover"] = float(getattr(session, "total_turnover", 0.0)) + metrics["max_initial_margin"] = float(online.max_initial_margin) + metrics["max_maintenance_margin"] = float(online.max_maintenance_margin) + score_metadata["score_scalar"] = True + score_metadata["score_retained_paths"] = { + name: bool(value is not None) for name, value in required.items() + } + score_metadata["total_fee"] = float(getattr(session, "total_fee", 0.0)) + score_metadata["total_funding"] = float(getattr(session, "total_funding", 0.0)) + score_metadata["total_turnover"] = float(getattr(session, "total_turnover", 0.0)) + return NativeEventScalarScoreResult( + final_equity=float(online.last_equity), + final_positions=np.asarray(session.current_pos, dtype=np.float64).copy(), + fill_count=counters["fill_count"], + rejection_count=counters["rejected_count"], + cancellation_count=counters["canceled_count"], + liquidated=bool(session.liquidated), + liquidation_bar=int(session.liquidation_bar), + metrics=metrics, + metadata=score_metadata, + ) + def _reactive_session_result( self, *, @@ -1894,14 +3156,12 @@ def _reactive_session_result( fill_ledger = self._compact_fill_ledger_from_session(session, symbol_list) lifecycle_counters = { "fill_count": int(len(session_fills)), - "event_count": int(sum(len(events) for events in session.events_by_bar.values())), + "event_count": int(len(session.events)), "rejected_count": int(np.sum(session.rejected_bar)), "canceled_count": int(np.sum(session.canceled_bar)), "filled_command_count": int(len(session_fills)), "pending_command_count": int(sum(1 for state in session.pending if session._is_pending(state))), - "expired_event_count": int( - sum(1 for events in session.events_by_bar.values() for event in events if event.event_name == "expire") - ), + "expired_event_count": int(sum(1 for event in session.events if event.event_name == "expire")), } command_report = pd.DataFrame() order_events = pd.DataFrame() @@ -1911,6 +3171,7 @@ def _reactive_session_result( compact_command_ledger = None compact_order_event_ledger = None audit_artifacts = {} + quantity_preflight = {"changed_count": 0, "dropped_count": 0, "dropped_orders": []} if replay_result is not None: command_report = replay_result.metadata.get("command_report", pd.DataFrame()) order_events = replay_result.metadata.get("order_events", pd.DataFrame()) @@ -1920,6 +3181,7 @@ def _reactive_session_result( compact_command_ledger = replay_result.metadata.get("compact_command_ledger") compact_order_event_ledger = replay_result.metadata.get("compact_order_event_ledger") audit_artifacts = replay_result.metadata.get("audit_artifacts", {}) + quantity_preflight = replay_result.metadata.get("quantity_preflight", quantity_preflight) metadata = { "backend": "native_event", @@ -1939,10 +3201,11 @@ def _reactive_session_result( "compact_command_ledger": compact_command_ledger if plan.keep_command_terminal_state else None, "compact_order_event_ledger": compact_order_event_ledger if plan.keep_event_ledger else None, "quantity_constraints": session.constraints.as_dict(), - "quantity_preflight": {"changed_count": 0, "dropped_count": 0, "dropped_orders": []}, + "quantity_preflight": quantity_preflight, "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), "liquidation_reason": int(session.liquidation_reason), "lifecycle_counters": lifecycle_counters, + "execution_counters": dict(getattr(session, "execution_counters", {})), "single_pass_accounting_source": "reactive_session_state", "single_pass_replay_certified": bool(replay_result is not None), } @@ -1968,28 +3231,27 @@ def _reactive_session_result( @staticmethod def _fills_from_reactive_session(session: _NativeEventReactiveSession) -> tuple[Fill, ...]: fills: list[Fill] = [] - for bar in sorted(session.fills_by_bar): - for fill in session.fills_by_bar[bar]: - fills.append( - Fill( - timestamp=fill.timestamp, - symbol=fill.symbol, - side=fill.side, - qty=float(fill.qty), - price=float(fill.price), - fee=float(fill.fee), - order_id=fill.order_id, - metadata={ - **dict(fill.metadata), - "tag": fill.tag, - "campaign_id": fill.campaign_id, - "cycle_id": fill.cycle_id, - "level_id": fill.level_id, - "parent_order_id": fill.parent_order_id, - "oco_group_id": fill.oco_group_id, - }, - ) + for fill in session.fills: + fills.append( + Fill( + timestamp=fill.timestamp, + symbol=fill.symbol, + side=fill.side, + qty=float(fill.qty), + price=float(fill.price), + fee=float(fill.fee), + order_id=fill.order_id, + metadata={ + **dict(fill.metadata), + "tag": fill.tag, + "campaign_id": fill.campaign_id, + "cycle_id": fill.cycle_id, + "level_id": fill.level_id, + "parent_order_id": fill.parent_order_id, + "oco_group_id": fill.oco_group_id, + }, ) + ) return tuple(fills) @staticmethod @@ -2008,24 +3270,21 @@ def _compact_fill_ledger_from_session( qty = [] price = [] fee = [] - fill_index = 0 - for bar in sorted(session.fills_by_bar): - for fill in session.fills_by_bar[bar]: - code = -1 - if fill.order_id: - if fill.order_id not in id_map: - id_map[fill.order_id] = len(id_map) - code = id_map[fill.order_id] - bars.append(int(bar)) - command_index.append(fill_index) - original_index.append(-1) - order_id_code.append(code) - symbol_code.append(symbol_to_col.get(fill.symbol, -1)) - side.append(fill.side.sign) - qty.append(float(fill.qty)) - price.append(float(fill.price)) - fee.append(float(fill.fee)) - fill_index += 1 + for fill_index, fill in enumerate(session.fills): + code = -1 + if fill.order_id: + if fill.order_id not in id_map: + id_map[fill.order_id] = len(id_map) + code = id_map[fill.order_id] + bars.append(int(session.idx.searchsorted(pd.Timestamp(fill.timestamp), side="left"))) + command_index.append(fill_index) + original_index.append(-1) + order_id_code.append(code) + symbol_code.append(symbol_to_col.get(fill.symbol, -1)) + side.append(fill.side.sign) + qty.append(float(fill.qty)) + price.append(float(fill.price)) + fee.append(float(fill.fee)) return CompactFillLedger( bar=np.asarray(bars, dtype=np.int64), command_index=np.asarray(command_index, dtype=np.int64), @@ -2312,6 +3571,43 @@ def _retime_reactive_commands( out.append(replace(command, timestamp=effective_ts, order_id=order_id)) return tuple(out), ignored + @staticmethod + def _record_reactive_commands_outside_tape( + *, + commands: Sequence[OrderCommand], + effective_bar: int, + emitted_order_ids: set[str], + ) -> tuple[OrderCommand, ...]: + """Retain non-executable callback output without replaying a fake fill. + + A final-close command has valid strategy intent but no next market bar. + It belongs in the audit tape, marked as outside executable data, while + the static replay consumes only the executable tape. + """ + out: list[OrderCommand] = [] + for seq, command in enumerate(tuple(commands)): + if not isinstance(command, OrderCommand): + raise TypeError("reactive strategy callbacks must return OrderCommand objects") + order_id = command.order_id + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if order_id is None: + order_id = command.tag or f"reactive-{effective_bar}-{seq}" + if order_id in emitted_order_ids: + raise ValueError(f"duplicate reactive order_id={order_id!r}") + emitted_order_ids.add(order_id) + out.append( + replace( + command, + order_id=order_id, + metadata={ + **dict(command.metadata), + "reactive_effective_bar": int(effective_bar), + "outside_executable_tape": True, + }, + ) + ) + return tuple(out) + @staticmethod def _call_strategy_callback(strategy, callback: str, context: NativeStrategyContext) -> tuple[OrderCommand, ...]: fn = getattr(strategy, callback, None) diff --git a/backtester.py b/backtester.py index 0953dd4..324935a 100644 --- a/backtester.py +++ b/backtester.py @@ -66,7 +66,6 @@ from .core.schema import InstrumentSpec from .sizing.modes import compute_target_units from .metrics.performance import full_report -from .viz.plots import quick_plot, tearsheet as _tearsheet class BacktestEngine: @@ -403,6 +402,8 @@ def analyze( """ Print a concise performance report, then show cumulative return + drawdown. """ + from .viz.plots import quick_plot + self.print_metrics(trading_days=trading_days) quick_plot(self.result, theme=theme, figsize=figsize) @@ -452,6 +453,8 @@ def tearsheet( benchmark: Optional[pd.Series] = None, ) -> None: """Full dashboard. Optional; call explicitly when needed.""" + from .viz.plots import tearsheet as _tearsheet + _tearsheet( self.result, theme = theme, diff --git a/benchmarks/README.md b/benchmarks/README.md index dfea896..f9ad9b8 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -89,6 +89,26 @@ python3 benchmarks/gamma_scalping_backtestsample.py \ - Cython/C++ should only be considered after a larger profile shows pure kernels, not pandas/tape/report facade work, dominating runtime. +Phase 47C Grid 2,000-bar parity and RSS: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha \ +poetry run python benchmarks/native_event/benchmark_grid_2000.py \ + --grid-module-dir /root/bobby/pool_alpha/alphas_storage/TA \ + --backend python --mode scalar --grid-mode long_only --bars 2000 + +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha \ +poetry run python benchmarks/native_event/benchmark_grid_2000.py \ + --grid-module-dir /root/bobby/pool_alpha/alphas_storage/TA \ + --backend rust --mode audit --grid-mode long_short --bars 2000 +``` + +The runner uses one warm-up and five measured runs in a backend-isolated +process and writes JSON with command/audit fingerprint, terminal accounting, +runtime, CPU time, peak/post RSS, and repeated-run RSS slope. See +[`docs/grid_native_event_phase47c.md`](../docs/grid_native_event_phase47c.md) +for the parity contract and backend policy. + Phase 31 intrabar execution: ```bash diff --git a/benchmarks/benchmark_phase48c_event_driven.py b/benchmarks/benchmark_phase48c_event_driven.py new file mode 100644 index 0000000..1cc1fcc --- /dev/null +++ b/benchmarks/benchmark_phase48c_event_driven.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +"""Benchmark the Phase 48C facade without mixing it into the grid baseline. + +The common case measures the same 2,000-bar single-symbol tape through the +legacy native-event constructor and the new stable facade. The reactive Grid +case is reported separately because indicator preparation and callback state +are part of that workload. Every case runs in a fresh process so RSS and +backend imports are not shared between measurements. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import resource +import subprocess +import sys +import time +from typing import Any + +import numpy as np +import pandas as pd + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_GRID_DIR = Path("/root/bobby/pool_alpha/alphas_storage/TA") +MARKER = "PHASE48C_RESULT=" + +for candidate in (ROOT, ROOT / "src"): + if str(candidate) not in sys.path: + sys.path.insert(0, str(candidate)) + + +def _bars(n: int = 2_000) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC") + x = np.arange(n, dtype=np.float64) + close = 100.0 + np.sin(x / 23.0) * 2.0 + x * 0.002 + open_ = close + 0.1 * np.sin(x / 7.0) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close) + 0.75, + "low": np.minimum(open_, close) - 0.75, + "close": close, + "volume": 10_000.0 + x, + }, + index=index, + ) + + +class PeriodicStrategy: + """Small deterministic reactive strategy for the common 2,000-bar case.""" + + def __init__(self, every: int = 37, hold: int = 11) -> None: + self.every = int(every) + self.hold = int(hold) + + def initialize(self, context): + return () + + def on_bar_close(self, context): + from quantbt import OrderCommand, OrderSide, OrderType, TimeInForce + + bar = int(context.bar_index) + if bar % self.every == 0: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.IOC, + order_id=f"entry-{bar}", + ) + ] + if bar > 0 and bar % self.every == self.hold: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"exit-{bar}", + ) + ] + return () + + def finalize(self, context): + return () + + +def _load_grid_module(module_dir: Path): + path = module_dir / "dynamic_grid_quantbt_native_event.py" + if not path.exists(): + raise FileNotFoundError(f"Grid fixture not found: {path}") + spec = importlib.util.spec_from_file_location("phase48c_grid_fixture", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import Grid fixture: {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _grid_data(n: int = 2_000) -> pd.DataFrame: + index = pd.date_range("2023-01-01", periods=n, freq="h", tz="UTC") + x = np.arange(n, dtype=np.float64) + close = 100.0 + 5.0 * np.sin(x / 11.0) + 0.01 * x + 1.5 * np.sin(x / 47.0) + open_ = close + 0.2 * np.sin(x / 3.0) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close) + 1.5, + "low": np.minimum(open_, close) - 1.5, + "close": close, + "volume": np.full(n, 1_000.0), + }, + index=index, + ) + + +def _grid_params() -> dict[str, Any]: + return { + "grid_mode": "long_only", + "ma_type": "EMA", + "ma_len": 8, + "ema_len_short": 3, + "logic": "ATR", + "band_mult": 0.25, + "zone_smoothing_len": 2, + "warmup_bars": 12, + "pyramiding": 3, + "neutral_position_mode": "hold", + "one_entry_fill_per_bar": True, + "one_exit_fill_per_bar": True, + "campaign_id": "PHASE48C_BENCH", + } + + +def _grid_execution(grid, backend: str = "python"): + return grid.GridExecutionConfig( + symbol="ETHUSDT", + initial_capital=20_000.0, + cash_per_entry=1_000.0, + leverage=5.0, + maintenance_ratio=0.005, + contract_size=1.0, + fee_rate=0.0005, + slippage_bps=2.0, + use_funding=True, + funding_rate=0.0001, + native_backend=backend, + reactive_execution_mode="audit", + reactive_kernel_mode="replay_certified", + report_level="audit", + audit_sink="memory", + ) + + +def _digest_array(digest, name: str, values) -> None: + array = np.ascontiguousarray(np.asarray(values, dtype=np.float64)) + digest.update(name.encode("ascii")) + digest.update(repr(array.shape).encode("ascii")) + digest.update(array.tobytes()) + + +def _fingerprint(result) -> str: + digest = hashlib.sha256() + _digest_array(digest, "equity", result.equity) + _digest_array(digest, "positions", result.positions) + _digest_array(digest, "fees", result.fees) + _digest_array(digest, "funding", result.funding) + _digest_array(digest, "margin", result.margin) + for fill in result.fills: + digest.update( + repr( + ( + int(pd.Timestamp(fill.timestamp).value), + str(fill.symbol), + getattr(fill.side, "value", str(fill.side)), + float(fill.qty), + float(fill.price), + float(fill.fee), + fill.order_id, + ) + ).encode("utf-8") + ) + counters = result.metadata.get("lifecycle_counters", {}) + digest.update(json.dumps(counters, sort_keys=True, default=str).encode("utf-8")) + digest.update(repr(bool(result.liquidated)).encode("ascii")) + digest.update(repr(int(result.liquidation_bar)).encode("ascii")) + return digest.hexdigest() + + +def _peak_rss_mb() -> float: + # Linux reports KiB for ru_maxrss; macOS reports bytes. The benchmark is + # run on Linux CI/VPS, but retaining the branch makes the script portable. + value = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + return value / 1024.0 if sys.platform == "linux" else value / (1024.0 * 1024.0) + + +def _endpoint_kwargs() -> dict[str, Any]: + return { + "initial_capital": 20_000.0, + "leverage": 5.0, + "maintenance_ratio": 0.005, + "fee_rate": 0.0005, + "slippage_bps": 2.0, + "use_funding": False, + "symbols": ["BTC"], + } + + +def _run_common(case: str, bars: int, runs: int) -> dict[str, Any]: + from quantbt import QuantBTEndpoint + + if case == "direct": + endpoint = QuantBTEndpoint.native_event_strategy( + native_backend="python", + reactive_execution_mode="fast", + reactive_kernel_mode="single_pass", + report_level="minimal", + audit_sink="none", + **_endpoint_kwargs(), + ) + else: + endpoint = QuantBTEndpoint.event_driven( + input_mode="strategy", + profile="research", + backend="python", + **_endpoint_kwargs(), + ) + + data = _bars(bars) + for _ in range(1): + endpoint.simulate(data=data, strategy=PeriodicStrategy(), symbols=["BTC"]) + + times = [] + result = None + for _ in range(runs): + start = time.perf_counter() + result = endpoint.simulate(data=data, strategy=PeriodicStrategy(), symbols=["BTC"]) + times.append(time.perf_counter() - start) + + assert result is not None + counters = result.metadata.get("lifecycle_counters", {}) + return { + "route": "native_event_strategy" if case == "direct" else "event_driven_facade", + "bars": bars, + "symbols": 1, + "runs": runs, + "runtime_median_seconds": float(np.median(times)), + "runtime_p95_seconds": float(np.percentile(times, 95)), + "throughput_bars_per_second": float(bars / np.median(times)), + "peak_rss_mb": _peak_rss_mb(), + "final_equity": float(result.equity.iloc[-1]), + "fill_count": int(counters.get("fill_count", len(result.fills))), + "fingerprint": _fingerprint(result), + } + + +def _run_grid(case: str, bars: int, runs: int, grid_dir: Path) -> dict[str, Any]: + from quantbt import QuantBTEndpoint + + grid = _load_grid_module(grid_dir) + data = _grid_data(bars) + params = _grid_params() + execution = _grid_execution(grid) + if case == "grid_direct": + build_endpoint = grid.build_grid_endpoint + else: + def build_endpoint(config): + return QuantBTEndpoint.event_driven( + input_mode="strategy", + profile="audit", + backend="python", + initial_capital=config.initial_capital, + leverage=config.leverage, + maintenance_ratio=config.maintenance_ratio, + contract_size=config.contract_size, + fee_rate=config.fee_rate, + slippage_bps=config.slippage_bps, + use_funding=config.use_funding, + funding_rate=config.funding_rate, + qty_step=config.qty_step, + lot_size=config.lot_size, + slot_size=config.slot_size, + min_qty=config.min_qty, + min_notional=config.min_notional, + symbols=[config.symbol], + ) + + for _ in range(1): + strategy = grid.build_grid_strategy(df=data, params=params, execution=execution) + build_endpoint(execution).simulate(data=data, strategy=strategy, symbols=[execution.symbol]) + + times = [] + result = None + for _ in range(runs): + strategy = grid.build_grid_strategy(df=data, params=params, execution=execution) + endpoint = build_endpoint(execution) + start = time.perf_counter() + result = endpoint.simulate(data=data, strategy=strategy, symbols=[execution.symbol]) + times.append(time.perf_counter() - start) + + assert result is not None + counters = result.metadata.get("lifecycle_counters", {}) + return { + "route": "grid_native_event_strategy" if case == "grid_direct" else "grid_event_driven_facade", + "bars": bars, + "symbols": 1, + "runs": runs, + "runtime_median_seconds": float(np.median(times)), + "runtime_p95_seconds": float(np.percentile(times, 95)), + "throughput_bars_per_second": float(bars / np.median(times)), + "peak_rss_mb": _peak_rss_mb(), + "final_equity": float(result.equity.iloc[-1]), + "fill_count": int(counters.get("fill_count", len(result.fills))), + "num_trades": int(result.metadata.get("num_trades") or counters.get("fill_count", len(result.fills))), + "fingerprint": _fingerprint(result), + } + + +def _worker(args) -> int: + if args.case in {"direct", "facade"}: + payload = _run_common(args.case, args.bars, args.runs) + else: + payload = _run_grid(args.case, args.bars, args.runs, args.grid_module_dir) + print(MARKER + json.dumps(payload, sort_keys=True)) + return 0 + + +def _run_worker(case: str, args) -> dict[str, Any]: + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join((str(ROOT / "src"), str(ROOT), env.get("PYTHONPATH", ""))) + env.setdefault("MPLCONFIGDIR", "/tmp") + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--worker", + "--case", + case, + "--bars", + str(args.bars), + "--runs", + str(args.runs), + "--grid-module-dir", + str(args.grid_module_dir), + ] + completed = subprocess.run(command, check=True, capture_output=True, text=True, env=env) + for line in reversed(completed.stdout.splitlines()): + if line.startswith(MARKER): + return json.loads(line[len(MARKER) :]) + raise RuntimeError(f"worker did not emit {MARKER}: {completed.stdout[-1000:]}") + + +def _render_markdown(payload: dict[str, Any]) -> str: + lines = [ + "# Phase 48C Event-Driven Facade Benchmark", + "", + f"Workload: **{payload['bars']:,} bars**, one symbol, fresh process per route.", + "The common table is the release baseline; the Grid table is a separate reactive workload.", + "", + "## Common 2,000-Bar Baseline", + "", + "| Route | Median s | P95 s | Bars/s | Peak RSS MB | Final Equity | Fills |", + "|---|---:|---:|---:|---:|---:|---:|", + ] + for row in payload["common"]["routes"]: + lines.append( + f"| `{row['route']}` | {row['runtime_median_seconds']:.6f} | " + f"{row['runtime_p95_seconds']:.6f} | {row['throughput_bars_per_second']:,.0f} | " + f"{row['peak_rss_mb']:.1f} | {row['final_equity']:,.6f} | {row['fill_count']} |" + ) + lines.extend( + [ + "", + f"Accounting parity: **{'PASS' if payload['common']['parity'] else 'FAIL'}**.", + f"Facade runtime overhead versus direct constructor: **{payload['common']['facade_overhead_pct']:+.2f}%**.", + "The facade is a resolver/delegator; it is not expected to speed up the accounting kernel.", + "", + "## Reactive Grid 2,000-Bar Workload", + "", + "| Route | Median s | P95 s | Bars/s | Peak RSS MB | Final Equity | Fills | Trades |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + ) + for row in payload["grid"]["routes"]: + lines.append( + f"| `{row['route']}` | {row['runtime_median_seconds']:.6f} | " + f"{row['runtime_p95_seconds']:.6f} | {row['throughput_bars_per_second']:,.0f} | " + f"{row['peak_rss_mb']:.1f} | {row['final_equity']:,.6f} | {row['fill_count']} | {row['num_trades']} |" + ) + lines.extend( + [ + "", + f"Grid accounting parity: **{'PASS' if payload['grid']['parity'] else 'FAIL'}**.", + "Grid runtime includes external indicator preparation and the reactive callback; it is intentionally not merged into the common baseline.", + "", + "## Interpretation", + "", + "- The new facade changes endpoint declaration and profile resolution only.", + "- Equal fingerprints, equity, fees, funding, positions, margin, and fill counts are the domain gate.", + "- `backend=auto` remains governed by the package release policy; this benchmark explicitly uses Python.", + ] + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--worker", action="store_true") + parser.add_argument("--case", choices=("direct", "facade", "grid_direct", "grid_facade")) + parser.add_argument("--bars", type=int, default=2_000) + parser.add_argument("--runs", type=int, default=5) + parser.add_argument("--grid-module-dir", type=Path, default=DEFAULT_GRID_DIR) + parser.add_argument("--json-output", type=Path, default=None) + parser.add_argument("--markdown-output", type=Path, default=None) + args = parser.parse_args() + if args.bars != 2_000: + parser.error("Phase 48C release baseline must use exactly 2,000 bars") + if args.runs <= 0: + parser.error("--runs must be > 0") + if args.worker: + if args.case is None: + parser.error("--worker requires --case") + return _worker(args) + + common_routes = [_run_worker(case, args) for case in ("direct", "facade")] + grid_routes = [_run_worker(case, args) for case in ("grid_direct", "grid_facade")] + common_direct, common_facade = common_routes + grid_direct, grid_facade = grid_routes + common_parity = common_direct["fingerprint"] == common_facade["fingerprint"] + grid_parity = grid_direct["fingerprint"] == grid_facade["fingerprint"] + if not common_parity or not grid_parity: + raise AssertionError("Phase 48C facade fingerprint parity failed") + + payload = { + "benchmark": "phase48c_event_driven_facade", + "bars": args.bars, + "runs": args.runs, + "common": { + "routes": common_routes, + "parity": common_parity, + "facade_overhead_pct": (common_facade["runtime_median_seconds"] / common_direct["runtime_median_seconds"] - 1.0) * 100.0, + }, + "grid": { + "routes": grid_routes, + "parity": grid_parity, + "facade_overhead_pct": (grid_facade["runtime_median_seconds"] / grid_direct["runtime_median_seconds"] - 1.0) * 100.0, + }, + "policy": { + "common_baseline_bars": 2_000, + "grid_reported_separately": True, + "fresh_process_per_route": True, + "domain_parity_required": True, + }, + } + rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" + print(rendered, end="") + if args.json_output is not None: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered) + if args.markdown_output is not None: + args.markdown_output.parent.mkdir(parents=True, exist_ok=True) + args.markdown_output.write_text(_render_markdown(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/native_event/benchmark_grid_2000.py b/benchmarks/native_event/benchmark_grid_2000.py new file mode 100644 index 0000000..6b20d83 --- /dev/null +++ b/benchmarks/native_event/benchmark_grid_2000.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Process-isolated Grid runtime/RSS benchmark for Phase 47C/47D. + +The external Grid alpha is loaded read-only. A scalar benchmark first creates +one audit reference for its parity fingerprint, then measures only fresh +prepared score calls. Each CLI invocation owns one backend process so Python +and Rust imports/caches cannot contaminate one another. +""" + +from __future__ import annotations + +import argparse +import gc +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import resource +import subprocess +import sys +import time +from typing import Any + +import numpy as np +import pandas as pd + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_GRID_DIR = Path("/root/bobby/pool_alpha/alphas_storage/TA") + +for candidate in (REPO_ROOT, REPO_ROOT / "src"): + if str(candidate) not in sys.path: + sys.path.insert(0, str(candidate)) + + +def _load_grid_module(module_dir: Path): + path = module_dir / "dynamic_grid_quantbt_native_event.py" + if not path.exists(): + raise FileNotFoundError(f"Grid module not found: {path}") + spec = importlib.util.spec_from_file_location("phase47c_benchmark_grid", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import Grid module: {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _synthetic_data(bars: int) -> pd.DataFrame: + index = pd.date_range("2023-01-01", periods=bars, freq="h", tz="UTC") + x = np.arange(bars, dtype=np.float64) + close = 100.0 + 5.0 * np.sin(x / 11.0) + 0.01 * x + 1.5 * np.sin(x / 47.0) + open_ = close + 0.2 * np.sin(x / 3.0) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close) + 1.5, + "low": np.minimum(open_, close) - 1.5, + "close": close, + "volume": np.full(bars, 1000.0), + }, + index=index, + ) + + +def _load_market(path: str | None, bars: int) -> pd.DataFrame: + if path is None: + data = _synthetic_data(bars) + else: + source = Path(path) + data = pd.read_csv(source, compression="infer") + lower = {str(column).lower(): column for column in data.columns} + time_column = next( + (lower[name] for name in ("timestamp", "datetime", "date", "time") if name in lower), + None, + ) + if time_column is not None: + index = pd.to_datetime(data.pop(time_column), utc=True) + else: + index = pd.to_datetime(data.index, utc=True) + data.index = index + rename = {} + for required in ("open", "high", "low", "close", "volume"): + if required in lower: + rename[lower[required]] = required + data = data.rename(columns=rename) + if "volume" not in data: + data["volume"] = 0.0 + required = ["open", "high", "low", "close", "volume"] + missing = [column for column in required if column not in data] + if missing: + raise ValueError(f"market data is missing columns: {missing}") + data = data[required].sort_index() + if data.index.has_duplicates: + raise ValueError("market data index must not contain duplicates") + data = data.iloc[-int(bars):].copy() + if len(data) != int(bars): + raise ValueError(f"expected exactly {bars} bars, received {len(data)}") + if not data.index.is_monotonic_increasing or data.index.has_duplicates: + raise ValueError("market data must be sorted and unique") + return data.astype(np.float64) + + +def _grid_params(grid_mode: str) -> dict[str, Any]: + return { + "grid_mode": grid_mode, + "ma_type": "EMA", + "ma_len": 8, + "ema_len_short": 3, + "logic": "ATR", + "band_mult": 0.25, + "zone_smoothing_len": 2, + "warmup_bars": 12, + "pyramiding": 3, + "neutral_position_mode": "hold", + "one_entry_fill_per_bar": True, + "one_exit_fill_per_bar": True, + "campaign_id": "PHASE47C_BENCH", + } + + +def _execution(grid, backend: str, mode: str): + audit = mode == "audit" + return grid.GridExecutionConfig( + symbol="ETHUSDT", + initial_capital=20_000.0, + cash_per_entry=1_000.0, + leverage=5.0, + maintenance_ratio=0.005, + contract_size=1.0, + fee_rate=0.0005, + slippage_bps=2.0, + use_funding=True, + funding_rate=0.0001, + native_backend=backend, + reactive_execution_mode="audit" if audit else "fast", + reactive_kernel_mode=( + "replay_certified" if backend == "replay_certified" else "single_pass" + ), + report_level="audit" if audit else "score", + audit_sink="memory" if audit else "none", + ) + + +def _jsonable(value: Any): + if isinstance(value, (np.integer, int)): + return int(value) + if isinstance(value, (np.floating, float)): + return float(value) + if isinstance(value, pd.Timestamp): + return int(value.value) + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if value is pd.NaT or pd.isna(value): + return None + return value + + +def _digest_update_array(digest, name: str, values) -> None: + array = np.ascontiguousarray(np.asarray(values)) + digest.update(name.encode("utf-8")) + digest.update(str(array.dtype).encode("ascii")) + digest.update(repr(array.shape).encode("ascii")) + digest.update(array.tobytes()) + + +def _audit_fingerprint(run) -> str: + digest = hashlib.sha256() + for command in run.command_tape: + payload = { + "timestamp": int(pd.Timestamp(command.timestamp).value), + "action": command.action.value, + "symbol": command.symbol, + "side": None if command.side is None else command.side.value, + "order_type": None if command.order_type is None else command.order_type.value, + "qty": float(command.qty or 0.0), + "price": None if command.price is None else float(command.price), + "trigger_price": None if command.trigger_price is None else float(command.trigger_price), + "tif": command.tif.value, + "reduce_only": bool(command.reduce_only), + "order_id": command.order_id, + "target_order_id": command.target_order_id, + "parent_order_id": command.parent_order_id, + "group_id": command.group_id, + "oco_group_id": command.oco_group_id, + "expires_at": None if command.expires_at is None else int(pd.Timestamp(command.expires_at).value), + "metadata": _jsonable(dict(command.metadata or {})), + } + digest.update(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")) + event_frame = run.order_events.reset_index(drop=True) + digest.update(event_frame.to_json(orient="split", date_format="iso").encode("utf-8")) + result = run.result + _digest_update_array(digest, "equity", result.equity) + _digest_update_array(digest, "positions", result.positions) + _digest_update_array(digest, "fees", result.fees) + _digest_update_array(digest, "funding", result.funding) + _digest_update_array(digest, "margin", result.margin) + for fill in run.result.fills: + payload = ( + int(pd.Timestamp(fill.timestamp).value), + str(fill.symbol), + getattr(fill.side, "value", str(fill.side)), + float(fill.qty), + float(fill.price), + float(fill.fee), + fill.order_id, + ) + digest.update(repr(payload).encode("utf-8")) + digest.update(repr((bool(result.liquidated), int(result.liquidation_bar))).encode("ascii")) + return digest.hexdigest() + + +def _rss_kb() -> int | None: + try: + for line in Path("/proc/self/status").read_text().splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except (OSError, ValueError): + return None + return None + + +def _peak_rss_kb() -> int: + return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + + +def _git_revision() -> str | None: + try: + return subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + cwd=REPO_ROOT, + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def _run_once(grid, data, params, execution, mode): + if mode == "audit": + return grid.run_grid_backtest(data, params, execution) + endpoint, prepared = grid.prepare_grid_score_runner(df=data, execution=execution) + score = grid.score_grid_params( + prepared_runner=prepared, + df=data, + params=params, + execution=execution, + ) + if not hasattr(score, "final_equity"): + raise AssertionError("scalar benchmark returned a dense score result") + return score + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--grid-module-dir", type=Path, default=DEFAULT_GRID_DIR) + parser.add_argument("--data", type=str, default=None, help="optional OHLCV CSV/CSV.GZ") + parser.add_argument("--backend", choices=("python", "rust", "replay_certified"), required=True) + parser.add_argument("--mode", choices=("audit", "scalar"), required=True) + parser.add_argument("--grid-mode", choices=("long_only", "long_short"), default="long_only") + parser.add_argument("--bars", type=int, default=2000) + parser.add_argument("--warmup", type=int, default=1) + parser.add_argument("--runs", type=int, default=5) + parser.add_argument("--phase", type=str, default="47C") + parser.add_argument("--output", type=Path, default=None) + args = parser.parse_args() + if args.bars <= 0 or args.warmup < 0 or args.runs <= 0: + parser.error("bars, runs must be > 0 and warmup must be >= 0") + if args.mode == "scalar" and args.backend == "replay_certified": + parser.error("replay_certified is an audit oracle, not a scalar backend") + + grid = _load_grid_module(args.grid_module_dir) + data = _load_market(args.data, args.bars) + params = _grid_params(args.grid_mode) + execution = _execution(grid, args.backend, args.mode) + audit_reference_fingerprint = None + if args.mode == "scalar": + audit_run = grid.run_grid_backtest( + data, + params, + _execution(grid, args.backend, "audit"), + ) + audit_reference_fingerprint = _audit_fingerprint(audit_run) + + for _ in range(args.warmup): + _run_once(grid, data, params, execution, args.mode) + + runtimes = [] + cpu_times = [] + post_rss = [] + first_result = None + for _ in range(args.runs): + start = time.perf_counter() + cpu_start = time.process_time() + result = _run_once(grid, data, params, execution, args.mode) + cpu_times.append(time.process_time() - cpu_start) + runtimes.append(time.perf_counter() - start) + if first_result is None: + first_result = result + else: + del result + gc.collect() + post_rss.append(_rss_kb()) + + peak = _peak_rss_kb() + numeric_rss = [value for value in post_rss if value is not None] + slope = 0.0 + if len(numeric_rss) >= 2: + slope = float(np.polyfit(np.arange(len(numeric_rss), dtype=np.float64), numeric_rss, 1)[0]) + tail_slope = 0.0 + if len(numeric_rss) >= 3: + # The first measured call can still populate allocator/PyO3 caches + # after the explicit warm-up. Leak detection therefore uses the + # remaining tail while retaining the full slope for transparency. + tail_values = np.asarray(numeric_rss[1:], dtype=np.float64) + tail_slope = float( + np.polyfit(np.arange(len(tail_values), dtype=np.float64), tail_values, 1)[0] + ) + if args.mode == "audit": + fingerprint = _audit_fingerprint(first_result) + final_equity = float(first_result.result.equity.iloc[-1]) + fill_count = int(len(first_result.result.fills)) + total_fee = float(first_result.result.fees.sum()) + total_funding = float(first_result.result.funding.sum()) + resolved = first_result.result.metadata.get("native_event_backend_resolved") + else: + fingerprint = audit_reference_fingerprint + final_equity = float(first_result.final_equity) + fill_count = int(first_result.fill_count) + total_fee = float(first_result.total_fee) + total_funding = float(first_result.metadata.get("total_funding", 0.0)) + resolved = first_result.metadata.get("native_event_backend_resolved") + if args.backend == "rust" and resolved != "rust": + raise RuntimeError(f"explicit Rust benchmark resolved to {resolved!r}") + + payload = { + "phase": str(args.phase), + "grid_module_version": getattr(grid, "MODULE_VERSION", None), + "git_revision": _git_revision(), + "backend_requested": args.backend, + "backend_resolved": resolved, + "mode": args.mode, + "grid_mode": args.grid_mode, + "bars": int(args.bars), + "warmup_runs": int(args.warmup), + "measured_runs": int(args.runs), + "runtime_seconds": [float(value) for value in runtimes], + "runtime_median_seconds": float(np.median(runtimes)), + "runtime_p95_seconds": float(np.percentile(runtimes, 95)), + "cpu_seconds": [float(value) for value in cpu_times], + "cpu_median_seconds": float(np.median(cpu_times)), + "peak_rss_kb": int(peak), + "post_run_rss_kb": post_rss, + "post_run_rss_median_kb": None if not numeric_rss else float(np.median(numeric_rss)), + "post_run_rss_slope_kb_per_run": slope, + "post_run_rss_tail_slope_kb_per_run": tail_slope, + "fingerprint": fingerprint, + "audit_reference_fingerprint": audit_reference_fingerprint, + "final_equity": final_equity, + "fill_count": fill_count, + "total_fee": total_fee, + "total_funding": total_funding, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": bool(tail_slope > max(1024.0, peak * 0.01)), + "pass": bool(tail_slope <= max(1024.0, peak * 0.01)), + }, + "policy": { + "python_default": True, + "rust_explicit_fail_fast": args.backend == "rust", + "auto_promoted": False, + "replay_is_oracle": True, + }, + } + rendered = json.dumps(payload, indent=2, sort_keys=True) + print(rendered) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/native_event/benchmark_phase45d_zero_object.py b/benchmarks/native_event/benchmark_phase45d_zero_object.py new file mode 100644 index 0000000..b29a402 --- /dev/null +++ b/benchmarks/native_event/benchmark_phase45d_zero_object.py @@ -0,0 +1,185 @@ +"""Fresh-process benchmark for the Phase 45D Python score contracts. + +The benchmark separates the compatibility ndarray score from the scalar +zero-retention score and the audit oracle. It intentionally warms each mode +before timing so first-use imports/Numba compilation are not misreported as +execution speed. +""" + +from __future__ import annotations + +import argparse +import json +import resource +import subprocess +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT / "src") not in sys.path: + sys.path.insert(0, str(ROOT / "src")) + +from quantbt import NativeCommandBatch, NativeEventScoreRequirements, OrderCommand, OrderSide, OrderType, QuantBTEndpoint, TimeInForce # noqa: E402 + + +def _rss_mb() -> float: + status = Path("/proc/self/status") + if status.exists(): + for line in status.read_text().splitlines(): + if line.startswith("VmHWM:"): + return float(line.split()[1]) / 1024.0 + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _data(rows: int) -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=rows, freq="1min", tz="UTC") + x = np.arange(rows, dtype=np.float64) + close = pd.Series(100.0 + np.sin(x / 41.0) * 2.0 + x * 0.0002, index=idx) + return pd.DataFrame( + { + "open": close, + "high": close + 1.25, + "low": close - 1.25, + "close": close, + "volume": 10_000.0 + x, + }, + index=idx, + ) + + +class HighChurnStrategy: + # This workload does not inspect callback payloads, so it opts out of + # transient fill/event/order snapshot objects for the scalar score. + native_context_requirements = { + "fills": False, + "events": False, + "active_orders": False, + "positions": False, + "margin": False, + } + + def on_bar_close(self, context): + bar = int(context.bar_index) + if bar % 20 == 0: + return NativeCommandBatch.from_commands( + ( + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.05, + tif=TimeInForce.IOC, + order_id=f"entry-{bar}", + ), + ) + ) + if bar % 20 == 5: + return ( + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.05, + reduce_only=True, + tif=TimeInForce.IOC, + order_id=f"exit-{bar}", + ), + ) + return () + + +def _child(mode: str, rows: int, repeats: int) -> dict: + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=50_000, + leverage=5, + maintenance_ratio=0.005, + use_funding=False, + fee_rate=0.0002, + report_level="audit", + reactive_kernel_mode="single_pass", + ) + prepared = endpoint.prepare_native_event_strategy(data=_data(rows), symbols=["BTC"]) + + def run_once(): + strategy = HighChurnStrategy() + if mode == "audit": + return float(prepared.run(strategy, report_level="audit").equity.iloc[-1]) + if mode == "compat_score": + return float(prepared.score(strategy).metrics["final_equity"]) + requirements = NativeEventScoreRequirements.from_strategy( + strategy, + base=NativeEventScoreRequirements.scalar_score_contract(), + ) + return float(prepared.score(strategy, score_requirements=requirements).metrics["final_equity"]) + + run_once() # warm imports, allocator, and Numba path + start = time.perf_counter() + final_equity = 0.0 + for _ in range(int(repeats)): + final_equity = run_once() + return { + "mode": mode, + "rows": int(rows), + "repeats": int(repeats), + "seconds": float(time.perf_counter() - start), + "peak_rss_mb": float(_rss_mb()), + "final_equity": final_equity, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--child", action="store_true") + parser.add_argument("--mode", choices=("audit", "compat_score", "scalar_score"), default="scalar_score") + parser.add_argument("--rows", type=int, default=100_000) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--json-out", default="benchmarks/native_event/phase45d_zero_object.json") + args = parser.parse_args() + if args.child: + print(json.dumps(_child(args.mode, args.rows, args.repeats), sort_keys=True)) + return + + runs = [] + for mode in ("audit", "compat_score", "scalar_score"): + completed = subprocess.run( + [ + sys.executable, + __file__, + "--child", + "--mode", + mode, + "--rows", + str(args.rows), + "--repeats", + str(args.repeats), + ], + check=True, + capture_output=True, + text=True, + ) + runs.append(json.loads(completed.stdout.strip().splitlines()[-1])) + by_mode = {row["mode"]: row for row in runs} + audit_equity = by_mode["audit"]["final_equity"] + scalar_equity = by_mode["scalar_score"]["final_equity"] + payload = { + "runs": runs, + "parity": bool(np.isclose(audit_equity, scalar_equity, rtol=0.0, atol=1e-12)), + "scalar_faster_than_compat": bool( + by_mode["scalar_score"]["seconds"] < by_mode["compat_score"]["seconds"] + ), + "scalar_rss_below_compat": bool( + by_mode["scalar_score"]["peak_rss_mb"] < by_mode["compat_score"]["peak_rss_mb"] + ), + } + Path(args.json_out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + print(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_phase45e_rust_batched.py b/benchmarks/native_event/benchmark_phase45e_rust_batched.py new file mode 100644 index 0000000..112dfc1 --- /dev/null +++ b/benchmarks/native_event/benchmark_phase45e_rust_batched.py @@ -0,0 +1,129 @@ +"""Fresh-process smoke benchmark for the Phase45E Rust full-tape boundary.""" + +from __future__ import annotations + +import json +import resource +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantbt import AccountConfig, ExecutionConfig, NativeEventBackend, NativeEventConfig, OrderCommand, OrderSide, OrderType + + +def main() -> None: + n_bars = 100_000 + index = pd.date_range("2020-01-01", periods=n_bars, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.sin(np.arange(n_bars) / 17.0), index=index) + frame = pd.DataFrame( + {"open": close, "high": close + 1.0, "low": close - 1.0, "close": close}, + index=index, + ) + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=20_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + market = backend.prepare_market_arrays( + index, + {"BTC": frame["close"]}, + {"BTC": frame["high"]}, + {"BTC": frame["low"]}, + symbols=["BTC"], + ) + commands = [] + for cycle, entry in enumerate(range(1, n_bars - 1000, 5000)): + exit_bar = entry + 1000 + commands.extend( + ( + OrderCommand( + timestamp=index[entry], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + order_id=f"entry-{cycle}", + ), + OrderCommand( + timestamp=index[exit_bar], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + reduce_only=True, + order_id=f"exit-{cycle}", + ), + ) + ) + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + runner = backend.prepare_rust_batched_runner( + index, + {"BTC": frame["close"]}, + {"BTC": frame["high"]}, + {"BTC": frame["low"]}, + symbols=["BTC"], + ) + + runner.run_tape_score(compiled) + backend.run_order_commands( + index, + commands, + {"BTC": frame["close"]}, + {"BTC": frame["high"]}, + {"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="minimal", + ) + + def timed(fn, repetitions: int = 5): + samples = [] + last = None + for _ in range(repetitions): + started = time.perf_counter() + last = fn() + samples.append(time.perf_counter() - started) + return float(np.median(samples)), last + + rust_seconds, rust = timed(lambda: runner.run_tape_score(compiled)) + python_seconds, python = timed( + lambda: backend.run_order_commands( + index, + commands, + {"BTC": frame["close"]}, + {"BTC": frame["high"]}, + {"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="minimal", + ) + ) + payload = { + "phase": "45E", + "bars": n_bars, + "commands": len(commands), + "repetitions": 5, + "rust_batched_score_seconds_median": rust_seconds, + "python_v2_seconds_median": python_seconds, + "speedup_python_over_rust": python_seconds / rust_seconds if rust_seconds else None, + "rust_final_equity": rust.final_equity, + "python_final_equity": float(python.equity.iloc[-1]), + "rust_fill_count": rust.fill_count, + "python_fill_count": int(python.metadata["lifecycle_counters"]["fill_count"]), + "maxrss_mb": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0, + "note": "Rust remains explicit experimental until isolated multi-scenario speed/RSS gates pass.", + } + path = Path(__file__).with_name("phase45e_rust_batched.json") + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_phase45f_release_gate.py b/benchmarks/native_event/benchmark_phase45f_release_gate.py new file mode 100644 index 0000000..8961b60 --- /dev/null +++ b/benchmarks/native_event/benchmark_phase45f_release_gate.py @@ -0,0 +1,269 @@ +"""Process-isolated Phase45F speed/RSS certification gate. + +Each backend is executed in a fresh child process. This prevents the Rust +prepared market and the Python prepared market from coexisting in one RSS +sample and records the gate result without changing backend rollout policy. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import resource +import subprocess +import sys +import time + +os.environ.setdefault("MPLCONFIGDIR", "/tmp") + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from quantbt import ( # noqa: E402 + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderCommand, + OrderSide, + OrderType, +) + + +def _rss_mb() -> float: + status_path = Path("/proc/self/status") + if status_path.exists(): + for line in status_path.read_text(encoding="utf-8").splitlines(): + if line.startswith("VmRSS:"): + return float(line.split()[1]) / 1024.0 + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _fixture(n_bars: int, scenario: str): + index = pd.date_range("2020-01-01", periods=n_bars, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.sin(np.arange(n_bars, dtype=np.float64) / 17.0), index=index) + frame = pd.DataFrame( + {"open": close, "high": close + 1.0, "low": close - 1.0, "close": close}, + index=index, + ) + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=20_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + closes = {"BTC": frame["close"]} + highs = {"BTC": frame["high"]} + lows = {"BTC": frame["low"]} + market = backend.prepare_market_arrays(index, closes, highs, lows, symbols=["BTC"]) + commands: list[OrderCommand] = [] + if scenario == "low_churn": + entries = range(1, n_bars - 1_000, max(1, n_bars // 20)) + elif scenario == "high_churn": + entries = range(1, n_bars - 2, max(1, n_bars // 1_500)) + else: + raise ValueError(f"unsupported scenario={scenario!r}") + for cycle, entry in enumerate(entries): + exit_bar = min(entry + 1, n_bars - 1) if scenario == "high_churn" else min(entry + 1_000, n_bars - 1) + commands.extend( + ( + OrderCommand( + timestamp=index[entry], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + order_id=f"entry-{cycle}", + ), + OrderCommand( + timestamp=index[exit_bar], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + reduce_only=True, + order_id=f"exit-{cycle}", + ), + ) + ) + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + runner = backend.prepare_rust_batched_runner(index, closes, highs, lows, symbols=["BTC"]) + return backend, index, closes, highs, lows, market, commands, compiled, runner + + +def _run_child(backend_name: str, scenario: str, n_bars: int, repetitions: int) -> dict[str, object]: + backend, index, closes, highs, lows, market, commands, compiled, runner = _fixture(n_bars, scenario) + if backend_name == "rust": + runner.run_tape_score(compiled) + fn = lambda: runner.run_tape_score(compiled) + elif backend_name == "python": + backend.run_order_commands( + index, + commands, + closes, + highs, + lows, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="minimal", + ) + + def fn(): + return backend.run_order_commands( + index, + commands, + closes, + highs, + lows, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="minimal", + ) + + else: + raise ValueError(f"unsupported backend={backend_name!r}") + + samples: list[float] = [] + rss_samples: list[float] = [] + last = None + for _ in range(repetitions): + started = time.perf_counter() + last = fn() + samples.append(time.perf_counter() - started) + rss_samples.append(_rss_mb()) + if backend_name == "rust": + final_equity = float(last.final_equity) + fill_count = int(last.fill_count) + else: + final_equity = float(last.equity.iloc[-1]) + fill_count = int(last.metadata["lifecycle_counters"]["fill_count"]) + return { + "backend": backend_name, + "scenario": scenario, + "bars": n_bars, + "commands": len(commands), + "repetitions": repetitions, + "seconds": [float(value) for value in samples], + "median_seconds": float(np.median(samples)), + "rss_samples_mb": rss_samples, + "post_run_rss_mb": float(rss_samples[-1]), + "peak_rss_mb": float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0, + "final_equity": final_equity, + "fill_count": fill_count, + } + + +def _child_main(args: argparse.Namespace) -> None: + result = _run_child(args.backend, args.scenario, args.bars, args.repetitions) + print(json.dumps(result, sort_keys=True)) + + +def _run_isolated(backend: str, scenario: str, bars: int, repetitions: int) -> dict[str, object]: + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--child", + "--backend", + backend, + "--scenario", + scenario, + "--bars", + str(bars), + "--repetitions", + str(repetitions), + ] + env = dict(os.environ) + env.setdefault("MPLCONFIGDIR", "/tmp") + completed = subprocess.run(command, cwd=ROOT, env=env, check=True, capture_output=True, text=True) + return json.loads(completed.stdout.strip().splitlines()[-1]) + + +def _parent_main(args: argparse.Namespace) -> None: + results = { + scenario: { + backend: _run_isolated(backend, scenario, args.bars, args.repetitions) + for backend in ("python", "rust") + } + for scenario in ("low_churn", "high_churn") + } + comparisons = {} + for scenario, values in results.items(): + python_result = values["python"] + rust_result = values["rust"] + speedup = python_result["median_seconds"] / rust_result["median_seconds"] + rss_reduction = (python_result["peak_rss_mb"] - rust_result["peak_rss_mb"]) / python_result["peak_rss_mb"] + parity = ( + abs(python_result["final_equity"] - rust_result["final_equity"]) <= 1e-12 + and python_result["fill_count"] == rust_result["fill_count"] + ) + comparisons[scenario] = { + "speedup_python_over_rust": float(speedup), + "peak_rss_reduction": float(rss_reduction), + "parity": bool(parity), + "high_churn_speed_gate": bool(speedup >= 2.0) if scenario == "high_churn" else None, + } + plateau = all( + max(values[backend]["rss_samples_mb"][-3:]) - min(values[backend]["rss_samples_mb"][-3:]) <= 16.0 + for values in results.values() + for backend in ("python", "rust") + ) + all_parity = all(value["parity"] for value in comparisons.values()) + median_speed = float(np.median([value["speedup_python_over_rust"] for value in comparisons.values()])) + min_rss_reduction = float(min(value["peak_rss_reduction"] for value in comparisons.values())) + release_ready = bool( + all_parity + and median_speed >= 1.5 + and comparisons["high_churn"]["high_churn_speed_gate"] + and min_rss_reduction >= 0.40 + and plateau + ) + payload = { + "phase": "45F", + "bars": args.bars, + "repetitions": args.repetitions, + "process_isolated": True, + "results": results, + "comparisons": comparisons, + "gate": { + "parity": all_parity, + "median_end_to_end_speedup": median_speed, + "minimum_peak_rss_reduction": min_rss_reduction, + "repeated_run_rss_plateau": plateau, + "release_ready": release_ready, + }, + "policy": "Rust remains explicit experimental and auto remains Python when release_ready is false.", + } + output_path = Path(args.output) if args.output else Path(__file__).with_name("phase45f_release_gate.json") + output_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--child", action="store_true") + parser.add_argument("--backend", choices=("python", "rust"), default="python") + parser.add_argument("--scenario", choices=("low_churn", "high_churn"), default="low_churn") + parser.add_argument("--bars", type=int, default=100_000) + parser.add_argument("--repetitions", type=int, default=5) + parser.add_argument("--output") + args = parser.parse_args() + if args.repetitions < 5: + parser.error("Phase45F requires at least five measured repetitions") + if args.child: + _child_main(args) + else: + _parent_main(args) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_phase46a_certification.py b/benchmarks/native_event/benchmark_phase46a_certification.py new file mode 100644 index 0000000..d81028c --- /dev/null +++ b/benchmarks/native_event/benchmark_phase46a_certification.py @@ -0,0 +1,99 @@ +"""Emit the Phase 46A deterministic parity certificate. + +This is a correctness evidence script, not a performance benchmark. It uses a +seeded audit-shaped fixture so CI can validate the certificate contract without +requiring a particular optional Rust wheel. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +from types import SimpleNamespace +import json + +import numpy as np + +from quantbt import ( + NATIVE_EVENT_CAPABILITY_MATRIX, + NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + assert_native_event_full_parity, + capability_matrix_fingerprint, +) + + +def _fixture(seed: int = 46) -> SimpleNamespace: + rng = np.random.default_rng(seed) + bars = 24 + positions = rng.choice((-1.0, 0.0, 1.0), size=(bars, 1)).astype(np.float64) + return SimpleNamespace( + equity=20_000.0 + np.cumsum(rng.normal(0.0, 0.2, bars)), + positions=positions, + fees=np.abs(rng.normal(0.01, 0.002, bars)), + funding=np.zeros(bars, dtype=np.float64), + turnover=np.abs(rng.normal(50.0, 1.0, bars)), + initial_margin=np.abs(positions[:, 0]) * 10.0, + maintenance_margin=np.abs(positions[:, 0]) * 0.5, + liquidated=False, + liquidation_bar=-1, + fill_bar=np.array([2, 8, 16], dtype=np.int64), + fill_order_id=np.array([0, 1, 2], dtype=np.int64), + fill_side=np.array([1, -1, 1], dtype=np.int64), + fill_qty=np.array([1.0, 1.0, 0.5], dtype=np.float64), + fill_price=np.array([100.0, 101.0, 102.0], dtype=np.float64), + fill_fee=np.array([0.01, 0.01, 0.005], dtype=np.float64), + event_bar=np.array([1, 2, 8, 16], dtype=np.int64), + event_kind=np.array([0, 4, 4, 4], dtype=np.int64), + event_status=np.array([0, 1, 1, 1], dtype=np.int64), + event_order_id=np.array([0, 0, 1, 2], dtype=np.int64), + event_target_id=np.array([-1, -1, -1, -1], dtype=np.int64), + ) + + +def build_evidence() -> dict[str, object]: + candidate = _fixture() + oracle = _fixture() + certificate = assert_native_event_full_parity( + candidate, + oracle, + command_tape=( + {"effective_bar": np.array([1, 2, 8, 16]), "sequence": np.arange(4, dtype=np.int64)}, + {"effective_bar": np.array([1, 2, 8, 16]), "sequence": np.arange(4, dtype=np.int64)}, + ), + ) + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + return { + "phase": "46A", + "status": "passed", + "source_commit": commit, + "oracle_fingerprint": certificate["oracle_fingerprint"], + "candidate_fingerprints": {"seed_46_python_replay_fixture": certificate["candidate_fingerprint"]}, + "exact_parity": certificate["passed"], + "compared_fields": certificate["compared_fields"], + "capability_matrix_version": NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + "capability_matrix_fingerprint": capability_matrix_fingerprint(), + "capabilities": dict(NATIVE_EVENT_CAPABILITY_MATRIX), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output", + type=Path, + default=Path("benchmarks/native_event/phase46a_correctness.json"), + ) + args = parser.parse_args() + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(build_evidence(), indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(args.output) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_phase46b_score_rss.py b/benchmarks/native_event/benchmark_phase46b_score_rss.py new file mode 100644 index 0000000..452d8b0 --- /dev/null +++ b/benchmarks/native_event/benchmark_phase46b_score_rss.py @@ -0,0 +1,553 @@ +"""Phase 46B apples-to-apples scalar score and staged RSS benchmark. + +Timing children execute exactly one backend. A separate parity child performs +one audit certification before timing; it is intentionally outside the RSS and +latency measurements. This prevents Python and Rust prepared ownership from +being mixed in a measured process. +""" + +from __future__ import annotations + +import argparse +import gc +import hashlib +import json +import os +from pathlib import Path +import resource +import subprocess +import sys +import time +from types import SimpleNamespace + + +PLATEAU_REPEATS = 100 + + +def _rss_current_mb() -> float: + statm = Path("/proc/self/statm") + if statm.exists(): + pages = int(statm.read_text().split()[1]) + return pages * os.sysconf("SC_PAGE_SIZE") / (1024.0 * 1024.0) + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _rss_hwm_mb() -> float: + status = Path("/proc/self/status") + if status.exists(): + for line in status.read_text().splitlines(): + if line.startswith("VmHWM:"): + return float(line.split()[1]) / 1024.0 + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _frame(rows: int): + import numpy as np + import pandas as pd + + index = pd.date_range("2024-01-01", periods=rows, freq="1min", tz="UTC") + values = 100.0 + np.sin(np.arange(rows, dtype=np.float64) / 17.0) + np.arange(rows) * 0.0001 + close = pd.Series(values, index=index) + return pd.DataFrame( + { + "open": close, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "volume": 1_000.0, + }, + index=index, + ) + + +def _commands(index, churn: str): + from quantbt import OrderCommand, OrderSide, OrderType, TimeInForce + + if churn == "low": + bars = (index[100], index[len(index) // 2]) + return ( + OrderCommand( + timestamp=bars[0], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.1, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=bars[1], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.1, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="exit", + ), + ) + + commands = [] + for bar in range(10, len(index) - 2, 4): + buy = bar % 8 == 2 + commands.append( + OrderCommand( + timestamp=index[bar], + symbol="BTC", + side=OrderSide.BUY if buy else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.1, + tif=TimeInForce.GTC, + reduce_only=not buy, + order_id=f"order-{bar}", + ) + ) + return tuple(commands) + + +def _backend(): + from quantbt import AccountConfig, ExecutionConfig, NativeEventBackend, NativeEventConfig + + return NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=50_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + + +def _audit_common_fingerprint(result, *, rust: bool) -> str: + import numpy as np + + if rust: + fields = { + "equity": result.equity, + "positions": result.positions, + "fees": result.fees, + "turnover": result.turnover, + "initial_margin": result.initial_margin, + "maintenance_margin": result.maintenance_margin, + } + else: + fields = { + "equity": result.equity.to_numpy(dtype=np.float64), + "positions": result.positions["Position_BTC"].to_numpy(dtype=np.float64), + "fees": result.fees.to_numpy(dtype=np.float64), + "turnover": result.diagnostics["turnover"].to_numpy(dtype=np.float64), + "initial_margin": result.margin["initial_margin"].to_numpy(dtype=np.float64), + "maintenance_margin": result.margin["maintenance_margin"].to_numpy(dtype=np.float64), + } + digest = hashlib.sha256() + for name in sorted(fields): + array = np.ascontiguousarray(fields[name], dtype=np.float64) + digest.update(name.encode("utf-8")) + digest.update(repr(array.shape).encode("utf-8")) + digest.update(array.tobytes()) + return digest.hexdigest() + + +def _scalar_fingerprint(result) -> str: + values = { + "final_equity": float(result.final_equity), + "final_position": float(result.final_position if hasattr(result, "final_position") else result.final_positions[0]), + "total_fee": float(result.total_fee), + "total_turnover": float(result.total_turnover), + "fill_count": int(result.fill_count), + "event_count": int(result.event_count), + "rejected_count": int(result.rejected_count), + "canceled_count": int(result.canceled_count), + "max_initial_margin": float(result.max_initial_margin), + "max_maintenance_margin": float(result.max_maintenance_margin), + } + encoded = json.dumps(values, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _rust_canonical_audit(result): + """Adapt the Rust transport event enum to the Python semantic enum. + + The Rust ABI intentionally uses a compact transport mapping while the + Python lifecycle ledger exposes ``core.event.ORDER_EVENT_*`` codes. The + parity certificate compares semantics, so the adapter is explicit and + local to this benchmark rather than silently changing either backend. + """ + import numpy as np + + rust_to_python_event = { + 0: 0, # place + 1: 1, # cancel + 2: 4, # fill + 3: 7, # reject + 4: 3, # amend + 5: 2, # replace + } + return SimpleNamespace( + equity=result.equity, + positions=result.positions, + fees=result.fees, + turnover=result.turnover, + initial_margin=result.initial_margin, + maintenance_margin=result.maintenance_margin, + fill_bar=result.fill_bar, + fill_order_id=result.fill_order_id, + fill_side=result.fill_side, + fill_qty=result.fill_qty, + fill_price=result.fill_price, + fill_fee=result.fill_fee, + event_bar=result.event_bar, + event_kind=np.asarray( + [rust_to_python_event[int(value)] for value in result.event_kind], + dtype=np.int64, + ), + event_status=result.event_status, + event_order_id=result.event_order_id, + event_target_id=result.event_target_id, + liquidated=False, + liquidation_bar=-1, + ) + + +def _child(*, backend_name: str, rows: int, repeats: int, churn: str) -> dict[str, object]: + rss_interpreter = _rss_current_mb() + import numpy as np + + backend = _backend() + if backend_name == "rust": + from quantbt import RustBatchedRunner + + rss_after_import_quantbt = _rss_current_mb() + frame = _frame(rows) + index = frame.index + market = backend.prepare_market_arrays( + datetime_index=index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + rss_after_market_prepare = _rss_current_mb() + commands = _commands(index, churn) + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + rss_after_command_compile = _rss_current_mb() + + runner = None + if backend_name == "rust": + runner = RustBatchedRunner( + idx=frame.index, + symbols=["BTC"], + market_arrays=market, + contract_size=1.0, + leverage=5.0, + fee_rate=0.0002, + initial_capital=50_000.0, + maintenance_ratio=0.0, + slippage=0.0002, + use_funding=False, + ) + rss_after_runner_prepare = _rss_current_mb() + + if backend_name in {"python", "rust"}: + del frame + gc.collect() + rss_after_runner_prepare = _rss_current_mb() + + if backend_name == "python": + def score_fn(): + return backend.run_compiled_tape_score(index, compiled, market_arrays=market) + elif backend_name == "rust": + def score_fn(): + return runner.run_tape_score(compiled) + else: + audit = backend.run_order_commands( + datetime_index=frame.index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", + ) + return { + "backend": backend_name, + "rows": int(rows), + "churn": churn, + "repeats": int(repeats), + "median_seconds": 0.0, + "mean_cpu_seconds": 0.0, + "audit_accounting_fingerprint": _audit_common_fingerprint(audit, rust=False), + "scalar_contract_fingerprint": None, + "scalar": None, + "rss_interpreter": float(rss_interpreter), + "rss_after_import_quantbt": float(rss_after_import_quantbt), + "rss_after_market_prepare": float(rss_after_market_prepare), + "rss_after_command_compile": float(rss_after_command_compile), + "rss_after_runner_prepare": float(rss_after_runner_prepare), + "rss_after_score_warmup": float(rss_after_runner_prepare), + "peak_rss_during_run": float(_rss_hwm_mb()), + "rss_after_run": float(_rss_current_mb()), + "import_baseline_rss": float(rss_after_import_quantbt - rss_interpreter), + "prepared_incremental_rss": float(rss_after_runner_prepare - rss_after_import_quantbt), + "incremental_prepared_rss": float(rss_after_runner_prepare - rss_after_import_quantbt), + "execution_incremental_peak": 0.0, + "incremental_execution_peak": 0.0, + "rss_samples": [], + "rss_plateau": False, + } + + # Warmup is outside the repeated latency sample and establishes the + # execution allocation baseline after market/tape preparation. + final_scalar = score_fn() + score_fingerprint = _scalar_fingerprint(final_scalar) + rss_after_score_warmup = _rss_current_mb() + peak_rss_during_run = max(_rss_hwm_mb(), rss_after_score_warmup) + timings = [] + cpu_timings = [] + rss_samples = [rss_after_score_warmup] + for _ in range(int(repeats)): + start = time.perf_counter() + cpu_start = time.process_time() + final_scalar = score_fn() + cpu_timings.append(time.process_time() - cpu_start) + timings.append(time.perf_counter() - start) + peak_rss_during_run = max(peak_rss_during_run, _rss_hwm_mb(), _rss_current_mb()) + rss_samples.append(_rss_current_mb()) + rss_after_run = _rss_current_mb() + scalar_payload = { + "final_equity": float(final_scalar.final_equity), + "final_position": float(final_scalar.final_position if hasattr(final_scalar, "final_position") else final_scalar.final_positions[0]), + "total_fee": float(final_scalar.total_fee), + "total_turnover": float(final_scalar.total_turnover), + "fill_count": int(final_scalar.fill_count), + "event_count": int(final_scalar.event_count), + "rejected_count": int(final_scalar.rejected_count), + "canceled_count": int(final_scalar.canceled_count), + "max_initial_margin": float(final_scalar.max_initial_margin), + "max_maintenance_margin": float(final_scalar.max_maintenance_margin), + } + return { + "backend": backend_name, + "rows": int(rows), + "churn": churn, + "repeats": int(repeats), + "median_seconds": float(np.median(np.asarray(timings, dtype=np.float64))), + "mean_cpu_seconds": float(np.mean(np.asarray(cpu_timings, dtype=np.float64))), + "audit_accounting_fingerprint": None, + "scalar_contract_fingerprint": score_fingerprint, + "scalar": scalar_payload, + "rss_interpreter": float(rss_interpreter), + "rss_after_import_quantbt": float(rss_after_import_quantbt), + "rss_after_market_prepare": float(rss_after_market_prepare), + "rss_after_command_compile": float(rss_after_command_compile), + "rss_after_runner_prepare": float(rss_after_runner_prepare), + "rss_after_score_warmup": float(rss_after_score_warmup), + "peak_rss_during_run": float(peak_rss_during_run), + "rss_after_run": float(rss_after_run), + "import_baseline_rss": float(rss_after_import_quantbt - rss_interpreter), + "prepared_incremental_rss": float(rss_after_runner_prepare - rss_after_import_quantbt), + "incremental_prepared_rss": float(rss_after_runner_prepare - rss_after_import_quantbt), + "execution_incremental_peak": float(peak_rss_during_run - rss_after_runner_prepare), + "incremental_execution_peak": float(peak_rss_during_run - rss_after_runner_prepare), + "rss_samples": [float(value) for value in rss_samples], + "rss_plateau": bool(max(rss_samples) - min(rss_samples) <= 2.0), + } + + +def _parity_child(*, rows: int, churn: str) -> dict[str, object]: + """Certify Rust audit against a replay audit outside timing children.""" + import numpy as np + from quantbt import RustBatchedRunner, assert_native_event_full_parity + + frame = _frame(rows) + backend = _backend() + market = backend.prepare_market_arrays( + datetime_index=frame.index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + commands = _commands(frame.index, churn) + compiled = backend.compile_order_commands(frame.index, commands, symbols=["BTC"]) + replay = backend.run_order_commands( + datetime_index=frame.index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", + ) + runner = RustBatchedRunner( + idx=frame.index, + symbols=["BTC"], + market_arrays=market, + contract_size=1.0, + leverage=5.0, + fee_rate=0.0002, + initial_capital=50_000.0, + maintenance_ratio=0.0, + slippage=0.0002, + use_funding=False, + ) + rust = runner.run_tape_audit(compiled) + fill_ledger = replay.metadata["compact_fill_ledger"] + event_ledger = replay.metadata["compact_order_event_ledger"] + event_order_id = np.where( + event_ledger.command_index >= 0, + compiled.command_order_id[event_ledger.command_index], + -1, + ) + event_target_id = np.where( + event_ledger.related_command_index >= 0, + compiled.command_order_id[event_ledger.related_command_index], + -1, + ) + replay_arrays = SimpleNamespace( + equity=replay.equity.to_numpy(dtype=np.float64), + positions=replay.positions["Position_BTC"].to_numpy(dtype=np.float64), + fees=replay.fees.to_numpy(dtype=np.float64), + turnover=replay.diagnostics["turnover"].to_numpy(dtype=np.float64), + initial_margin=replay.margin["initial_margin"].to_numpy(dtype=np.float64), + maintenance_margin=replay.margin["maintenance_margin"].to_numpy(dtype=np.float64), + fill_bar=fill_ledger.bar, + fill_order_id=fill_ledger.order_id_code, + fill_side=fill_ledger.side, + fill_qty=fill_ledger.qty, + fill_price=fill_ledger.price, + fill_fee=fill_ledger.fee, + event_bar=event_ledger.bar, + event_kind=event_ledger.event_type, + event_status=event_ledger.status, + event_order_id=event_order_id, + event_target_id=event_target_id, + ) + certificate = assert_native_event_full_parity( + _rust_canonical_audit(rust), + replay_arrays, + capabilities={"funding": False, "liquidation": False}, + ) + return { + "full_parity_passed": bool(certificate["passed"]), + "oracle_fingerprint": certificate["oracle_fingerprint"], + "python_fingerprint": certificate["oracle_fingerprint"], + "rust_fingerprint": certificate["candidate_fingerprint"], + "compared_fields": certificate["compared_fields"], + "python_audit_accounting_fingerprint": _audit_common_fingerprint(replay, rust=False), + "rust_audit_accounting_fingerprint": _audit_common_fingerprint(rust, rust=True), + } + + +def _run_child(backend_name: str, rows: int, repeats: int, churn: str) -> dict[str, object]: + completed = subprocess.run( + [ + sys.executable, + __file__, + "--child", + "--backend", + backend_name, + "--rows", + str(rows), + "--repeats", + str(repeats), + "--churn", + churn, + ], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout.strip().splitlines()[-1]) + + +def _run_parity(rows: int, churn: str) -> dict[str, object]: + completed = subprocess.run( + [sys.executable, __file__, "--parity", "--rows", str(rows), "--churn", churn], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout.strip().splitlines()[-1]) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--child", action="store_true") + parser.add_argument("--parity", action="store_true") + parser.add_argument("--backend", choices=("python", "rust", "replay"), default="python") + parser.add_argument("--rows", type=int, default=2_000) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--churn", choices=("low", "high"), default="low") + parser.add_argument("--json-out", default="benchmarks/native_event/phase46b_score_rss.json") + args = parser.parse_args() + + if args.child: + print(json.dumps(_child(backend_name=args.backend, rows=args.rows, repeats=args.repeats, churn=args.churn), sort_keys=True)) + return + if args.parity: + print(json.dumps(_parity_child(rows=args.rows, churn=args.churn), sort_keys=True)) + return + + parity = {churn: _run_parity(args.rows, churn) for churn in ("low", "high")} + runs = {} + for churn in ("low", "high"): + runs[churn] = { + "python": _run_child("python", args.rows, args.repeats, churn), + "rust": _run_child("rust", args.rows, args.repeats, churn), + "replay": _run_child("replay", args.rows, 1, churn), + "plateau_python": _run_child("python", args.rows, PLATEAU_REPEATS, churn), + "plateau_rust": _run_child("rust", args.rows, PLATEAU_REPEATS, churn), + } + score_parity = { + churn: { + "passed": runs[churn]["python"]["scalar_contract_fingerprint"] + == runs[churn]["rust"]["scalar_contract_fingerprint"], + "python_fingerprint": runs[churn]["python"]["scalar_contract_fingerprint"], + "rust_fingerprint": runs[churn]["rust"]["scalar_contract_fingerprint"], + } + for churn in ("low", "high") + } + full_parity_passed = all(bool(item["full_parity_passed"]) for item in parity.values()) and all( + bool(item["passed"]) for item in score_parity.values() + ) + payload = { + "phase": "46B", + "status": "passed" if full_parity_passed else "parity_failed", + "full_parity_passed": full_parity_passed, + "oracle_fingerprint": parity["low"]["oracle_fingerprint"], + "python_fingerprint": parity["low"]["python_fingerprint"], + "rust_fingerprint": parity["low"]["rust_fingerprint"], + "parity": parity, + "score_parity": score_parity, + "runs": runs, + "benchmark_contract": { + "artifact": "scalar_tape_score", + "timing_excludes_full_audit": True, + "separate_backend_processes": True, + "repetitions": int(args.repeats), + "plateau_repetitions": PLATEAU_REPEATS, + "rss_checkpoints": [ + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "rss_after_score_warmup", + "peak_rss_during_run", + "rss_after_run", + ], + }, + } + output = Path(args.json_out) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_phase46c_import_rss.py b/benchmarks/native_event/benchmark_phase46c_import_rss.py new file mode 100644 index 0000000..4aa3c14 --- /dev/null +++ b/benchmarks/native_event/benchmark_phase46c_import_rss.py @@ -0,0 +1,129 @@ +"""Fresh-process import/RSS evidence for Phase 46C. + +Run from the repository root with the source layout selected, for example:: + + MPLCONFIGDIR=/tmp PYTHONPATH=src poetry run python \ + benchmarks/native_event/benchmark_phase46c_import_rss.py + +The child process deliberately starts outside the repository so the root +compatibility mirror cannot shadow ``src/quantbt``. RSS is reported as a +process floor, not as an engine execution-memory claim. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SOURCE_ROOT = PROJECT_ROOT / "src" +FORBIDDEN = ("matplotlib", "seaborn", "optuna", "nautilus_trader", "quantstats") + + +def _rss_bytes() -> int: + with Path("/proc/self/statm").open(encoding="utf-8") as handle: + resident_pages = int(handle.read().split()[1]) + return resident_pages * os.sysconf("SC_PAGE_SIZE") + + +def _child_import() -> None: + import quantbt as _quantbt # noqa: F401 + + loaded = sorted( + name + for name in sys.modules + if any(name == prefix or name.startswith(prefix + ".") for prefix in FORBIDDEN) + ) + before_endpoint = _rss_bytes() + from quantbt import QuantBTEndpoint + + print( + json.dumps( + { + "rss_after_import_quantbt": before_endpoint, + "rss_after_endpoint_export": _rss_bytes(), + "modules_loaded": len(sys.modules), + "forbidden_modules": loaded, + "endpoint_module": QuantBTEndpoint.__module__, + } + ) + ) + + +def _run_child() -> dict[str, Any]: + env = os.environ.copy() + env.update( + { + "PYTHONNOUSERSITE": "1", + "PYTHONPATH": str(SOURCE_ROOT), + "MPLCONFIGDIR": "/tmp", + } + ) + completed = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), "--child"], + cwd="/tmp", + env=env, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout.strip().splitlines()[-1]) + + +def _importtime_summary() -> dict[str, Any]: + env = os.environ.copy() + env.update( + { + "PYTHONNOUSERSITE": "1", + "PYTHONPATH": str(SOURCE_ROOT), + "MPLCONFIGDIR": "/tmp", + } + ) + completed = subprocess.run( + [sys.executable, "-X", "importtime", "-c", "import quantbt"], + cwd="/tmp", + env=env, + check=True, + capture_output=True, + text=True, + ) + lines = [line for line in completed.stderr.splitlines() if line.strip()] + return {"importtime_line_count": len(lines), "importtime_tail": lines[-3:]} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--child", action="store_true") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.child: + _child_import() + return + + report = { + "phase": "46C", + "source_root": str(SOURCE_ROOT), + "cwd_for_child": "/tmp", + "import": _run_child(), + "importtime": _importtime_summary(), + } + report["passed"] = ( + report["import"]["forbidden_modules"] == [] + and report["import"]["endpoint_module"] == "quantbt.endpoint" + ) + payload = json.dumps(report, indent=2, sort_keys=True) + "\n" + print(payload, end="") + if args.output: + args.output.write_text(payload, encoding="utf-8") + if not report["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_phase46d_ownership_r2.py b/benchmarks/native_event/benchmark_phase46d_ownership_r2.py new file mode 100644 index 0000000..6afa920 --- /dev/null +++ b/benchmarks/native_event/benchmark_phase46d_ownership_r2.py @@ -0,0 +1,242 @@ +"""Phase 46D ownership/order-table benchmark. + +This benchmark intentionally measures the Rust-owned prepared market and +static command tape after Python fixture construction. It is not a total +process RSS claim; import floor and Python DataFrame construction are reported +separately by Phase 46C/46B evidence. +""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict +import gc +import json +import os +from pathlib import Path +import resource +import time + +import numpy as np +import pandas as pd + +from quantbt import ( + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderAction, + OrderCommand, + OrderSide, + OrderType, + TimeInForce, +) +from quantbt.backends._native_event_rust import RustBatchedRunner + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PAGE_SIZE = os.sysconf("SC_PAGE_SIZE") + + +def _rss_bytes() -> int: + with Path("/proc/self/statm").open(encoding="utf-8") as handle: + return int(handle.read().split()[1]) * PAGE_SIZE + + +def _peak_rss_bytes() -> int: + return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) * 1024 + + +def _fixture(n_bars: int, churn: str): + index = pd.date_range("2024-01-01", periods=n_bars, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.arange(n_bars, dtype=np.float64) * 0.01, index=index) + frame = pd.DataFrame( + { + "open": close, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "volume": 1_000.0, + }, + index=index, + ) + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + market = backend.prepare_market_arrays( + datetime_index=index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + commands: list[OrderCommand] = [] + if churn == "low": + for bar in range(1, n_bars, max(1, n_bars // 20)): + order_id = f"low-{bar}" + commands.append( + OrderCommand( + timestamp=index[bar], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=1.0, + tif=TimeInForce.GTC, + order_id=order_id, + ) + ) + commands.append( + OrderCommand( + timestamp=index[min(bar + 1, n_bars - 1)], + action=OrderAction.CANCEL, + target_order_id=order_id, + ) + ) + elif churn == "high": + for bar in range(n_bars): + order_id = f"high-{bar}" + commands.append( + OrderCommand( + timestamp=index[bar], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=1.0, + tif=TimeInForce.GTC, + order_id=order_id, + ) + ) + if bar: + commands.append( + OrderCommand( + timestamp=index[bar], + action=OrderAction.CANCEL, + target_order_id=f"high-{bar - 1}", + ) + ) + else: + raise ValueError("churn must be low or high") + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + runner = RustBatchedRunner( + idx=index, + symbols=["BTC"], + market_arrays=market, + contract_size=1.0, + leverage=5.0, + fee_rate=0.0002, + initial_capital=10_000.0, + slippage=0.0002, + use_funding=False, + ) + return runner, compiled + + +def _profile(n_bars: int, churn: str, repeats: int): + runner, compiled = _fixture(n_bars, churn) + before = _rss_bytes() + first_start = time.perf_counter() + first = runner.run_tape_score(compiled) + first_seconds = time.perf_counter() - first_start + after_first = _rss_bytes() + repeat_start = time.perf_counter() + last = None + for _ in range(repeats): + last = runner.run_tape_score(compiled) + repeat_seconds = time.perf_counter() - repeat_start + after_repeats = _rss_bytes() + assert last is not None + order_count = int(compiled.command_action.size) + scalar_fields = ( + "final_equity", + "final_position", + "total_fee", + "total_turnover", + "fill_count", + "event_count", + "rejected_count", + "canceled_count", + "max_initial_margin", + "max_maintenance_margin", + "bars", + ) + parity = all(getattr(first, field) == getattr(last, field) for field in scalar_fields) + sparse = runner.open_sparse_session(compiled) + first_chunk = sparse.run_until(n_bars - 1, wake_on_fill=False, wake_on_order_event=False) + reset_start = _rss_bytes() + reset_last = first_chunk + for _ in range(repeats): + sparse.reset() + reset_last = sparse.run_until(n_bars - 1, wake_on_fill=False, wake_on_order_event=False) + reset_end = _rss_bytes() + session_reset_parity = all( + getattr(first_chunk, field) == getattr(reset_last, field) + for field in scalar_fields + if hasattr(first_chunk, field) + ) + cached_bytes = runner.tape_cache_bytes + max_cached_bytes = runner.max_tape_cache_bytes + runner.clear_tape_cache() + cleared = runner.tape_cache_bytes == 0 + del runner, compiled + gc.collect() + return { + "bars": n_bars, + "churn": churn, + "orders": order_count, + "tape_cache_bytes_before_clear": cached_bytes, + "max_tape_cache_bytes": max_cached_bytes, + "first_seconds": first_seconds, + "repeat_seconds": repeat_seconds, + "repeat_seconds_per_run": repeat_seconds / max(repeats, 1), + "rss_before_first_score": before, + "rss_after_first_score": after_first, + "rss_after_repeats": after_repeats, + "incremental_first_score_rss": after_first - before, + "incremental_repeat_rss": after_repeats - after_first, + "peak_rss_bytes": _peak_rss_bytes(), + "tape_cache_cleared": cleared, + "reset_scalar_parity": parity, + "session_reset_parity": session_reset_parity, + "session_reset_rss_delta": reset_end - reset_start, + "score_metadata": asdict(first), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--bars", type=int, default=2_000) + parser.add_argument("--repeats", type=int, default=100) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + report = { + "phase": "46D", + "bars": args.bars, + "repeats": args.repeats, + "low": _profile(args.bars, "low", args.repeats), + "high": _profile(args.bars, "high", args.repeats), + } + report["passed"] = bool( + report["low"]["reset_scalar_parity"] + and report["high"]["reset_scalar_parity"] + and report["low"]["session_reset_parity"] + and report["high"]["session_reset_parity"] + and report["low"]["tape_cache_cleared"] + and report["high"]["tape_cache_cleared"] + ) + payload = json.dumps(report, indent=2, sort_keys=True) + "\n" + print(payload, end="") + if args.output: + args.output.write_text(payload, encoding="utf-8") + if not report["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_phase46e_release_gate.py b/benchmarks/native_event/benchmark_phase46e_release_gate.py new file mode 100644 index 0000000..d5f653c --- /dev/null +++ b/benchmarks/native_event/benchmark_phase46e_release_gate.py @@ -0,0 +1,137 @@ +"""Phase 46E dual-backend release gate. + +This wrapper reuses the Phase 46B apples-to-apples benchmark so the release +decision cannot drift from the established artifact contract. It records +speed, staged RSS, full parity and the explicit policy that ``auto`` remains +Python when any RSS gate is not met. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +SOURCE_BENCHMARK = Path(__file__).with_name("benchmark_phase46b_score_rss.py") + + +def _run_source(rows: int, repeats: int) -> dict: + with tempfile.NamedTemporaryFile(suffix=".json") as handle: + completed = subprocess.run( + [ + sys.executable, + str(SOURCE_BENCHMARK), + "--rows", + str(rows), + "--repeats", + str(repeats), + "--json-out", + handle.name, + ], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + # The benchmark writes the JSON file and prints a short status line; + # reading the file avoids depending on stdout formatting. + del completed + return json.loads(Path(handle.name).read_text()) + + +def _speedup(run: dict) -> float: + return float(run["python"]["median_seconds"]) / float(run["rust"]["median_seconds"]) + + +def _reduction(run: dict, key: str) -> float: + python_value = float(run["python"][key]) + rust_value = float(run["rust"][key]) + if python_value <= 0.0: + return 0.0 + return (python_value - rust_value) / python_value + + +def build_gate(source: dict) -> dict: + runs = source["runs"] + parity = source.get("parity", {}) + score_parity = source.get("score_parity", {}) + low = runs["low"] + high = runs["high"] + speedups = {"low": _speedup(low), "high": _speedup(high)} + prepared_reduction = { + churn: _reduction(runs[churn], "prepared_incremental_rss") for churn in ("low", "high") + } + execution_reduction = { + churn: _reduction(runs[churn], "execution_incremental_peak") for churn in ("low", "high") + } + parity_passed = bool(source.get("full_parity_passed", False)) and all( + bool(item.get("full_parity_passed", False)) for item in parity.values() + ) and all(bool(item.get("passed", False)) for item in score_parity.values()) + speed_passed = speedups["low"] >= 1.50 and speedups["high"] >= 2.00 + prepared_rss_passed = all(value >= 0.40 for value in prepared_reduction.values()) + execution_rss_passed = all(value >= 0.40 for value in execution_reduction.values()) + plateau_passed = all( + bool(runs[churn][backend]["rss_plateau"]) + for churn in ("low", "high") + for backend in ("plateau_python", "plateau_rust") + ) + absolute_peak_rss = max( + float(runs[churn][backend]["peak_rss_during_run"]) + for churn in ("low", "high") + for backend in ("python", "rust") + ) + absolute_budget_mb = 512.0 + return { + "phase": "46E", + "benchmark_source": "benchmark_phase46b_score_rss.py", + "benchmark_contract": source.get("benchmark_contract", {}), + "status": "passed" if parity_passed and speed_passed and prepared_rss_passed and execution_rss_passed and plateau_passed else "rss_gate_pending", + "dual_backend_contract": { + "python": "full reactive/default/canonical", + "rust": "explicit capability-gated batched tape", + "auto": "python until all release gates pass", + "replay_certified": "audit oracle", + }, + "gates": { + "full_parity_100_percent": parity_passed, + "low_churn_speedup_ge_1_50x": speedups["low"] >= 1.50, + "high_churn_speedup_ge_2_00x": speedups["high"] >= 2.00, + "prepared_rss_reduction_ge_40_percent": prepared_rss_passed, + "execution_rss_reduction_ge_40_percent": execution_rss_passed, + "absolute_peak_rss_under_budget": absolute_peak_rss <= absolute_budget_mb, + "rss_plateau_100_runs": plateau_passed, + }, + "speedup": speedups, + "prepared_rss_reduction": prepared_reduction, + "execution_rss_reduction": execution_reduction, + "absolute_peak_rss_mb": absolute_peak_rss, + "absolute_rss_budget_mb": absolute_budget_mb, + "source": source, + "release_policy": { + "rust_auto_enabled": False, + "rust_native_extra_ready": False, + "reason": "The explicit prepared-RSS gate remains a measured policy gate; no false release claim is made.", + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=2_000) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--json-out", default="benchmarks/native_event/phase46e_release_gate.json") + args = parser.parse_args() + result = build_gate(_run_source(args.rows, args.repeats)) + output = ROOT / args.json_out + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + print(json.dumps({"phase": result["phase"], "status": result["status"], "gates": result["gates"]}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/native_event/benchmark_pre48e.py b/benchmarks/native_event/benchmark_pre48e.py new file mode 100644 index 0000000..cbd7422 --- /dev/null +++ b/benchmarks/native_event/benchmark_pre48e.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python3 +"""Apples-to-apples native-event benchmark for the pre-48E gate. + +The common and explicit workloads use the same deterministic 2,000-bar tape, +the same command tape, and a fresh subprocess per route. Cold preparation is +reported separately from seven warm executions. Grid/reactive integration is +intentionally not included in the README table; it is a separate workload. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import resource +import subprocess +import sys +import time +from typing import Any + +import numpy as np +import pandas as pd + + +ROOT = Path(__file__).resolve().parents[2] +MARKER = "PRE48E_RESULT=" +N_BARS = 2_000 +N_RUNS = 7 + +for candidate in (ROOT, ROOT / "src"): + if str(candidate) not in sys.path: + sys.path.insert(0, str(candidate)) + + +def _bars(n: int = N_BARS) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC") + x = np.arange(n, dtype=np.float64) + close = 100.0 + np.sin(x / 23.0) * 2.0 + x * 0.002 + open_ = close + 0.1 * np.sin(x / 7.0) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close) + 0.75, + "low": np.minimum(open_, close) - 0.75, + "close": close, + "volume": 10_000.0 + x, + }, + index=index, + ) + + +def _commands(index: pd.DatetimeIndex, *, high_churn: bool = False): + from quantbt import OrderCommand, OrderSide, OrderType, TimeInForce + + every = 40 if high_churn else 125 + hold = 8 if high_churn else 20 + commands = [] + for bar in range(20, len(index) - hold - 1, every): + commands.append( + OrderCommand( + timestamp=index[bar], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.GTC, + order_id=f"entry-{bar}", + ) + ) + commands.append( + OrderCommand( + timestamp=index[bar + hold], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.GTC, + reduce_only=True, + order_id=f"exit-{bar + hold}", + ) + ) + return tuple(commands) + + +class GenericStrategy: + """Deterministic callback with no indicator or allocation work.""" + + def __init__(self, *, high_churn: bool = False): + self.every = 40 if high_churn else 125 + self.hold = 8 if high_churn else 20 + + def initialize(self, context): + return () + + def on_bar_close(self, context): + from quantbt import OrderCommand, OrderSide, OrderType, TimeInForce + + bar = int(context.bar_index) + if bar >= 20 and bar % self.every == 0: + return ( + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.GTC, + order_id=f"entry-{bar}", + ), + ) + if bar >= 20 and bar % self.every == self.hold: + return ( + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.GTC, + reduce_only=True, + order_id=f"exit-{bar}", + ), + ) + return () + + def finalize(self, context): + return () + + +def _peak_rss_mb() -> float: + value = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + return value / 1024.0 if sys.platform == "linux" else value / (1024.0 * 1024.0) + + +def _array_digest(digest, name: str, value: Any) -> None: + array = np.ascontiguousarray(np.asarray(value)) + digest.update(name.encode("ascii")) + digest.update(str(array.dtype).encode("ascii")) + digest.update(repr(array.shape).encode("ascii")) + digest.update(array.tobytes()) + + +def _fingerprint(value: Any, *, mode: str, index: pd.DatetimeIndex | None = None) -> str: + digest = hashlib.sha256() + if mode == "score": + for name in ("final_equity", "final_positions", "fill_count", "event_count", "rejected_count", "canceled_count"): + item = getattr(value, name, None) + if item is None and isinstance(value, dict): + item = value.get(name) + if isinstance(item, (np.ndarray, list, tuple)): + _array_digest(digest, name, item) + else: + digest.update(f"{name}={item!r}".encode("utf-8")) + return digest.hexdigest() + + for name in ("equity", "positions", "fees", "funding", "margin"): + item = getattr(value, name, None) + if item is not None: + _array_digest(digest, name, item) + if hasattr(value, "initial_margin") and not hasattr(value, "margin"): + _array_digest( + digest, + "margin", + np.column_stack((value.initial_margin, value.maintenance_margin)), + ) + metadata = getattr(value, "metadata", {}) + if not isinstance(metadata, dict): + metadata = {} + source_counters = metadata.get("lifecycle_counters", {}) + counters = { + name: int(source_counters.get(name, getattr(value, name, 0))) + for name in ("fill_count", "event_count", "rejected_count", "canceled_count") + } + digest.update(json.dumps(counters, sort_keys=True, default=str).encode("utf-8")) + if hasattr(value, "fill_bar"): + fill_rows = [ + (int(bar), int(side), float(qty), float(price), float(fee)) + for bar, side, qty, price, fee in zip( + value.fill_bar, value.fill_side, value.fill_qty, value.fill_price, value.fill_fee + ) + ] + digest.update(repr(fill_rows).encode("utf-8")) + elif index is not None: + fill_rows = [] + for fill in getattr(value, "fills", ()): + timestamp = pd.Timestamp(fill.timestamp) + bar = int(index.searchsorted(timestamp, side="left")) + side = getattr(fill.side, "sign", 1.0 if str(fill.side).lower().endswith("buy") else -1.0) + fill_rows.append((bar, int(round(float(side))), float(fill.qty), float(fill.price), float(fill.fee))) + digest.update(repr(fill_rows).encode("utf-8")) + return digest.hexdigest() + + +def _config(backend: str, level: str): + from quantbt.backends.native_event import NativeEventConfig + from quantbt.core.schema import AccountConfig, ExecutionConfig + + return NativeEventConfig( + account=AccountConfig(initial_capital=100_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=0.0), + fee_rate=0.0002, + use_funding=False, + report_level=level, + audit_sink="none" if level == "score" else "memory", + native_backend=backend, + ) + + +def _explicit(case: str, backend: str, level: str, high_churn: bool) -> dict[str, Any]: + from quantbt.backends.native_event import NativeEventBackend + + data = _bars() + idx = data.index + commands = _commands(idx, high_churn=high_churn) + native = NativeEventBackend(_config(backend, level)) + cold_start = time.perf_counter() + market = native.prepare_market_arrays( + idx, + {"BTC": data["close"]}, + highs={"BTC": data["high"]}, + lows={"BTC": data["low"]}, + symbols=["BTC"], + ) + compiled = native.compile_order_commands(idx, commands, symbols=["BTC"]) + runner = None + if backend == "rust": + runner = native.prepare_rust_batched_runner( + idx, + {"BTC": data["close"]}, + highs={"BTC": data["high"]}, + lows={"BTC": data["low"]}, + symbols=["BTC"], + ) + cold_prepare = time.perf_counter() - cold_start + rss_after_prepare = _peak_rss_mb() + + def run_once(): + if backend == "rust": + return runner.run_tape_score(compiled) if level == "score" else runner.run_tape_audit(compiled) + if level == "score": + return native.run_compiled_tape_score(idx, compiled, market_arrays=market) + return native.run_order_commands( + idx, + commands, + closes={"BTC": data["close"]}, + highs={"BTC": data["high"]}, + lows={"BTC": data["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", + ) + + run_once() + timings = [] + result = None + for _ in range(N_RUNS): + start = time.perf_counter() + result = run_once() + timings.append(time.perf_counter() - start) + final_equity = result.get("final_equity") if isinstance(result, dict) else getattr(result, "final_equity", None) + if final_equity is None: + final_equity = float(np.asarray(result.equity, dtype=np.float64)[-1]) + fill_count = result.get("fill_count", 0) if isinstance(result, dict) else getattr(result, "fill_count", None) + if fill_count is None: + fill_count = len(getattr(result, "fills", ())) + return { + "workload": "explicit_high_churn" if high_churn else "explicit_low_churn", + "route": f"explicit_{backend}_{level}", + "backend": backend, + "report_level": level, + "bars": N_BARS, + "commands": len(commands), + "cold_prepare_seconds": cold_prepare, + "rss_after_prepare_mb": rss_after_prepare, + "warm_median_seconds": float(np.median(timings)), + "warm_p95_seconds": float(np.percentile(timings, 95)), + "throughput_bars_per_second": float(N_BARS / np.median(timings)), + "peak_rss_mb": _peak_rss_mb(), + "fingerprint": _fingerprint(result, mode=level, index=idx), + "final_equity": float(final_equity), + "fill_count": int(fill_count), + "bridge_counters": { + "pycalls": 1 if backend == "rust" else 0, + "prepared_market_core": bool(runner is not None and runner.prepared_market_core is not None), + "tape_cache_bytes": int(getattr(runner, "tape_cache_bytes", 0)) if runner is not None else 0, + "runner_cache_info": runner.cache_info() if runner is not None else {}, + }, + } + + +def _common(case: str, backend: str, high_churn: bool) -> dict[str, Any]: + from quantbt import QuantBTEndpoint + + data = _bars() + level = "score" if case.endswith("score") else "audit" + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=100_000.0, + leverage=5.0, + maintenance_ratio=0.0, + fee_rate=0.0002, + use_funding=False, + native_backend=backend, + report_level=level, + reactive_execution_mode="fast" if level == "score" else "audit", + reactive_kernel_mode="single_pass" if level == "score" else "replay_certified", + audit_sink="none" if level == "score" else "memory", + ) + cold_start = time.perf_counter() + result = endpoint.simulate(data=data, strategy=GenericStrategy(high_churn=high_churn), symbols=["BTC"]) + cold_prepare = time.perf_counter() - cold_start + rss_after_prepare = _peak_rss_mb() + timings = [] + for _ in range(N_RUNS): + start = time.perf_counter() + result = endpoint.simulate(data=data, strategy=GenericStrategy(high_churn=high_churn), symbols=["BTC"]) + timings.append(time.perf_counter() - start) + counters = result.metadata.get("execution_counters", {}) + return { + "workload": "common_high_churn" if high_churn else "common_low_churn", + "route": f"common_{backend}_{level}", + "backend": backend, + "report_level": level, + "bars": N_BARS, + "commands": int(result.metadata.get("emitted_command_count", 0)), + "cold_prepare_seconds": cold_prepare, + "rss_after_prepare_mb": rss_after_prepare, + "warm_median_seconds": float(np.median(timings)), + "warm_p95_seconds": float(np.percentile(timings, 95)), + "throughput_bars_per_second": float(N_BARS / np.median(timings)), + "peak_rss_mb": _peak_rss_mb(), + "fingerprint": _fingerprint(result, mode="audit" if level == "audit" else "score", index=data.index), + "final_equity": float(result.equity.iloc[-1]), + "fill_count": int(result.metadata.get("lifecycle_counters", {}).get("fill_count", len(result.fills))), + "execution_counters": counters, + } + + +def _worker(args) -> int: + try: + if args.route.startswith("explicit"): + row = _explicit(args.route, args.backend, args.level, args.high_churn) + else: + row = _common(args.level, args.backend, args.high_churn) + except Exception as exc: + row = { + "route": args.route, + "backend": args.backend, + "report_level": args.level, + "workload": "high_churn" if args.high_churn else "low_churn", + "status": "unavailable", + "error": f"{type(exc).__name__}: {exc}", + } + print(MARKER + json.dumps(row, sort_keys=True, default=str)) + return 0 + + +def _run_worker(route: str, backend: str, level: str, high_churn: bool) -> dict[str, Any]: + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join((str(ROOT / "src"), str(ROOT), env.get("PYTHONPATH", ""))) + env.setdefault("MPLCONFIGDIR", "/tmp") + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--worker", + "--route", + route, + "--backend", + backend, + "--level", + level, + "--high-churn" if high_churn else "--low-churn", + ] + completed = subprocess.run(command, check=True, capture_output=True, text=True, env=env) + for line in reversed(completed.stdout.splitlines()): + if line.startswith(MARKER): + return json.loads(line[len(MARKER) :]) + raise RuntimeError(f"worker did not emit {MARKER}: {completed.stdout[-1000:]}") + + +def _environment() -> dict[str, Any]: + import numba + + return { + "python": platform.python_version(), + "numpy": np.__version__, + "pandas": pd.__version__, + "numba": numba.__version__, + "platform": platform.platform(), + "cpu": platform.processor() or platform.machine(), + "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), + "dirty": bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT, text=True).strip()), + } + + +def _render(payload: dict[str, Any]) -> str: + rows = payload["results"] + title = payload.get("benchmark_title", "Pre-48E Native Event Performance Pass") + lines = [ + f"# {title}", + "", + f"Contract: **{N_BARS:,} bars**, one symbol, fresh process per route, `{N_RUNS}` warm runs.", + "All runtime columns use seconds; RSS uses MB.", + "", + "## Common Native Event / Event-Driven", + "", + "| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status |", + "|---|---|---:|---:|---:|---:|---:|---:|---|", + ] + for row in rows: + if not row["route"].startswith("common"): + continue + lines.append( + f"| {row.get('workload', '-')} | `{row['route']}` | {row.get('cold_prepare_seconds', float('nan')):.6f} | " + f"{row.get('warm_median_seconds', float('nan')):.6f} | {row.get('warm_p95_seconds', float('nan')):.6f} | " + f"{row.get('throughput_bars_per_second', float('nan')):,.0f} | {row.get('peak_rss_mb', float('nan')):.1f} | " + f"{row.get('fill_count', 0)} | {row.get('status', 'ok')} |" + ) + lines.extend( + [ + "", + "## Explicit Native Event Lifecycle", + "", + "| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status |", + "|---|---|---:|---:|---:|---:|---:|---:|---|", + ] + ) + for row in rows: + if not row["route"].startswith("explicit"): + continue + lines.append( + f"| {row.get('workload', '-')} | `{row['route']}` | {row.get('cold_prepare_seconds', float('nan')):.6f} | " + f"{row.get('warm_median_seconds', float('nan')):.6f} | {row.get('warm_p95_seconds', float('nan')):.6f} | " + f"{row.get('throughput_bars_per_second', float('nan')):,.0f} | {row.get('peak_rss_mb', float('nan')):.1f} | " + f"{row.get('fill_count', 0)} | {row.get('status', 'ok')} |" + ) + lines.extend( + [ + "", + "## Contract", + "", + "- Score and audit are never compared as the same artifact.", + f"- Python/Rust parity groups: `{json.dumps(payload['parity'], sort_keys=True)}`.", + "- Python/Rust parity is exact on the supported full-contract fields; unavailable Rust capabilities are reported, not silently routed to Python.", + "- Reactive Grid is intentionally excluded from this common table and is recorded separately in `upgrade/implement.md`.", + ] + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--worker", action="store_true") + parser.add_argument("--route", choices=("common", "explicit")) + parser.add_argument("--backend", choices=("python", "rust"), default="python") + parser.add_argument("--level", choices=("score", "audit"), default="score") + parser.add_argument("--high-churn", action="store_true") + parser.add_argument("--low-churn", action="store_true") + parser.add_argument("--json-output", type=Path, default=ROOT / "benchmarks/native_event/results/pre48e/after.json") + parser.add_argument("--markdown-output", type=Path, default=ROOT / "benchmarks/native_event/results/pre48e/report.md") + args = parser.parse_args() + if args.worker: + if args.route is None: + parser.error("--worker requires --route") + return _worker(args) + rows = [] + for high_churn in (False, True): + for route in ("common", "explicit"): + for level in ("score", "audit"): + for backend in ("python", "rust"): + rows.append(_run_worker(route, backend, level, high_churn)) + benchmark_name = ( + "phase48e1_native_event_production_closure" + if "phase48e1" in str(args.json_output) + else "pre48e_native_event_performance" + ) + payload = { + "benchmark": benchmark_name, + "benchmark_title": ( + "Phase 48E.1 Native Production Closure Benchmark" + if benchmark_name.startswith("phase48e1") + else "Pre-48E Native Event Performance Pass" + ), + "bars": N_BARS, + "warm_runs": N_RUNS, + "environment": _environment(), + "results": rows, + "parity_policy": {"numeric_atol": 1e-12, "discrete_exact": True}, + } + parity = {} + for workload in ("common_low_churn", "common_high_churn", "explicit_low_churn", "explicit_high_churn"): + for level in ("score", "audit"): + group = [ + row for row in rows + if row.get("workload") == workload and row.get("report_level") == level and row.get("status", "ok") == "ok" + ] + python_rows = [row for row in group if row.get("backend") == "python"] + rust_rows = [row for row in group if row.get("backend") == "rust"] + key = f"{workload}:{level}" + if python_rows and rust_rows: + left, right = python_rows[0], rust_rows[0] + parity[key] = bool( + left.get("fingerprint") == right.get("fingerprint") + and abs(float(left.get("final_equity", 0.0)) - float(right.get("final_equity", 0.0))) <= 1e-12 + ) + if not parity[key]: + raise AssertionError(f"pre-48E Python/Rust parity failed for {key}") + else: + parity[key] = "rust_unavailable" + payload["parity"] = parity + rendered = json.dumps(payload, indent=2, sort_keys=True, default=str) + "\n" + print(rendered, end="") + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(rendered) + args.markdown_output.write_text(_render(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/native_event/benchmark_reactive_session.py b/benchmarks/native_event/benchmark_reactive_session.py new file mode 100644 index 0000000..50c50bb --- /dev/null +++ b/benchmarks/native_event/benchmark_reactive_session.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import argparse +import json +import os +import resource +import sys +import time +from pathlib import Path +from statistics import mean + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from quantbt import AccountConfig, ExecutionConfig, NativeEventBackend, NativeEventConfig, OrderCommand, OrderSide, OrderType, QuantBTEndpoint, TimeInForce # noqa: E402 + + +def _rss_mb() -> float: + status = Path("/proc/self/status") + if status.exists(): + for line in status.read_text().splitlines(): + if line.startswith("VmRSS:"): + return float(line.split()[1]) / 1024.0 + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _bars(n: int, *, symbols: tuple[str, ...] = ("BTC",)): + idx = pd.date_range("2024-01-01", periods=n, freq="1min", tz="UTC") + x = np.arange(n, dtype=np.float64) + base = 100.0 + np.sin(x / 41.0) * 2.0 + x * 0.0002 + out = {} + for j, symbol in enumerate(symbols): + scale = 1.0 + j * 0.17 + close = pd.Series(base * scale, index=idx) + out[symbol] = pd.DataFrame( + { + "open": close.shift(1).fillna(close.iloc[0]), + "high": close + 1.25 * scale, + "low": close - 1.25 * scale, + "close": close, + "volume": 10_000.0 + x, + }, + index=idx, + ) + return out[symbols[0]] if len(symbols) == 1 else out + + +class PeriodicStrategy: + def __init__(self, *, every: int, hold: int, symbols: tuple[str, ...] = ("BTC",), bracket: bool = False, gtd: bool = False): + self.every = int(every) + self.hold = int(hold) + self.symbols = symbols + self.bracket = bool(bracket) + self.gtd = bool(gtd) + + def on_bar_close(self, context): + commands = [] + bar = int(context.bar_index) + for j, symbol in enumerate(self.symbols): + if bar % self.every == 0: + oid = f"{symbol}-entry-{bar}" + commands.append( + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.BUY if j % 2 == 0 else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.IOC, + order_id=oid, + ) + ) + if self.bracket: + px = float(context.close[j]) + commands.append( + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.SELL if j % 2 == 0 else OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=0.25, + price=px + (0.75 if j % 2 == 0 else -0.75), + reduce_only=True, + parent_order_id=oid, + order_id=f"{symbol}-tp-{bar}", + ) + ) + if bar > 0 and bar % self.every == self.hold: + commands.append( + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.SELL if j % 2 == 0 else OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"{symbol}-exit-{bar}", + ) + ) + if self.gtd and bar % (self.every * 2) == 1: + commands.append( + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=0.1, + price=1.0, + tif=TimeInForce.GTD, + expires_at=pd.Timestamp(context.timestamp) + pd.Timedelta(minutes=5), + order_id=f"{symbol}-gtd-{bar}", + ) + ) + return commands + + +class R1PeriodicStrategy: + """Single-symbol GTC-only workload inside the PyO3 R1 support contract.""" + + def __init__(self, *, every: int, hold: int): + self.every = int(every) + self.hold = int(hold) + + def on_bar_close(self, context): + bar = int(context.bar_index) + if bar % self.every == 0: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.05, + tif=TimeInForce.GTC, + order_id=f"r1-entry-{bar}", + ) + ] + if bar > 0 and bar % self.every == self.hold: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.05, + tif=TimeInForce.GTC, + order_id=f"r1-exit-{bar}", + ) + ] + return [] + + +def _run_case( + name: str, + n_bars: int, + strategy, + symbols: tuple[str, ...] = ("BTC",), + repeats: int = 1, + prepared_score: bool = False, + backend: str = "python", +): + data = _bars(n_bars, symbols=symbols) + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=100_000, + leverage=5, + maintenance_ratio=0.0 if backend == "rust" else 0.005, + use_funding=False, + fee_rate=0.0002, + report_level="minimal" if prepared_score else "audit", + reactive_kernel_mode="single_pass", + ) + rss_before = _rss_mb() + t0 = time.perf_counter() + c0 = time.process_time() + result = None + if prepared_score: + prepared = endpoint.prepare_native_event_strategy(data=data, symbols=list(symbols)) + scores = [] + for _ in range(repeats): + score = prepared.score(strategy) + scores.append(float(score.equity[-1])) + event_count = 0 + command_count = 0 + fill_count = 0 + max_active_orders = 0 + final_equity = mean(scores) + elif len(symbols) > 1: + idx = next(iter(data.values())).index + commands = [] + for bar in range(1, n_bars - 1, 250): + for j, symbol in enumerate(symbols): + commands.append( + OrderCommand( + timestamp=idx[bar], + symbol=symbol, + side=OrderSide.BUY if j % 2 == 0 else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.IOC, + order_id=f"{symbol}-entry-{bar}", + ) + ) + exit_bar = min(bar + 20, n_bars - 1) + commands.append( + OrderCommand( + timestamp=idx[exit_bar], + symbol=symbol, + side=OrderSide.SELL if j % 2 == 0 else OrderSide.BUY, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"{symbol}-exit-{exit_bar}", + ) + ) + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=100_000, leverage=5), + execution=ExecutionConfig(slippage_bps=0.0), + fee_rate=0.0002, + use_funding=False, + report_level="audit", + ) + ) + result = backend.run_order_commands( + idx, + commands, + closes={symbol: frame["close"] for symbol, frame in data.items()}, + highs={symbol: frame["high"] for symbol, frame in data.items()}, + lows={symbol: frame["low"] for symbol, frame in data.items()}, + symbols=list(symbols), + ) + counters = result.metadata.get("lifecycle_counters", {}) + command_count = int(len(commands)) + event_count = int(counters.get("event_count", 0)) + fill_count = int(counters.get("fill_count", 0)) + max_active_orders = int(len(result.metadata.get("active_orders", ()))) + final_equity = float(result.equity.iloc[-1]) + else: + result = endpoint.simulate(data=data, strategy=strategy, symbols=list(symbols)) + counters = result.metadata.get("lifecycle_counters", {}) + command_count = int(counters.get("filled_command_count", 0) + counters.get("pending_command_count", 0) + counters.get("rejected_count", 0) + counters.get("canceled_count", 0)) + event_count = int(counters.get("event_count", 0)) + fill_count = int(counters.get("fill_count", 0)) + max_active_orders = int(len(result.metadata.get("active_orders", ()))) + final_equity = float(result.equity.iloc[-1]) + cpu = time.process_time() - c0 + wall = time.perf_counter() - t0 + rss_after = _rss_mb() + return { + "name": name, + "bars": n_bars, + "symbols": len(symbols), + "repeats": repeats, + "wall_seconds": wall, + "cpu_seconds": cpu, + "peak_rss_mb": float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0, + "post_run_rss_mb": rss_after, + "rss_delta_mb": rss_after - rss_before, + "command_count": command_count, + "event_count": event_count, + "fill_count": fill_count, + "max_active_orders": max_active_orders, + "final_equity": final_equity, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benchmark Python or PyO3 native-event reactive session paths") + parser.add_argument("--backend", choices=("python", "rust"), default="python") + parser.add_argument("--r1-only", action="store_true", help="run only the single-symbol R1-compatible comparison cases") + args = parser.parse_args() + os.environ["QUANTBT_NATIVE_BACKEND"] = args.backend + + cases = [ + ("25k_low_orders", 25_000, PeriodicStrategy(every=2_000, hold=20), ("BTC",), 1, False), + ("25k_high_churn", 25_000, PeriodicStrategy(every=40, hold=8), ("BTC",), 1, False), + ("100k_low_orders", 100_000, PeriodicStrategy(every=8_000, hold=20), ("BTC",), 1, False), + ("100k_high_churn", 100_000, PeriodicStrategy(every=200, hold=20), ("BTC",), 1, False), + ("parent_oco_heavy", 25_000, PeriodicStrategy(every=80, hold=16, bracket=True), ("BTC",), 1, False), + ("gtd_heavy", 25_000, PeriodicStrategy(every=120, hold=12, gtd=True), ("BTC",), 1, False), + ("multi_symbol", 25_000, PeriodicStrategy(every=250, hold=20, symbols=("BTC", "ETH")), ("BTC", "ETH"), 1, False), + ("prepared_100_scores", 5_000, PeriodicStrategy(every=500, hold=20), ("BTC",), 100, True), + ] + if args.backend == "rust": + cases = [ + ("r1_25k_low_orders", 25_000, R1PeriodicStrategy(every=2_000, hold=20), ("BTC",), 1, False), + ("r1_25k_high_churn", 25_000, R1PeriodicStrategy(every=40, hold=8), ("BTC",), 1, False), + ] + elif args.r1_only: + cases = [ + ("r1_25k_low_orders", 25_000, R1PeriodicStrategy(every=2_000, hold=20), ("BTC",), 1, False), + ("r1_25k_high_churn", 25_000, R1PeriodicStrategy(every=40, hold=8), ("BTC",), 1, False), + ] + results = [_run_case(*case, backend=args.backend) for case in cases] + payload = {"benchmark": f"native_event_reactive_session_{args.backend}", "results": results} + suffix = "r1_python" if args.backend == "python" and args.r1_only else ("baseline" if args.backend == "python" else "r1_rust") + out_json = Path(__file__).with_name(f"reactive_session_{suffix}.json") + out_md = Path(__file__).with_name(f"reactive_session_{suffix}.md") + out_json.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + lines = ["# Native Event Reactive Session Baseline", "", "| Case | Bars | Symbols | Wall s | CPU s | Peak RSS MB | Commands | Events | Fills |", "|---|---:|---:|---:|---:|---:|---:|---:|---:|"] + for row in results: + lines.append( + "| {name} | {bars} | {symbols} | {wall_seconds:.4f} | {cpu_seconds:.4f} | {peak_rss_mb:.2f} | {command_count} | {event_count} | {fill_count} |".format( + **row + ) + ) + out_md.write_text("\n".join(lines) + "\n") + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/native_event/phase45d_zero_object.json b/benchmarks/native_event/phase45d_zero_object.json new file mode 100644 index 0000000..de00564 --- /dev/null +++ b/benchmarks/native_event/phase45d_zero_object.json @@ -0,0 +1,31 @@ +{ + "parity": true, + "runs": [ + { + "final_equity": 49989.275173113085, + "mode": "audit", + "peak_rss_mb": 443.57421875, + "repeats": 1, + "rows": 100000, + "seconds": 10.270810868125409 + }, + { + "final_equity": 49989.275173113085, + "mode": "compat_score", + "peak_rss_mb": 294.30078125, + "repeats": 1, + "rows": 100000, + "seconds": 7.760626588016748 + }, + { + "final_equity": 49989.275173113085, + "mode": "scalar_score", + "peak_rss_mb": 294.35546875, + "repeats": 1, + "rows": 100000, + "seconds": 7.351332436781377 + } + ], + "scalar_faster_than_compat": true, + "scalar_rss_below_compat": false +} diff --git a/benchmarks/native_event/phase45e_rust_batched.json b/benchmarks/native_event/phase45e_rust_batched.json new file mode 100644 index 0000000..fbf8a31 --- /dev/null +++ b/benchmarks/native_event/phase45e_rust_batched.json @@ -0,0 +1,15 @@ +{ + "phase": "45E", + "bars": 100000, + "commands": 40, + "repetitions": 5, + "rust_batched_score_seconds_median": 0.00531427888199687, + "python_v2_seconds_median": 0.02485075406730175, + "speedup_python_over_rust": 4.676223175168395, + "rust_final_equity": 19999.884029203757, + "python_final_equity": 19999.884029203757, + "rust_fill_count": 40, + "python_fill_count": 40, + "maxrss_mb": 361.08984375, + "note": "Rust remains explicit experimental until isolated multi-scenario speed/RSS gates pass." +} diff --git a/benchmarks/native_event/phase45f_release_gate.json b/benchmarks/native_event/phase45f_release_gate.json new file mode 100644 index 0000000..9ba0d7b --- /dev/null +++ b/benchmarks/native_event/phase45f_release_gate.json @@ -0,0 +1,138 @@ +{ + "phase": "45F", + "bars": 100000, + "repetitions": 5, + "process_isolated": true, + "results": { + "low_churn": { + "python": { + "backend": "python", + "bars": 100000, + "commands": 40, + "fill_count": 40, + "final_equity": 19999.884029203757, + "median_seconds": 0.02794578392058611, + "peak_rss_mb": 357.9609375, + "post_run_rss_mb": 345.07421875, + "repetitions": 5, + "rss_samples_mb": [ + 348.55859375, + 357.06640625, + 352.80859375, + 357.9609375, + 345.07421875 + ], + "scenario": "low_churn", + "seconds": [ + 0.035926816053688526, + 0.02794578392058611, + 0.02132273092865944, + 0.02887671161442995, + 0.022661636117845774 + ] + }, + "rust": { + "backend": "rust", + "bars": 100000, + "commands": 40, + "fill_count": 40, + "final_equity": 19999.884029203757, + "median_seconds": 0.005487040150910616, + "peak_rss_mb": 292.56640625, + "post_run_rss_mb": 292.56640625, + "repetitions": 5, + "rss_samples_mb": [ + 292.56640625, + 292.56640625, + 292.56640625, + 292.56640625, + 292.56640625 + ], + "scenario": "low_churn", + "seconds": [ + 0.0054585570469498634, + 0.005476885009557009, + 0.005487040150910616, + 0.005539251957088709, + 0.005545840132981539 + ] + } + }, + "high_churn": { + "python": { + "backend": "python", + "bars": 100000, + "commands": 3032, + "fill_count": 3032, + "final_equity": 19878.76256887449, + "median_seconds": 0.4909677291288972, + "peak_rss_mb": 362.78515625, + "post_run_rss_mb": 362.78515625, + "repetitions": 5, + "rss_samples_mb": [ + 352.7421875, + 362.0234375, + 362.78515625, + 362.78515625, + 362.78515625 + ], + "scenario": "high_churn", + "seconds": [ + 0.46796485502272844, + 0.45656465413048863, + 0.5193740469403565, + 0.5123407319188118, + 0.4909677291288972 + ] + }, + "rust": { + "backend": "rust", + "bars": 100000, + "commands": 3032, + "fill_count": 3032, + "final_equity": 19878.76256887449, + "median_seconds": 0.006210046820342541, + "peak_rss_mb": 293.078125, + "post_run_rss_mb": 293.078125, + "repetitions": 5, + "rss_samples_mb": [ + 293.078125, + 293.078125, + 293.078125, + 293.078125, + 293.078125 + ], + "scenario": "high_churn", + "seconds": [ + 0.0062201907858252525, + 0.006210046820342541, + 0.006190197076648474, + 0.006191210355609655, + 0.006616781931370497 + ] + } + } + }, + "comparisons": { + "low_churn": { + "speedup_python_over_rust": 5.093052566044791, + "peak_rss_reduction": 0.1826862218730221, + "parity": true, + "high_churn_speed_gate": null + }, + "high_churn": { + "speedup_python_over_rust": 79.06022987147395, + "peak_rss_reduction": 0.1921441107749292, + "parity": true, + "high_churn_speed_gate": true + } + }, + "gate": { + "parity": true, + "median_end_to_end_speedup": 42.07664121875937, + "minimum_peak_rss_reduction": 0.1826862218730221, + "repeated_run_rss_plateau": true, + "release_ready": false + }, + "policy": "Rust remains explicit experimental and auto remains Python when release_ready is false." +} diff --git a/benchmarks/native_event/phase46a_correctness.json b/benchmarks/native_event/phase46a_correctness.json new file mode 100644 index 0000000..341f296 --- /dev/null +++ b/benchmarks/native_event/phase46a_correctness.json @@ -0,0 +1,48 @@ +{ + "candidate_fingerprints": { + "seed_46_python_replay_fixture": "4840e2fcc4156fec4a524ee79869f91554f4b71d9e918ec57cb3979351aae551" + }, + "capabilities": { + "amend": true, + "cancel": true, + "fok": false, + "funding": false, + "gtc": true, + "gtd": false, + "ioc": false, + "limit": true, + "liquidation": false, + "market": true, + "multi_symbol": false, + "oco": false, + "parent_child": false, + "place": true, + "quantity_constraints": true, + "reduce_only": true, + "replace": true, + "single_symbol": true, + "stop_limit": true, + "stop_market": true + }, + "capability_matrix_fingerprint": "f3b2c32771f43ca02463594b2431aca8445c51728e16fb18a3b3e7a1011eb73c", + "capability_matrix_version": "single-symbol-r2-0.3", + "compared_fields": [ + "equity", + "positions", + "fees", + "funding", + "turnover", + "initial_margin", + "maintenance_margin", + "liquidated", + "liquidation_bar", + "fills", + "events", + "command_tape" + ], + "exact_parity": true, + "oracle_fingerprint": "4840e2fcc4156fec4a524ee79869f91554f4b71d9e918ec57cb3979351aae551", + "phase": "46A", + "source_commit": "56e6bef5365fa3705155e74b89496d8c7dce0e7f", + "status": "passed" +} diff --git a/benchmarks/native_event/phase46b_score_rss.json b/benchmarks/native_event/phase46b_score_rss.json new file mode 100644 index 0000000..a308f70 --- /dev/null +++ b/benchmarks/native_event/phase46b_score_rss.json @@ -0,0 +1,865 @@ +{ + "benchmark_contract": { + "artifact": "scalar_tape_score", + "plateau_repetitions": 100, + "repetitions": 5, + "rss_checkpoints": [ + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "rss_after_score_warmup", + "peak_rss_during_run", + "rss_after_run" + ], + "separate_backend_processes": true, + "timing_excludes_full_audit": true + }, + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "parity": { + "high": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "python_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "python_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "rust_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "rust_fingerprint": "f1a786437ea0e0388df058e6d99953edf1df700c6c02cd3c4edd4a836af05be7" + }, + "low": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "python_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "rust_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745" + } + }, + "phase": "46B", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "runs": { + "high": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.43359375, + "import_baseline_rss": 251.3828125, + "incremental_execution_peak": 0.43359375, + "incremental_prepared_rss": 2.4453125, + "mean_cpu_seconds": 0.03537682160999998, + "median_seconds": 0.035118798492476344, + "peak_rss_during_run": 271.35546875, + "prepared_incremental_rss": 2.4453125, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 270.921875, + "rss_after_import_quantbt": 268.4765625, + "rss_after_market_prepare": 270.60546875, + "rss_after_run": 271.35546875, + "rss_after_runner_prepare": 270.921875, + "rss_after_score_warmup": 270.921875, + "rss_interpreter": 17.09375, + "rss_plateau": true, + "rss_samples": [ + 270.921875, + 270.921875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875, + 271.35546875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 251.26171875, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.4375, + "mean_cpu_seconds": 0.0002208390800000215, + "median_seconds": 0.00021689734421670437, + "peak_rss_during_run": 270.8515625, + "prepared_incremental_rss": 2.4375, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 270.625, + "rss_after_import_quantbt": 268.4140625, + "rss_after_market_prepare": 270.26171875, + "rss_after_run": 270.8515625, + "rss_after_runner_prepare": 270.8515625, + "rss_after_score_warmup": 270.8515625, + "rss_interpreter": 17.15234375, + "rss_plateau": true, + "rss_samples": [ + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625, + 270.8515625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.375, + "import_baseline_rss": 250.984375, + "incremental_execution_peak": 0.375, + "incremental_prepared_rss": 2.24609375, + "mean_cpu_seconds": 0.03245405840000002, + "median_seconds": 0.03245894378051162, + "peak_rss_during_run": 270.6875, + "prepared_incremental_rss": 2.24609375, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 270.3125, + "rss_after_import_quantbt": 268.06640625, + "rss_after_market_prepare": 269.99609375, + "rss_after_run": 270.6875, + "rss_after_runner_prepare": 270.3125, + "rss_after_score_warmup": 270.3125, + "rss_interpreter": 17.08203125, + "rss_plateau": true, + "rss_samples": [ + 270.3125, + 270.3125, + 270.6875, + 270.6875, + 270.6875, + 270.6875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "replay": { + "audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "backend": "replay", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 251.19140625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.453125, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 319.01953125, + "prepared_incremental_rss": 2.453125, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 270.8046875, + "rss_after_import_quantbt": 268.3515625, + "rss_after_market_prepare": 270.48828125, + "rss_after_run": 319.01953125, + "rss_after_runner_prepare": 270.8046875, + "rss_after_score_warmup": 270.8046875, + "rss_interpreter": 17.16015625, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 251.91796875, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.5703125, + "mean_cpu_seconds": 0.00023023199999991917, + "median_seconds": 0.00022686971351504326, + "peak_rss_during_run": 271.64453125, + "prepared_incremental_rss": 2.5703125, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 271.41796875, + "rss_after_import_quantbt": 269.07421875, + "rss_after_market_prepare": 271.07421875, + "rss_after_run": 271.64453125, + "rss_after_runner_prepare": 271.64453125, + "rss_after_score_warmup": 271.64453125, + "rss_interpreter": 17.15625, + "rss_plateau": true, + "rss_samples": [ + 271.64453125, + 271.64453125, + 271.64453125, + 271.64453125, + 271.64453125, + 271.64453125 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + } + }, + "low": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 250.97265625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.015625, + "mean_cpu_seconds": 0.020812576880000003, + "median_seconds": 0.020612912485376, + "peak_rss_during_run": 270.0, + "prepared_incremental_rss": 2.015625, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 270.0, + "rss_after_import_quantbt": 267.984375, + "rss_after_market_prepare": 270.0, + "rss_after_run": 270.0, + "rss_after_runner_prepare": 270.0, + "rss_after_score_warmup": 270.0, + "rss_interpreter": 17.01171875, + "rss_plateau": true, + "rss_samples": [ + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0, + 270.0 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 251.015625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.2265625, + "mean_cpu_seconds": 0.0001233991299999948, + "median_seconds": 0.00012081931345164776, + "peak_rss_during_run": 270.390625, + "prepared_incremental_rss": 2.2265625, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 270.12890625, + "rss_after_import_quantbt": 268.1640625, + "rss_after_market_prepare": 270.12890625, + "rss_after_run": 270.390625, + "rss_after_runner_prepare": 270.390625, + "rss_after_score_warmup": 270.390625, + "rss_interpreter": 17.1484375, + "rss_plateau": true, + "rss_samples": [ + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625, + 270.390625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 251.09765625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.11328125, + "mean_cpu_seconds": 0.022872191999999902, + "median_seconds": 0.023199476767331362, + "peak_rss_during_run": 270.421875, + "prepared_incremental_rss": 2.11328125, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 270.421875, + "rss_after_import_quantbt": 268.30859375, + "rss_after_market_prepare": 270.421875, + "rss_after_run": 270.421875, + "rss_after_runner_prepare": 270.421875, + "rss_after_score_warmup": 270.421875, + "rss_interpreter": 17.2109375, + "rss_plateau": true, + "rss_samples": [ + 270.421875, + 270.421875, + 270.421875, + 270.421875, + 270.421875, + 270.421875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "replay": { + "audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "backend": "replay", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 250.8984375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.1171875, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 315.52734375, + "prepared_incremental_rss": 2.1171875, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 270.12890625, + "rss_after_import_quantbt": 268.01171875, + "rss_after_market_prepare": 270.12890625, + "rss_after_run": 315.52734375, + "rss_after_runner_prepare": 270.12890625, + "rss_after_score_warmup": 270.12890625, + "rss_interpreter": 17.11328125, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 251.16015625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.3515625, + "mean_cpu_seconds": 0.0001273614000000478, + "median_seconds": 0.00012571271508932114, + "peak_rss_during_run": 270.6015625, + "prepared_incremental_rss": 2.3515625, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 270.30078125, + "rss_after_import_quantbt": 268.25, + "rss_after_market_prepare": 270.30078125, + "rss_after_run": 270.6015625, + "rss_after_runner_prepare": 270.6015625, + "rss_after_score_warmup": 270.6015625, + "rss_interpreter": 17.08984375, + "rss_plateau": true, + "rss_samples": [ + 270.6015625, + 270.6015625, + 270.6015625, + 270.6015625, + 270.6015625, + 270.6015625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + } + }, + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745", + "score_parity": { + "high": { + "passed": true, + "python_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6", + "rust_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "low": { + "passed": true, + "python_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026", + "rust_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + }, + "status": "passed" +} diff --git a/benchmarks/native_event/phase46c_import_rss.json b/benchmarks/native_event/phase46c_import_rss.json new file mode 100644 index 0000000..50db53a --- /dev/null +++ b/benchmarks/native_event/phase46c_import_rss.json @@ -0,0 +1,21 @@ +{ + "cwd_for_child": "/tmp", + "import": { + "endpoint_module": "quantbt.endpoint", + "forbidden_modules": [], + "modules_loaded": 1007, + "rss_after_endpoint_export": 188170240, + "rss_after_import_quantbt": 188170240 + }, + "importtime": { + "importtime_line_count": 1014, + "importtime_tail": [ + "import time: 545 | 545 | quantbt.engines", + "import time: 8054 | 99700 | quantbt.endpoint", + "import time: 1689 | 953641 | quantbt" + ] + }, + "passed": true, + "phase": "46C", + "source_root": "/root/bobby/pool_alpha/quantbt/src" +} diff --git a/benchmarks/native_event/phase46d1_ownership_r2.json b/benchmarks/native_event/phase46d1_ownership_r2.json new file mode 100644 index 0000000..74807b8 --- /dev/null +++ b/benchmarks/native_event/phase46d1_ownership_r2.json @@ -0,0 +1,82 @@ +{ + "bars": 2000, + "high": { + "bars": 2000, + "churn": "high", + "first_seconds": 0.00423587579280138, + "incremental_first_score_rss": 0, + "incremental_repeat_rss": 0, + "max_tape_cache_bytes": 67108864, + "orders": 3999, + "peak_rss_bytes": 194392064, + "repeat_seconds": 0.022173178382217884, + "repeat_seconds_per_run": 0.00022173178382217884, + "reset_scalar_parity": true, + "rss_after_first_score": 194392064, + "rss_after_repeats": 194392064, + "rss_before_first_score": 194392064, + "score_metadata": { + "bars": 2000, + "canceled_count": 1999, + "event_count": 3999, + "fill_count": 0, + "final_equity": 10000.0, + "final_position": 0.0, + "max_initial_margin": 0.0, + "max_maintenance_margin": 0.0, + "metadata": { + "backend": "rust_batched", + "mode": "score", + "pycalls": 1 + }, + "rejected_count": 0, + "total_fee": 0.0, + "total_turnover": 0.0 + }, + "session_reset_parity": true, + "session_reset_rss_delta": 0, + "tape_cache_bytes_before_clear": 399912, + "tape_cache_cleared": true + }, + "low": { + "bars": 2000, + "churn": "low", + "first_seconds": 0.00044868141412734985, + "incremental_first_score_rss": 0, + "incremental_repeat_rss": 0, + "max_tape_cache_bytes": 67108864, + "orders": 40, + "peak_rss_bytes": 190361600, + "repeat_seconds": 0.013972373213618994, + "repeat_seconds_per_run": 0.00013972373213618993, + "reset_scalar_parity": true, + "rss_after_first_score": 190361600, + "rss_after_repeats": 190361600, + "rss_before_first_score": 190361600, + "score_metadata": { + "bars": 2000, + "canceled_count": 20, + "event_count": 40, + "fill_count": 0, + "final_equity": 10000.0, + "final_position": 0.0, + "max_initial_margin": 0.0, + "max_maintenance_margin": 0.0, + "metadata": { + "backend": "rust_batched", + "mode": "score", + "pycalls": 1 + }, + "rejected_count": 0, + "total_fee": 0.0, + "total_turnover": 0.0 + }, + "session_reset_parity": true, + "session_reset_rss_delta": 0, + "tape_cache_bytes_before_clear": 19848, + "tape_cache_cleared": true + }, + "passed": true, + "phase": "46D", + "repeats": 100 +} diff --git a/benchmarks/native_event/phase46d1_score_rss.json b/benchmarks/native_event/phase46d1_score_rss.json new file mode 100644 index 0000000..14edbcf --- /dev/null +++ b/benchmarks/native_event/phase46d1_score_rss.json @@ -0,0 +1,865 @@ +{ + "benchmark_contract": { + "artifact": "scalar_tape_score", + "plateau_repetitions": 100, + "repetitions": 5, + "rss_checkpoints": [ + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "rss_after_score_warmup", + "peak_rss_during_run", + "rss_after_run" + ], + "separate_backend_processes": true, + "timing_excludes_full_audit": true + }, + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "parity": { + "high": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "python_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "python_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "rust_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "rust_fingerprint": "f1a786437ea0e0388df058e6d99953edf1df700c6c02cd3c4edd4a836af05be7" + }, + "low": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "python_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "rust_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745" + } + }, + "phase": "46B", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "runs": { + "high": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.53515625, + "import_baseline_rss": 162.2421875, + "incremental_execution_peak": 0.53515625, + "incremental_prepared_rss": 2.66015625, + "mean_cpu_seconds": 0.034005317770000015, + "median_seconds": 0.03358399751596153, + "peak_rss_during_run": 182.671875, + "prepared_incremental_rss": 2.66015625, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 182.13671875, + "rss_after_import_quantbt": 179.4765625, + "rss_after_market_prepare": 181.7734375, + "rss_after_run": 182.671875, + "rss_after_runner_prepare": 182.13671875, + "rss_after_score_warmup": 182.13671875, + "rss_interpreter": 17.234375, + "rss_plateau": true, + "rss_samples": [ + 182.13671875, + 182.13671875, + 182.13671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875, + 182.671875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.71875, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 3.12890625, + "mean_cpu_seconds": 0.00014622460999999643, + "median_seconds": 0.00013823295012116432, + "peak_rss_during_run": 182.83984375, + "prepared_incremental_rss": 3.12890625, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 182.4609375, + "rss_after_import_quantbt": 179.7109375, + "rss_after_market_prepare": 182.14453125, + "rss_after_run": 182.83984375, + "rss_after_runner_prepare": 182.83984375, + "rss_after_score_warmup": 182.83984375, + "rss_interpreter": 16.9921875, + "rss_plateau": true, + "rss_samples": [ + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375, + 182.83984375 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.6015625, + "import_baseline_rss": 161.25, + "incremental_execution_peak": 0.6015625, + "incremental_prepared_rss": 2.67578125, + "mean_cpu_seconds": 0.03384283739999998, + "median_seconds": 0.03352790605276823, + "peak_rss_during_run": 181.515625, + "prepared_incremental_rss": 2.67578125, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 180.9140625, + "rss_after_import_quantbt": 178.23828125, + "rss_after_market_prepare": 180.5390625, + "rss_after_run": 181.515625, + "rss_after_runner_prepare": 180.9140625, + "rss_after_score_warmup": 180.9140625, + "rss_interpreter": 16.98828125, + "rss_plateau": true, + "rss_samples": [ + 180.9140625, + 180.9140625, + 180.9140625, + 181.515625, + 181.515625, + 181.515625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "replay": { + "audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "backend": "replay", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.734375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.80078125, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 244.67578125, + "prepared_incremental_rss": 2.80078125, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 182.703125, + "rss_after_import_quantbt": 179.90234375, + "rss_after_market_prepare": 182.38671875, + "rss_after_run": 244.67578125, + "rss_after_runner_prepare": 182.703125, + "rss_after_score_warmup": 182.703125, + "rss_interpreter": 17.16796875, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.20703125, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.98046875, + "mean_cpu_seconds": 0.00019385900000004063, + "median_seconds": 0.00019131693989038467, + "peak_rss_during_run": 182.34375, + "prepared_incremental_rss": 2.98046875, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 181.99609375, + "rss_after_import_quantbt": 179.36328125, + "rss_after_market_prepare": 181.64453125, + "rss_after_run": 182.34375, + "rss_after_runner_prepare": 182.34375, + "rss_after_score_warmup": 182.34375, + "rss_interpreter": 17.15625, + "rss_plateau": true, + "rss_samples": [ + 182.34375, + 182.34375, + 182.34375, + 182.34375, + 182.34375, + 182.34375 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + } + }, + "low": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.5390625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.39453125, + "mean_cpu_seconds": 0.020498570650000013, + "median_seconds": 0.020149023039266467, + "peak_rss_during_run": 182.1640625, + "prepared_incremental_rss": 2.39453125, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 182.1640625, + "rss_after_import_quantbt": 179.76953125, + "rss_after_market_prepare": 182.1640625, + "rss_after_run": 182.1640625, + "rss_after_runner_prepare": 182.1640625, + "rss_after_score_warmup": 182.1640625, + "rss_interpreter": 17.23046875, + "rss_plateau": true, + "rss_samples": [ + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 161.390625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.79296875, + "mean_cpu_seconds": 0.00011333814999999969, + "median_seconds": 0.00011113239452242851, + "peak_rss_during_run": 181.30859375, + "prepared_incremental_rss": 2.79296875, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 180.83984375, + "rss_after_import_quantbt": 178.515625, + "rss_after_market_prepare": 180.83984375, + "rss_after_run": 181.30859375, + "rss_after_runner_prepare": 181.30859375, + "rss_after_score_warmup": 181.30859375, + "rss_interpreter": 17.125, + "rss_plateau": true, + "rss_samples": [ + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375, + 181.30859375 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.6875, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.36328125, + "mean_cpu_seconds": 0.02886716819999995, + "median_seconds": 0.030460841953754425, + "peak_rss_during_run": 182.203125, + "prepared_incremental_rss": 2.36328125, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 182.203125, + "rss_after_import_quantbt": 179.83984375, + "rss_after_market_prepare": 182.203125, + "rss_after_run": 182.203125, + "rss_after_runner_prepare": 182.203125, + "rss_after_score_warmup": 182.203125, + "rss_interpreter": 17.15234375, + "rss_plateau": true, + "rss_samples": [ + 182.203125, + 182.203125, + 182.203125, + 182.203125, + 182.203125, + 182.203125 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "replay": { + "audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "backend": "replay", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.41015625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.3671875, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 240.55859375, + "prepared_incremental_rss": 2.3671875, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 181.9296875, + "rss_after_import_quantbt": 179.5625, + "rss_after_market_prepare": 181.9296875, + "rss_after_run": 240.55859375, + "rss_after_runner_prepare": 181.9296875, + "rss_after_score_warmup": 181.9296875, + "rss_interpreter": 17.15234375, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.14453125, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.7890625, + "mean_cpu_seconds": 0.00011622899999998992, + "median_seconds": 0.0001126900315284729, + "peak_rss_during_run": 182.1640625, + "prepared_incremental_rss": 2.7890625, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 181.66796875, + "rss_after_import_quantbt": 179.375, + "rss_after_market_prepare": 181.66796875, + "rss_after_run": 182.1640625, + "rss_after_runner_prepare": 182.1640625, + "rss_after_score_warmup": 182.1640625, + "rss_interpreter": 17.23046875, + "rss_plateau": true, + "rss_samples": [ + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625, + 182.1640625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + } + }, + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745", + "score_parity": { + "high": { + "passed": true, + "python_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6", + "rust_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "low": { + "passed": true, + "python_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026", + "rust_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + }, + "status": "passed" +} diff --git a/benchmarks/native_event/phase46d_ownership_r2.json b/benchmarks/native_event/phase46d_ownership_r2.json new file mode 100644 index 0000000..8d2304c --- /dev/null +++ b/benchmarks/native_event/phase46d_ownership_r2.json @@ -0,0 +1,82 @@ +{ + "bars": 2000, + "high": { + "bars": 2000, + "churn": "high", + "first_seconds": 0.005148773081600666, + "incremental_first_score_rss": 0, + "incremental_repeat_rss": 0, + "max_tape_cache_bytes": 67108864, + "orders": 3999, + "peak_rss_bytes": 209686528, + "repeat_seconds": 0.13493354804813862, + "repeat_seconds_per_run": 0.0013493354804813861, + "reset_scalar_parity": true, + "rss_after_first_score": 194588672, + "rss_after_repeats": 194588672, + "rss_before_first_score": 194588672, + "score_metadata": { + "bars": 2000, + "canceled_count": 1999, + "event_count": 3999, + "fill_count": 0, + "final_equity": 10000.0, + "final_position": 0.0, + "max_initial_margin": 0.0, + "max_maintenance_margin": 0.0, + "metadata": { + "backend": "rust_batched", + "mode": "score", + "pycalls": 1 + }, + "rejected_count": 0, + "total_fee": 0.0, + "total_turnover": 0.0 + }, + "session_reset_parity": true, + "session_reset_rss_delta": 15097856, + "tape_cache_bytes_before_clear": 399912, + "tape_cache_cleared": true + }, + "low": { + "bars": 2000, + "churn": "low", + "first_seconds": 0.0006942879408597946, + "incremental_first_score_rss": 0, + "incremental_repeat_rss": 0, + "max_tape_cache_bytes": 67108864, + "orders": 40, + "peak_rss_bytes": 190566400, + "repeat_seconds": 0.033089503180235624, + "repeat_seconds_per_run": 0.00033089503180235626, + "reset_scalar_parity": true, + "rss_after_first_score": 190566400, + "rss_after_repeats": 190566400, + "rss_before_first_score": 190566400, + "score_metadata": { + "bars": 2000, + "canceled_count": 20, + "event_count": 40, + "fill_count": 0, + "final_equity": 10000.0, + "final_position": 0.0, + "max_initial_margin": 0.0, + "max_maintenance_margin": 0.0, + "metadata": { + "backend": "rust_batched", + "mode": "score", + "pycalls": 1 + }, + "rejected_count": 0, + "total_fee": 0.0, + "total_turnover": 0.0 + }, + "session_reset_parity": true, + "session_reset_rss_delta": 0, + "tape_cache_bytes_before_clear": 19848, + "tape_cache_cleared": true + }, + "passed": true, + "phase": "46D", + "repeats": 100 +} diff --git a/benchmarks/native_event/phase46e_release_gate.json b/benchmarks/native_event/phase46e_release_gate.json new file mode 100644 index 0000000..64620eb --- /dev/null +++ b/benchmarks/native_event/phase46e_release_gate.json @@ -0,0 +1,921 @@ +{ + "absolute_peak_rss_mb": 183.13671875, + "absolute_rss_budget_mb": 512.0, + "benchmark_contract": { + "artifact": "scalar_tape_score", + "plateau_repetitions": 100, + "repetitions": 5, + "rss_checkpoints": [ + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "rss_after_score_warmup", + "peak_rss_during_run", + "rss_after_run" + ], + "separate_backend_processes": true, + "timing_excludes_full_audit": true + }, + "benchmark_source": "benchmark_phase46b_score_rss.py", + "dual_backend_contract": { + "auto": "python until all release gates pass", + "python": "full reactive/default/canonical", + "replay_certified": "audit oracle", + "rust": "explicit capability-gated batched tape" + }, + "execution_rss_reduction": { + "high": 1.0, + "low": 0.0 + }, + "gates": { + "absolute_peak_rss_under_budget": true, + "execution_rss_reduction_ge_40_percent": false, + "full_parity_100_percent": true, + "high_churn_speedup_ge_2_00x": true, + "low_churn_speedup_ge_1_50x": true, + "prepared_rss_reduction_ge_40_percent": false, + "rss_plateau_100_runs": true + }, + "phase": "46E", + "prepared_rss_reduction": { + "high": -0.17755681818181818, + "low": -0.2847682119205298 + }, + "release_policy": { + "reason": "The explicit prepared-RSS gate remains a measured policy gate; no false release claim is made.", + "rust_auto_enabled": false, + "rust_native_extra_ready": false + }, + "source": { + "benchmark_contract": { + "artifact": "scalar_tape_score", + "plateau_repetitions": 100, + "repetitions": 5, + "rss_checkpoints": [ + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "rss_after_score_warmup", + "peak_rss_during_run", + "rss_after_run" + ], + "separate_backend_processes": true, + "timing_excludes_full_audit": true + }, + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "parity": { + "high": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "python_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "python_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "rust_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "rust_fingerprint": "f1a786437ea0e0388df058e6d99953edf1df700c6c02cd3c4edd4a836af05be7" + }, + "low": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "python_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "rust_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745" + } + }, + "phase": "46B", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "runs": { + "high": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.6015625, + "import_baseline_rss": 162.73046875, + "incremental_execution_peak": 0.6015625, + "incremental_prepared_rss": 2.8828125, + "mean_cpu_seconds": 0.03570653790000001, + "median_seconds": 0.03548924857750535, + "peak_rss_during_run": 183.26953125, + "prepared_incremental_rss": 2.8828125, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 182.66796875, + "rss_after_import_quantbt": 179.78515625, + "rss_after_market_prepare": 182.32421875, + "rss_after_run": 183.26953125, + "rss_after_runner_prepare": 182.66796875, + "rss_after_score_warmup": 182.66796875, + "rss_interpreter": 17.0546875, + "rss_plateau": true, + "rss_samples": [ + 182.66796875, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125, + 183.26953125 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.6328125, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 3.0859375, + "mean_cpu_seconds": 0.00013656769000000902, + "median_seconds": 0.00013167201541364193, + "peak_rss_during_run": 182.859375, + "prepared_incremental_rss": 3.0859375, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 182.484375, + "rss_after_import_quantbt": 179.7734375, + "rss_after_market_prepare": 182.13671875, + "rss_after_run": 182.859375, + "rss_after_runner_prepare": 182.859375, + "rss_after_score_warmup": 182.859375, + "rss_interpreter": 17.140625, + "rss_plateau": true, + "rss_samples": [ + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375, + 182.859375 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.6015625, + "import_baseline_rss": 162.39453125, + "incremental_execution_peak": 0.6015625, + "incremental_prepared_rss": 2.75, + "mean_cpu_seconds": 0.032138072000000004, + "median_seconds": 0.03258121060207486, + "peak_rss_during_run": 182.9453125, + "prepared_incremental_rss": 2.75, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 182.34375, + "rss_after_import_quantbt": 179.59375, + "rss_after_market_prepare": 182.02734375, + "rss_after_run": 182.9453125, + "rss_after_runner_prepare": 182.34375, + "rss_after_score_warmup": 182.34375, + "rss_interpreter": 17.19921875, + "rss_plateau": true, + "rss_samples": [ + 182.34375, + 182.9453125, + 182.9453125, + 182.9453125, + 182.9453125, + 182.9453125 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "replay": { + "audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "backend": "replay", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.1796875, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.85546875, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 243.50390625, + "prepared_incremental_rss": 2.85546875, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 182.2734375, + "rss_after_import_quantbt": 179.41796875, + "rss_after_market_prepare": 181.8984375, + "rss_after_run": 243.50390625, + "rss_after_runner_prepare": 182.2734375, + "rss_after_score_warmup": 182.2734375, + "rss_interpreter": 17.23828125, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.44140625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 3.23828125, + "mean_cpu_seconds": 0.00014188700000001831, + "median_seconds": 0.00014917412772774696, + "peak_rss_during_run": 182.83203125, + "prepared_incremental_rss": 3.23828125, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 182.4765625, + "rss_after_import_quantbt": 179.59375, + "rss_after_market_prepare": 182.1015625, + "rss_after_run": 182.83203125, + "rss_after_runner_prepare": 182.83203125, + "rss_after_score_warmup": 182.83203125, + "rss_interpreter": 17.15234375, + "rss_plateau": true, + "rss_samples": [ + 182.83203125, + 182.83203125, + 182.83203125, + 182.83203125, + 182.83203125, + 182.83203125 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + } + }, + "low": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.5859375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.48046875, + "mean_cpu_seconds": 0.020146478169999996, + "median_seconds": 0.02014439506456256, + "peak_rss_during_run": 182.1796875, + "prepared_incremental_rss": 2.48046875, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 182.1796875, + "rss_after_import_quantbt": 179.69921875, + "rss_after_market_prepare": 182.1796875, + "rss_after_run": 182.1796875, + "rss_after_runner_prepare": 182.1796875, + "rss_after_score_warmup": 182.1796875, + "rss_interpreter": 17.11328125, + "rss_plateau": true, + "rss_samples": [ + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875, + 182.1796875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.1953125, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.78515625, + "mean_cpu_seconds": 0.00011902080999998788, + "median_seconds": 0.00011149421334266663, + "peak_rss_during_run": 182.18359375, + "prepared_incremental_rss": 2.78515625, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 181.70703125, + "rss_after_import_quantbt": 179.3984375, + "rss_after_market_prepare": 181.70703125, + "rss_after_run": 182.18359375, + "rss_after_runner_prepare": 182.18359375, + "rss_after_score_warmup": 182.18359375, + "rss_interpreter": 17.203125, + "rss_plateau": true, + "rss_samples": [ + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375, + 182.18359375 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 163.66015625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.359375, + "mean_cpu_seconds": 0.021329649800000093, + "median_seconds": 0.021677598357200623, + "peak_rss_during_run": 183.13671875, + "prepared_incremental_rss": 2.359375, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 183.13671875, + "rss_after_import_quantbt": 180.77734375, + "rss_after_market_prepare": 183.13671875, + "rss_after_run": 183.13671875, + "rss_after_runner_prepare": 183.13671875, + "rss_after_score_warmup": 183.13671875, + "rss_interpreter": 17.1171875, + "rss_plateau": true, + "rss_samples": [ + 183.13671875, + 183.13671875, + 183.13671875, + 183.13671875, + 183.13671875, + 183.13671875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "replay": { + "audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "backend": "replay", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.609375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.34375, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 241.375, + "prepared_incremental_rss": 2.34375, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 182.1796875, + "rss_after_import_quantbt": 179.8359375, + "rss_after_market_prepare": 182.1796875, + "rss_after_run": 241.375, + "rss_after_runner_prepare": 182.1796875, + "rss_after_score_warmup": 182.1796875, + "rss_interpreter": 17.2265625, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.46484375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 3.03125, + "mean_cpu_seconds": 0.00013323160000000556, + "median_seconds": 0.0001392960548400879, + "peak_rss_during_run": 182.63671875, + "prepared_incremental_rss": 3.03125, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 182.12109375, + "rss_after_import_quantbt": 179.60546875, + "rss_after_market_prepare": 182.12109375, + "rss_after_run": 182.63671875, + "rss_after_runner_prepare": 182.63671875, + "rss_after_score_warmup": 182.63671875, + "rss_interpreter": 17.140625, + "rss_plateau": true, + "rss_samples": [ + 182.63671875, + 182.63671875, + 182.63671875, + 182.63671875, + 182.63671875, + 182.63671875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + } + }, + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745", + "score_parity": { + "high": { + "passed": true, + "python_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6", + "rust_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "low": { + "passed": true, + "python_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026", + "rust_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + }, + "status": "passed" + }, + "speedup": { + "high": 218.4105990653943, + "low": 155.622486093282 + }, + "status": "rss_gate_pending" +} diff --git a/benchmarks/native_event/phase46f_release_gate.json b/benchmarks/native_event/phase46f_release_gate.json new file mode 100644 index 0000000..1055a16 --- /dev/null +++ b/benchmarks/native_event/phase46f_release_gate.json @@ -0,0 +1,921 @@ +{ + "absolute_peak_rss_mb": 184.10546875, + "absolute_rss_budget_mb": 512.0, + "benchmark_contract": { + "artifact": "scalar_tape_score", + "plateau_repetitions": 100, + "repetitions": 5, + "rss_checkpoints": [ + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "rss_after_score_warmup", + "peak_rss_during_run", + "rss_after_run" + ], + "separate_backend_processes": true, + "timing_excludes_full_audit": true + }, + "benchmark_source": "benchmark_phase46b_score_rss.py", + "dual_backend_contract": { + "auto": "python until all release gates pass", + "python": "full reactive/default/canonical", + "replay_certified": "audit oracle", + "rust": "explicit capability-gated batched tape" + }, + "execution_rss_reduction": { + "high": 1.0, + "low": 0.0 + }, + "gates": { + "absolute_peak_rss_under_budget": true, + "execution_rss_reduction_ge_40_percent": false, + "full_parity_100_percent": true, + "high_churn_speedup_ge_2_00x": true, + "low_churn_speedup_ge_1_50x": true, + "prepared_rss_reduction_ge_40_percent": false, + "rss_plateau_100_runs": true + }, + "phase": "46E", + "prepared_rss_reduction": { + "high": -0.07607192254495158, + "low": -0.2605633802816901 + }, + "release_policy": { + "reason": "The explicit prepared-RSS gate remains a measured policy gate; no false release claim is made.", + "rust_auto_enabled": false, + "rust_native_extra_ready": false + }, + "source": { + "benchmark_contract": { + "artifact": "scalar_tape_score", + "plateau_repetitions": 100, + "repetitions": 5, + "rss_checkpoints": [ + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "rss_after_score_warmup", + "peak_rss_during_run", + "rss_after_run" + ], + "separate_backend_processes": true, + "timing_excludes_full_audit": true + }, + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "parity": { + "high": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "python_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "python_fingerprint": "d12937717e94459203ba43bd34bc8cd48d528b69e45b0725d14e05fb4747dd00", + "rust_audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "rust_fingerprint": "f1a786437ea0e0388df058e6d99953edf1df700c6c02cd3c4edd4a836af05be7" + }, + "low": { + "compared_fields": [ + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fills", + "events" + ], + "full_parity_passed": true, + "oracle_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "python_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "rust_audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745" + } + }, + "phase": "46B", + "python_fingerprint": "6ad0639c5655da4280c88b447ac8df50cb7ef00378148c217343c3c9d5749df3", + "runs": { + "high": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.53515625, + "import_baseline_rss": 160.578125, + "incremental_execution_peak": 0.53515625, + "incremental_prepared_rss": 2.63671875, + "mean_cpu_seconds": 0.03698502572999999, + "median_seconds": 0.03616123739629984, + "peak_rss_during_run": 180.88671875, + "prepared_incremental_rss": 2.63671875, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 180.3515625, + "rss_after_import_quantbt": 177.71484375, + "rss_after_market_prepare": 179.9765625, + "rss_after_run": 180.88671875, + "rss_after_runner_prepare": 180.3515625, + "rss_after_score_warmup": 180.3515625, + "rss_interpreter": 17.13671875, + "rss_plateau": true, + "rss_samples": [ + 180.3515625, + 180.3515625, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875, + 180.88671875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 161.6015625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 3.11328125, + "mean_cpu_seconds": 0.00015816270999999382, + "median_seconds": 0.00013961317017674446, + "peak_rss_during_run": 181.9375, + "prepared_incremental_rss": 3.11328125, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 181.5234375, + "rss_after_import_quantbt": 178.82421875, + "rss_after_market_prepare": 181.20703125, + "rss_after_run": 181.9375, + "rss_after_runner_prepare": 181.9375, + "rss_after_score_warmup": 181.9375, + "rss_interpreter": 17.22265625, + "rss_plateau": true, + "rss_samples": [ + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375, + 181.9375 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "high", + "execution_incremental_peak": 0.6015625, + "import_baseline_rss": 163.484375, + "incremental_execution_peak": 0.6015625, + "incremental_prepared_rss": 2.82421875, + "mean_cpu_seconds": 0.033503867599999994, + "median_seconds": 0.03358702501282096, + "peak_rss_during_run": 184.10546875, + "prepared_incremental_rss": 2.82421875, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 183.50390625, + "rss_after_import_quantbt": 180.6796875, + "rss_after_market_prepare": 183.1875, + "rss_after_run": 184.10546875, + "rss_after_runner_prepare": 183.50390625, + "rss_after_score_warmup": 183.50390625, + "rss_interpreter": 17.1953125, + "rss_plateau": true, + "rss_samples": [ + 183.50390625, + 183.50390625, + 183.50390625, + 184.10546875, + 184.10546875, + 184.10546875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "replay": { + "audit_accounting_fingerprint": "921a99620591097e58929a499b8beb4a25f5915b52850d59fde3941ce86d46ff", + "backend": "replay", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 163.6640625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.73828125, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 244.5390625, + "prepared_incremental_rss": 2.73828125, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 183.3203125, + "rss_after_import_quantbt": 180.58203125, + "rss_after_market_prepare": 182.9765625, + "rss_after_run": 244.5390625, + "rss_after_runner_prepare": 183.3203125, + "rss_after_score_warmup": 183.3203125, + "rss_interpreter": 16.91796875, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "high", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.15234375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 3.0390625, + "mean_cpu_seconds": 0.0001375991999999826, + "median_seconds": 0.00013364199548959732, + "peak_rss_during_run": 182.38671875, + "prepared_incremental_rss": 3.0390625, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 182.01171875, + "rss_after_import_quantbt": 179.34765625, + "rss_after_market_prepare": 181.66015625, + "rss_after_run": 182.38671875, + "rss_after_runner_prepare": 182.38671875, + "rss_after_score_warmup": 182.38671875, + "rss_interpreter": 17.1953125, + "rss_plateau": true, + "rss_samples": [ + 182.38671875, + 182.38671875, + 182.38671875, + 182.38671875, + 182.38671875, + 182.38671875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 994, + "fill_count": 497, + "final_equity": 49997.94578775806, + "final_position": 0.1, + "max_initial_margin": 2.0238935594373446, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.9951053400259041, + "total_turnover": 4975.526700129521 + }, + "scalar_contract_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + } + }, + "low": { + "plateau_python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.40234375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.34765625, + "mean_cpu_seconds": 0.020754829680000007, + "median_seconds": 0.020328280981630087, + "peak_rss_during_run": 181.97265625, + "prepared_incremental_rss": 2.34765625, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 181.97265625, + "rss_after_import_quantbt": 179.625, + "rss_after_market_prepare": 181.97265625, + "rss_after_run": 181.97265625, + "rss_after_runner_prepare": 181.97265625, + "rss_after_score_warmup": 181.97265625, + "rss_interpreter": 17.22265625, + "rss_plateau": true, + "rss_samples": [ + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625, + 181.97265625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "plateau_rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 160.47265625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.71875, + "mean_cpu_seconds": 0.00011179686999997607, + "median_seconds": 0.00010931841097772121, + "peak_rss_during_run": 180.32421875, + "prepared_incremental_rss": 2.71875, + "repeats": 100, + "rows": 2000, + "rss_after_command_compile": 179.8671875, + "rss_after_import_quantbt": 177.60546875, + "rss_after_market_prepare": 179.8671875, + "rss_after_run": 180.32421875, + "rss_after_runner_prepare": 180.32421875, + "rss_after_score_warmup": 180.32421875, + "rss_interpreter": 17.1328125, + "rss_plateau": true, + "rss_samples": [ + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875, + 180.32421875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "python": { + "audit_accounting_fingerprint": null, + "backend": "python", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 161.33984375, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.21875, + "mean_cpu_seconds": 0.02266408639999997, + "median_seconds": 0.022953911684453487, + "peak_rss_during_run": 180.72265625, + "prepared_incremental_rss": 2.21875, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 180.72265625, + "rss_after_import_quantbt": 178.50390625, + "rss_after_market_prepare": 180.72265625, + "rss_after_run": 180.72265625, + "rss_after_runner_prepare": 180.72265625, + "rss_after_score_warmup": 180.72265625, + "rss_interpreter": 17.1640625, + "rss_plateau": true, + "rss_samples": [ + 180.72265625, + 180.72265625, + 180.72265625, + 180.72265625, + 180.72265625, + 180.72265625 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + }, + "replay": { + "audit_accounting_fingerprint": "6a3d840b2439a60cc03ef036c9897de9f422ed77a171d41c914a92295540bafa", + "backend": "replay", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 162.0546875, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.265625, + "mean_cpu_seconds": 0.0, + "median_seconds": 0.0, + "peak_rss_during_run": 239.29296875, + "prepared_incremental_rss": 2.265625, + "repeats": 1, + "rows": 2000, + "rss_after_command_compile": 181.484375, + "rss_after_import_quantbt": 179.21875, + "rss_after_market_prepare": 181.484375, + "rss_after_run": 239.29296875, + "rss_after_runner_prepare": 181.484375, + "rss_after_score_warmup": 181.484375, + "rss_interpreter": 17.1640625, + "rss_plateau": false, + "rss_samples": [], + "scalar": null, + "scalar_contract_fingerprint": null + }, + "rust": { + "audit_accounting_fingerprint": null, + "backend": "rust", + "churn": "low", + "execution_incremental_peak": 0.0, + "import_baseline_rss": 160.515625, + "incremental_execution_peak": 0.0, + "incremental_prepared_rss": 2.796875, + "mean_cpu_seconds": 0.00012473180000003304, + "median_seconds": 0.00012595811858773232, + "peak_rss_during_run": 180.41796875, + "prepared_incremental_rss": 2.796875, + "repeats": 5, + "rows": 2000, + "rss_after_command_compile": 179.921875, + "rss_after_import_quantbt": 177.62109375, + "rss_after_market_prepare": 179.921875, + "rss_after_run": 180.41796875, + "rss_after_runner_prepare": 180.41796875, + "rss_after_score_warmup": 180.41796875, + "rss_interpreter": 17.10546875, + "rss_plateau": true, + "rss_samples": [ + 180.41796875, + 180.41796875, + 180.41796875, + 180.41796875, + 180.41796875, + 180.41796875 + ], + "scalar": { + "canceled_count": 0, + "event_count": 4, + "fill_count": 2, + "final_equity": 50000.11622090278, + "final_position": 0.0, + "max_initial_margin": 2.021975966983935, + "max_maintenance_margin": 0.0, + "rejected_count": 0, + "total_fee": 0.004009635671509747, + "total_turnover": 20.048178357548736 + }, + "scalar_contract_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + } + }, + "rust_fingerprint": "82ee9907fd0c9810ea2cc2668f6f53a1409ccf5f2bfc633c905a5026e3c18745", + "score_parity": { + "high": { + "passed": true, + "python_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6", + "rust_fingerprint": "8af6a3912527e603d1aa0bb57e72acd52b958d3633ecda9b7a19647786099ba6" + }, + "low": { + "passed": true, + "python_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026", + "rust_fingerprint": "eb4809e1c3fcf635b1ba4cf382e2ad90cc2cb062bb76c4a95e96d2ebaf1dd026" + } + }, + "status": "passed" + }, + "speedup": { + "high": 251.32088824156602, + "low": 182.23447556868385 + }, + "status": "rss_gate_pending" +} diff --git a/benchmarks/native_event/profile_grid_optimizer_trial.py b/benchmarks/native_event/profile_grid_optimizer_trial.py new file mode 100644 index 0000000..7b04210 --- /dev/null +++ b/benchmarks/native_event/profile_grid_optimizer_trial.py @@ -0,0 +1,239 @@ +"""Profile one Grid optimizer trial by ownership boundary. + +This is intentionally a process-local diagnostic rather than a speed claim for +the Rust static tape. It measures the external Grid alpha preparation, +stateful strategy construction, prepared scalar execution, and the public +objective facade separately so Phase 47D can target the real bottleneck. + +Example:: + + PYTHONPATH=. poetry run python \ + benchmarks/native_event/profile_grid_optimizer_trial.py \ + --grid-module-dir /root/bobby/pool_alpha/alphas_storage/TA \ + --bars 2000 --repeats 5 --output /tmp/grid_profile.json +""" + +from __future__ import annotations + +import argparse +import gc +import importlib.util +import json +import sys +import time +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pandas as pd + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_GRID_DIR = Path("/root/bobby/pool_alpha/alphas_storage/TA") +GRID_FILENAME = "dynamic_grid_quantbt_native_event.py" + + +def _load_grid(grid_module_dir: Path): + path = grid_module_dir / GRID_FILENAME + if not path.exists(): + raise FileNotFoundError(path) + spec = importlib.util.spec_from_file_location("phase47d_grid_profile", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load Grid module: {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _data(bars: int) -> pd.DataFrame: + index = pd.date_range("2023-01-01", periods=bars, freq="h", tz="UTC") + x = np.arange(bars, dtype=np.float64) + close = 100.0 + 5.0 * np.sin(x / 11.0) + 0.01 * x + 1.5 * np.sin(x / 47.0) + open_ = close + 0.2 * np.sin(x / 3.0) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close) + 1.5, + "low": np.minimum(open_, close) - 1.5, + "close": close, + "volume": np.full(bars, 1000.0), + }, + index=index, + ) + + +def _params() -> dict: + return { + "grid_mode": "long_only", + "ma_type": "EMA", + "ma_len": 8, + "ema_len_short": 3, + "logic": "ATR", + "band_mult": 0.25, + "zone_smoothing_len": 2, + "warmup_bars": 12, + "pyramiding": 3, + "neutral_position_mode": "hold", + "one_entry_fill_per_bar": True, + "one_exit_fill_per_bar": True, + "campaign_id": "PHASE47D", + } + + +def _execution(grid): + return grid.GridExecutionConfig( + symbol="ETHUSDT", + initial_capital=20_000.0, + cash_per_entry=1_000.0, + leverage=5.0, + maintenance_ratio=0.005, + contract_size=1.0, + fee_rate=0.0005, + slippage_bps=2.0, + use_funding=True, + funding_rate=0.0001, + native_backend="python", + reactive_execution_mode="fast", + reactive_kernel_mode="single_pass", + report_level="score", + audit_sink="none", + ) + + +def _median(rows: list[dict]) -> dict: + frame = pd.DataFrame(rows) + return { + key: float(frame[key].median()) + for key in frame.select_dtypes(include=[np.number]).columns + } + + +def _profile_prepared_scalar(grid, data, params, execution, repeats: int): + endpoint, prepared = grid.prepare_grid_score_runner(df=data, execution=execution) + rows = [] + score_execution = replace( + execution, + collect_diagnostics=False, + ) + for _ in range(repeats): + started = time.perf_counter() + alpha_frame = grid.prepare_grid_alpha_frame( + data, + dict(params), + include_diagnostic_aliases=False, + ) + after_alpha = time.perf_counter() + strategy = grid.ReactiveDynamicGridStrategy( + alpha_frame=alpha_frame, + params=dict(params), + execution=score_execution, + ) + after_strategy = time.perf_counter() + requirements = grid.NativeEventScoreRequirements.from_strategy( + strategy, + base=grid.NativeEventScoreRequirements.scalar_score_contract(), + ) + score = prepared.score( + strategy, + trading_days=365, + score_requirements=requirements, + ) + after_score = time.perf_counter() + rows.append( + { + "alpha_seconds": after_alpha - started, + "strategy_init_seconds": after_strategy - after_alpha, + "engine_score_seconds": after_score - after_strategy, + "total_seconds": after_score - started, + "fill_count": int(score.fill_count), + "num_trades": int(score.metrics["num_trades"]), + } + ) + return rows, prepared, endpoint + + +def _profile_public_objective(grid, data, params, execution, repeats: int): + rows = [] + for _ in range(repeats): + started = time.perf_counter() + run = grid.run_grid_backtest(data, params, execution) + after_run = time.perf_counter() + report = run.result.full_report(trading_days=365) + after_report = time.perf_counter() + rows.append( + { + "run_seconds": after_run - started, + "report_seconds": after_report - after_run, + "total_seconds": after_report - started, + "fill_count": int(len(run.result.fills)), + "num_trades": int(report["num_trades"]), + } + ) + del run, report + gc.collect() + return rows + + +def _add_percentages(median: dict, keys: tuple[str, ...], total_key: str = "total_seconds"): + total = median.get(total_key, 0.0) + if total <= 0.0: + return + for key in keys: + median[f"{key}_pct"] = 100.0 * median.get(key, 0.0) / total + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--grid-module-dir", type=Path, default=DEFAULT_GRID_DIR) + parser.add_argument("--bars", type=int, default=2000) + parser.add_argument("--repeats", type=int, default=5) + parser.add_argument("--output", type=Path, default=None) + args = parser.parse_args() + if args.bars <= 0 or args.repeats <= 0: + parser.error("bars and repeats must be > 0") + + grid = _load_grid(args.grid_module_dir) + data = _data(args.bars) + params = _params() + execution = _execution(grid) + + public_rows = _profile_public_objective(grid, data, params, execution, args.repeats) + scalar_rows, prepared, endpoint = _profile_prepared_scalar( + grid, data, params, execution, args.repeats + ) + public_median = _median(public_rows) + scalar_median = _median(scalar_rows) + _add_percentages(public_median, ("run_seconds", "report_seconds")) + _add_percentages( + scalar_median, + ("alpha_seconds", "strategy_init_seconds", "engine_score_seconds"), + ) + payload = { + "phase": "47D", + "grid_module": str(args.grid_module_dir / GRID_FILENAME), + "bars": args.bars, + "repeats": args.repeats, + "backend": execution.native_backend, + "public_objective": {"samples": public_rows, "median": public_median}, + "prepared_scalar": {"samples": scalar_rows, "median": scalar_median}, + "gate": { + "scores": int(prepared.scores), + "runs": int(prepared.runs), + "endpoint_result_is_none": endpoint.result is None, + }, + "note": ( + "Public objective includes result/report facade. Prepared scalar is " + "the optimizer path and includes alpha/strategy timing by boundary." + ), + } + encoded = json.dumps(payload, indent=2, default=str) + print(encoded) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(encoded + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/native_event/reactive_session_baseline.json b/benchmarks/native_event/reactive_session_baseline.json new file mode 100644 index 0000000..df7c735 --- /dev/null +++ b/benchmarks/native_event/reactive_session_baseline.json @@ -0,0 +1,133 @@ +{ + "benchmark": "native_event_reactive_session_phase43a", + "results": [ + { + "bars": 25000, + "command_count": 26, + "cpu_seconds": 1.2487215370000002, + "event_count": 52, + "fill_count": 26, + "final_equity": 99999.93697020283, + "max_active_orders": 0, + "name": "25k_low_orders", + "peak_rss_mb": 329.046875, + "post_run_rss_mb": 329.046875, + "repeats": 1, + "rss_delta_mb": 60.84765625, + "symbols": 1, + "wall_seconds": 1.2509717750363052 + }, + { + "bars": 25000, + "command_count": 1250, + "cpu_seconds": 1.3556866469999997, + "event_count": 2500, + "fill_count": 1250, + "final_equity": 99993.87295292482, + "max_active_orders": 0, + "name": "25k_high_churn", + "peak_rss_mb": 335.97265625, + "post_run_rss_mb": 335.97265625, + "repeats": 1, + "rss_delta_mb": 11.59765625, + "symbols": 1, + "wall_seconds": 1.3620397127233446 + }, + { + "bars": 100000, + "command_count": 26, + "cpu_seconds": 3.532709833000001, + "event_count": 52, + "fill_count": 26, + "final_equity": 99999.10287375665, + "max_active_orders": 0, + "name": "100k_low_orders", + "peak_rss_mb": 385.93359375, + "post_run_rss_mb": 385.93359375, + "repeats": 1, + "rss_delta_mb": 49.9609375, + "symbols": 1, + "wall_seconds": 3.5484877033159137 + }, + { + "bars": 100000, + "command_count": 1000, + "cpu_seconds": 3.8087803409999985, + "event_count": 2000, + "fill_count": 1000, + "final_equity": 99994.99583847362, + "max_active_orders": 0, + "name": "100k_high_churn", + "peak_rss_mb": 387.2734375, + "post_run_rss_mb": 387.2734375, + "repeats": 1, + "rss_delta_mb": 51.76171875, + "symbols": 1, + "wall_seconds": 3.815936630126089 + }, + { + "bars": 25000, + "command_count": 939, + "cpu_seconds": 1.1353104970000008, + "event_count": 1878, + "fill_count": 626, + "final_equity": 100055.44381312688, + "max_active_orders": 0, + "name": "parent_oco_heavy", + "peak_rss_mb": 387.2734375, + "post_run_rss_mb": 336.5546875, + "repeats": 1, + "rss_delta_mb": 1.0234375, + "symbols": 1, + "wall_seconds": 1.1371686980128288 + }, + { + "bars": 25000, + "command_count": 523, + "cpu_seconds": 1.085844761999999, + "event_count": 1046, + "fill_count": 418, + "final_equity": 99998.11321355036, + "max_active_orders": 0, + "name": "gtd_heavy", + "peak_rss_mb": 387.2734375, + "post_run_rss_mb": 336.5546875, + "repeats": 1, + "rss_delta_mb": 0.0, + "symbols": 1, + "wall_seconds": 1.0885962881147861 + }, + { + "bars": 25000, + "command_count": 400, + "cpu_seconds": 0.09030850999999984, + "event_count": 800, + "fill_count": 400, + "final_equity": 99997.81503303988, + "max_active_orders": 0, + "name": "multi_symbol", + "peak_rss_mb": 387.2734375, + "post_run_rss_mb": 336.5546875, + "repeats": 1, + "rss_delta_mb": 0.0, + "symbols": 2, + "wall_seconds": 0.0913150580599904 + }, + { + "bars": 5000, + "command_count": 0, + "cpu_seconds": 21.498725391, + "event_count": 0, + "fill_count": 0, + "final_equity": 100000.12106150453, + "max_active_orders": 0, + "name": "prepared_100_scores", + "peak_rss_mb": 387.2734375, + "post_run_rss_mb": 336.5546875, + "repeats": 100, + "rss_delta_mb": 0.0, + "symbols": 1, + "wall_seconds": 21.635784132871777 + } + ] +} diff --git a/benchmarks/native_event/reactive_session_baseline.md b/benchmarks/native_event/reactive_session_baseline.md new file mode 100644 index 0000000..e100c98 --- /dev/null +++ b/benchmarks/native_event/reactive_session_baseline.md @@ -0,0 +1,12 @@ +# Native Event Reactive Session Baseline + +| Case | Bars | Symbols | Wall s | CPU s | Peak RSS MB | Commands | Events | Fills | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 25k_low_orders | 25000 | 1 | 1.2510 | 1.2487 | 329.05 | 26 | 52 | 26 | +| 25k_high_churn | 25000 | 1 | 1.3620 | 1.3557 | 335.97 | 1250 | 2500 | 1250 | +| 100k_low_orders | 100000 | 1 | 3.5485 | 3.5327 | 385.93 | 26 | 52 | 26 | +| 100k_high_churn | 100000 | 1 | 3.8159 | 3.8088 | 387.27 | 1000 | 2000 | 1000 | +| parent_oco_heavy | 25000 | 1 | 1.1372 | 1.1353 | 387.27 | 939 | 1878 | 626 | +| gtd_heavy | 25000 | 1 | 1.0886 | 1.0858 | 387.27 | 523 | 1046 | 418 | +| multi_symbol | 25000 | 2 | 0.0913 | 0.0903 | 387.27 | 400 | 800 | 400 | +| prepared_100_scores | 5000 | 1 | 21.6358 | 21.4987 | 387.27 | 0 | 0 | 0 | diff --git a/benchmarks/native_event/results/phase47c/python_audit_long_only.json b/benchmarks/native_event/results/phase47c/python_audit_long_only.json new file mode 100644 index 0000000..f07f417 --- /dev/null +++ b/benchmarks/native_event/results/phase47c/python_audit_long_only.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": null, + "backend_requested": "python", + "backend_resolved": "python", + "bars": 2000, + "cpu_median_seconds": 1.3599455010000003, + "cpu_seconds": [ + 1.4551069669999999, + 1.389437666, + 1.3512640080000011, + 1.3468982460000003, + 1.3599455010000003 + ], + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "git_revision": "54525d3", + "grid_mode": "long_only", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "audit", + "peak_rss_kb": 286188, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": false + }, + "post_run_rss_kb": [ + 270340, + 283276, + 284604, + 285660, + 286188 + ], + "post_run_rss_median_kb": 284604.0, + "post_run_rss_slope_kb_per_run": 3407.9999999999727, + "post_run_rss_tail_slope_kb_per_run": 979.199999999944, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.3625655872747302, + "runtime_p95_seconds": 1.443108623381704, + "runtime_seconds": [ + 1.4556716200895607, + 1.3928566365502775, + 1.358347091358155, + 1.3510226211510599, + 1.3625655872747302 + ], + "total_fee": 424.18830718151395, + "total_funding": 33.05610695634668, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47c/python_audit_long_short.json b/benchmarks/native_event/results/phase47c/python_audit_long_short.json new file mode 100644 index 0000000..068b072 --- /dev/null +++ b/benchmarks/native_event/results/phase47c/python_audit_long_short.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": null, + "backend_requested": "python", + "backend_resolved": "python", + "bars": 2000, + "cpu_median_seconds": 2.5046089030000003, + "cpu_seconds": [ + 2.5046089030000003, + 2.573951697000001, + 2.757203627999999, + 2.470201265, + 2.464312918000001 + ], + "fill_count": 107, + "final_equity": 20457.971765918566, + "fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "git_revision": "54525d3", + "grid_mode": "long_short", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "audit", + "peak_rss_kb": 328976, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": false + }, + "post_run_rss_kb": [ + 300696, + 309928, + 310976, + 313032, + 314040 + ], + "post_run_rss_median_kb": 310976.0, + "post_run_rss_slope_kb_per_run": 2979.199999999958, + "post_run_rss_tail_slope_kb_per_run": 1439.1999999999357, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 2.5823427704162896, + "runtime_p95_seconds": 2.741734031308442, + "runtime_seconds": [ + 2.51557513512671, + 2.5823427704162896, + 2.77607977995649, + 2.6043510367162526, + 2.481067919638008 + ], + "total_fee": 53.99093702080406, + "total_funding": -0.28817876653661534, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47c/python_scalar_long_only.json b/benchmarks/native_event/results/phase47c/python_scalar_long_only.json new file mode 100644 index 0000000..39c2c86 --- /dev/null +++ b/benchmarks/native_event/results/phase47c/python_scalar_long_only.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "backend_requested": "python", + "backend_resolved": "python", + "bars": 2000, + "cpu_median_seconds": 1.1338916129999994, + "cpu_seconds": [ + 1.1514802150000003, + 1.1123359950000005, + 1.2123222799999986, + 1.1338916129999994, + 1.1186609940000007 + ], + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "git_revision": "54525d3", + "grid_mode": "long_only", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 272020, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": false + }, + "post_run_rss_kb": [ + 270512, + 270512, + 270512, + 270512, + 270512 + ], + "post_run_rss_median_kb": 270512.0, + "post_run_rss_slope_kb_per_run": -5.6797036363100246e-12, + "post_run_rss_tail_slope_kb_per_run": -4.9223063440280823e-11, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.1377169508486986, + "runtime_p95_seconds": 1.2065768348053099, + "runtime_seconds": [ + 1.1594691900536418, + 1.119192838203162, + 1.2183537459932268, + 1.1377169508486986, + 1.1232997477054596 + ], + "total_fee": 424.18830718151406, + "total_funding": 33.05610695634667, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47c/python_scalar_long_short.json b/benchmarks/native_event/results/phase47c/python_scalar_long_short.json new file mode 100644 index 0000000..02072eb --- /dev/null +++ b/benchmarks/native_event/results/phase47c/python_scalar_long_short.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "backend_requested": "python", + "backend_resolved": "python", + "bars": 2000, + "cpu_median_seconds": 1.8297140459999994, + "cpu_seconds": [ + 1.8574830030000005, + 1.8050197790000002, + 1.7957384370000007, + 1.8297140459999994, + 1.833336182 + ], + "fill_count": 107, + "final_equity": 20457.971765918566, + "fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "git_revision": "54525d3", + "grid_mode": "long_short", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 298104, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": false + }, + "post_run_rss_kb": [ + 292572, + 292572, + 292572, + 292572, + 292572 + ], + "post_run_rss_median_kb": 292572.0, + "post_run_rss_slope_kb_per_run": -1.682997631331872e-11, + "post_run_rss_tail_slope_kb_per_run": -5.249274689558284e-11, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.8456540466286242, + "runtime_p95_seconds": 1.8748404739424587, + "runtime_seconds": [ + 1.8767477069050074, + 1.8122127749957144, + 1.8366738301701844, + 1.8672115420922637, + 1.8456540466286242 + ], + "total_fee": 53.990937020804054, + "total_funding": -0.28817876653661534, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47c/rust_audit_long_only.json b/benchmarks/native_event/results/phase47c/rust_audit_long_only.json new file mode 100644 index 0000000..2a31e6a --- /dev/null +++ b/benchmarks/native_event/results/phase47c/rust_audit_long_only.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": null, + "backend_requested": "rust", + "backend_resolved": "rust", + "bars": 2000, + "cpu_median_seconds": 1.5959021609999997, + "cpu_seconds": [ + 1.6444528270000003, + 1.631166093, + 1.5049514360000007, + 1.5857731200000007, + 1.5959021609999997 + ], + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "git_revision": "54525d3", + "grid_mode": "long_only", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "audit", + "peak_rss_kb": 294012, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": true + }, + "post_run_rss_kb": [ + 279808, + 287048, + 287600, + 288620, + 289180 + ], + "post_run_rss_median_kb": 287600.0, + "post_run_rss_slope_kb_per_run": 2031.5999999999913, + "post_run_rss_tail_slope_kb_per_run": 741.5999999999128, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.6247680089436471, + "runtime_p95_seconds": 1.6478540726937354, + "runtime_seconds": [ + 1.65031629614532, + 1.638005178887397, + 1.522061834577471, + 1.5881927269510925, + 1.6247680089436471 + ], + "total_fee": 424.18830718151395, + "total_funding": 33.05610695634668, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47c/rust_audit_long_short.json b/benchmarks/native_event/results/phase47c/rust_audit_long_short.json new file mode 100644 index 0000000..d772887 --- /dev/null +++ b/benchmarks/native_event/results/phase47c/rust_audit_long_short.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": null, + "backend_requested": "rust", + "backend_resolved": "rust", + "bars": 2000, + "cpu_median_seconds": 2.685722052000001, + "cpu_seconds": [ + 2.756462282, + 2.684161757, + 2.742522448999999, + 2.685722052000001, + 2.6323253939999987 + ], + "fill_count": 107, + "final_equity": 20457.971765918566, + "fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "git_revision": "54525d3", + "grid_mode": "long_short", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "audit", + "peak_rss_kb": 338084, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": true + }, + "post_run_rss_kb": [ + 307140, + 320044, + 320084, + 322128, + 323140 + ], + "post_run_rss_median_kb": 320084.0, + "post_run_rss_slope_kb_per_run": 3408.399999999943, + "post_run_rss_tail_slope_kb_per_run": 1133.1999999999714, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 2.6971161481924355, + "runtime_p95_seconds": 2.7555430798791347, + "runtime_seconds": [ + 2.758294310886413, + 2.6925436621531844, + 2.744538155850023, + 2.6971161481924355, + 2.6346550369635224 + ], + "total_fee": 53.99093702080406, + "total_funding": -0.28817876653661534, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47c/rust_scalar_long_only.json b/benchmarks/native_event/results/phase47c/rust_scalar_long_only.json new file mode 100644 index 0000000..8a6d9f5 --- /dev/null +++ b/benchmarks/native_event/results/phase47c/rust_scalar_long_only.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "backend_requested": "rust", + "backend_resolved": "rust", + "bars": 2000, + "cpu_median_seconds": 1.2435303490000003, + "cpu_seconds": [ + 1.2562242540000002, + 1.2871366269999998, + 1.2359744960000008, + 1.2399977979999992, + 1.2435303490000003 + ], + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "git_revision": "54525d3", + "grid_mode": "long_only", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 279740, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": true + }, + "post_run_rss_kb": [ + 279212, + 279740, + 278788, + 278788, + 278788 + ], + "post_run_rss_median_kb": 278788.0, + "post_run_rss_slope_kb_per_run": -180.00000000000418, + "post_run_rss_tail_slope_kb_per_run": -285.60000000006136, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.2452328260987997, + "runtime_p95_seconds": 1.29007907230407, + "runtime_seconds": [ + 1.263630743138492, + 1.2966911545954645, + 1.2400304400362074, + 1.2442902871407568, + 1.2452328260987997 + ], + "total_fee": 424.18830718151406, + "total_funding": 33.05610695634667, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47c/rust_scalar_long_short.json b/benchmarks/native_event/results/phase47c/rust_scalar_long_short.json new file mode 100644 index 0000000..e8689a1 --- /dev/null +++ b/benchmarks/native_event/results/phase47c/rust_scalar_long_short.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "backend_requested": "rust", + "backend_resolved": "rust", + "bars": 2000, + "cpu_median_seconds": 1.9799801699999975, + "cpu_seconds": [ + 1.9611749849999995, + 1.9975429580000004, + 2.055511161, + 1.9591825159999985, + 1.9799801699999975 + ], + "fill_count": 107, + "final_equity": 20457.971765918566, + "fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "git_revision": "54525d3", + "grid_mode": "long_short", + "grid_module_version": "2026-07-29-phase34-prepared-native-event-v3", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 300432, + "phase": "47C", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": true + }, + "post_run_rss_kb": [ + 294824, + 294824, + 294824, + 294824, + 294824 + ], + "post_run_rss_median_kb": 294824.0, + "post_run_rss_slope_kb_per_run": -1.5190583875778267e-11, + "post_run_rss_tail_slope_kb_per_run": -6.917027814108568e-11, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.9850474949926138, + "runtime_p95_seconds": 2.0490150056779384, + "runtime_seconds": [ + 1.9660245799459517, + 2.001668579876423, + 2.0608516121283174, + 1.9617330362088978, + 1.9850474949926138 + ], + "total_fee": 53.990937020804054, + "total_funding": -0.28817876653661534, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47d/profile_scalar.json b/benchmarks/native_event/results/phase47d/profile_scalar.json new file mode 100644 index 0000000..c08b948 --- /dev/null +++ b/benchmarks/native_event/results/phase47d/profile_scalar.json @@ -0,0 +1,116 @@ +{ + "phase": "47D", + "grid_module": "/root/bobby/pool_alpha/alphas_storage/TA/dynamic_grid_quantbt_native_event.py", + "bars": 2000, + "repeats": 5, + "backend": "python", + "public_objective": { + "samples": [ + { + "run_seconds": 1.248933516908437, + "report_seconds": 0.0029217731207609177, + "total_seconds": 1.251855290029198, + "fill_count": 0, + "num_trades": 794 + }, + { + "run_seconds": 1.1467866371385753, + "report_seconds": 0.0017385408282279968, + "total_seconds": 1.1485251779668033, + "fill_count": 0, + "num_trades": 794 + }, + { + "run_seconds": 1.2253255806863308, + "report_seconds": 0.0018004169687628746, + "total_seconds": 1.2271259976550937, + "fill_count": 0, + "num_trades": 794 + }, + { + "run_seconds": 1.172583345323801, + "report_seconds": 0.0016640317626297474, + "total_seconds": 1.1742473770864308, + "fill_count": 0, + "num_trades": 794 + }, + { + "run_seconds": 1.1392091778106987, + "report_seconds": 0.0018485900945961475, + "total_seconds": 1.141057767905295, + "fill_count": 0, + "num_trades": 794 + } + ], + "median": { + "run_seconds": 1.172583345323801, + "report_seconds": 0.0018004169687628746, + "total_seconds": 1.1742473770864308, + "fill_count": 0.0, + "num_trades": 794.0, + "run_seconds_pct": 99.85828950567821, + "report_seconds_pct": 0.15332518546731694 + } + }, + "prepared_scalar": { + "samples": [ + { + "alpha_seconds": 0.017120220698416233, + "strategy_init_seconds": 5.3612980991601944e-05, + "engine_score_seconds": 0.7962638223543763, + "total_seconds": 0.8134376560337842, + "fill_count": 839, + "num_trades": 794 + }, + { + "alpha_seconds": 0.017597327008843422, + "strategy_init_seconds": 0.00011811964213848114, + "engine_score_seconds": 0.792645042296499, + "total_seconds": 0.8103604889474809, + "fill_count": 839, + "num_trades": 794 + }, + { + "alpha_seconds": 0.017491152975708246, + "strategy_init_seconds": 0.0001720399595797062, + "engine_score_seconds": 0.7946259281598032, + "total_seconds": 0.8122891210950911, + "fill_count": 839, + "num_trades": 794 + }, + { + "alpha_seconds": 0.017332741990685463, + "strategy_init_seconds": 0.00013088714331388474, + "engine_score_seconds": 0.8262469749897718, + "total_seconds": 0.8437106041237712, + "fill_count": 839, + "num_trades": 794 + }, + { + "alpha_seconds": 0.019333916250616312, + "strategy_init_seconds": 0.00019873492419719696, + "engine_score_seconds": 0.8642722158692777, + "total_seconds": 0.8838048670440912, + "fill_count": 839, + "num_trades": 794 + } + ], + "median": { + "alpha_seconds": 0.017491152975708246, + "strategy_init_seconds": 0.00013088714331388474, + "engine_score_seconds": 0.7962638223543763, + "total_seconds": 0.8134376560337842, + "fill_count": 839.0, + "num_trades": 794.0, + "alpha_seconds_pct": 2.150275788926815, + "strategy_init_seconds_pct": 0.016090617682008153, + "engine_score_seconds_pct": 97.88873387505255 + } + }, + "gate": { + "scores": 5, + "runs": 0, + "endpoint_result_is_none": true + }, + "note": "Public objective includes result/report facade. Prepared scalar is the optimizer path and includes alpha/strategy timing by boundary." +} diff --git a/benchmarks/native_event/results/phase47d/python_scalar_long_only.json b/benchmarks/native_event/results/phase47d/python_scalar_long_only.json new file mode 100644 index 0000000..bd61a82 --- /dev/null +++ b/benchmarks/native_event/results/phase47d/python_scalar_long_only.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "backend_requested": "python", + "backend_resolved": "python", + "bars": 2000, + "cpu_median_seconds": 0.7924911629999993, + "cpu_seconds": [ + 0.7975947300000001, + 0.7980110280000003, + 0.7877454310000003, + 0.7866582390000003, + 0.7924911629999993 + ], + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "git_revision": "7043c3f", + "grid_mode": "long_only", + "grid_module_version": "2026-08-02-phase47d-optimizer-safe-hot-path-v1", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 271800, + "phase": "47D", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": false + }, + "post_run_rss_kb": [ + 270492, + 270492, + 270492, + 270492, + 270492 + ], + "post_run_rss_median_kb": 270492.0, + "post_run_rss_slope_kb_per_run": -1.5577006449697628e-11, + "post_run_rss_tail_slope_kb_per_run": -4.9262433268162864e-11, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 0.8495033131912351, + "runtime_p95_seconds": 0.8930041712708772, + "runtime_seconds": [ + 0.8495033131912351, + 0.8959734258241951, + 0.83362772827968, + 0.881127153057605, + 0.847237064037472 + ], + "total_fee": 424.18830718151406, + "total_funding": 33.05610695634667, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47d/python_scalar_long_short.json b/benchmarks/native_event/results/phase47d/python_scalar_long_short.json new file mode 100644 index 0000000..c86bddc --- /dev/null +++ b/benchmarks/native_event/results/phase47d/python_scalar_long_short.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "backend_requested": "python", + "backend_resolved": "python", + "bars": 2000, + "cpu_median_seconds": 1.4030903129999999, + "cpu_seconds": [ + 1.6881001420000006, + 1.6901008080000004, + 1.3646704009999997, + 1.390870156, + 1.4030903129999999 + ], + "fill_count": 107, + "final_equity": 20457.971765918566, + "fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "git_revision": "7043c3f", + "grid_mode": "long_short", + "grid_module_version": "2026-08-02-phase47d-optimizer-safe-hot-path-v1", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 297992, + "phase": "47D", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": false + }, + "post_run_rss_kb": [ + 292424, + 292424, + 292424, + 292424, + 292424 + ], + "post_run_rss_median_kb": 292424.0, + "post_run_rss_slope_kb_per_run": -2.5382090207204018e-11, + "post_run_rss_tail_slope_kb_per_run": -4.086147050944543e-11, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.4124071751721203, + "runtime_p95_seconds": 1.789842084608972, + "runtime_seconds": [ + 1.777704511769116, + 1.792876477818936, + 1.3928132667206228, + 1.4124071751721203, + 1.4121430362574756 + ], + "total_fee": 53.990937020804054, + "total_funding": -0.28817876653661534, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47d/rust_scalar_long_only.json b/benchmarks/native_event/results/phase47d/rust_scalar_long_only.json new file mode 100644 index 0000000..db09ffa --- /dev/null +++ b/benchmarks/native_event/results/phase47d/rust_scalar_long_only.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "backend_requested": "rust", + "backend_resolved": "rust", + "bars": 2000, + "cpu_median_seconds": 1.0689989319999995, + "cpu_seconds": [ + 1.0689989319999995, + 1.0636789970000002, + 1.0587122250000007, + 1.076345311999999, + 1.1710071929999994 + ], + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "78e1f92e5d1ce3096bb0778ed9d33ae64003817c77c824e65ce4a0c89fc4da77", + "git_revision": "7043c3f", + "grid_mode": "long_only", + "grid_module_version": "2026-08-02-phase47d-optimizer-safe-hot-path-v1", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 277704, + "phase": "47D", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": true + }, + "post_run_rss_kb": [ + 277704, + 276928, + 276928, + 276928, + 276928 + ], + "post_run_rss_median_kb": 276928.0, + "post_run_rss_slope_kb_per_run": -155.20000000004035, + "post_run_rss_tail_slope_kb_per_run": -5.228354082336147e-11, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.0856618820689619, + "runtime_p95_seconds": 1.179039555322379, + "runtime_seconds": [ + 1.0856618820689619, + 1.0787537782453, + 1.0689099300652742, + 1.159334700088948, + 1.1839657691307366 + ], + "total_fee": 424.18830718151406, + "total_funding": 33.05610695634667, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase47d/rust_scalar_long_short.json b/benchmarks/native_event/results/phase47d/rust_scalar_long_short.json new file mode 100644 index 0000000..5288be2 --- /dev/null +++ b/benchmarks/native_event/results/phase47d/rust_scalar_long_short.json @@ -0,0 +1,57 @@ +{ + "audit_reference_fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "backend_requested": "rust", + "backend_resolved": "rust", + "bars": 2000, + "cpu_median_seconds": 1.7909561660000008, + "cpu_seconds": [ + 1.774969714, + 1.7909561660000008, + 1.9216595210000005, + 1.8072804090000005, + 1.7697879979999982 + ], + "fill_count": 107, + "final_equity": 20457.971765918566, + "fingerprint": "eb9c3143e65c6d7b16f419e39a73b40f544dbc11a25204a22d34fda83361595a", + "git_revision": "7043c3f", + "grid_mode": "long_short", + "grid_module_version": "2026-08-02-phase47d-optimizer-safe-hot-path-v1", + "measured_runs": 5, + "mode": "scalar", + "peak_rss_kb": 300664, + "phase": "47D", + "policy": { + "auto_promoted": false, + "python_default": true, + "replay_is_oracle": true, + "rust_explicit_fail_fast": true + }, + "post_run_rss_kb": [ + 295096, + 295096, + 295096, + 295096, + 295096 + ], + "post_run_rss_median_kb": 295096.0, + "post_run_rss_slope_kb_per_run": -3.8536836477980345e-11, + "post_run_rss_tail_slope_kb_per_run": -6.334569856605596e-11, + "rss_gate": { + "accepted_baseline_note": "approximately 180 MB; no 10-15% regression and no linear leak", + "linear_leak_observed": false, + "pass": true + }, + "runtime_median_seconds": 1.8314293650910258, + "runtime_p95_seconds": 1.9385116894729435, + "runtime_seconds": [ + 1.7863101810216904, + 1.8475343589670956, + 1.9612560220994055, + 1.8314293650910258, + 1.7953219348564744 + ], + "total_fee": 53.990937020804054, + "total_funding": -0.28817876653661534, + "warmup_runs": 1 +} diff --git a/benchmarks/native_event/results/phase48e/after.json b/benchmarks/native_event/results/phase48e/after.json new file mode 100644 index 0000000..68ea014 --- /dev/null +++ b/benchmarks/native_event/results/phase48e/after.json @@ -0,0 +1,401 @@ +{ + "bars": 2000, + "benchmark": "pre48e_native_event_performance", + "environment": { + "commit": "4ebd3933b3e1d1de43aaa2a77d40e8150c85d336", + "cpu": "x86_64", + "dirty": true, + "numba": "0.65.1", + "numpy": "2.2.6", + "pandas": "2.3.3", + "platform": "Linux-5.15.0-46-generic-x86_64-with-glibc2.35", + "python": "3.12.13" + }, + "parity": { + "common_high_churn:audit": true, + "common_high_churn:score": true, + "common_low_churn:audit": true, + "common_low_churn:score": true, + "explicit_high_churn:audit": true, + "explicit_high_churn:score": true, + "explicit_low_churn:audit": true, + "explicit_low_churn:score": true + }, + "parity_policy": { + "discrete_exact": true, + "numeric_atol": 1e-12 + }, + "results": [ + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.08666422264650464, + "commands": 31, + "execution_counters": { + "active_snapshot_materializations": 0, + "bars_processed": 2000, + "bars_with_commands": 31, + "commands_quantized": 0, + "commands_retimed": 31, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 31, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1970, + "timestamp_objects_materialized": 2001 + }, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 181.96484375, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 181.44921875, + "throughput_bars_per_second": 21175.7426672997, + "warm_median_seconds": 0.09444769099354744, + "warm_p95_seconds": 0.12884083772078156, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.22673266706988215, + "commands": 31, + "execution_counters": { + "active_snapshot_materializations": 2000, + "bars_processed": 2000, + "bars_with_commands": 31, + "bytes_copied_to_rust": 4960, + "command_buffer_growths": 1, + "commands_compiled": 31, + "commands_quantized": 0, + "commands_retimed": 31, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 62, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1970, + "timestamp_objects_materialized": 0 + }, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 183.9375, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 182.0703125, + "throughput_bars_per_second": 11141.682403688703, + "warm_median_seconds": 0.1795061039738357, + "warm_p95_seconds": 0.21356605337932702, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.8342961641028523, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 238.97265625, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 237.54296875, + "throughput_bars_per_second": 21300.923175649325, + "warm_median_seconds": 0.09389264415949583, + "warm_p95_seconds": 0.11899890941567719, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.6078344457782805, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 242.6015625, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 239.7734375, + "throughput_bars_per_second": 11201.324218896374, + "warm_median_seconds": 0.17855031788349152, + "warm_p95_seconds": 0.1830525452736765, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.005948296748101711, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 180.3671875, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 180.3671875, + "throughput_bars_per_second": 104131.68318958525, + "warm_median_seconds": 0.019206450320780277, + "warm_p95_seconds": 0.020711912121623755, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 21128 + }, + "cold_prepare_seconds": 0.009929163847118616, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 180.5546875, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 180.5546875, + "throughput_bars_per_second": 6614704.46291887, + "warm_median_seconds": 0.00030235666781663895, + "warm_p95_seconds": 0.00031893770210444927, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.007894334848970175, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 237.7578125, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 180.25, + "throughput_bars_per_second": 199946.8307767023, + "warm_median_seconds": 0.010002659168094397, + "warm_p95_seconds": 0.01245177644304931, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 21128 + }, + "cold_prepare_seconds": 0.009915712289512157, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 182.1640625, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 180.828125, + "throughput_bars_per_second": 798410.5460147299, + "warm_median_seconds": 0.0025049769319593906, + "warm_p95_seconds": 0.0034448320511728516, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.11335275880992413, + "commands": 98, + "execution_counters": { + "active_snapshot_materializations": 0, + "bars_processed": 2000, + "bars_with_commands": 98, + "commands_quantized": 0, + "commands_retimed": 98, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 98, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1903, + "timestamp_objects_materialized": 2001 + }, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 183.53515625, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 182.9609375, + "throughput_bars_per_second": 18627.35633948626, + "warm_median_seconds": 0.1073689665645361, + "warm_p95_seconds": 0.13606172865256666, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.1907910299487412, + "commands": 98, + "execution_counters": { + "active_snapshot_materializations": 2000, + "bars_processed": 2000, + "bars_with_commands": 98, + "bytes_copied_to_rust": 15680, + "command_buffer_growths": 1, + "commands_compiled": 98, + "commands_quantized": 0, + "commands_retimed": 98, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 196, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1903, + "timestamp_objects_materialized": 0 + }, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 185.1953125, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 183.3515625, + "throughput_bars_per_second": 10607.323815442975, + "warm_median_seconds": 0.18854897189885378, + "warm_p95_seconds": 0.23055666480213402, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.51919772522524, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 241.07421875, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 239.875, + "throughput_bars_per_second": 18801.450933243497, + "warm_median_seconds": 0.10637476900592446, + "warm_p95_seconds": 0.16157129984349008, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.6320911599323153, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 241.33984375, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 239.15234375, + "throughput_bars_per_second": 9585.239673668466, + "warm_median_seconds": 0.20865414617583156, + "warm_p95_seconds": 0.28984632203355426, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.0060852281749248505, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 180.4375, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 180.4375, + "throughput_bars_per_second": 94725.54439648043, + "warm_median_seconds": 0.02111362898722291, + "warm_p95_seconds": 0.02208457556553185, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 32008 + }, + "cold_prepare_seconds": 0.009842989966273308, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 180.7578125, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 180.7578125, + "throughput_bars_per_second": 5103341.729255857, + "warm_median_seconds": 0.00039190007373690605, + "warm_p95_seconds": 0.00042622722685337067, + "workload": "explicit_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.006447069346904755, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 238.5546875, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 180.40625, + "throughput_bars_per_second": 160156.97725734962, + "warm_median_seconds": 0.012487748172134161, + "warm_p95_seconds": 0.013141952967271208, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 32008 + }, + "cold_prepare_seconds": 0.010716584045439959, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 183.0078125, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 181.62109375, + "throughput_bars_per_second": 618555.4059201748, + "warm_median_seconds": 0.0032333401031792164, + "warm_p95_seconds": 0.004208092251792549, + "workload": "explicit_high_churn" + } + ], + "warm_runs": 7 +} diff --git a/benchmarks/native_event/results/phase48e/after.md b/benchmarks/native_event/results/phase48e/after.md new file mode 100644 index 0000000..90deac7 --- /dev/null +++ b/benchmarks/native_event/results/phase48e/after.md @@ -0,0 +1,37 @@ +# Pre-48E Native Event Performance Pass + +Contract: **2,000 bars**, one symbol, fresh process per route, `7` warm runs. +All runtime columns use seconds; RSS uses MB. + +## Common Native Event / Event-Driven + +| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status | +|---|---|---:|---:|---:|---:|---:|---:|---| +| common_low_churn | `common_python_score` | 0.086664 | 0.094448 | 0.128841 | 21,176 | 182.0 | 30 | ok | +| common_low_churn | `common_rust_score` | 0.226733 | 0.179506 | 0.213566 | 11,142 | 183.9 | 30 | ok | +| common_low_churn | `common_python_audit` | 0.834296 | 0.093893 | 0.118999 | 21,301 | 239.0 | 30 | ok | +| common_low_churn | `common_rust_audit` | 0.607834 | 0.178550 | 0.183053 | 11,201 | 242.6 | 30 | ok | +| common_high_churn | `common_python_score` | 0.113353 | 0.107369 | 0.136062 | 18,627 | 183.5 | 98 | ok | +| common_high_churn | `common_rust_score` | 0.190791 | 0.188549 | 0.230557 | 10,607 | 185.2 | 98 | ok | +| common_high_churn | `common_python_audit` | 0.519198 | 0.106375 | 0.161571 | 18,801 | 241.1 | 98 | ok | +| common_high_churn | `common_rust_audit` | 0.632091 | 0.208654 | 0.289846 | 9,585 | 241.3 | 98 | ok | + +## Explicit Native Event Lifecycle + +| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status | +|---|---|---:|---:|---:|---:|---:|---:|---| +| explicit_low_churn | `explicit_python_score` | 0.005948 | 0.019206 | 0.020712 | 104,132 | 180.4 | 32 | ok | +| explicit_low_churn | `explicit_rust_score` | 0.009929 | 0.000302 | 0.000319 | 6,614,704 | 180.6 | 32 | ok | +| explicit_low_churn | `explicit_python_audit` | 0.007894 | 0.010003 | 0.012452 | 199,947 | 237.8 | 32 | ok | +| explicit_low_churn | `explicit_rust_audit` | 0.009916 | 0.002505 | 0.003445 | 798,411 | 182.2 | 32 | ok | +| explicit_high_churn | `explicit_python_score` | 0.006085 | 0.021114 | 0.022085 | 94,726 | 180.4 | 100 | ok | +| explicit_high_churn | `explicit_rust_score` | 0.009843 | 0.000392 | 0.000426 | 5,103,342 | 180.8 | 100 | ok | +| explicit_high_churn | `explicit_python_audit` | 0.006447 | 0.012488 | 0.013142 | 160,157 | 238.6 | 100 | ok | +| explicit_high_churn | `explicit_rust_audit` | 0.010717 | 0.003233 | 0.004208 | 618,555 | 183.0 | 100 | ok | + +## Contract + +- Score and audit are never compared as the same artifact. +- Python/Rust parity groups: `{"common_high_churn:audit": true, "common_high_churn:score": true, "common_low_churn:audit": true, "common_low_churn:score": true, "explicit_high_churn:audit": true, "explicit_high_churn:score": true, "explicit_low_churn:audit": true, "explicit_low_churn:score": true}`. +- Python/Rust parity is exact on the supported full-contract fields; unavailable Rust capabilities are reported, not silently routed to Python. +- Reactive Grid is intentionally excluded from this common table and is recorded separately in `upgrade/implement.md`. diff --git a/benchmarks/native_event/results/phase48e1/after.json b/benchmarks/native_event/results/phase48e1/after.json new file mode 100644 index 0000000..9f8b2ec --- /dev/null +++ b/benchmarks/native_event/results/phase48e1/after.json @@ -0,0 +1,466 @@ +{ + "bars": 2000, + "benchmark": "phase48e1_native_event_production_closure", + "benchmark_title": "Phase 48E.1 Native Production Closure Benchmark", + "environment": { + "commit": "9ba166331a2b79bf9e3a566f9f640e91182eb4ed", + "cpu": "x86_64", + "dirty": true, + "numba": "0.65.1", + "numpy": "2.2.6", + "pandas": "2.3.3", + "platform": "Linux-5.15.0-46-generic-x86_64-with-glibc2.35", + "python": "3.12.13" + }, + "parity": { + "common_high_churn:audit": true, + "common_high_churn:score": true, + "common_low_churn:audit": true, + "common_low_churn:score": true, + "explicit_high_churn:audit": true, + "explicit_high_churn:score": true, + "explicit_low_churn:audit": true, + "explicit_low_churn:score": true + }, + "parity_policy": { + "discrete_exact": true, + "numeric_atol": 1e-12 + }, + "results": [ + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.09266948094591498, + "commands": 31, + "execution_counters": { + "active_snapshot_materializations": 0, + "bars_processed": 2000, + "bars_with_commands": 31, + "commands_quantized": 0, + "commands_retimed": 31, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 31, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1970, + "timestamp_objects_materialized": 2001 + }, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 182.22265625, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 181.7109375, + "throughput_bars_per_second": 23295.64253722341, + "warm_median_seconds": 0.0858529657125473, + "warm_p95_seconds": 0.12437806166708466, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.2245904551818967, + "commands": 31, + "execution_counters": { + "active_snapshot_materializations": 2000, + "bars_processed": 2000, + "bars_with_commands": 31, + "bytes_copied_to_rust": 4960, + "command_buffer_growths": 1, + "commands_compiled": 31, + "commands_quantized": 0, + "commands_retimed": 31, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 62, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1970, + "timestamp_objects_materialized": 0 + }, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 185.66015625, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 183.8046875, + "throughput_bars_per_second": 9161.982053398498, + "warm_median_seconds": 0.21829337673261762, + "warm_p95_seconds": 0.2674950213637203, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.5078923348337412, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 239.3671875, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 238.19140625, + "throughput_bars_per_second": 21028.206108951406, + "warm_median_seconds": 0.09511034796014428, + "warm_p95_seconds": 0.09600745500065386, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.624691101256758, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 242.13671875, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 239.02734375, + "throughput_bars_per_second": 8666.671275474098, + "warm_median_seconds": 0.23076910804957151, + "warm_p95_seconds": 0.2476177306845784, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "runner_cache_info": {}, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.007029097992926836, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 180.43359375, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 180.43359375, + "throughput_bars_per_second": 84113.91370665364, + "warm_median_seconds": 0.023777279071509838, + "warm_p95_seconds": 0.024740204913541675, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "runner_cache_info": { + "command_buffer_capacity": 32, + "command_buffer_growth_count": 1, + "commands_compiled": 32, + "margin_recompute_count": 2000, + "order_arena_capacity": 32, + "order_arena_slots": 32, + "order_compactions": 0, + "step_active_order_buffer_capacity": 0, + "step_event_buffer_capacity": 0, + "step_fill_buffer_capacity": 0, + "tape_cache_bytes": 21128, + "tape_cache_entries": 1, + "terminal_orders_removed": 0 + }, + "tape_cache_bytes": 21128 + }, + "cold_prepare_seconds": 0.008598325308412313, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 181.64453125, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 181.06640625, + "throughput_bars_per_second": 6921851.453841616, + "warm_median_seconds": 0.00028894003480672836, + "warm_p95_seconds": 0.00032315975986421105, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "runner_cache_info": {}, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.005985777359455824, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 237.71875, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 180.13671875, + "throughput_bars_per_second": 270813.62275406625, + "warm_median_seconds": 0.007385152857750654, + "warm_p95_seconds": 0.008216808550059795, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "runner_cache_info": { + "command_buffer_capacity": 32, + "command_buffer_growth_count": 1, + "commands_compiled": 32, + "margin_recompute_count": 2000, + "order_arena_capacity": 32, + "order_arena_slots": 32, + "order_compactions": 0, + "step_active_order_buffer_capacity": 0, + "step_event_buffer_capacity": 0, + "step_fill_buffer_capacity": 0, + "tape_cache_bytes": 21128, + "tape_cache_entries": 1, + "terminal_orders_removed": 0 + }, + "tape_cache_bytes": 21128 + }, + "cold_prepare_seconds": 0.008947268594056368, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 181.96875, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 180.6171875, + "throughput_bars_per_second": 459060.30505778216, + "warm_median_seconds": 0.004356726072728634, + "warm_p95_seconds": 0.005948848370462655, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.09916248731315136, + "commands": 98, + "execution_counters": { + "active_snapshot_materializations": 0, + "bars_processed": 2000, + "bars_with_commands": 98, + "commands_quantized": 0, + "commands_retimed": 98, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 98, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1903, + "timestamp_objects_materialized": 2001 + }, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 182.05078125, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 181.48046875, + "throughput_bars_per_second": 21843.174213421305, + "warm_median_seconds": 0.09156178403645754, + "warm_p95_seconds": 0.12137889559380707, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.2657051980495453, + "commands": 98, + "execution_counters": { + "active_snapshot_materializations": 2000, + "bars_processed": 2000, + "bars_with_commands": 98, + "bytes_copied_to_rust": 15680, + "command_buffer_growths": 1, + "commands_compiled": 98, + "commands_quantized": 0, + "commands_retimed": 98, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 196, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1903, + "timestamp_objects_materialized": 0 + }, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 185.09375, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 183.23828125, + "throughput_bars_per_second": 9002.280258272462, + "warm_median_seconds": 0.22216593381017447, + "warm_p95_seconds": 0.2881452966481447, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.513897739816457, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 239.55078125, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 238.35546875, + "throughput_bars_per_second": 19100.012969874737, + "warm_median_seconds": 0.1047119707800448, + "warm_p95_seconds": 0.1665932037867605, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.6253028330393136, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 241.44921875, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 239.30078125, + "throughput_bars_per_second": 8415.599259459246, + "warm_median_seconds": 0.23765390180051327, + "warm_p95_seconds": 0.28027606066316363, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "runner_cache_info": {}, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.006964471191167831, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 180.2109375, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 180.2109375, + "throughput_bars_per_second": 92214.31301992925, + "warm_median_seconds": 0.021688607055693865, + "warm_p95_seconds": 0.022250804863870145, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "runner_cache_info": { + "command_buffer_capacity": 100, + "command_buffer_growth_count": 1, + "commands_compiled": 100, + "margin_recompute_count": 2000, + "order_arena_capacity": 64, + "order_arena_slots": 36, + "order_compactions": 1, + "step_active_order_buffer_capacity": 0, + "step_event_buffer_capacity": 0, + "step_fill_buffer_capacity": 0, + "tape_cache_bytes": 32008, + "tape_cache_entries": 1, + "terminal_orders_removed": 64 + }, + "tape_cache_bytes": 32008 + }, + "cold_prepare_seconds": 0.009406995959579945, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 181.90234375, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 181.90234375, + "throughput_bars_per_second": 5461020.851213704, + "warm_median_seconds": 0.00036623189225792885, + "warm_p95_seconds": 0.0003780250437557697, + "workload": "explicit_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "runner_cache_info": {}, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.006354076322168112, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 239.55859375, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 181.0, + "throughput_bars_per_second": 145951.82112389195, + "warm_median_seconds": 0.013703152071684599, + "warm_p95_seconds": 0.014699424151331186, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "runner_cache_info": { + "command_buffer_capacity": 100, + "command_buffer_growth_count": 1, + "commands_compiled": 100, + "margin_recompute_count": 2000, + "order_arena_capacity": 64, + "order_arena_slots": 36, + "order_compactions": 1, + "step_active_order_buffer_capacity": 0, + "step_event_buffer_capacity": 0, + "step_fill_buffer_capacity": 0, + "tape_cache_bytes": 32008, + "tape_cache_entries": 1, + "terminal_orders_removed": 64 + }, + "tape_cache_bytes": 32008 + }, + "cold_prepare_seconds": 0.011131081730127335, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 183.125, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 181.1328125, + "throughput_bars_per_second": 309174.24480466335, + "warm_median_seconds": 0.006468844134360552, + "warm_p95_seconds": 0.008693503774702549, + "workload": "explicit_high_churn" + } + ], + "warm_runs": 7 +} diff --git a/benchmarks/native_event/results/phase48e1/after.md b/benchmarks/native_event/results/phase48e1/after.md new file mode 100644 index 0000000..5f27df0 --- /dev/null +++ b/benchmarks/native_event/results/phase48e1/after.md @@ -0,0 +1,37 @@ +# Phase 48E.1 Native Production Closure Benchmark + +Contract: **2,000 bars**, one symbol, fresh process per route, `7` warm runs. +All runtime columns use seconds; RSS uses MB. + +## Common Native Event / Event-Driven + +| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status | +|---|---|---:|---:|---:|---:|---:|---:|---| +| common_low_churn | `common_python_score` | 0.092669 | 0.085853 | 0.124378 | 23,296 | 182.2 | 30 | ok | +| common_low_churn | `common_rust_score` | 0.224590 | 0.218293 | 0.267495 | 9,162 | 185.7 | 30 | ok | +| common_low_churn | `common_python_audit` | 0.507892 | 0.095110 | 0.096007 | 21,028 | 239.4 | 30 | ok | +| common_low_churn | `common_rust_audit` | 0.624691 | 0.230769 | 0.247618 | 8,667 | 242.1 | 30 | ok | +| common_high_churn | `common_python_score` | 0.099162 | 0.091562 | 0.121379 | 21,843 | 182.1 | 98 | ok | +| common_high_churn | `common_rust_score` | 0.265705 | 0.222166 | 0.288145 | 9,002 | 185.1 | 98 | ok | +| common_high_churn | `common_python_audit` | 0.513898 | 0.104712 | 0.166593 | 19,100 | 239.6 | 98 | ok | +| common_high_churn | `common_rust_audit` | 0.625303 | 0.237654 | 0.280276 | 8,416 | 241.4 | 98 | ok | + +## Explicit Native Event Lifecycle + +| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status | +|---|---|---:|---:|---:|---:|---:|---:|---| +| explicit_low_churn | `explicit_python_score` | 0.007029 | 0.023777 | 0.024740 | 84,114 | 180.4 | 32 | ok | +| explicit_low_churn | `explicit_rust_score` | 0.008598 | 0.000289 | 0.000323 | 6,921,851 | 181.6 | 32 | ok | +| explicit_low_churn | `explicit_python_audit` | 0.005986 | 0.007385 | 0.008217 | 270,814 | 237.7 | 32 | ok | +| explicit_low_churn | `explicit_rust_audit` | 0.008947 | 0.004357 | 0.005949 | 459,060 | 182.0 | 32 | ok | +| explicit_high_churn | `explicit_python_score` | 0.006964 | 0.021689 | 0.022251 | 92,214 | 180.2 | 100 | ok | +| explicit_high_churn | `explicit_rust_score` | 0.009407 | 0.000366 | 0.000378 | 5,461,021 | 181.9 | 100 | ok | +| explicit_high_churn | `explicit_python_audit` | 0.006354 | 0.013703 | 0.014699 | 145,952 | 239.6 | 100 | ok | +| explicit_high_churn | `explicit_rust_audit` | 0.011131 | 0.006469 | 0.008694 | 309,174 | 183.1 | 100 | ok | + +## Contract + +- Score and audit are never compared as the same artifact. +- Python/Rust parity groups: `{"common_high_churn:audit": true, "common_high_churn:score": true, "common_low_churn:audit": true, "common_low_churn:score": true, "explicit_high_churn:audit": true, "explicit_high_churn:score": true, "explicit_low_churn:audit": true, "explicit_low_churn:score": true}`. +- Python/Rust parity is exact on the supported full-contract fields; unavailable Rust capabilities are reported, not silently routed to Python. +- Reactive Grid is intentionally excluded from this common table and is recorded separately in `upgrade/implement.md`. diff --git a/benchmarks/native_event/results/pre48e/after.json b/benchmarks/native_event/results/pre48e/after.json new file mode 100644 index 0000000..9ac673f --- /dev/null +++ b/benchmarks/native_event/results/pre48e/after.json @@ -0,0 +1,373 @@ +{ + "bars": 2000, + "benchmark": "pre48e_native_event_performance", + "environment": { + "commit": "0121163d6f5559a7870257f255df248d25c782a7", + "cpu": "x86_64", + "dirty": true, + "numba": "0.65.1", + "numpy": "2.2.6", + "pandas": "2.3.3", + "platform": "Linux-5.15.0-46-generic-x86_64-with-glibc2.35", + "python": "3.12.13" + }, + "parity": { + "common_high_churn:audit": true, + "common_high_churn:score": true, + "common_low_churn:audit": true, + "common_low_churn:score": true, + "explicit_high_churn:audit": true, + "explicit_high_churn:score": true, + "explicit_low_churn:audit": true, + "explicit_low_churn:score": true + }, + "parity_policy": { + "discrete_exact": true, + "numeric_atol": 1e-12 + }, + "results": [ + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.09446803014725447, + "commands": 31, + "execution_counters": { + "active_snapshot_materializations": 0, + "bars_processed": 2000, + "bars_with_commands": 31, + "commands_quantized": 0, + "commands_retimed": 31, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 31, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1970, + "timestamp_objects_materialized": 2001 + }, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 183.21484375, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 182.64453125, + "throughput_bars_per_second": 22795.660473571963, + "warm_median_seconds": 0.0877359970472753, + "warm_p95_seconds": 0.12097821822389958, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.23265773616731167, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 185.71875, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 183.34765625, + "throughput_bars_per_second": 10612.995565987654, + "warm_median_seconds": 0.18844820838421583, + "warm_p95_seconds": 0.2546277825254946, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.5518056331202388, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 240.8203125, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 239.39453125, + "throughput_bars_per_second": 22902.458847500784, + "warm_median_seconds": 0.0873268679715693, + "warm_p95_seconds": 0.11650824174284934, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.5585132981650531, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 243.91796875, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 240.3984375, + "throughput_bars_per_second": 11358.81694547926, + "warm_median_seconds": 0.1760746748186648, + "warm_p95_seconds": 0.1830581807065755, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.0060952031053602695, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 180.80859375, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 180.80859375, + "throughput_bars_per_second": 106475.29401101783, + "warm_median_seconds": 0.018783700186759233, + "warm_p95_seconds": 0.023569752229377624, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.008849171921610832, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 182.76953125, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 181.71875, + "throughput_bars_per_second": 2075634.5212303507, + "warm_median_seconds": 0.0009635607711970806, + "warm_p95_seconds": 0.001989846490323543, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.005662702023983002, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 239.47265625, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 180.94921875, + "throughput_bars_per_second": 256878.50798084837, + "warm_median_seconds": 0.007785781752318144, + "warm_p95_seconds": 0.008634034963324665, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.009381336160004139, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 182.93359375, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 182.1640625, + "throughput_bars_per_second": 821591.0209269148, + "warm_median_seconds": 0.0024343011900782585, + "warm_p95_seconds": 0.003401127783581614, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.09173994092270732, + "commands": 98, + "execution_counters": { + "active_snapshot_materializations": 0, + "bars_processed": 2000, + "bars_with_commands": 98, + "commands_quantized": 0, + "commands_retimed": 98, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 98, + "contexts_materialized": 2001, + "empty_command_batches_skipped": 1903, + "timestamp_objects_materialized": 2001 + }, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 182.9453125, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 182.36328125, + "throughput_bars_per_second": 23092.29444752386, + "warm_median_seconds": 0.08660897705703974, + "warm_p95_seconds": 0.120729656657204, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.19093398423865438, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 186.14453125, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 183.81640625, + "throughput_bars_per_second": 10621.426237823576, + "warm_median_seconds": 0.18829862913116813, + "warm_p95_seconds": 0.2503123251255602, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.49427982326596975, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 241.15234375, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 239.9765625, + "throughput_bars_per_second": 16768.863533879172, + "warm_median_seconds": 0.11926866695284843, + "warm_p95_seconds": 0.19646020592190322, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.5878904862329364, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 243.08203125, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 240.5859375, + "throughput_bars_per_second": 10074.484186586873, + "warm_median_seconds": 0.19852133002132177, + "warm_p95_seconds": 0.2760247623547911, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.006497267168015242, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 181.30859375, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 181.30859375, + "throughput_bars_per_second": 83882.52973746209, + "warm_median_seconds": 0.023842866998165846, + "warm_p95_seconds": 0.030283105047419667, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.009676589164882898, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 182.33984375, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 181.00390625, + "throughput_bars_per_second": 1424350.272784223, + "warm_median_seconds": 0.001404148992151022, + "warm_p95_seconds": 0.0023043232038617127, + "workload": "explicit_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.00915976520627737, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 240.18359375, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 181.3515625, + "throughput_bars_per_second": 164519.21034008238, + "warm_median_seconds": 0.01215663505718112, + "warm_p95_seconds": 0.01272506546229124, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.009526526089757681, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 182.66015625, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 181.85546875, + "throughput_bars_per_second": 694619.8647268052, + "warm_median_seconds": 0.0028792726807296276, + "warm_p95_seconds": 0.0038014152552932495, + "workload": "explicit_high_churn" + } + ], + "warm_runs": 7 +} diff --git a/benchmarks/native_event/results/pre48e/baseline.json b/benchmarks/native_event/results/pre48e/baseline.json new file mode 100644 index 0000000..09849d4 --- /dev/null +++ b/benchmarks/native_event/results/pre48e/baseline.json @@ -0,0 +1,351 @@ +{ + "bars": 2000, + "benchmark": "pre48e_native_event_performance", + "environment": { + "commit": "0121163d6f5559a7870257f255df248d25c782a7", + "cpu": "x86_64", + "dirty": true, + "numba": "0.65.1", + "numpy": "2.2.6", + "pandas": "2.3.3", + "platform": "Linux-5.15.0-46-generic-x86_64-with-glibc2.35", + "python": "3.12.13" + }, + "parity": { + "common_high_churn:audit": true, + "common_high_churn:score": true, + "common_low_churn:audit": true, + "common_low_churn:score": true, + "explicit_high_churn:audit": true, + "explicit_high_churn:score": true, + "explicit_low_churn:audit": true, + "explicit_low_churn:score": true + }, + "parity_policy": { + "discrete_exact": true, + "numeric_atol": 1e-12 + }, + "results": [ + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.1557889929972589, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 191.70703125, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 191.70703125, + "throughput_bars_per_second": 13469.558964801285, + "warm_median_seconds": 0.148482961114496, + "warm_p95_seconds": 0.17835535882040857, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.24324726266786456, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 185.4609375, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 183.33203125, + "throughput_bars_per_second": 8618.32191374243, + "warm_median_seconds": 0.23206373816356063, + "warm_p95_seconds": 0.27376840421929954, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 10.033178729005158, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 316.30859375, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 316.30859375, + "throughput_bars_per_second": 11979.988318934318, + "warm_median_seconds": 0.16694507095962763, + "warm_p95_seconds": 0.2905341746285557, + "workload": "common_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.7665560361929238, + "commands": 31, + "execution_counters": {}, + "fill_count": 30, + "final_equity": 100000.07855495511, + "fingerprint": "5be7091b821e7792d6b86cb58054b70a17d02bca513690132c3f193cc8a3e28d", + "peak_rss_mb": 244.1484375, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 240.70703125, + "throughput_bars_per_second": 7991.999261194983, + "warm_median_seconds": 0.25025027338415384, + "warm_p95_seconds": 0.3119780026376247, + "workload": "common_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.006925127934664488, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 181.26171875, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 180.64453125, + "throughput_bars_per_second": 96881.95616332171, + "warm_median_seconds": 0.020643678959459066, + "warm_p95_seconds": 0.026681719534099098, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.009174927603453398, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "7a8d4f681772db3ddebbf39acbde7d4898ea291c817bb1f61deff15988e2ebe3", + "peak_rss_mb": 182.453125, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 181.390625, + "throughput_bars_per_second": 1937177.8130787334, + "warm_median_seconds": 0.0010324297472834587, + "warm_p95_seconds": 0.001998791471123695, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.0067627509124577045, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 239.57421875, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 180.7265625, + "throughput_bars_per_second": 217679.53952342973, + "warm_median_seconds": 0.00918781803920865, + "warm_p95_seconds": 0.009674757765606045, + "workload": "explicit_low_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.008254977874457836, + "commands": 32, + "fill_count": 32, + "final_equity": 100000.16445504455, + "fingerprint": "0395bbc685ba8f52c654ae36234b14fd7d25246483e6189acccc73c41b68763c", + "peak_rss_mb": 183.015625, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 181.66015625, + "throughput_bars_per_second": 832011.8152388919, + "warm_median_seconds": 0.0024038120172917843, + "warm_p95_seconds": 0.003943782672286033, + "workload": "explicit_low_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.1618034429848194, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 182.40234375, + "report_level": "score", + "route": "common_python_score", + "rss_after_prepare_mb": 181.8359375, + "throughput_bars_per_second": 12092.594594379854, + "warm_median_seconds": 0.16539047798141837, + "warm_p95_seconds": 0.19977103322744366, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.2570755579508841, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "19b3e4e58759acd8f4e9959927b0a4210ecb1a84e2a3d382b44e4b81d7c51e5e", + "peak_rss_mb": 185.91015625, + "report_level": "score", + "route": "common_rust_score", + "rss_after_prepare_mb": 183.58984375, + "throughput_bars_per_second": 8098.106660124628, + "warm_median_seconds": 0.2469713087193668, + "warm_p95_seconds": 0.2739621200133115, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "cold_prepare_seconds": 0.5655467477627099, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 241.59765625, + "report_level": "audit", + "route": "common_python_audit", + "rss_after_prepare_mb": 240.44140625, + "throughput_bars_per_second": 11958.390808989823, + "warm_median_seconds": 0.1672465829178691, + "warm_p95_seconds": 0.2144552476238459, + "workload": "common_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "cold_prepare_seconds": 0.768560613039881, + "commands": 98, + "execution_counters": {}, + "fill_count": 98, + "final_equity": 99999.48305543358, + "fingerprint": "03f51fc38b6bdc56a8d155a51a77d3406cc041824ca825ab2adc5b35ad46ad12", + "peak_rss_mb": 243.36328125, + "report_level": "audit", + "route": "common_rust_audit", + "rss_after_prepare_mb": 240.89453125, + "throughput_bars_per_second": 7865.511641336136, + "warm_median_seconds": 0.2542746220715344, + "warm_p95_seconds": 0.31920700185000894, + "workload": "common_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.006609664764255285, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 180.5859375, + "report_level": "score", + "route": "explicit_python_score", + "rss_after_prepare_mb": 180.5859375, + "throughput_bars_per_second": 93223.59973288576, + "warm_median_seconds": 0.0214537950232625, + "warm_p95_seconds": 0.04156870334409176, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.011422306299209595, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "1b191efd9029f4460842d152d45c565c4def57faa6c1d195e86b732cb7eadef0", + "peak_rss_mb": 183.0078125, + "report_level": "score", + "route": "explicit_rust_score", + "rss_after_prepare_mb": 181.63671875, + "throughput_bars_per_second": 1472565.0962728905, + "warm_median_seconds": 0.0013581742532551289, + "warm_p95_seconds": 0.0025285508483648294, + "workload": "explicit_high_churn" + }, + { + "backend": "python", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": false, + "pycalls": 0, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.007422915659844875, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 239.55078125, + "report_level": "audit", + "route": "explicit_python_audit", + "rss_after_prepare_mb": 180.62890625, + "throughput_bars_per_second": 130661.54935963945, + "warm_median_seconds": 0.015306721907109022, + "warm_p95_seconds": 0.016907946858555078, + "workload": "explicit_high_churn" + }, + { + "backend": "rust", + "bars": 2000, + "bridge_counters": { + "prepared_market_core": true, + "pycalls": 1, + "tape_cache_bytes": 0 + }, + "cold_prepare_seconds": 0.009576883632689714, + "commands": 100, + "fill_count": 100, + "final_equity": 99999.58644675027, + "fingerprint": "07ddb60b78c247aaed4fa013f3dd21ddb357119af83ace9e660217fea14b1466", + "peak_rss_mb": 182.53515625, + "report_level": "audit", + "route": "explicit_rust_audit", + "rss_after_prepare_mb": 181.19140625, + "throughput_bars_per_second": 694584.8164058464, + "warm_median_seconds": 0.0028794179670512676, + "warm_p95_seconds": 0.0039040645584464064, + "workload": "explicit_high_churn" + } + ], + "warm_runs": 7 +} diff --git a/benchmarks/native_event/results/pre48e/baseline.md b/benchmarks/native_event/results/pre48e/baseline.md new file mode 100644 index 0000000..9422094 --- /dev/null +++ b/benchmarks/native_event/results/pre48e/baseline.md @@ -0,0 +1,37 @@ +# Pre-48E Native Event Performance Pass + +Contract: **2,000 bars**, one symbol, fresh process per route, `7` warm runs. +All runtime columns use seconds; RSS uses MB. + +## Common Native Event / Event-Driven + +| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status | +|---|---|---:|---:|---:|---:|---:|---:|---| +| common_low_churn | `common_python_score` | 0.155789 | 0.148483 | 0.178355 | 13,470 | 191.7 | 30 | ok | +| common_low_churn | `common_rust_score` | 0.243247 | 0.232064 | 0.273768 | 8,618 | 185.5 | 30 | ok | +| common_low_churn | `common_python_audit` | 10.033179 | 0.166945 | 0.290534 | 11,980 | 316.3 | 30 | ok | +| common_low_churn | `common_rust_audit` | 0.766556 | 0.250250 | 0.311978 | 7,992 | 244.1 | 30 | ok | +| common_high_churn | `common_python_score` | 0.161803 | 0.165390 | 0.199771 | 12,093 | 182.4 | 98 | ok | +| common_high_churn | `common_rust_score` | 0.257076 | 0.246971 | 0.273962 | 8,098 | 185.9 | 98 | ok | +| common_high_churn | `common_python_audit` | 0.565547 | 0.167247 | 0.214455 | 11,958 | 241.6 | 98 | ok | +| common_high_churn | `common_rust_audit` | 0.768561 | 0.254275 | 0.319207 | 7,866 | 243.4 | 98 | ok | + +## Explicit Native Event Lifecycle + +| Workload | Route | Cold prepare s | Warm median s | P95 s | Bars/s | Peak RSS MB | Fills | Status | +|---|---|---:|---:|---:|---:|---:|---:|---| +| explicit_low_churn | `explicit_python_score` | 0.006925 | 0.020644 | 0.026682 | 96,882 | 181.3 | 32 | ok | +| explicit_low_churn | `explicit_rust_score` | 0.009175 | 0.001032 | 0.001999 | 1,937,178 | 182.5 | 32 | ok | +| explicit_low_churn | `explicit_python_audit` | 0.006763 | 0.009188 | 0.009675 | 217,680 | 239.6 | 32 | ok | +| explicit_low_churn | `explicit_rust_audit` | 0.008255 | 0.002404 | 0.003944 | 832,012 | 183.0 | 32 | ok | +| explicit_high_churn | `explicit_python_score` | 0.006610 | 0.021454 | 0.041569 | 93,224 | 180.6 | 100 | ok | +| explicit_high_churn | `explicit_rust_score` | 0.011422 | 0.001358 | 0.002529 | 1,472,565 | 183.0 | 100 | ok | +| explicit_high_churn | `explicit_python_audit` | 0.007423 | 0.015307 | 0.016908 | 130,662 | 239.6 | 100 | ok | +| explicit_high_churn | `explicit_rust_audit` | 0.009577 | 0.002879 | 0.003904 | 694,585 | 182.5 | 100 | ok | + +## Contract + +- Score and audit are never compared as the same artifact. +- Python/Rust parity groups: `{"common_high_churn:audit": true, "common_high_churn:score": true, "common_low_churn:audit": true, "common_low_churn:score": true, "explicit_high_churn:audit": true, "explicit_high_churn:score": true, "explicit_low_churn:audit": true, "explicit_low_churn:score": true}`. +- Python/Rust parity is exact on the supported full-contract fields; unavailable Rust capabilities are reported, not silently routed to Python. +- Reactive Grid is intentionally excluded from this common table and is recorded separately in `upgrade/implement.md`. diff --git a/benchmarks/native_event/results/pre48e/report.md b/benchmarks/native_event/results/pre48e/report.md new file mode 100644 index 0000000..32f0f2e --- /dev/null +++ b/benchmarks/native_event/results/pre48e/report.md @@ -0,0 +1,67 @@ +# Pre-48E Native Event Performance Pass + +Contract: **2,000 bars**, one symbol, identical deterministic tape, fresh +process per route, seven measured warm runs. Runtime is reported in seconds; +RSS is MB. Commit `0121163` is the frozen pre-patch baseline and the current +working tree is the after result. + +## Parity Gate + +All eight groups passed Python/Rust fingerprint parity: + +```text +common_low/high_churn x score/audit PASS +explicit_low/high_churn x score/audit PASS +numeric tolerance: atol <= 1e-12 +discrete lifecycle fields: exact +``` + +The fingerprint covers equity, positions, fees, funding, margin, fill rows, +and the core lifecycle counters (`fill_count`, `event_count`, rejection and +cancellation counts). Final equity and fill counts are equal for every group. + +## Warm Runtime Before / After + +| Workload | Route | Before s | After s | Change | Before bars/s | After bars/s | After RSS MB | +|---|---|---:|---:|---:|---:|---:|---:| +| common low | Python score | 0.148483 | 0.087736 | -40.9% | 13,470 | 22,796 | 183.2 | +| common low | Rust score | 0.232064 | 0.188448 | -18.8% | 8,618 | 10,613 | 185.7 | +| common low | Python audit | 0.166945 | 0.087327 | -47.7% | 11,980 | 22,902 | 240.8 | +| common low | Rust audit | 0.250250 | 0.176075 | -29.6% | 7,992 | 11,359 | 243.9 | +| common high | Python score | 0.165390 | 0.086609 | -47.6% | 12,093 | 23,092 | 182.9 | +| common high | Rust score | 0.246971 | 0.188299 | -23.8% | 8,098 | 10,621 | 186.1 | +| common high | Python audit | 0.167247 | 0.119269 | -28.7% | 11,958 | 16,769 | 241.2 | +| common high | Rust audit | 0.254275 | 0.198521 | -21.9% | 7,866 | 10,074 | 243.1 | +| explicit low | Python score | 0.020644 | 0.018784 | -9.0% | 96,882 | 106,475 | 180.8 | +| explicit low | Rust score | 0.001032 | 0.000964 | -6.6% | 1,937,178 | 2,075,635 | 182.8 | +| explicit low | Python audit | 0.009188 | 0.007786 | -15.3% | 217,680 | 256,879 | 239.5 | +| explicit low | Rust audit | 0.002404 | 0.002434 | +1.2% | 832,012 | 821,591 | 182.9 | +| explicit high | Python score | 0.021454 | 0.023843 | +11.1% | 93,224 | 83,883 | 181.3 | +| explicit high | Rust score | 0.001358 | 0.001404 | +3.4% | 1,472,565 | 1,424,350 | 182.3 | +| explicit high | Python audit | 0.015307 | 0.012157 | -20.6% | 130,662 | 164,519 | 240.2 | +| explicit high | Rust audit | 0.002879 | 0.002879 | 0.0% | 694,585 | 694,620 | 182.7 | + +The explicit high-churn score rows are within normal short-run variance and +are not treated as a speed claim. The reliable improvement is in the generic +callback path, where empty-bar retime/quantize work was removed. No domain +accounting was skipped. + +## What Changed + +- Cache quantity-constraint enablement once per Python reactive session. +- Skip retime, schedule and quantity preflight when a callback emits no + commands. +- Preserve quantity preflight for enabled `PLACE`/`REPLACE` commands. +- Add execution counters to Python score/audit metadata. +- Use the existing prepared Rust full-tape runner with one PyO3 tape call per + measured execution; no implicit Python fallback is used. + +Reactive Grid remains a separate integration workload and is deliberately not +included in the README native-event throughput headline. + +Artifacts: + +- `baseline.json`: frozen pre-patch result. +- `after.json`: post-patch result and parity matrix. +- `baseline.md`: baseline table. +- `benchmark_pre48e.py`: reproducible process-isolated runner. diff --git a/benchmarks/phase34a_native_event_memory.json b/benchmarks/phase34a_native_event_memory.json index f756bc9..153470f 100644 --- a/benchmarks/phase34a_native_event_memory.json +++ b/benchmarks/phase34a_native_event_memory.json @@ -2,49 +2,49 @@ { "audit_sink": "memory", "command_report_rows": 0, - "commands": 1575, - "events": 3075, - "fills": 1500, + "commands": 3100, + "events": 6100, + "fills": 3000, "fills_materialized": 0, - "final_equity": 100006.59999999916, - "levels": 10, + "final_equity": 100019.19999999809, + "levels": 15, "order_event_rows": 0, "orders_materialized": 0, - "peak_rss_mb": 333.08984375, + "peak_rss_mb": 339.95703125, "report_level": "minimal", - "rows": 3000, - "seconds": 1.22510303882882 + "rows": 5000, + "seconds": 0.33456376707181334 }, { "audit_sink": "memory", - "command_report_rows": 1575, - "commands": 1575, - "events": 3075, - "fills": 1500, - "fills_materialized": 1500, - "final_equity": 100006.59999999916, - "levels": 10, + "command_report_rows": 3100, + "commands": 3100, + "events": 6100, + "fills": 3000, + "fills_materialized": 3000, + "final_equity": 100019.19999999809, + "levels": 15, "order_event_rows": 0, - "orders_materialized": 1500, - "peak_rss_mb": 336.640625, + "orders_materialized": 3000, + "peak_rss_mb": 344.85546875, "report_level": "standard", - "rows": 3000, - "seconds": 1.045848773792386 + "rows": 5000, + "seconds": 0.5011384710669518 }, { "audit_sink": "memory", - "command_report_rows": 1575, - "commands": 1575, - "events": 3075, - "fills": 1500, - "fills_materialized": 1500, - "final_equity": 100006.59999999916, - "levels": 10, - "order_event_rows": 3075, - "orders_materialized": 1500, - "peak_rss_mb": 292.08984375, + "command_report_rows": 3100, + "commands": 3100, + "events": 6100, + "fills": 3000, + "fills_materialized": 3000, + "final_equity": 100019.19999999809, + "levels": 15, + "order_event_rows": 6100, + "orders_materialized": 3000, + "peak_rss_mb": 348.37109375, "report_level": "audit", - "rows": 3000, - "seconds": 1.009142744820565 + "rows": 5000, + "seconds": 0.6382076730951667 } ] diff --git a/benchmarks/phase34a_native_event_memory.md b/benchmarks/phase34a_native_event_memory.md index 5ad7492..81af2b2 100644 --- a/benchmarks/phase34a_native_event_memory.md +++ b/benchmarks/phase34a_native_event_memory.md @@ -2,9 +2,9 @@ | report_level | seconds | peak RSS MB | commands | fills | events | command rows | event rows | fills obj | orders obj | |---|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| minimal | 1.225103 | 333.090 | 1575 | 1500 | 3075 | 0 | 0 | 0 | 0 | -| standard | 1.045849 | 336.641 | 1575 | 1500 | 3075 | 1575 | 0 | 1500 | 1500 | -| audit | 1.009143 | 292.090 | 1575 | 1500 | 3075 | 1575 | 3075 | 1500 | 1500 | +| minimal | 0.334564 | 339.957 | 3100 | 3000 | 6100 | 0 | 0 | 0 | 0 | +| standard | 0.501138 | 344.855 | 3100 | 3000 | 6100 | 3100 | 0 | 3000 | 3000 | +| audit | 0.638208 | 348.371 | 3100 | 3000 | 6100 | 3100 | 6100 | 3000 | 3000 | Notes: diff --git a/benchmarks/phase34b_native_event_prepared_score.json b/benchmarks/phase34b_native_event_prepared_score.json index ee6b064..a94f6c7 100644 --- a/benchmarks/phase34b_native_event_prepared_score.json +++ b/benchmarks/phase34b_native_event_prepared_score.json @@ -1,12 +1,12 @@ { "metric_parity": true, - "peak_rss_mb": 337.92578125, + "peak_rss_mb": 335.64453125, "prepared_endpoint_result_retained": false, - "prepared_score_seconds": 0.6344219469465315, - "prepared_scores": 12, - "public_audit_seconds": 1.7633190099149942, + "prepared_score_seconds": 1.8460372514091432, + "prepared_scores": 20, + "public_audit_seconds": 2.8690464491955936, "public_last_report_level": "audit", - "rows": 600, - "speedup": 2.779410482884201, - "trials": 12 + "rows": 1000, + "speedup": 1.5541649806934028, + "trials": 20 } diff --git a/benchmarks/phase34b_native_event_prepared_score.md b/benchmarks/phase34b_native_event_prepared_score.md index 89b7b61..0d15019 100644 --- a/benchmarks/phase34b_native_event_prepared_score.md +++ b/benchmarks/phase34b_native_event_prepared_score.md @@ -1,11 +1,11 @@ # Phase 34B Native Event Prepared Score Benchmark -- Rows: `600` -- Trials: `12` -- Public audit seconds: `1.763319` -- Prepared score seconds: `0.634422` -- Speedup: `2.779x` -- Peak RSS MB: `337.926` +- Rows: `1000` +- Trials: `20` +- Public audit seconds: `2.869046` +- Prepared score seconds: `1.846037` +- Speedup: `1.554x` +- Peak RSS MB: `335.645` - Metric parity: `True` - Prepared endpoint result retained: `False` diff --git a/benchmarks/phase34c_native_event_single_pass.json b/benchmarks/phase34c_native_event_single_pass.json index fe6d149..2ef863c 100644 --- a/benchmarks/phase34c_native_event_single_pass.json +++ b/benchmarks/phase34c_native_event_single_pass.json @@ -1,11 +1,11 @@ { "accounting_parity": true, - "peak_rss_mb": 333.76953125, - "replay_certified_seconds": 1.4313148567453027, - "replay_certified_static_replays": 12, - "rows": 600, - "single_pass_seconds": 0.7509573502466083, + "peak_rss_mb": 347.4375, + "replay_certified_seconds": 2.3528817230835557, + "replay_certified_static_replays": 20, + "rows": 1000, + "single_pass_seconds": 1.9774253377690911, "single_pass_static_replays": 0, - "speedup": 1.9059868796480526, - "trials": 12 + "speedup": 1.1898713332651285, + "trials": 20 } diff --git a/benchmarks/phase34c_native_event_single_pass.md b/benchmarks/phase34c_native_event_single_pass.md index d40656b..bc1e790 100644 --- a/benchmarks/phase34c_native_event_single_pass.md +++ b/benchmarks/phase34c_native_event_single_pass.md @@ -1,13 +1,13 @@ # Phase 34C Native Event Single-Pass Benchmark -- Rows: `600` -- Trials: `12` -- Replay-certified seconds: `1.431315` -- Single-pass seconds: `0.750957` -- Speedup: `1.906x` -- Replay-certified static replays: `12` +- Rows: `1000` +- Trials: `20` +- Replay-certified seconds: `2.352882` +- Single-pass seconds: `1.977425` +- Speedup: `1.190x` +- Replay-certified static replays: `20` - Single-pass static replays: `0` - Accounting parity: `True` -- Peak RSS MB: `333.770` +- Peak RSS MB: `347.438` This benchmark isolates the Phase 34C mode switch: `single_pass` materializes accounting from the reactive session for minimal/score runs and skips the final static replay. diff --git a/benchmarks/phase48c_event_driven_facade.json b/benchmarks/phase48c_event_driven_facade.json new file mode 100644 index 0000000..7e9ee6c --- /dev/null +++ b/benchmarks/phase48c_event_driven_facade.json @@ -0,0 +1,77 @@ +{ + "bars": 2000, + "benchmark": "phase48c_event_driven_facade", + "common": { + "facade_overhead_pct": -4.130490791544739, + "parity": true, + "routes": [ + { + "bars": 2000, + "fill_count": 109, + "final_equity": 19998.269071829167, + "fingerprint": "5e83a4ab0158ac2626c4a38583a211b69ea82215e3fa06a5e6ab73f050d67cbb", + "peak_rss_mb": 184.23046875, + "route": "native_event_strategy", + "runs": 5, + "runtime_median_seconds": 0.16119503695517778, + "runtime_p95_seconds": 0.19709980087354778, + "symbols": 1, + "throughput_bars_per_second": 12407.329889170993 + }, + { + "bars": 2000, + "fill_count": 109, + "final_equity": 19998.269071829167, + "fingerprint": "5e83a4ab0158ac2626c4a38583a211b69ea82215e3fa06a5e6ab73f050d67cbb", + "peak_rss_mb": 183.41015625, + "route": "event_driven_facade", + "runs": 5, + "runtime_median_seconds": 0.15453689079731703, + "runtime_p95_seconds": 0.20907546980306504, + "symbols": 1, + "throughput_bars_per_second": 12941.893613112105 + } + ] + }, + "grid": { + "facade_overhead_pct": -1.4155499227624158, + "parity": true, + "routes": [ + { + "bars": 2000, + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "6c20b1472ca2e6db0da4cbd1f6ee55a27f68ebb15697e0f700be1fdf0a7c6c38", + "num_trades": 839, + "peak_rss_mb": 274.515625, + "route": "grid_native_event_strategy", + "runs": 5, + "runtime_median_seconds": 1.4186582509428263, + "runtime_p95_seconds": 1.4995531063526868, + "symbols": 1, + "throughput_bars_per_second": 1409.782799113754 + }, + { + "bars": 2000, + "fill_count": 839, + "final_equity": 28972.788456089613, + "fingerprint": "6c20b1472ca2e6db0da4cbd1f6ee55a27f68ebb15697e0f700be1fdf0a7c6c38", + "num_trades": 839, + "peak_rss_mb": 274.53515625, + "route": "grid_event_driven_facade", + "runs": 5, + "runtime_median_seconds": 1.3985764351673424, + "runtime_p95_seconds": 1.4352879355661572, + "symbols": 1, + "throughput_bars_per_second": 1430.0255243187305 + } + ] + }, + "policy": { + "common_baseline_bars": 2000, + "domain_parity_required": true, + "fresh_process_per_route": true, + "grid_reported_separately": true + }, + "runs": 5 +} diff --git a/benchmarks/phase48c_event_driven_facade.md b/benchmarks/phase48c_event_driven_facade.md new file mode 100644 index 0000000..40b3386 --- /dev/null +++ b/benchmarks/phase48c_event_driven_facade.md @@ -0,0 +1,31 @@ +# Phase 48C Event-Driven Facade Benchmark + +Workload: **2,000 bars**, one symbol, fresh process per route. +The common table is the release baseline; the Grid table is a separate reactive workload. + +## Common 2,000-Bar Baseline + +| Route | Median s | P95 s | Bars/s | Peak RSS MB | Final Equity | Fills | +|---|---:|---:|---:|---:|---:|---:| +| `native_event_strategy` | 0.161195 | 0.197100 | 12,407 | 184.2 | 19,998.269072 | 109 | +| `event_driven_facade` | 0.154537 | 0.209075 | 12,942 | 183.4 | 19,998.269072 | 109 | + +Accounting parity: **PASS**. +Facade runtime overhead versus direct constructor: **-4.13%**. +The facade is a resolver/delegator; it is not expected to speed up the accounting kernel. + +## Reactive Grid 2,000-Bar Workload + +| Route | Median s | P95 s | Bars/s | Peak RSS MB | Final Equity | Fills | Trades | +|---|---:|---:|---:|---:|---:|---:|---:| +| `grid_native_event_strategy` | 1.418658 | 1.499553 | 1,410 | 274.5 | 28,972.788456 | 839 | 839 | +| `grid_event_driven_facade` | 1.398576 | 1.435288 | 1,430 | 274.5 | 28,972.788456 | 839 | 839 | + +Grid accounting parity: **PASS**. +Grid runtime includes external indicator preparation and the reactive callback; it is intentionally not merged into the common baseline. + +## Interpretation + +- The new facade changes endpoint declaration and profile resolution only. +- Equal fingerprints, equity, fees, funding, positions, margin, and fill counts are the domain gate. +- `backend=auto` remains governed by the package release policy; this benchmark explicitly uses Python. diff --git a/benchmarks/run_phase45b_native_event_score_rss.py b/benchmarks/run_phase45b_native_event_score_rss.py new file mode 100644 index 0000000..e7d29a7 --- /dev/null +++ b/benchmarks/run_phase45b_native_event_score_rss.py @@ -0,0 +1,84 @@ +"""Fresh-process RSS and parity gate for prepared native-event scoring.""" + +from __future__ import annotations + +import argparse +import json +import resource +import subprocess +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantbt import QuantBTEndpoint +from quantbt.core.orders import OrderCommand +from quantbt.core.schema import OrderSide, OrderType, TimeInForce + + +def _rss_mb() -> float: + status = Path("/proc/self/status") + if status.exists(): + for line in status.read_text().splitlines(): + if line.startswith("VmHWM:"): + return float(line.split()[1]) / 1024.0 + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _data(rows: int) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=rows, freq="1min", tz="UTC") + values = 100.0 + np.sin(np.arange(rows) / 17.0) + np.arange(rows) * 0.0001 + close = pd.Series(values, index=index) + return pd.DataFrame({"open": close, "high": close + 1.0, "low": close - 1.0, "close": close, "volume": 1_000.0}, index=index) + + +class _Strategy: + def on_bar_close(self, context): + symbol = context.symbols[0] + if context.bar_index % 20 == 0: + return [OrderCommand(timestamp=context.timestamp, symbol=symbol, side=OrderSide.BUY, order_type=OrderType.MARKET, qty=0.1, tif=TimeInForce.IOC, order_id=f"b-{context.bar_index}")] + if context.bar_index % 20 == 5 and context.positions[symbol] > 0.0: + return [OrderCommand(timestamp=context.timestamp, symbol=symbol, side=OrderSide.SELL, order_type=OrderType.MARKET, qty=0.1, reduce_only=True, tif=TimeInForce.IOC, order_id=f"s-{context.bar_index}")] + return [] + + +def _child(rows: int, repeats: int, mode: str) -> dict: + endpoint = QuantBTEndpoint.native_event_strategy(initial_capital=50_000, leverage=5, use_funding=False, fee_rate=0.0002, reactive_kernel_mode="single_pass") + prepared = endpoint.prepare_native_event_strategy(data=_data(rows), symbols=["BTC"]) + # Compile/cache warm-up is outside measurements by contract. + prepared.score(_Strategy()) + start = time.perf_counter() + final_equity = 0.0 + for _ in range(repeats): + if mode == "score": + final_equity = float(prepared.score(_Strategy()).metrics["final_equity"]) + else: + final_equity = float(prepared.run(_Strategy(), report_level="audit").equity.iloc[-1]) + return {"mode": mode, "rows": rows, "repeats": repeats, "seconds": time.perf_counter() - start, "peak_rss_mb": _rss_mb(), "final_equity": final_equity, "endpoint_result_retained": endpoint.result is not None} + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--child", action="store_true") + parser.add_argument("--mode", choices=("score", "audit"), default="score") + parser.add_argument("--rows", type=int, default=2_000) + parser.add_argument("--repeats", type=int, default=100) + parser.add_argument("--json-out", default="benchmarks/phase45b_native_event_score_rss.json") + args = parser.parse_args() + if args.child: + print(json.dumps(_child(args.rows, args.repeats, args.mode), sort_keys=True)) + return + rows = [] + for mode in ("score", "audit"): + completed = subprocess.run([sys.executable, __file__, "--child", "--mode", mode, "--rows", str(args.rows), "--repeats", str(args.repeats)], check=True, capture_output=True, text=True) + rows.append(json.loads(completed.stdout.strip().splitlines()[-1])) + score, audit = rows + payload = {"runs": rows, "parity": bool(np.isclose(score["final_equity"], audit["final_equity"], rtol=0.0, atol=1e-12)), "score_faster_than_audit": bool(score["seconds"] < audit["seconds"]), "score_rss_not_higher_than_audit": bool(score["peak_rss_mb"] <= audit["peak_rss_mb"])} + Path(args.json_out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + print(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/core/__init__.py b/core/__init__.py index 7d25106..17bbfcd 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -2,7 +2,12 @@ from .event import _engine_event_v1 from .vectorized import _engine_units_v2 from .types import BacktestResult -from .results import BacktestResultV2, NativeAccountingArrays, NativeEventScoreResult +from .results import ( + BacktestResultV2, + NativeAccountingArrays, + NativeEventScalarScoreResult, + NativeEventScoreResult, +) from .execution_contract import ( EXECUTION_CONTRACT_REGISTRY, AmbiguityPolicy, @@ -53,6 +58,20 @@ classify_alpha_source, scan_alpha_directory, ) +from .native_event_capabilities import ( + NATIVE_EVENT_CAPABILITY_MATRIX, + NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + capability_matrix_fingerprint, + native_event_capability_matrix, + normalize_native_event_capabilities, + validate_native_event_capability_matrix, +) +from .native_event_parity import ( + DEFAULT_NUMERIC_ATOL, + NativeEventParityCertificate, + NativeEventParityError, + assert_native_event_full_parity, +) from .orders import ( BasketIntent, Fill, @@ -80,6 +99,8 @@ ) from .reactive import ( NativeActiveOrderSnapshot, + NativeCommandBatch, + NativeEventStrategy, NativeEventStrategyError, NativeEventStrategyProtocol, NativeFillEvent, @@ -159,11 +180,17 @@ "_engine_portfolio", "BacktestResult", "BacktestResultV2", + "DEFAULT_NUMERIC_ATOL", "NativeAccountingArrays", "NativeEventScoreResult", + "NativeEventScalarScoreResult", + "NativeEventParityCertificate", + "NativeEventParityError", "BracketOrderSpec", "AccountConfig", "AlphaExecutionClassification", + "NATIVE_EVENT_CAPABILITY_MATRIX", + "NATIVE_EVENT_CAPABILITY_MATRIX_VERSION", "AmbiguityPolicy", "ArbExecutionPolicy", "ArbitrageLeg", @@ -226,7 +253,9 @@ "NativeFillReplayResult", "NativeIntrabarKernelResult", "NativeActiveOrderSnapshot", + "NativeCommandBatch", "NativeEventStrategyError", + "NativeEventStrategy", "NativeEventStrategyProtocol", "NativeFillEvent", "NativeOrderEvent", @@ -286,4 +315,9 @@ "prepare_funding", "make_funding_mask", "build_arrays", + "assert_native_event_full_parity", + "capability_matrix_fingerprint", + "native_event_capability_matrix", + "normalize_native_event_capabilities", + "validate_native_event_capability_matrix", ] diff --git a/core/native_event_capabilities.py b/core/native_event_capabilities.py new file mode 100644 index 0000000..75df199 --- /dev/null +++ b/core/native_event_capabilities.py @@ -0,0 +1,121 @@ +"""Canonical native-event capability contract. + +The Rust extension exposes a low-level capability map whose names are tied to +its release history (for example ``rust_batched_tape``). Public selectors, +tests, and documentation need a stable vocabulary instead. This module is +the single Python-side source of truth for the currently certified +single-symbol R2 surface. Full-contract 0.4 flags are additive and only +normalize to the wider vocabulary when the extension advertises the complete +capability gate. +""" + +from __future__ import annotations + +from hashlib import sha256 +import json +from types import MappingProxyType +from typing import Mapping + + +NATIVE_EVENT_CAPABILITY_MATRIX_VERSION = "full-contract-v2-0.4" + +_CAPABILITIES = { + "single_symbol": True, + "market": True, + "limit": True, + "stop_market": True, + "stop_limit": True, + "place": True, + "cancel": True, + "amend": True, + "replace": True, + "reduce_only": True, + "quantity_constraints": True, + "gtc": True, + "gtd": True, + "ioc": True, + "fok": True, + "parent_child": True, + "oco": True, + "funding": True, + "liquidation": True, + "multi_symbol": True, +} + +NATIVE_EVENT_CAPABILITY_MATRIX: Mapping[str, bool] = MappingProxyType(_CAPABILITIES) + + +def native_event_capability_matrix() -> dict[str, bool]: + """Return a mutable copy of the canonical capability matrix.""" + + return dict(NATIVE_EVENT_CAPABILITY_MATRIX) + + +def capability_matrix_fingerprint() -> str: + """Return a reproducible SHA-256 fingerprint for the capability contract.""" + + payload = { + "version": NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + "capabilities": dict(sorted(NATIVE_EVENT_CAPABILITY_MATRIX.items())), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return sha256(encoded).hexdigest() + + +def normalize_native_event_capabilities(raw: Mapping[str, object] | None) -> dict[str, bool]: + """Map extension-specific flags into the stable public vocabulary. + + Unknown raw flags are intentionally ignored. A raw flag cannot silently + enable a capability that is outside the certified matrix; a later release + must update this module and its tests first. + """ + + source = {str(key): bool(value) for key, value in (raw or {}).items()} + lifecycle = source.get("reactive_session", False) or source.get("r1_single_symbol", False) + place_cancel = source.get("r1_place_cancel_market_limit_gtc", False) + r2 = source.get("r2_stop_amend_replace_reduce_only_constraints", False) + batched = source.get("rust_batched_tape", False) or source.get("rust_batched_tape_audit", False) + full = source.get("native_event_v2_full_contract", False) + + normalized = native_event_capability_matrix() + normalized["single_symbol"] = bool(full or lifecycle or batched) + normalized["market"] = bool(full or place_cancel or batched) + normalized["limit"] = bool(full or place_cancel or batched) + normalized["stop_market"] = bool(full or r2) + normalized["stop_limit"] = bool(full or r2) + normalized["place"] = bool(full or place_cancel or batched) + normalized["cancel"] = bool(full or place_cancel or batched) + normalized["amend"] = bool(full or r2) + normalized["replace"] = bool(full or r2) + normalized["reduce_only"] = bool(full or r2) + normalized["quantity_constraints"] = bool(full or r2) + normalized["gtc"] = bool(full or place_cancel or batched) + if full: + normalized.update({ + "gtd": True, "ioc": True, "fok": True, "parent_child": True, + "oco": True, "funding": True, "liquidation": True, "multi_symbol": True, + }) + else: + normalized.update({ + "gtd": False, "ioc": False, "fok": False, "parent_child": False, + "oco": False, "funding": False, "liquidation": False, "multi_symbol": False, + }) + return normalized + + +def validate_native_event_capability_matrix(matrix: Mapping[str, object]) -> None: + """Raise if a consumer attempts to advertise an unknown capability.""" + + unknown = sorted(set(matrix) - set(NATIVE_EVENT_CAPABILITY_MATRIX)) + if unknown: + raise ValueError(f"unknown native-event capability fields: {unknown}") + + +__all__ = [ + "NATIVE_EVENT_CAPABILITY_MATRIX_VERSION", + "NATIVE_EVENT_CAPABILITY_MATRIX", + "capability_matrix_fingerprint", + "native_event_capability_matrix", + "normalize_native_event_capabilities", + "validate_native_event_capability_matrix", +] diff --git a/core/native_event_parity.py b/core/native_event_parity.py new file mode 100644 index 0000000..a3fc339 --- /dev/null +++ b/core/native_event_parity.py @@ -0,0 +1,336 @@ +"""Strict parity certificates for native-event execution artifacts. + +This module deliberately lives above the execution kernels. It compares +observable lifecycle/accounting artifacts and therefore can certify Python, +Rust, and replay results without making any backend responsible for another +backend's object model. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import json +from typing import Any, Mapping + +import numpy as np +import pandas as pd + +from .native_event_capabilities import NATIVE_EVENT_CAPABILITY_MATRIX + + +DEFAULT_NUMERIC_ATOL = 1e-12 +_NUMERIC_FIELDS = ( + "equity", + "positions", + "fees", + "funding", + "turnover", + "initial_margin", + "maintenance_margin", +) +_DISCRETE_FIELDS = ( + "liquidated", + "liquidation_bar", +) + + +class NativeEventParityError(AssertionError): + """Raised when two native-event artifacts are not lifecycle-equivalent.""" + + +@dataclass(frozen=True) +class NativeEventParityCertificate: + """Serializable summary returned by :func:`assert_native_event_full_parity`.""" + + passed: bool + numeric_atol: float + compared_fields: tuple[str, ...] + missing_fields: tuple[str, ...] + candidate_fingerprint: str + oracle_fingerprint: str + command_fingerprint: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "passed": self.passed, + "numeric_atol": self.numeric_atol, + "compared_fields": list(self.compared_fields), + "missing_fields": list(self.missing_fields), + "candidate_fingerprint": self.candidate_fingerprint, + "oracle_fingerprint": self.oracle_fingerprint, + "command_fingerprint": self.command_fingerprint, + } + + +def _metadata(result: object) -> Mapping[str, object]: + value = getattr(result, "metadata", None) + return value if isinstance(value, Mapping) else {} + + +def _array(value: object, *, name: str) -> np.ndarray | None: + if value is None: + return None + if isinstance(value, pd.Series): + return value.to_numpy(copy=True) + if isinstance(value, pd.DataFrame): + if name == "positions": + columns = [column for column in value.columns if str(column).startswith("Position_")] + if columns: + return value[columns].to_numpy(copy=True) + if name in value: + return value[name].to_numpy(copy=True) + return value.to_numpy(copy=True) + return np.asarray(value).copy() + + +def _result_field(result: object, name: str) -> np.ndarray | object | None: + value = getattr(result, name, None) + if name == "turnover" and value is None: + diagnostics = getattr(result, "diagnostics", None) + value = diagnostics.get("turnover") if isinstance(diagnostics, pd.DataFrame) else None + if name in {"initial_margin", "maintenance_margin"} and value is None: + margin = getattr(result, "margin", None) + if isinstance(margin, pd.DataFrame): + value = margin.get(name) + if value is None: + value = _metadata(result).get(name) + return _array(value, name=name) if name not in _DISCRETE_FIELDS else value + + +def _stable_bytes(value: object) -> bytes: + if isinstance(value, Mapping): + value = {str(key): value[key] for key in sorted(value, key=str)} + return json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode("utf-8") + if isinstance(value, (str, int, float, bool)) or value is None: + return json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode("utf-8") + array = np.asarray(value) + if array.dtype.kind in "OUS": + payload = [str(item) for item in array.reshape(-1)] + return json.dumps({"shape": array.shape, "values": payload}, separators=(",", ":")).encode("utf-8") + contiguous = np.ascontiguousarray(array) + return b"|".join((str(contiguous.dtype).encode(), repr(contiguous.shape).encode(), contiguous.tobytes())) + + +def _fingerprint(fields: Mapping[str, object]) -> str: + digest = sha256() + for name in sorted(fields): + digest.update(name.encode("utf-8")) + digest.update(b"=") + digest.update(_stable_bytes(fields[name])) + digest.update(b"\n") + return digest.hexdigest() + + +def _record_value(record: object, name: str, default: object = None) -> object: + if isinstance(record, Mapping): + return record.get(name, default) + return getattr(record, name, default) + + +def _fill_records(result: object) -> list[tuple[object, ...]] | None: + arrays = {name: getattr(result, f"fill_{name}", None) for name in ( + "bar", "order_id", "side", "qty", "price", "fee" + )} + if all(value is not None for value in arrays.values()): + length = len(np.asarray(arrays["bar"])) + return [ + tuple(np.asarray(arrays[name])[idx].item() for name in arrays) + for idx in range(length) + ] + fills = getattr(result, "fills", None) + if fills is None: + fills = _metadata(result).get("fills") + if fills is None: + return None + records = [] + for fill in fills: + side = _record_value(fill, "side") + side = getattr(side, "value", side) + records.append( + ( + str(_record_value(fill, "timestamp")), + str(_record_value(fill, "symbol")), + side, + float(_record_value(fill, "qty")), + float(_record_value(fill, "price")), + float(_record_value(fill, "fee", 0.0)), + str(_record_value(fill, "order_id")), + ) + ) + return records + + +def _event_records(result: object) -> list[tuple[object, ...]] | None: + arrays = { + name: getattr(result, f"event_{name}", None) + for name in ("bar", "kind", "status", "order_id", "target_id") + } + if all(value is not None for value in arrays.values()): + length = len(np.asarray(arrays["bar"])) + return [ + tuple(np.asarray(arrays[name])[idx].item() for name in arrays) + for idx in range(length) + ] + ledger = _metadata(result).get("compact_order_event_ledger") + if ledger is None: + ledger = _metadata(result).get("event_ledger") + if ledger is None: + return None + if isinstance(ledger, Mapping): + names = ("bar", "kind", "status", "order_id", "target_id") + if all(name in ledger for name in names): + arrays = {name: np.asarray(ledger[name]) for name in names} + return [tuple(arrays[name][idx].item() for name in names) for idx in range(len(arrays["bar"]))] + names = ("bar", "event_type", "status", "command_index", "related_command_index") + if all(hasattr(ledger, name) for name in names): + arrays = {name: np.asarray(getattr(ledger, name)) for name in names} + return [ + ( + arrays["bar"][idx].item(), + arrays["event_type"][idx].item(), + arrays["status"][idx].item(), + arrays["command_index"][idx].item(), + arrays["related_command_index"][idx].item(), + ) + for idx in range(len(arrays["bar"])) + ] + return None + + +def _command_fingerprint(command_tape: object) -> str: + if isinstance(command_tape, Mapping): + fields = command_tape + else: + names = ( + "effective_bar", "command_ptr", "command_action", "command_order_id", + "command_status", "command_symbol", "command_sequence", + ) + fields = {name: getattr(command_tape, name) for name in names if hasattr(command_tape, name)} + if not fields: + raise ValueError("command_tape must expose at least one deterministic command field") + return _fingerprint({str(name): value for name, value in fields.items()}) + + +def _snapshot(result: object) -> dict[str, object]: + fields: dict[str, object] = {} + for name in _NUMERIC_FIELDS: + value = _result_field(result, name) + if value is not None: + fields[name] = value + fills = _fill_records(result) + if fills is not None: + fields["fills"] = fills + events = _event_records(result) + if events is not None: + fields["events"] = events + for name in _DISCRETE_FIELDS: + value = getattr(result, name, None) + if value is not None: + fields[name] = value + return fields + + +def assert_native_event_full_parity( + candidate: object, + oracle: object, + *, + numeric_atol: float = DEFAULT_NUMERIC_ATOL, + capabilities: Mapping[str, object] | None = None, + command_tape: object | tuple[object, object] | None = None, + require_full: bool = True, +) -> dict[str, object]: + """Compare complete observable lifecycle and accounting artifacts. + + Discrete lifecycle artifacts (fills, event order, statuses, and boolean + state) must match exactly. Numeric paths use ``rtol=0`` and the supplied + absolute tolerance. ``require_full=True`` requires fills and event ledgers + on both sides; use ``False`` only for an explicitly scalar/minimal run. + ``command_tape`` may be one shared tape or ``(candidate, oracle)``. + """ + + atol = float(numeric_atol) + if atol < 0.0 or not np.isfinite(atol): + raise ValueError("numeric_atol must be finite and >= 0") + left = _snapshot(candidate) + right = _snapshot(oracle) + capabilities = dict(NATIVE_EVENT_CAPABILITY_MATRIX if capabilities is None else capabilities) + compared: list[str] = [] + missing: list[str] = [] + + for name in _NUMERIC_FIELDS: + left_value = left.get(name) + right_value = right.get(name) + required = name in {"equity", "positions", "fees", "turnover", "initial_margin", "maintenance_margin"} + if name == "funding": + required = bool(capabilities.get("funding", False)) + if left_value is None or right_value is None: + if required: + missing.append(name) + continue + lhs = np.asarray(left_value) + rhs = np.asarray(right_value) + if lhs.shape != rhs.shape: + raise NativeEventParityError(f"{name} shape mismatch: {lhs.shape} != {rhs.shape}") + if not np.allclose(lhs, rhs, rtol=0.0, atol=atol, equal_nan=True): + difference = float(np.nanmax(np.abs(lhs.astype(float) - rhs.astype(float)))) + raise NativeEventParityError(f"{name} mismatch: max_abs_diff={difference:.17g}, atol={atol:.17g}") + compared.append(name) + + for name in _DISCRETE_FIELDS: + if name not in left or name not in right: + if name in {"liquidated", "liquidation_bar"} and not capabilities.get("liquidation", False): + continue + missing.append(name) + continue + if left[name] != right[name]: + raise NativeEventParityError(f"{name} mismatch: {left[name]!r} != {right[name]!r}") + compared.append(name) + + for name in ("fills", "events"): + lhs = left.get(name) + rhs = right.get(name) + if lhs is None or rhs is None: + if require_full: + missing.append(name) + continue + if lhs != rhs: + raise NativeEventParityError(f"{name} lifecycle sequence mismatch") + compared.append(name) + + command_fingerprint = None + if command_tape is not None: + if isinstance(command_tape, tuple): + if len(command_tape) != 2: + raise ValueError("command_tape tuple must contain (candidate_tape, oracle_tape)") + left_command = _command_fingerprint(command_tape[0]) + right_command = _command_fingerprint(command_tape[1]) + if left_command != right_command: + raise NativeEventParityError("command sequence/effective-bar fingerprint mismatch") + command_fingerprint = left_command + else: + command_fingerprint = _command_fingerprint(command_tape) + compared.append("command_tape") + + if missing: + raise NativeEventParityError(f"parity artifacts missing: {sorted(set(missing))}") + candidate_fingerprint = _fingerprint(left) + oracle_fingerprint = _fingerprint(right) + certificate = NativeEventParityCertificate( + passed=True, + numeric_atol=atol, + compared_fields=tuple(compared), + missing_fields=tuple(missing), + candidate_fingerprint=candidate_fingerprint, + oracle_fingerprint=oracle_fingerprint, + command_fingerprint=command_fingerprint, + ) + return certificate.to_dict() + + +__all__ = [ + "DEFAULT_NUMERIC_ATOL", + "NativeEventParityCertificate", + "NativeEventParityError", + "assert_native_event_full_parity", +] diff --git a/core/order_compiler.py b/core/order_compiler.py index 0322532..98abc67 100644 --- a/core/order_compiler.py +++ b/core/order_compiler.py @@ -8,7 +8,8 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace +import hashlib from typing import Dict, Sequence, Tuple import numpy as np @@ -91,12 +92,54 @@ class CompiledOrderCommandArrays: command_expires_bar: np.ndarray original_index: np.ndarray id_values: Tuple[str, ...] + tape_fingerprint: str = "" @property def n_commands(self) -> int: return int(len(self.original_index)) +def command_tape_fingerprint(compiled: CompiledOrderCommandArrays) -> str: + """Return a complete identity for the immutable primitive command tape. + + The digest includes fields used by Rust validation as well as execution. + It is computed when the compiler creates a tape so repeated score calls do + not hash every array on the measured execution path. + """ + + digest = hashlib.blake2b(digest_size=16) + digest.update(repr(compiled.index_signature).encode("utf-8")) + digest.update(repr(tuple(compiled.symbols)).encode("utf-8")) + digest.update(repr(tuple(compiled.id_values)).encode("utf-8")) + for name in ( + "command_ptr", + "command_bar", + "command_action", + "command_symbol", + "command_side", + "command_type", + "command_qty", + "command_price", + "command_trigger_price", + "command_tif", + "command_reduce_only", + "command_order_id", + "command_target_order_id", + "command_parent_order_id", + "command_group_id", + "command_oco_group_id", + "command_activation", + "command_expires_bar", + "original_index", + ): + array = np.ascontiguousarray(getattr(compiled, name)) + digest.update(name.encode("ascii")) + digest.update(str(array.dtype).encode("ascii")) + digest.update(str(array.shape).encode("ascii")) + digest.update(array.tobytes()) + return digest.hexdigest() + + def compile_order_intents( idx: pd.DatetimeIndex, orders: Sequence[OrderIntent], @@ -247,7 +290,7 @@ def compile_order_commands( original_index = np.ascontiguousarray(original_unsorted[order_sort], dtype=np.int64) sorted_commands = tuple((int(orig_idx), commands[int(orig_idx)]) for orig_idx in original_index) id_values = tuple(sorted(id_map, key=id_map.get)) - return CompiledOrderCommandArrays( + compiled = CompiledOrderCommandArrays( index_signature=market_data_signature(idx, list(symbol_to_col.keys())), symbols=tuple(symbol_to_col.keys()), sorted_commands=sorted_commands, @@ -272,6 +315,29 @@ def compile_order_commands( original_index=original_index, id_values=id_values, ) + for name in ( + "command_ptr", + "command_bar", + "command_action", + "command_symbol", + "command_side", + "command_type", + "command_qty", + "command_price", + "command_trigger_price", + "command_tif", + "command_reduce_only", + "command_order_id", + "command_target_order_id", + "command_parent_order_id", + "command_group_id", + "command_oco_group_id", + "command_activation", + "command_expires_bar", + "original_index", + ): + getattr(compiled, name).flags.writeable = False + return replace(compiled, tape_fingerprint=command_tape_fingerprint(compiled)) def order_intents_to_commands(orders: Sequence[OrderIntent]) -> Tuple[OrderCommand, ...]: diff --git a/core/preprocessor.py b/core/preprocessor.py index 924de04..0f01425 100644 --- a/core/preprocessor.py +++ b/core/preprocessor.py @@ -207,14 +207,21 @@ def build_market_arrays( funding[:, k] = funding_dict[sym].fillna(0).values is_funding_bar = make_funding_mask(idx) + closes = np.ascontiguousarray(closes, dtype=np.float64) + highs = np.ascontiguousarray(highs, dtype=np.float64) + lows = np.ascontiguousarray(lows, dtype=np.float64) + funding = np.ascontiguousarray(funding, dtype=np.float64) + is_funding_bar = np.ascontiguousarray(is_funding_bar, dtype=np.bool_) + for arr in (closes, highs, lows, funding, is_funding_bar): + arr.setflags(write=False) return PreparedMarketArrays( idx=idx, symbols=tuple(symbols), - closes=np.ascontiguousarray(closes, dtype=np.float64), - highs=np.ascontiguousarray(highs, dtype=np.float64), - lows=np.ascontiguousarray(lows, dtype=np.float64), - funding=np.ascontiguousarray(funding, dtype=np.float64), - is_funding_bar=np.ascontiguousarray(is_funding_bar, dtype=np.bool_), + closes=closes, + highs=highs, + lows=lows, + funding=funding, + is_funding_bar=is_funding_bar, signature=market_data_signature(idx, symbols), ) diff --git a/core/reactive.py b/core/reactive.py index db47b74..2aea26b 100644 --- a/core/reactive.py +++ b/core/reactive.py @@ -8,13 +8,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Callable, Mapping, Optional, Sequence, Tuple +from typing import Callable, Mapping, Optional, Protocol, Sequence, Tuple, runtime_checkable import numpy as np import pandas as pd from .orders import OrderCommand -from .schema import OrderSide, OrderType +from .schema import OrderSide @dataclass(frozen=True) @@ -51,6 +51,7 @@ class NativeOrderEvent: level_id: Optional[str] = None original_index: int = -1 related_original_index: int = -1 + metadata: Mapping = field(default_factory=dict) @dataclass(frozen=True) @@ -73,6 +74,32 @@ class NativeActiveOrderSnapshot: level_id: Optional[str] = None +@dataclass(frozen=True, slots=True) +class NativeCommandBatch: + """Optional compact callback container for reactive command batches. + + Existing strategies may continue returning ``list[OrderCommand]`` or a + tuple. This wrapper makes the batch boundary explicit for strategies that + already build a fixed command tuple, without changing command semantics or + the public ``OrderCommand`` type. + """ + + commands: Tuple[OrderCommand, ...] = field(default_factory=tuple) + + @classmethod + def from_commands(cls, commands: Sequence[OrderCommand]) -> "NativeCommandBatch": + return cls(tuple(commands)) + + def __iter__(self): + return iter(self.commands) + + def __len__(self) -> int: + return len(self.commands) + + def __bool__(self) -> bool: + return bool(self.commands) + + @dataclass(frozen=True) class NativeStrategyContext: bar_index: int @@ -124,3 +151,23 @@ def on_bar_close(self, context: NativeStrategyContext) -> Sequence[OrderCommand] def finalize(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: return () + + +@runtime_checkable +class NativeEventStrategy(Protocol): + """Public structural protocol for stateful native-event strategies. + + Implementations are discovered by duck typing; subclassing this protocol + is optional. A strategy may optionally declare + ``native_context_requirements`` to reduce callback context materialization + for score/optimization runs. + """ + + def initialize(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + ... + + def on_bar_close(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + ... + + def finalize(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + ... diff --git a/core/results.py b/core/results.py index ac71499..9eefec0 100644 --- a/core/results.py +++ b/core/results.py @@ -224,6 +224,83 @@ def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: ) +@dataclass(frozen=True, slots=True) +class NativeEventScalarScoreResult: + """Low-retention score contract for prepared native-event optimization. + + Unlike :class:`NativeEventScoreResult`, this result does not retain an + equity, position, fee, funding, or margin path. The reactive session + computes the same report metrics online and keeps only scalar accounting + state. Public audit runs and the compatibility ``score()`` contract keep + using ``NativeEventScoreResult`` with ndarray accounting. + """ + + final_equity: float + final_positions: np.ndarray + fill_count: int + rejection_count: int + cancellation_count: int + liquidated: bool + liquidation_bar: int + metrics: Mapping[str, float] + metadata: Mapping[str, object] = field(default_factory=dict) + + def _lifecycle_counter(self, name: str, default: int = 0) -> int: + counters = self.metadata.get("lifecycle_counters", {}) + return int(counters.get(name, default)) if isinstance(counters, Mapping) else int(default) + + @property + def event_count(self) -> int: + """Number of lifecycle events emitted by the scalar run.""" + + return self._lifecycle_counter("event_count") + + @property + def rejected_count(self) -> int: + """Number of rejected commands in the scalar run.""" + + return int(self.rejection_count) + + @property + def canceled_count(self) -> int: + """Number of canceled commands in the scalar run.""" + + return int(self.cancellation_count) + + @property + def max_initial_margin(self) -> float: + return float(self.metrics.get("max_initial_margin", 0.0)) + + @property + def max_maintenance_margin(self) -> float: + return float(self.metrics.get("max_maintenance_margin", 0.0)) + + @property + def total_fee(self) -> float: + return float(self.metadata.get("total_fee", 0.0)) + + @property + def total_turnover(self) -> float: + return float(self.metadata.get("total_turnover", 0.0)) + + def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """Return the online report captured for this score run. + + A scalar score has no path from which to recompute a different + annualization convention. Callers requesting a different + ``trading_days`` value must rerun the score with that value. + """ + if str(scope).lower().strip() not in {"auto", "full"}: + raise ValueError("NativeEventScalarScoreResult supports scope='auto' or scope='full'") + recorded_days = int(self.metadata.get("trading_days", trading_days)) + if int(trading_days) != recorded_days: + raise ValueError( + "scalar score metrics were computed with trading_days=" + f"{recorded_days}; rerun the score to use trading_days={int(trading_days)}" + ) + return dict(self.metrics) + + @dataclass class OptionBacktestResult(BacktestResultV2): """ diff --git a/docs/README.md b/docs/README.md index cff475e..f36538e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,10 @@ Use this page as the first stop when deciding which QuantBT document to read. | Use Nautilus as third-party execution validation, reports, and depth preflight | [Nautilus backend](nautilus_backend.md) | | Understand WFO parameter selection methodology | [Walk-forward methodology](walkforward_methodology_vi.md) | | Tune params across signal, intrabar, portfolio, and generic endpoints | [Domain-agnostic optimization](optimization.md) | +| Package, release, or install QuantBT in Pool Alpha | [Packaging and release](release_packaging.md) | +| Prepare and inspect a TestPyPI RC | [TestPyPI release checklist](testpypi_release_checklist.md) | +| Inspect the Rust Native Event V2 full contract and conformance gate | [Rust full contract](native_event_rust_full_contract.md) | +| Certify the external Grid alpha on Python/Rust with 2,000-bar parity, RSS, and optimizer evidence | [Grid Phase 47C/47D](grid_native_event_phase47c.md) | ## Strategy Route Map diff --git a/docs/endpoint.md b/docs/endpoint.md index cf4acd2..b4b42e4 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -59,6 +59,7 @@ bt.metrics # alias for bt.full_report() | `QuantBTEndpoint.fill_replay()` | `fill_replay` | `native_intrabar` | fast accounting replay from explicit fills | | `QuantBTEndpoint.dca_ladder()` | `dca_ladder` | `legacy` | structural DCA/grid levels with high/low limit-touch simulation | | `QuantBTEndpoint.orders()` | `orders` | `native_event` | explicit `OrderIntent` market/limit/stop simulation | +| `QuantBTEndpoint.event_driven()` | `native_event_strategy` or `orders` | `auto` | stable facade for reactive strategies or explicit lifecycle commands | | `QuantBTEndpoint.basket()` | `basket` | `native_event` | pair/basket entry with frozen hedge-ratio units | | `QuantBTEndpoint.arbitrage()` | `arbitrage` | `native_event` | package-style arbitrage specs and validation | | `QuantBTEndpoint.walk_forward()` | `walk_forward` | `auto` | split/stitch OOS signals then route into existing endpoints | @@ -85,6 +86,122 @@ Use `backend="auto"` when service code wants QuantBT to choose the safest route: - `nautilus_validation` routes to Nautilus; - other signal modes route to native vectorized. +## Stable Event-Driven Facade + +`QuantBTEndpoint.event_driven()` is the recommended public entry point for new +event-driven integrations. It keeps the common declaration small while leaving +the existing lifecycle engine, matching rules, accounting, and audit artifacts +unchanged underneath. + +```python +from quantbt import QuantBTEndpoint + +bt = QuantBTEndpoint.event_driven( + input_mode="strategy", + profile="research", + backend="auto", + initial_capital=20_000, + leverage=5, + fee_rate=0.0005, # canonical one-way fee + slippage_bps=2.0, + use_funding=False, +) + +result = bt.simulate( + data=df, + strategy=strategy, + symbols=["BTCUSDT"], +) +bt.show_metrics() +``` + +### Profiles + +The profile is an explicit retention and execution policy. It does not change +fill or accounting semantics: + +| Profile | Execution | Kernel | Result/report retention | Audit sink | +|---|---|---|---|---| +| `research` | `fast` | `single_pass` | `minimal` | `none` | +| `optimize` | `fast` | `single_pass` | `score` | `none` | +| `audit` | `audit` | `replay_certified` | `audit` | `memory` | + +Use `research` for ordinary notebook/service runs, `optimize` for parameter +search, and `audit` when fills, order events, replay evidence, and detailed +accounting must be retained. `backend="auto"` follows the package release +policy. `backend="python"` selects the canonical portable implementation; +`backend="rust"` is an explicit capability-gated request for the optional +native wheel and never silently changes to Rust. + +### Input modes + +`input_mode="strategy"` accepts a stateful callback object. The strategy owns +signal generation and look-ahead control; the engine owns market processing, +order lifecycle, fills, fees, slippage, margin, funding, and PnL. + +```python +class MyStrategy: + def initialize(self, context): + return () + + def on_bar_close(self, context): + # Return OrderCommand objects for the next causal bar. + return () + + def finalize(self, context): + return () + +bt = QuantBTEndpoint.event_driven(profile="audit", backend="python") +result = bt.simulate(data=df, strategy=MyStrategy(), symbols=["BTCUSDT"]) +``` + +`initialize` and `finalize` may return an empty tuple. A strategy may subclass +`NativeEventStrategyProtocol`, or simply satisfy the public structural +`NativeEventStrategy` protocol by duck typing. The optional +`native_context_requirements` declaration can reduce context materialization +for specialized optimization runs. Commands emitted at bar close are handled +according to the native-event lifecycle and do not become an implicit +same-bar fill. + +`input_mode="orders"` is for an already-created execution tape. Use it when +the alpha or an upstream planner owns order generation but still needs the +native lifecycle to process placement, cancellation, replacement, OCO links, +trigger rules, fees, margin, and fills: + +```python +bt = QuantBTEndpoint.event_driven( + input_mode="orders", + profile="audit", + backend="python", + initial_capital=20_000, +) +result = bt.simulate(data=df, order_commands=commands, symbols=["BTCUSDT"]) +fills = result.fills +events = result.metadata.get("order_events") +``` + +Legacy `OrderIntent` inputs remain supported through the existing +`QuantBTEndpoint.orders(...)` route. The new facade accepts the canonical +`OrderCommand` lifecycle tape and delegates to +`native_event_lifecycle(...)`; it does not introduce a second order engine. + +### Advanced controls and compatibility + +The facade owns the four low-level values in its selected profile. Passing a +conflicting `reactive_execution_mode`, `reactive_kernel_mode`, `report_level`, +or `audit_sink` raises a clear `ValueError` instead of silently overriding the +user's configuration. Use `native_event_strategy(...)` or +`native_event_lifecycle(...)` directly when an advanced, non-profile +combination is required. Existing endpoint constructors and notebook snippets +remain valid. + +For the recommended stable path, users need only choose `input_mode`, +`profile`, and `backend`; account, instrument, quantity, and execution fields +remain available as normal shared endpoint parameters. See +[`execution_contracts.md`](execution_contracts.md) for exact fill policy and +[`release_packaging.md`](release_packaging.md) for backend capability and +wheel-release policy. + `native_vectorized` is explicitly the `close_target_v2` execution contract: signals are interpreted as target exposure at the same bar close, with no engine-owned intrabar SL/TP/trailing path. Results include contract metadata @@ -1055,6 +1172,51 @@ result.metadata["reactive_static_replay_count"] # 0 for single_pass minimal/sc result.metadata["emitted_command_tape"] # replayable OrderCommand tape ``` +### Native-event backend selector (Phase 46E) + +Native-event endpoints accept the optional `native_backend` selector: + +```python +bt = QuantBTEndpoint.orders( + backend="native_event", + native_backend="python", # python | rust | auto | replay_certified + initial_capital=20_000, + leverage=5, + maintenance_ratio=0.0, + use_funding=False, +) +``` + +`python` is the full-featured canonical reactive backend. `rust` is explicit +and fail-fast: with the installed API `0.4` full-contract wheel it supports +the same Native Event V2 lifecycle surface used by this endpoint, including +multi-symbol tapes, funding, maintenance/liquidation, quantity preflight, +MARKET/LIMIT/STOP orders, GTC/GTD/IOC/FOK, amend/replace/cancel-all, and +parent/group/OCO relationships. A wheel without the required capability keys +raises a capability error; it is never silently downgraded to Python. +`auto` remains Python for the release policy and does not activate Rust yet. +`replay_certified` is the deterministic audit oracle. Rust audit results are +adapted to `BacktestResultV2`, so the normal `show_metrics()`, `full_report()`, +`quick_plot()`, and `tearsheet()` helpers remain available. The score path +crosses the PyO3 boundary with typed arrays and does not build pandas report +frames; rerun the selected tape at audit level when full evidence is required. + +The complete Phase 47B contract and conformance evidence are documented in +[`native_event_rust_full_contract.md`](native_event_rust_full_contract.md). +The external Grid 2,000-bar parity, scalar-score retention contract, backend +policy, and isolated RSS benchmark are documented in +[`grid_native_event_phase47c.md`](grid_native_event_phase47c.md). + +The Grid optimizer-safe Phase 47D policy is documented in the same guide. The +public/audit default keeps `collect_diagnostics=True`; the external Grid +`score_grid_params(...)` helper overrides only that artifact policy to +`False`, derives the minimal context contract from the strategy, and keeps +the prepared runner scalar-only. This does not alter order generation, +matching, fees, funding, margin, liquidation, or terminal accounting. A +diagnostics-off strategy cannot build the stakeholder audit frame; rerun the +candidate with the default audit policy for `build_output_frame()`, plots, and +full reports. + For reactive strategies, `report_level="minimal"` intentionally omits `emitted_command_tape` from metadata while preserving `emitted_command_count`. Use `report_level="audit"` when a replayable command @@ -1096,6 +1258,47 @@ parity with `prepared.run(..., report_level="audit")`. `prepared.run(...)` returns the normal public `BacktestResultV2` and should be used for final audit/replay exports. +For high-volume prepared optimization, use the zero-retention score contract: + +```python +from quantbt import NativeEventScoreRequirements + +score = prepared.score( + DynamicGridStrategy(params), + trading_days=365, + score_requirements=NativeEventScoreRequirements.scalar_score_contract(), +) +report = score.full_report() +``` + +This returns `NativeEventScalarScoreResult`. It keeps scalar online metrics, +live order state, counters, and final positions; it does not allocate full +equity/position/fee/funding/margin paths, pandas reports, or a command tape. +Its metrics are parity-locked to the same array-first report implementation. +The compatibility call without `score_requirements` keeps the ndarray +`NativeEventScoreResult` contract for existing callers that inspect paths. + +Strategies may opt out of callback payload objects when they do not consume +them: + +```python +class GridStrategy: + native_context_requirements = { + "fills": False, + "events": False, + "active_orders": False, + "positions": False, + "margin": False, + } +``` + +The declaration only changes context materialization. It never changes order +timing, matching, fees, funding, margin, liquidation, or accounting formulas. +`PreparedNativeEventStrategyEvaluator` uses the scalar contract by default and +still accepts legacy list/tuple callback returns. Strategies that want an +explicit immutable callback batch may return +`NativeCommandBatch.from_commands(commands)`. + Scoped cancel-all: ```python diff --git a/docs/grid_native_event_phase47c.md b/docs/grid_native_event_phase47c.md new file mode 100644 index 0000000..4d3825f --- /dev/null +++ b/docs/grid_native_event_phase47c.md @@ -0,0 +1,209 @@ +# Grid Native Event Phase 47C + +Phase 47C is the Grid integration certification gate for the external alpha +module: + +```text +/root/bobby/pool_alpha/alphas_storage/TA/dynamic_grid_quantbt_native_event.py +``` + +QuantBT imports that file read-only. It is not copied into this package and no +Grid-specific endpoint is introduced. + +## Backend policy + +The existing Grid adapter accepts four values in `GridExecutionConfig`: + +| Value | Meaning | +|---|---| +| `python` | Canonical full reactive implementation and default. | +| `rust` | Explicit capability-gated Rust V2. Failure is raised; no fallback. | +| `replay_certified` | Python replay oracle used for audit evidence. | +| `auto` | Resolves to Python until every release gate is certified. | + +The public endpoint remains: + +```python +QuantBTEndpoint.native_event_strategy(...) +QuantBTEndpoint.prepare_native_event_strategy(...) +``` + +The Grid strategy still owns command generation. The backend owns lifecycle, +matching, fees, funding, margin, liquidation, and result accounting. + +## 2,000-bar certification fixture + +Phase 47C requires both `grid_mode="long_only"` and +`grid_mode="long_short"` on a sorted, unique 2,000-bar OHLCV tape. The +certification order is: + +```text +replay-certified audit +Python single-pass audit +Python scalar v2 +Rust reactive audit +Rust scalar +``` + +The audit gate compares the emitted command tape and effective bars, order +events/status/rejects, fills, positions, equity, fees, funding, margin, +liquidation state, and final equity. Discrete lifecycle fields are exact; +numeric paths use zero relative tolerance and only the documented floating +point tolerance. + +`filled_command_count` is not a canonical parity field. The replay ledger +counts command states that reached `FILLED`, while a reactive session counts +fill records. The exact order-event and fill ledgers, plus the accounting +paths, remain the authoritative comparison. + +## Prepared scalar score + +Use a fresh strategy for every score and prepare the market tape once: + +```python +execution = GridExecutionConfig( + native_backend="rust", # or "python" + reactive_execution_mode="fast", + reactive_kernel_mode="single_pass", + report_level="score", + audit_sink="none", +) + +endpoint, prepared = prepare_grid_score_runner( + df=data_2000, + execution=execution, +) +score = score_grid_params( + prepared_runner=prepared, + df=data_2000, + params=params, + execution=execution, +) +``` + +The result is `NativeEventScalarScoreResult`. It retains scalar accounting, +final positions, metrics, and lifecycle counts, but no pandas report frame or +dense equity/fee/funding/margin paths. `endpoint.result` remains `None`. +For stakeholder reports, rerun the same strategy/config at `report_level="audit"`. + +Scalar certification is not based on Sharpe or final equity alone. The audit +run with the same backend/config provides the retained fingerprint; scalar +totals and terminal state must match it: + +```text +final equity +final positions +fill/reject/cancel counts +total fee +total funding +total turnover +liquidation state +``` + +## Isolated benchmark + +Run one backend per process: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha \ +poetry run python benchmarks/native_event/benchmark_grid_2000.py \ + --grid-module-dir /root/bobby/pool_alpha/alphas_storage/TA \ + --backend python --mode scalar --grid-mode long_only --bars 2000 + +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha \ +poetry run python benchmarks/native_event/benchmark_grid_2000.py \ + --grid-module-dir /root/bobby/pool_alpha/alphas_storage/TA \ + --backend rust --mode scalar --grid-mode long_short --bars 2000 +``` + +Default measurement is one warm-up and five measured runs. The JSON records +module version, commit, backend resolution, runtime median/p95, CPU time, peak +RSS/VmHWM, post-run RSS, full and post-warm-up tail slopes, audit fingerprint, +terminal accounting, and gate status. Optional `--data path.csv.gz` accepts an +OHLCV file; without it the deterministic 2,000-bar smoke tape is used. The +tail slope is the leak gate because the first measured call can still populate +allocator/PyO3 caches after the explicit warm-up; the full slope remains in +the artifact for inspection. + +RSS is interpreted as a process-level evidence point, not a universal machine +claim. The repeated-run tail-slope gate passes and shows no live-object leak. +The observed full Grid facade peaks are approximately 265.6--293.4 MB. The +approximately 180 MB figure in the broader guide belongs to a different +native-event process profile; Phase 47C therefore does not claim an absolute +no-regression comparison until an apples-to-apples pre-Phase47C Grid run is +archived. A further 40% reduction is not a Phase 47C requirement. + +## Phase 47D optimizer certification + +Phase 47D profiles the actual Grid optimizer path rather than using the +static Rust tape as a proxy. The profile separates: + +```text +alpha preparation +strategy initialization +prepared engine score +public objective/report facade +``` + +On the deterministic 2,000-bar tape, the apples-to-apples prepared scalar +profile after the patch measured `0.813s`, with alpha preparation at `2.15%` +and the engine score at `97.89%`. This is an observed local measurement, not +a fixed performance guarantee. The profile did not justify an indicator +cache: the alpha layer is not the dominant cost, so no cache was added that +could complicate parameter isolation or retain full DataFrames. + +The external Grid adapter now has these optimizer-safe policies: + +```python +GridExecutionConfig(collect_diagnostics=True) # public/audit default +GridExecutionConfig(collect_diagnostics=False) # scalar-only artifact policy +``` + +`score_grid_params(...)` always creates a fresh diagnostics-off execution +policy for the trial. It also derives context requirements from +`ReactiveDynamicGridStrategy.native_context_requirements`: fills, active +orders, and positions remain enabled; full order events and margin payloads +are not requested by the callback. Diagnostic alias columns and per-bar +`_diag_*` arrays are therefore absent from scalar trials, while canonical +execution columns and all accounting decisions remain unchanged. Calling +`build_output_frame()` on a diagnostics-off strategy raises a clear error; +final stakeholder plots must rerun the same params with the default audit +policy. + +The scalar gate remains strict: + +```text +prepared.scores += 1 +prepared.runs unchanged +endpoint.result is None +evaluator retains no result/strategy +``` + +The 47D tests compare terminal equity, fee, funding, fill count, liquidation, +and the retained audit evidence. Public/audit diagnostics remain enabled by +default. The exact source patch is committed in the external Grid alpha as +`fda46c3`; QuantBT does not copy or own that strategy source. + +Current scalar benchmark evidence after the patch: + +| Mode | Python median | Rust median | Python peak RSS | Rust peak RSS | Fingerprint | +|---|---:|---:|---:|---:|---| +| Long-only | 0.850 s | 1.086 s | 265.4 MB | 271.2 MB | pass | +| Long-short | 1.412 s | 1.831 s | 291.0 MB | 293.6 MB | pass | + +The repeated-run RSS gates pass with no positive tail slope. Rust remains an +explicit, correctness-certified experimental backend for this workload and +`auto` remains Python. The reactive callback itself is still the dominant +runtime owner; this phase does not claim a new Numba/Rust optimization of the +Python Grid callback or portfolio/arbitrage/options parity. Raw benchmark +artifacts are stored under +`benchmarks/native_event/results/phase47d/`. + +## Certification boundary + +After Phase 47D, Python/replay/Rust are certified for this single-symbol Grid +workload on the tested full Native Event V2 contract. Rust is still explicit; +`auto` remains Python. Portfolio, arbitrage, options, L2 depth, and venue- +specific cross-margin behavior are outside this certificate. The remaining +performance debt is deeper callback-level optimization, which requires a new +parity-first phase rather than an indicator cache based on this profile. diff --git a/docs/import_graph_and_rss_floor.md b/docs/import_graph_and_rss_floor.md new file mode 100644 index 0000000..973d18d --- /dev/null +++ b/docs/import_graph_and_rss_floor.md @@ -0,0 +1,99 @@ +# Phase 46C: Import Graph And RSS Floor + +Phase 46C makes the core `quantbt-engine` import independent from optional +visualization, optimization, reporting, and Nautilus packages. The source +layout remains `src/quantbt`, while the root compatibility mirror remains +present and is checked byte-for-byte during this packaging transition. + +## Dependency contract + +The core distribution contains only: + +- `numpy`; +- `pandas`; +- `numba`. + +Optional capabilities are owned by explicit extras: + +| Extra | Capability | Main dependencies | +| --- | --- | --- | +| `viz` | `quick_plot`, `tearsheet`, themes | matplotlib, seaborn | +| `optimization` | Optuna and robust search helpers | optuna, arch, scikit-learn | +| `reports` | QuantStats report integration | quantstats | +| `validation` | Nautilus validation adapter | nautilus-trader | +| `native` | Reserved for the separately published native wheel | empty until release | + +`all` is a convenience extra. It does not change the core import contract. + +## Lazy public API + +The package keeps core engines, schemas, execution contracts, and metrics +eagerly importable. Public optional names remain available through +`quantbt.__getattr__`, so existing imports continue to work after their +corresponding extra is installed: + +```python +from quantbt import QuantBTEndpoint + +# Loads visualization dependencies only when the symbol is used. +from quantbt import quick_plot + +# Loads Optuna only when optimization is requested. +from quantbt import OptunaOptimizer +``` + +The lazy resolver caches the resolved object in the package namespace. This +preserves identity with direct module imports, for example +`quantbt.quick_plot is quantbt.viz.quick_plot`, and Python's import lock +provides safe concurrent first-load behavior. + +## Fresh-process gate + +Run from the repository root: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=src poetry run python \ + benchmarks/native_event/benchmark_phase46c_import_rss.py \ + --output benchmarks/native_event/phase46c_import_rss.json +``` + +The child process runs from `/tmp`, preventing the root mirror from shadowing +the distribution source. The gate records current RSS after `import quantbt`, +RSS after resolving the core `QuantBTEndpoint`, the module count, forbidden +optional modules, and `python -X importtime` summary lines. These values are +an import/process floor only; they are not a claim about prepared or execution +RSS, which remains covered by the Phase 46B staged benchmark. + +The local packaging gate also builds both artifacts with the pinned build +toolchain and imports the wheel from a target directory with `--no-deps`. +The wheel metadata contains only NumPy, pandas, and Numba as unconditional +requirements; optional requirements are guarded by their extra markers. The +sdist contains the `src/quantbt` package and the same `0.1.0` metadata. + +## Acceptance criteria + +Phase 46C is accepted when: + +1. `import quantbt` succeeds with core dependencies and does not import + matplotlib, seaborn, Optuna, Nautilus, or QuantStats. +2. Core public exports and lazy optional exports remain accessible and retain + direct-import identity. +3. Metadata and `uv.lock` agree that visualization/reporting/optimization/ + validation dependencies are optional. +4. The source mirror is byte-identical to `src/quantbt` for every mirrored + module. +5. Focused import tests and the full regression suite pass. + +Evidence from the current host: + +```text +fresh source import: 0 forbidden optional modules +fresh source import RSS: 188,170,240 bytes +wheel import: pass, 0 forbidden optional modules +wheel: quantbt_engine-0.1.0-py3-none-any.whl +sdist: quantbt_engine-0.1.0.tar.gz +full regression: 648 passed, 3 skipped +``` + +The next planned phase is 46D: ownership separation for market tape memory and +Rust hot state. It is intentionally not included in this import-graph change. diff --git a/docs/native_event_dual_backend_phase46e.md b/docs/native_event_dual_backend_phase46e.md new file mode 100644 index 0000000..6bf0c20 --- /dev/null +++ b/docs/native_event_dual_backend_phase46e.md @@ -0,0 +1,88 @@ +# Native Event Dual Backend: Phase 46E + +Phase 46E closes the Python/Rust selection and reporting boundary for the +single-symbol explicit-order scope. It does not claim that Rust replaces the +full Python reactive engine. + +## Contract + +`NativeEventConfig.native_backend` accepts: + +| Selector | Contract | +| --- | --- | +| `python` | Full reactive Python implementation. This is the canonical and compatibility backend. | +| `rust` | Explicit, fail-fast Rust batched tape path. Only certified single-symbol static tapes are accepted. | +| `auto` | Python for the current release policy. It does not silently enable an experimental wheel. | +| `replay_certified` | Deterministic audit/replay oracle used for candidate certification. | + +The endpoint also accepts `native_backend=...` and passes it through +`BacktestEngineV2` without changing existing endpoint names or defaults. + +Example: + +```python +bt = QuantBTEndpoint.orders( + backend="native_event", + native_backend="rust", + initial_capital=10_000, + leverage=5, + maintenance_ratio=0.0, + use_funding=False, + fee_rate=0.0002, +) +result = bt.backtest(data=bars, order_commands=commands, symbols=["BTC"]) +``` + +Rust requests fail before execution when the tape requires unsupported +funding, liquidation, multiple symbols, or quantity constraints. There is no +silent downgrade to Python for an explicit `rust` request. + +## Common reporting boundary + +`RustBatchedAuditResult.to_backtest_result(...)` converts Rust SoA arrays once +into the common `BacktestResultV2` surface. It provides: + +- equity, returns, position, fee, funding and margin paths; +- `fills_report` and `order_report` metadata tables; +- `Fill` objects for report/export compatibility; +- `show_metrics()`, `full_report()`, `quick_plot()` and `tearsheet()` through + the normal `BacktestResultV2` helpers. + +The adapter is intentionally outside the score hot path. Score runs keep +typed scalar fields only; audit runs retain SoA buffers and materialize report +objects only when requested. + +## Python hot state + +Scalar Python score runs keep the existing public command and context contract. +When a strategy explicitly disables fills, events, active-order snapshots, +positions, margin, ledgers and terminal orders, pending score state drops +non-execution strategy metadata. Parent/OCO/group/tag fields remain because +they affect lifecycle matching. Full audit/default runs retain the complete +metadata and object behavior. + +## Release evidence + +The reproducible gate is: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=. poetry run python \ + benchmarks/native_event/benchmark_phase46e_release_gate.py +``` + +Evidence is saved in +[`../benchmarks/native_event/phase46e_release_gate.json`](../benchmarks/native_event/phase46e_release_gate.json). +The current run passed lifecycle/scalar parity, 100-run RSS plateau, +absolute RSS budget, and speed thresholds. The 40% incremental prepared-RSS +reduction gate did not pass: Rust ownership is compact and execution is much +faster, but the prepared native object is not yet 40% smaller than the Python +prepared baseline in this process. Therefore the release policy remains: + +```text +Rust: explicit experimental/capability-gated batched backend +auto: Python +native PyPI extra: not released +``` + +This is a gate result, not a correctness failure or a claim that total process +RSS should fall by 40%; interpreter and package imports dominate that metric. diff --git a/docs/native_event_parity.md b/docs/native_event_parity.md new file mode 100644 index 0000000..a5f913a --- /dev/null +++ b/docs/native_event_parity.md @@ -0,0 +1,49 @@ +# Native Event Parity Contract + +Phase 46A establishes the correctness gate used before any Python, Numba, Rust, +or PyPI performance claim. + +## Full parity + +```python +from quantbt import assert_native_event_full_parity + +certificate = assert_native_event_full_parity( + candidate_result, + replay_oracle_result, + numeric_atol=1e-12, + command_tape=(candidate_tape, oracle_tape), +) +``` + +The helper requires the lifecycle artifacts for a full certificate. It checks +effective command bars and sequences when a command tape is supplied; event +order, status, fills, equity, positions, fees, funding, turnover, margin, and +final liquidation state are checked when the selected capability supports that +field. Discrete values must be exact. Numeric arrays use `rtol=0` and +`atol=1e-12`; this tolerance is for floating-point operation order, not for +different execution decisions. + +`require_full=False` is reserved for explicitly minimal or scalar runs. It is +not a production certification and must not be reported as full parity. + +## Capability source of truth + +`quantbt.NATIVE_EVENT_CAPABILITY_MATRIX` is the stable public vocabulary for +the certified single-symbol R2 surface. The Rust extension may expose release- +specific raw flags, but `normalize_native_event_capabilities()` maps those +flags into the canonical matrix and never enables an unreviewed capability. +Unsupported requests remain explicit errors; they do not silently fall back to +a different execution model. + +The current matrix supports single-symbol market/limit/stop commands, place, +cancel, amend, replace, reduce-only, quantity constraints, and GTC. It does +not certify funding, liquidation, multi-symbol, OCO, parent-child, IOC, FOK, +or GTD semantics for the Rust path. + +## Packaging baseline + +The wheel source remains under `src/quantbt`. During the migration the root +compatibility mirror is retained and checked byte-for-byte by +`tests/test_phase45a_source_tree_sync.py`. Phase 46A does not delete or +rewrite that mirror. diff --git a/docs/native_event_rust_batched.md b/docs/native_event_rust_batched.md new file mode 100644 index 0000000..3f41b08 --- /dev/null +++ b/docs/native_event_rust_batched.md @@ -0,0 +1,95 @@ +# Rust Batched Native Event + +> Phase 47B adds the API `0.4` `RustFullRunner` contract. Read +> [`native_event_rust_full_contract.md`](native_event_rust_full_contract.md) +> for the current explicit `native_backend="rust"` path. This document +> describes the earlier `RustBatchedRunner` compatibility surface below; it +> remains intentionally single-symbol and fail-fast. + +QuantBT includes an explicit, experimental Rust/PyO3 full-tape runner for a +precomputed single-symbol `OrderCommand` tape. It is designed for a static +command sequence produced outside the execution kernel, not for compiling an +arbitrary Python strategy. + +## Usage + +```python +from quantbt import NativeEventBackend, NativeEventConfig, AccountConfig + +backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig( + initial_capital=10_000, + leverage=5, + maintenance_ratio=0.0, + ), + fee_rate=0.0002, + use_funding=False, + ) +) + +market = backend.prepare_market_arrays( + datetime_index=index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], +) +compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) +runner = backend.prepare_rust_batched_runner( + index, + {"BTC": frame["close"]}, + {"BTC": frame["high"]}, + {"BTC": frame["low"]}, + symbols=["BTC"], +) + +score = runner.run_tape_score(compiled) +audit = runner.run_tape_audit(compiled) + +# Stateful sparse continuation: no dense equity/position path per chunk. +session = runner.open_sparse_session(compiled) +first = session.run_until(3) +second = session.run_until(len(index) - 1) +``` + +`score` returns scalars only. `audit` returns contiguous struct-of-arrays +buffers such as `fill_bar`, `fill_price`, `event_kind`, `equity`, and +`positions`. The market preparation is reusable, while each call creates a +fresh mutable session so trials cannot leak order state into one another. + +`RustBatchedSession.run_until(stop_bar)` keeps the same Rust order/account +state across consecutive chunks. Each chunk returns scalar accounting plus +contiguous sparse `fill_*`, `event_*`, and `wake_*` arrays. `wake_kind` uses +`0=fill`, `1=order event`, and `2=end of chunk`. No dense bar path is created; +run `run_tape_audit` separately when a full audit ledger is required. The +current sparse contract is still the same certified single-symbol slice as +the full-tape runner: immediate GTC market/limit/stop, cancel/amend/replace, +reduce-only, fee and slippage, without funding, liquidation, quantity rules, +package orders, non-GTC TIF, or multi-symbol state. + +## Certified scope + +The current Rust slice supports one symbol, immediate GTC market/limit/stop +orders, cancel/amend/replace, reduce-only, fee and slippage. The command tape +must follow the native-event v2 effective-bar contract. Unsupported funding, +liquidation, quantity constraints, OCO/parent packages, expiry, IOC/FOK/GTD, +and multi-symbol input raise explicitly. Use the Python/replay-certified +backend for those semantics. + +`auto` never selects this runner, and no public endpoint default changes. The +replay-certified Python/Numba engine remains the domain oracle. Rust can only be +promoted after the isolated benchmark, RSS and installed-wheel gates in +`upgrade/implement.md` Phase 45F pass. + +## Phase45F certification evidence + +`benchmarks/native_event/benchmark_phase45f_release_gate.py` runs each backend +in a fresh child process, with five measured runs after warm-up. Exact +final-equity/fill-count parity passed. The warmed score-path speedups were +`5.09x` for low churn and `79.06x` for high churn, with repeated RSS plateau +in both backends. Peak RSS reduction was only `18.3%` at the lower scenario, +below the required +`40%` release threshold; the remaining overhead is consistent with Python +prepared arrays coexisting with the Rust-owned prepared market. Rust +therefore remains explicit experimental and `auto` remains Python. diff --git a/docs/native_event_rust_full_contract.md b/docs/native_event_rust_full_contract.md new file mode 100644 index 0000000..e005326 --- /dev/null +++ b/docs/native_event_rust_full_contract.md @@ -0,0 +1,218 @@ +# Native Event Rust V2 Full Contract + +Phase 47B upgrades the optional PyO3 backend from the earlier R1/R2 +single-symbol slice to the public Native Event V2 contract. Python/replay +remains the correctness oracle and `auto` remains Python until the later Grid +workload and release gates pass. + +## Capability boundary + +The explicit selector is: + +```python +from quantbt import AccountConfig, ExecutionConfig +from quantbt.backends.native_event import NativeEventBackend, NativeEventConfig + +backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig( + initial_capital=20_000, + leverage=5, + maintenance_ratio=0.005, + ), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0005, + use_funding=True, + native_backend="rust", + report_level="audit", + ) +) +``` + +An API `0.4` wheel must advertise all full-contract capability keys before +the explicit Rust path is allowed to execute: + +```text +native_event_v2_full_contract +native_event_v2_multisymbol +native_event_v2_funding +native_event_v2_liquidation +native_event_v2_cancel_all_oco +native_event_v2_tif_expiry +native_event_v2_relationships +native_event_v2_quantity_preflight +``` + +Older API `0.3` wheels remain readable for the historical R1/R2 adapter, but +they cannot claim the full contract. A requested `native_backend="rust"` +fails explicitly when the binary or its capability set is incomplete. + +## Supported domain surface + +The Rust full session receives the same primitive command tape as the Python +replay engine: + +- `PLACE`, `CANCEL`, `CANCEL_ALL`, `AMEND`, and `REPLACE`; +- MARKET, LIMIT, STOP_MARKET, and STOP_LIMIT orders; +- GTC, GTD, IOC, and FOK time-in-force behavior; +- next-bar command effectiveness and stable insertion priority; +- reduce-only, exchange quantity preflight, fees, slippage, and contract size; +- parent activation, group filters, OCO sibling cancellation, and expiry; +- funding masks/rates, initial and maintenance margin, and liquidation; +- flattened multi-symbol OHLCV/funding arrays and per-symbol positions. + +The Rust execution order is intentionally copied from the replay-certified +Python oracle: + +```text +mark/PnL +intrabar liquidation +funding +after-funding liquidation +GTD expiry +lifecycle commands +matching/fills +parent/OCO activation +after-order liquidation +state recording +``` + +The adapter preserves active-order relationship metadata (`parent_order_id`, +`group_id`, `oco_group_id`, activation state, tag, campaign, cycle, and level) +for reactive contexts. Audit reports also retain event status and reject code. + +## Static tape and reporting + +```python +market = backend.prepare_market_arrays( + datetime_index=index, + closes={"A": frame_a["close"], "B": frame_b["close"]}, + highs={"A": frame_a["high"], "B": frame_b["high"]}, + lows={"A": frame_a["low"], "B": frame_b["low"]}, + funding_rate={"A": funding_a, "B": funding_b}, + symbols=["A", "B"], +) +compiled = backend.compile_order_commands(index, commands, symbols=["A", "B"]) +result = backend.run_order_commands( + datetime_index=index, + commands=commands, + closes={"A": frame_a["close"], "B": frame_b["close"]}, + highs={"A": frame_a["high"], "B": frame_b["high"]}, + lows={"A": frame_a["low"], "B": frame_b["low"]}, + funding_rate={"A": funding_a, "B": funding_b}, + symbols=["A", "B"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", +) +``` + +The returned `BacktestResultV2` has the normal equity/position/fee/funding/ +margin paths, fills, `fills_report`, `order_report`, and reporting helpers. +The score facade keeps pandas report construction out of the optimization +boundary; use an audit rerun for stakeholder-level ledgers and plots. + +## Phase 48E.1 production-closure contract + +Phase 48E.1 keeps the public command ABI and endpoint stable while closing the +native allocation/report boundary before the TestPyPI gate. + +### Execution profiles + +The Rust lifecycle is one implementation. Its output profile changes what is +retained, never what is executed: + +| Profile | Retained output | Intended use | +|---|---|---| +| `score` | scalar accounting, terminal state, counters, liquidation | Optuna/search | +| `research` / `minimal` | dense equity, positions, fees, funding, turnover, margin | metrics and diagnostics | +| `audit` | dense paths plus fills, lifecycle events, reject codes, command metadata | stakeholder replay/export | + +The score sink is count-only for fills/events and does not create nested row +vectors. Audit uses reusable Rust-owned SoA buffers and converts them at the +Python report boundary. No borrowed NumPy view is used, so Rust buffers cannot +be mutated while Python holds a view. + +API 0.4 reactive callers can use the typed `FullStepResultCore` path. Scalar +fields are always present; `positions`, `fills`, `events`, and `active_orders` +are `None` unless their output-mask bit was requested. The legacy dictionary +`step()` remains available for compatibility. + +### Report semantics + +The reports are intentionally different: + +- `command_report` is the immutable command-intent table from the compiled + tape. It contains requested action, order identity, quantity/price/trigger, + TIF, relationship fields, expiry and strategy metadata. +- `order_report` is the Rust lifecycle event table: event bar/type/status, + target identity and reject code. +- `fills_report` is the execution table with bar, symbol, side, quantity, + price, fee and enriched tag/campaign/cycle/level metadata. + +`command_report` is never assigned to `order_report`. `result.orders` may stay +empty for the Rust audit adapter; reports and visualizations must use the +explicit report tables instead. + +### Memory and lifecycle guarantees + +Prepared market arrays use immutable fixed-length Rust storage shared through +`Arc`; account arrays use fixed boxed storage and public order identities remain +`i64`. Internal side/order-type/TIF values are validated and stored in compact +integer representations; no public command field changes. + +The existing terminal-order compaction runs only after a bar lifecycle is +complete. It preserves replacement aliases, parent activation, OCO cancellation, +GTD expiry and insertion priority. Reset clears logical state while retaining +capacity, and `release_step_buffer_capacity()` is an explicit maintenance +operation rather than a per-trial shrink. + +Close-price margin accounting uses a per-bar cache. The first lookup computes +the complete symbol aggregate; a fill then updates the affected symbol's +initial and maintenance contribution using the old and new absolute quantity. +Liquidation invalidates the cache. This is an accounting optimization only: +the original margin formulas, post-cost margin gate and liquidation ordering +remain unchanged, and the Rust/Python parity suite covers additions, reductions, +reversals and multi-fill bars. + +The authoritative closure evidence is the Phase 48E.1 test and wheel matrix: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=src poetry run pytest -q \ + tests/native_event/test_phase48e1_closure.py \ + tests/native_event/contract/test_phase47b_full_contract.py +``` + +See [`upgrade/implement.md`](../upgrade/implement.md) for the complete P0-P7 +acceptance matrix, benchmark artifacts and CI wheel gate. + +`prepare_rust_batched_runner(...)` retains its historical name for endpoint +compatibility, but on a full-capability wheel it returns `RustFullRunner`. +The older `RustBatchedRunner` remains a separate legacy single-symbol runner +and deliberately keeps its narrower fail-fast contract. + +## Conformance evidence + +The shared suite is: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=. poetry run pytest -q \ + tests/native_event/contract/test_phase47b_full_contract.py +``` + +The Phase 47B fixture matrix compares Python and explicit Rust on: + +```text +equity, positions, fees, funding, turnover, margin, liquidation; +fills and fill prices; +event order, event status, event reject code; +parent/OCO activation and active-order metadata; +multi-symbol quantity constraints; +TIF and expiry; +replace alias resolution. +``` + +Current focused evidence: **13 passed** after Rust rebuild. Related R0/R1/R2, +score/RSS, and capability regression suites also pass. Grid 2,000-bar +long-only/long-short parity, isolated RSS evidence, and `auto` promotion are +Phase 47C gates and are intentionally not claimed here. diff --git a/docs/native_event_rust_ownership_r2.md b/docs/native_event_rust_ownership_r2.md new file mode 100644 index 0000000..d2e5505 --- /dev/null +++ b/docs/native_event_rust_ownership_r2.md @@ -0,0 +1,131 @@ +# Phase 46D: Market Ownership And Rust R2 Hot State + +Phase 46D follows sections 6–9 and patches F4/F5 of the dual-backend guide. +It reduces avoidable allocation and ownership overhead without changing the +public event contract or silently changing execution semantics. + +## Ownership contract + +`PreparedMarketCore` copies the validated NumPy inputs exactly once into +Rust-owned immutable `Box<[T]>` arrays. The Rust session retains an `Arc` to +that prepared object, but it does not retain the source DataFrame, Series, or +temporary NumPy arrays. Callers may release those Python inputs after runner +construction; the prepared Rust session remains executable. + +This is intentionally a safe copy boundary. Phase 46D does not borrow NumPy +memory unsafely and does not claim that source Python arrays are mutated or +shared with Rust. + +## Order table + +The old reactive Rust session used a `Vec` and linear +`position/find/remove` operations. R2 now uses: + +- primitive `OrderSlot` storage; +- `id_to_slot` for O(1) normal lookup; +- `active_sequence` to preserve command/priority order; +- tombstones for terminal orders, avoiding `Vec.remove` shifts; +- bounded compaction when tombstones become material; +- a fixed stack alias path for replacement-chain resolution and cycle guard. + +Slots are not reused while a tombstone still exists in the priority sequence. +This prevents a same-bar replace from appearing twice. They become reusable +after compaction, preserving both performance and lifecycle order. + +The static tape adapter translates canonical compiler action codes to the +stable reactive R2 ABI explicitly. This keeps the existing reactive ABI +compatible while preventing a replace/amend code collision at the Rust +boundary. + +## Score and audit paths + +Score mode calls the same state machine with `materialize=false`. It retains +scalar counters and accounting only; it does not build per-bar fill/event or +active-order ledgers. The PyO3 boundary returns a frozen typed +`BatchedScoreResultCore` instead of a final `PyDict`. + +Audit and sparse paths retain their existing SoA arrays and lifecycle events. +They remain the correctness/audit oracle and are not weakened to obtain a +smaller benchmark result. Python converts each returned vector once into a +contiguous NumPy array. + +## Command tape cache + +`RustBatchedRunner` now fingerprints the primitive command arrays, does not +retain the original compiled command object merely for cache identity, and +keeps at most one tape bounded by `max_tape_cache_bytes` (64 MiB by default). +Use: + +```python +runner.clear_tape_cache() +print(runner.tape_cache_bytes) +``` + +This cache is runner-local, not process-global. Setting the byte limit to zero +disables resident tape caching while preserving one-call execution. + +## Verification + +Run the targeted ownership/R2 suite: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=. poetry run pytest -q \ + tests/native_event/test_rust_phase46d_ownership.py \ + tests/native_event tests/test_phase46b_score_rss.py +``` + +The current local run is `60 passed, 2 skipped`; the full repository +regression is `654 passed, 3 skipped`. + +Run low/high churn and 100-run reset/RSS evidence: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=. poetry run python \ + benchmarks/native_event/benchmark_phase46d_ownership_r2.py \ + --output benchmarks/native_event/phase46d_ownership_r2.json +``` + +The benchmark reports Rust-owned incremental RSS, cache bytes, low/high order +counts, score reset parity, sparse-session reset parity, and peak process RSS +separately. The current 2,000-bar/100-run evidence passed with 40 low-churn +orders and 3,999 high-churn orders; repeated score RSS stayed flat at 0-byte +incremental growth in both profiles, and both cache-clear/reset gates passed. +It must not be read as a total process RSS comparison against Phase 46C's +import floor. Sparse result arrays are returned to the caller on each +`run_until` call, so allocator RSS observed during high-churn sparse reset +loops is reported separately rather than claimed as a session-state +reduction. + +Acceptance requires exact audit/score accounting parity, replacement-chain and +cycle safety, prepared-input release functionality, cache clearability, and a +100-run scalar plateau. The next planned phase is 46E; Python full-featured +reactive state remains canonical and is not replaced by this Rust optimization. + +## Phase 46D.1 refinement + +The first Phase 46D apples-to-apples rerun exposed two hot-path costs. The +runner was hashing every primitive tape on every score call, and the order +table retained too many dead priority slots for a small same-bar market-order +book. The refinement now computes the complete tape fingerprint during +compilation, locks compiled primitive arrays read-only, and uses the stored +digest on cache hits. Small live books use bounded priority-sequence lookup; +larger books retain the O(1) numeric ID index. Tombstones compact earlier when +the live book is small. Sparse calls with both wake payload flags disabled +retain scalar accounting only and return empty fill/event arrays. + +The final fresh-child Phase 46B benchmark evidence is stored at +`benchmarks/native_event/phase46d1_score_rss.json`: + +| Profile | Rust median | Python median | Rust/Python speedup | +|---|---:|---:|---:| +| Low churn | 0.000113 s | 0.030461 s | 270.3x | +| High churn | 0.000191 s | 0.033528 s | 175.3x | + +Both profiles passed scalar/full parity and repeated RSS plateau. The +ownership benchmark evidence is stored at +`benchmarks/native_event/phase46d1_ownership_r2.json`; its 100-run sparse +reset RSS delta was 0 bytes in both profiles. Prepared incremental RSS in the +Phase 46B process remained approximately 2.79 MB (low) and 2.98 MB (high), so +the optional 20% prepared-RSS improvement target is not claimed. That +checkpoint retains the Python prepared container for the staged benchmark; +the explicit input-release test remains the ownership correctness evidence. diff --git a/docs/native_event_score_rss.md b/docs/native_event_score_rss.md new file mode 100644 index 0000000..275f084 --- /dev/null +++ b/docs/native_event_score_rss.md @@ -0,0 +1,84 @@ +# Native Event Score And RSS Evidence + +Phase 46B defines the fair comparison between the Python and Rust static-tape +execution paths. + +## Artifact Contract + +Both score paths consume the same prepared market signature and the same +`CompiledOrderCommandArrays`. Neither path materializes a pandas result or a +full audit ledger during timing. Each returns the following scalar accounting +fields: + +```text +final_equity +final_position +total_fee +total_turnover +fill_count +event_count +rejected_count +canceled_count +max_initial_margin +max_maintenance_margin +``` + +The Python implementation is available through the internal +`NativeEventBackend.run_compiled_tape_score(...)` method. Existing public +`run_order_commands(..., report_level="audit")` behavior is unchanged. + +## Certification Before Timing + +`benchmarks/native_event/benchmark_phase46b_score_rss.py` runs a fresh parity +child for low and high order churn before measuring latency. The certificate +compares: + +- equity, positions, fees, turnover, and margin paths; +- every fill including bar, order, side, quantity, price, and fee; +- every lifecycle event including bar, semantic event kind, status, order, + and related order identifiers. + +Rust transport event codes are explicitly normalized to the Python semantic +event codes inside the benchmark adapter. This keeps the ABI mapping visible +without weakening the parity certificate. + +## RSS Checkpoints + +Each backend runs in its own child process. The benchmark records +`/proc/self/statm` current RSS and `VmHWM` peak RSS at: + +```text +rss_interpreter +rss_after_import_quantbt +rss_after_market_prepare +rss_after_command_compile +rss_after_runner_prepare +rss_after_score_warmup +peak_rss_during_run +rss_after_run +``` + +The reported deltas are: + +```text +import_baseline_rss = after_import - interpreter +prepared_incremental_rss = after_runner_prepare - after_import +execution_incremental_peak = peak_during_run - after_runner_prepare +``` + +The score warmup is outside the latency sample. Full audit/replay is isolated +from score timing. A 100-run prepared-score plateau checks that repeated +runs do not retain growing result state. + +Run the standard evidence profile with: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=. poetry run python \ + benchmarks/native_event/benchmark_phase46b_score_rss.py \ + --rows 2000 --repeats 5 \ + --json-out benchmarks/native_event/phase46b_score_rss.json +``` + +The JSON is evidence, not a universal hardware claim. Rust remains explicit +and capability-gated until later phases close import-floor, ownership, wheel, +and release gates. diff --git a/docs/release_packaging.md b/docs/release_packaging.md new file mode 100644 index 0000000..12545b8 --- /dev/null +++ b/docs/release_packaging.md @@ -0,0 +1,419 @@ +# QuantBT Packaging And Release + +This document records the Phase 48F final release contract for `quantbt-engine`. +The older Phase 42C rules remain valid unless this document explicitly updates +them. + +## Package Contract + +- PyPI distribution: `quantbt-engine`. +- Python import package: `quantbt`. +- Public import remains: + +```python +from quantbt import QuantBTEndpoint +``` + +- Source layout is `src/quantbt`. +- Root source is retained during migration until later compatibility gates + explicitly remove it. +- The current package release line is `1.0.x`, continuing the existing GitHub + release series without changing the public Python import contract. +- Earlier `0.1.x` references belong to the pre-PyPI packaging plan and were not + published. +- Phase 48F release candidate: `1.0.7rc1` for TestPyPI; final target `1.0.7`. +- Phase 48F local artifact gate: complete for the core Python distribution; + TestPyPI publication remains an explicit operator action. +- Python is the canonical/full-featured implementation for the first release. +- `quantbt-native` is experimental and is not a dependency of the core wheel. + +Phase 45C keeps the root source mirror temporarily for rollback and editable +compatibility. Distribution artifacts are built from `src/quantbt`, while the +SHA256 source-sync test prevents the two source locations from drifting. +Deleting the root mirror is a later, separately approved migration step. + +## CI Contract + +The main CI workflow runs on pull requests and pushes to `dev` and `main`. + +Required checks: + +- Python matrix: `3.11`, `3.12`, `3.13`. +- `uv sync --extra optimization --extra reports --extra viz --dev`. +- `.venv/bin/python -m pytest -q --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py --ignore=tests/native_event`. +- The separate Native Event API 0.4 workflow runs the complete `tests/native_event` suite after installing the native wheel. +- `uv build`. +- Clean wheel install in a fresh virtual environment. +- Public import smoke from outside the repository root. +- Pool Alpha style import smoke. + +CI must not rely on `PYTHONPATH` to pretend the package is installed. + +Core CI intentionally tests the Python package separately from the native +wheel. It installs the optimization, report, and visualization extras needed +by the shared test suite, but omits the optional Nautilus validation stack. +The `native` extra is currently an empty reservation, so CI cannot accidentally +claim that a native PyPI distribution exists. + +The two `tests/test_real*.py` files are notebook-style data scripts, not +portable unit tests: they read Pool Alpha data outside this repository and +execute a backtest during module import. Run them separately in the Pool +Alpha environment; do not include them in public package CI. + +NautilusTrader validation is optional and only resolves on Python `>=3.12` +because `nautilus-trader==1.230.0` does not support Python 3.11. The core +QuantBT package remains import/testable on Python 3.11. + +## Release Contract + +Publishing is only allowed from a GitHub Release event: + +```text +on: + release: + types: [published] +``` + +Normal pushes to `main` or `dev` must never publish to PyPI. + +The intended branch flow is: + +```text +feature branches -> dev -> release branch -> main -> GitHub Release -> PyPI +``` + +Do not tag from `dev`. + +Do not publish from an uncommitted local tree. + +The release workflow runs `pip check` after both wheel and sdist installation. +The package build source is `src/quantbt`; the root mirror is retained for +editable Pool Alpha compatibility and is protected by the source-sync tests. +It is not a second distribution source. + +The exact handoff fields, artifact hashes, RC tag procedure, and post-upload +smoke steps are maintained in the +[`TestPyPI release checklist`](testpypi_release_checklist.md). CI creates a +`quantbt-release-manifest-v1` JSON artifact containing the release commit, +version, wheel/sdist SHA256 values, benchmark evidence hashes and backend +policy. The manifest is evidence only; it is never uploaded to PyPI. + +## Trusted Publishing + +The default publish path uses PyPI Trusted Publishing/OIDC. + +Configure PyPI pending publisher: + +```text +Project: quantbt-engine +Owner: BobbyAxerol +Repository: quantbt +Workflow: publish.yml +Environment: pypi +``` + +The GitHub environment `pypi` should be protected by reviewer approval. + +## Token Fallback + +Long-lived PyPI tokens are not the normal release path. + +Token fallback is only for: + +- manual TestPyPI; +- debug publish; +- emergency fallback. + +If a token is used: + +- prefer project-scoped token; +- use username `__token__`; +- never commit the token; +- remove the GitHub secret after OIDC works; +- revoke the token on PyPI after use. + +## Version Gate + +`tools/check_release_version.py` compares `pyproject.toml` version with the +release tag. + +Example: + +```text +pyproject.toml version = 1.0.7 +required release tag = v1.0.7 +``` + +The publish workflow fails if the tag does not match. + +The same script validates an RC tag. To publish `1.0.7rc1`, first commit +`version = "1.0.7rc1"`, create `v1.0.7rc1`, and run the manual TestPyPI +workflow with that tag. Do not reuse the final `1.0.7` version for an RC. + +## Local Release Gate + +Run these commands from a clean feature/release commit. They use a temporary +artifact directory and do not remove the repository's existing `.venv`, `dist`, +or build directories: + +```bash +poetry run python tools/check_release_version.py +.venv/bin/python -m pytest -q --ignore=tests/test_real.py --ignore=tests/test_real_endpoints.py --ignore=tests/native_event +poetry run python -m build --no-isolation --outdir /tmp/quantbt-engine-dist +poetry run twine check /tmp/quantbt-engine-dist/* +poetry run python tools/check_release_artifacts.py --dist /tmp/quantbt-engine-dist +poetry run python tools/create_release_manifest.py \ + --dist /tmp/quantbt-engine-dist \ + --output /tmp/quantbt-release-manifest.json +``` + +Inspect the artifacts before installing them: + +```bash +poetry run python -c "import zipfile, pathlib; p=next(pathlib.Path('/tmp/quantbt-engine-dist').glob('*.whl')); print(*zipfile.ZipFile(p).namelist(), sep='\\n')" +poetry run python -c "import tarfile, pathlib; p=next(pathlib.Path('/tmp/quantbt-engine-dist').glob('*.tar.gz')); print(*tarfile.open(p).getnames(), sep='\\n')" +``` + +Validate both formats outside the repository root. `--no-deps` makes this a +package-content smoke; the CI workflow additionally installs dependencies and +runs `pip check`: + +```bash +python3 -m venv /tmp/quantbt-engine-wheel-smoke +/tmp/quantbt-engine-wheel-smoke/bin/python -m pip install --upgrade pip +/tmp/quantbt-engine-wheel-smoke/bin/python -m pip install --no-deps /tmp/quantbt-engine-dist/quantbt_engine-*.whl +(cd /tmp && /tmp/quantbt-engine-wheel-smoke/bin/python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)") + +python3 -m venv /tmp/quantbt-engine-sdist-smoke +/tmp/quantbt-engine-sdist-smoke/bin/python -m pip install --upgrade pip +/tmp/quantbt-engine-sdist-smoke/bin/python -m pip install --no-deps /tmp/quantbt-engine-dist/quantbt_engine-*.tar.gz +(cd /tmp && /tmp/quantbt-engine-sdist-smoke/bin/python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)") +``` + +For a dependency-complete check, install the wheel without `--no-deps` in a +fresh environment and run `python3 -m pip check`. Never use a repository-root +`PYTHONPATH` as evidence that a wheel works. + +## Pool Alpha Development + +During local development, Pool Alpha can use editable/path install: + +```bash +pip install -e /root/bobby/pool_alpha/quantbt +``` + +Or a Poetry path dependency: + +```toml +quantbt = { path = "../quantbt", develop = true } +``` + +After release: + +```toml +quantbt-engine = "^1.0.7" +``` + +Alpha/notebook imports do not change: + +```python +from quantbt import QuantBTEndpoint +``` + +## Native Package Note + +`quantbt-native` is not published in the current Phase 48F core release. Its current Rust crate version +and native API version are separate from the core package version. Rust remains +available only through an explicitly installed local wheel and an explicit +`native_backend="rust"` request. + +Historical Phase 46F rerun evidence retained for comparison is: + +| Gate | Result | +|---|---| +| Python/Rust lifecycle and accounting parity | pass | +| Low/high churn score runtime | pass (`20.33/36.16 ms` Python; `0.109/0.140 ms` Rust) | +| Low/high churn throughput | pass (`98,385/55,308` Python bars/s; `18.30M/14.33M` Rust bars/s) | +| Absolute peak RSS | pass (`184.11 MB < 512 MB`) | +| 100-run RSS plateau | pass | +| Prepared RSS reduction >= 40% | fail (`-26.1%` / `-7.6%`) | +| Automatic Rust routing | disabled | +| Non-empty `quantbt-engine[native]` extra | not released | + +Consequently the core package can be released independently, while the native +wheel remains behind its own manylinux CPython 3.11-3.13, parity, fallback, +and incremental-RSS certification gate. + +## Native Event Rust API 0.4 + +The optional `quantbt-native` package implements the public Native Event V2 +contract certified by the shared Python/replay/Rust conformance suite. Its +distribution version is currently `0.4.0` and its executable native API is +`0.4`; these are separate version contracts. + +`native_backend="rust"` is explicit and fail-fast. It does not silently +downgrade to Python. `native_backend="auto"` remains Python in +`quantbt-engine 1.0.7` until the public wheel matrix and release gates pass. + +The API 0.4 capability contract covers: + +```text +Native Event V2 full contract +single- and multi-symbol execution +funding, margin and liquidation +PLACE/CANCEL/CANCEL_ALL/AMEND/REPLACE +MARKET/LIMIT/STOP_MARKET/STOP_LIMIT +GTC/GTD/IOC/FOK +reduce-only, quantity preflight, parent/group/OCO and expiry +``` + +See: + +- [`native_event_rust_full_contract.md`](native_event_rust_full_contract.md) +- [`grid_native_event_phase47c.md`](grid_native_event_phase47c.md) +- [`endpoint.md`](endpoint.md) + +For local Rust validation once the Rust toolchain and Maturin are installed: + +```bash +cd rust/native_event +cargo fmt --check +cargo clippy -- -D warnings +cargo test +maturin build --release +``` + +`QUANTBT_NATIVE_BACKEND=auto` and `python` continue using the existing Python +Native Event implementation. `rust` is explicit and is capability-gated at +API 0.4 before execution. A missing or incomplete native wheel fails clearly; +it never falls back silently. Public native installation remains a separate +manylinux CPython 3.11–3.13 release gate. + +Native publishing must wait until the API 0.4 package builds for every +advertised wheel target, installs beside the matching `quantbt-engine` wheel, +and passes Python/replay/Rust parity, Grid integration, and performance/RSS +gates. Native CI builds both distributions from the same ref, installs them in +a clean environment, verifies API 0.4 capabilities, and runs parity/RSS smoke. + +### Historical R0/R1/R2 scaffold + +The earlier R0/R1/R2 milestones remain useful engineering history. They +covered the initial local PyO3 import, single-symbol reactive execution, and +the early explicit-order subset. They are not the current public Rust +contract, and their restrictions must not be used as the release policy for +API 0.4. + +## Repository Mirror And Artifact Safety + +The Python wheel source of truth is `src/quantbt`. The root-level Python tree +is a temporary compatibility mirror for local Pool Alpha imports. Its scope is +explicitly limited by `tools/source_mirror_manifest.py`; benchmark scripts, +tests, and tools are not package mirror entries. + +Check or synchronize one direction at a time: + +```bash +poetry run python tools/sync_source_mirror.py --check +poetry run python tools/sync_source_mirror.py --src-to-root +poetry run python tools/sync_source_mirror.py --root-to-src +``` + +The sync tool never merges both trees automatically and never deletes an +unknown root-only file. A missing, extra, or byte-different Python file is a +reviewable failure. `src/quantbt` remains the wheel source until the mirror is +formally retired. + +Before a public release, CI verifies that `upgrade/implement.md` remains +tracked and visible, scans tracked files for high-confidence credential +patterns, and inspects wheel/sdist members. Generic words such as `token`, +`password`, or the PyPI publish action are documented terms and are not leaks +by themselves; credential-like matches still require manual review. + +The core wheel allowlist is `quantbt/**` plus its own +`quantbt_engine-*.dist-info/**`. `MANIFEST.in` controls sdist content only; +it is not a substitute for removing a secret from Git history. Private data, +credentials, compiler output, profiler traces, and local benchmark output are +ignored by path-specific rules, while public plans, tests, tools, docs, and +accepted benchmark evidence remain trackable. + +## TestPyPI To PyPI Workflow + +### TestPyPI release candidate + +1. Update the package version to an unused RC version such as `1.0.7rc1`. +2. Commit the version and changelog on a release candidate ref. +3. Create the matching tag, for example `v1.0.7rc1`. +4. Configure the pending TestPyPI publisher for repository `BobbyAxerol/quantbt`, + workflow `publish-testpypi.yml`, and GitHub environment `testpypi`. +5. Push the matching RC tag to trigger **Publish quantbt-engine to TestPyPI**, + or run it manually with the exact tag. The workflow runs the clean + wheel/sdist installation gate before the publish job and uploads the release + manifest separately for review. +6. Install and smoke-test the RC from both TestPyPI and the Pool Alpha + environment: + +```bash +python3 -m venv /tmp/quantbt-testpypi-smoke +/tmp/quantbt-testpypi-smoke/bin/python -m pip install --upgrade pip +/tmp/quantbt-testpypi-smoke/bin/python -m pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + quantbt-engine==1.0.7rc1 +/tmp/quantbt-testpypi-smoke/bin/python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +/tmp/quantbt-testpypi-smoke/bin/python -m pip check +``` + +### Production PyPI release + +1. Merge the verified release commit to protected `main`. +2. Set the final version, for example `1.0.7`, and add the changelog entry. +3. Create and push the matching protected tag `v1.0.7`. +4. Create a GitHub Release from that tag and mark it published. +5. The production workflow runs the matrix regression, builds the core wheel + and sdist, runs metadata and clean-install checks, then pauses at the + protected `pypi` environment reviewer gate. +6. Approve only after the artifact name, version, and release notes have been + checked. The workflow publishes through OIDC; no long-lived API token is + needed. +7. Verify `pip install quantbt-engine==1.0.7` from a fresh environment and + archive the wheel, sdist, test output, and release manifest. + +Do not publish `quantbt-native` in this flow. It has a separate future release +when its wheel matrix and RSS gates pass. Until then, `auto` remains Python and +the native extra remains empty. + +## Benchmark Evidence And Open Optimization Scope + +The committed benchmark evidence distinguishes score throughput from full +facade/report runtime. The Phase 46F Rust batched score rerun reports `182.2x` +low-churn and `251.3x` high-churn speedup against the Python score path, with +full parity and an absolute `184.11 MB` peak RSS. The prepared RSS threshold +did not pass, so these numbers do not justify automatic Rust selection. The +prior Phase 46E snapshot (`155.6x` / `218.4x`) remains available for historical +comparison. + +Phase 45F's isolated end-to-end reference reported a `42.08x` median speedup +and an `18.3%` minimum peak-RSS reduction across its workload. These snapshots +are evidence for different benchmark contracts, not interchangeable claims; +always cite the JSON artifact and workload when comparing runs. + +The next optimization scope remains deliberately open and domain-preserving: +Python scalar/object reduction and Rust batched paths may later be extended to +portfolio, arbitrage, options, vectorized, intrabar, and Nautilus adapter +workloads. Such work requires a separate parity/RSS gate for each domain and +must not change the core PyPI release or silently change backend selection. + +### Local Native Evidence Gate + +Phase 45B.1 ran the native evidence gate on Linux x86_64 with CPython 3.12 and +Rust stable 1.97.1. The core and native wheels built from one commit, installed +cleanly, and the installed R1/R2 parity suite passed for every advertised Rust +capability. This is a correctness result, not an automatic performance claim. + +Repeated warmed 25,000-bar R1 workloads put the current PyO3 path at roughly +`0.69x-0.83x` Python throughput, with no RSS reduction. The current adapter +crosses the Python boundary once per bar and creates Python result payloads, so +prepared market data alone cannot amortize that cost. Therefore `auto` remains +Python and `quantbt-native` remains unpublished and experimental. A future +native rollout requires a batched or compiled-strategy boundary and a fresh +parity plus throughput/RSS certification run. diff --git a/docs/testpypi_release_checklist.md b/docs/testpypi_release_checklist.md new file mode 100644 index 0000000..1498515 --- /dev/null +++ b/docs/testpypi_release_checklist.md @@ -0,0 +1,90 @@ +# QuantBT TestPyPI RC Checklist + +This checklist is the final handoff for `quantbt-engine`. It is deliberately +separate from the native wheel decision: the core Python package can be tested +and released while `quantbt-native` remains experimental. + +## Before The Workflow + +1. Work from a release commit, not `dev`. +2. Set `project.version` in `pyproject.toml` to an unused RC version, for + example `1.0.7rc1`. +3. Keep `CHANGELOG.md` and the release notes aligned with that version. +4. Create the matching tag, for example `v1.0.7rc1`. +5. Configure the pending TestPyPI publisher: + `BobbyAxerol/quantbt`, workflow `publish-testpypi.yml`, environment + `testpypi`. + +The workflow refuses a tag that does not equal `v{project.version}`. Do not +upload a final `1.0.7` artifact under an RC tag. + +## Workflow Gate + +Push the matching `v*rc*` tag to trigger **Publish quantbt-engine to TestPyPI** +automatically, or run the same workflow manually with the exact tag in the +`ref` input. Before upload, CI performs: + +- Python regression and package build (the two external-data `test_real*.py` + scripts are intentionally excluded from portable CI); +- `twine check`; +- tracked-secret scan and archive allowlist scan; +- clean wheel install, import from `/tmp`, and `pip check`; +- clean sdist install, import from `/tmp`, and `pip check`; +- release manifest creation with commit SHA and artifact SHA256 values. + +The workflow uploads the distributions and the manifest as separate artifacts. +Only `.whl` and `.tar.gz` files are sent to TestPyPI. + +## Record The Evidence + +Archive the downloaded `release-manifest.json` and record: + +```text +git_sha: +git_ref: +distribution: +version: +wheel name + sha256: +sdist name + sha256: +Python matrix: +portable pytest result (excluding `test_real*.py`): +native-event parity result: +RSS/benchmark artifact: +auto backend policy: Python +native extra policy: empty +``` + +## Install From TestPyPI + +Use a fresh environment and the public PyPI index as a dependency fallback: + +```bash +python3 -m venv /tmp/quantbt-testpypi-smoke +/tmp/quantbt-testpypi-smoke/bin/python -m pip install --upgrade pip +/tmp/quantbt-testpypi-smoke/bin/python -m pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + quantbt-engine==1.0.7rc1 +(cd /tmp && /tmp/quantbt-testpypi-smoke/bin/python -c \ + "import quantbt; print(quantbt.__file__)") +/tmp/quantbt-testpypi-smoke/bin/python -m pip check +``` + +Verify that `quantbt.__file__` points into the temporary environment's +`site-packages`, not the repository checkout. Run one representative endpoint +smoke and compare its metadata/config with the local artifact run. + +## Production Handoff + +Only after the RC is inspected: + +1. Set the final version and changelog entry. +2. Merge the verified commit to protected `main`. +3. Create the matching final tag and GitHub Release. +4. Let `publish.yml` build and test the exact release ref. +5. Approve the protected `pypi` environment only after reviewing the artifact + manifest and release notes. + +Do not publish `quantbt-native` from this flow. `backend="auto"` remains +Python and explicit Rust remains an opt-in local/CI capability until its public +wheel matrix and native release gate are separately approved. diff --git a/endpoint.py b/endpoint.py index d315277..548f737 100644 --- a/endpoint.py +++ b/endpoint.py @@ -10,10 +10,11 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field, is_dataclass, replace +from enum import Enum import hashlib import json from pathlib import Path -from typing import Dict, Optional, Sequence, Union +from typing import TYPE_CHECKING, Dict, Optional, Sequence, Union import warnings import numpy as np @@ -23,6 +24,7 @@ from .backends import ( NativeEventBackend, NativeEventConfig, + NativeEventScoreRequirements, NativeOptionConfig, NativePortfolioBackend, NativePortfolioConfig, @@ -58,7 +60,11 @@ from .core.intrabar_kernel import FillReplayTape, run_fill_replay_kernel, run_intrabar_kernel, run_intrabar_session_kernel from .core.market_tape import PreparedMarketTape, prepare_market_tape from .core.orders import OrderCommand, OrderIntent, order_intents_to_lifecycle_commands -from .core.results import BacktestResultV2, NativeAccountingArrays, NativeEventScoreResult, OptionBacktestResult +from .core.results import ( + BacktestResultV2, + NativeEventScalarScoreResult, + NativeEventScoreResult, +) from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce from .core.structured_orders import ( BracketOrderSpec, @@ -68,8 +74,6 @@ ) from .core.types import BacktestResult from .engines import BacktestEngineV2, OptionBacktestEngine, PortfolioBacktestEngine -from .metrics import full_report as _full_report -from .reporting import build_portfolio_nautilus_validation_report from .sizing.modes import compute_target_units from .options.execution import OptionExecutionConfig from .options.fees import OptionFeeSchedule @@ -79,15 +83,107 @@ from .options.packages import OptionPackageIntent from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec from .options.strategy import OptionStrategyRun -from .viz import quick_plot as _quick_plot -from .viz import tearsheet as _tearsheet -from .walkforward import WalkForwardConfig, WalkForwardEngine + +if TYPE_CHECKING: + from .walkforward import WalkForwardConfig SeriesMap = Dict[str, pd.Series] FrameMap = Dict[str, pd.DataFrame] +class NativeEventProfile(str, Enum): + """Stable high-level retention/execution profile for event-driven runs.""" + + RESEARCH = "research" + OPTIMIZE = "optimize" + AUDIT = "audit" + + +_NATIVE_EVENT_PROFILE_OPTIONS = { + NativeEventProfile.RESEARCH: { + "reactive_execution_mode": "fast", + "reactive_kernel_mode": "single_pass", + "report_level": "minimal", + "audit_sink": "none", + }, + NativeEventProfile.OPTIMIZE: { + "reactive_execution_mode": "fast", + "reactive_kernel_mode": "single_pass", + "report_level": "score", + "audit_sink": "none", + }, + NativeEventProfile.AUDIT: { + "reactive_execution_mode": "audit", + "reactive_kernel_mode": "replay_certified", + "report_level": "audit", + "audit_sink": "memory", + }, +} +_NATIVE_EVENT_PUBLIC_BACKENDS = frozenset({"auto", "python", "rust"}) + + +def _normalize_native_event_profile(profile: Union[str, NativeEventProfile]) -> NativeEventProfile: + value = profile.value if isinstance(profile, NativeEventProfile) else str(profile).lower().strip() + try: + return NativeEventProfile(value) + except ValueError as exc: + valid = ", ".join(item.value for item in NativeEventProfile) + raise ValueError(f"profile must be one of: {valid}; received {profile!r}") from exc + + +def _resolve_event_driven_kwargs( + *, + input_mode: str, + profile: Union[str, NativeEventProfile], + backend: str, + kwargs: Dict, +) -> Dict: + """Resolve the small public facade into one legacy endpoint config. + + This function only resolves configuration. Matching, accounting, and + result construction remain owned by the existing endpoint constructors. + """ + + mode = str(input_mode).lower().strip() + if mode not in {"strategy", "orders"}: + raise ValueError("input_mode must be 'strategy' or 'orders'") + + profile_value = _normalize_native_event_profile(profile) + public_backend = str(backend).lower().strip() + if public_backend not in _NATIVE_EVENT_PUBLIC_BACKENDS: + raise ValueError("backend must be one of: auto, python, rust") + + resolved = dict(kwargs) + profile_options = _NATIVE_EVENT_PROFILE_OPTIONS[profile_value] + for key, value in profile_options.items(): + if key in resolved: + raise ValueError( + f"profile='{profile_value.value}' controls {key}; " + f"use the advanced native_event_{'strategy' if mode == 'strategy' else 'lifecycle'} " + "constructor for custom low-level combinations" + ) + resolved[key] = value + + advanced_backend = resolved.get("native_backend") + if advanced_backend is not None and public_backend != "auto": + raise ValueError("pass either backend=... or advanced native_backend=..., not both") + if advanced_backend is None: + resolved["native_backend"] = public_backend + + metadata = dict(resolved.pop("metadata", {}) or {}) + metadata.setdefault( + "event_driven_facade", + { + "input_mode": mode, + "profile": profile_value.value, + "backend": public_backend, + }, + ) + resolved["metadata"] = metadata + return resolved + + @dataclass(frozen=True) class EndpointConfig: """ @@ -103,6 +199,10 @@ class EndpointConfig: backend: Engine selector. Use `auto` for domain-safe defaults, or explicitly set `legacy`, `native_vectorized`, `native_event`, or `nautilus`. + native_backend: + Native-event implementation selector: `python`, `rust`, `auto`, or + `replay_certified`. It is only consulted by native-event backends; + omitted means preserve the environment/default selection policy. sizing: Position sizing contract for signal modes. Examples: `%_equity`, `signal_notional`, `notional`, `unit`, `dca_ladder`. @@ -169,6 +269,7 @@ class EndpointConfig: mode: str = "single_signal" backend: str = "auto" + native_backend: Optional[str] = None sizing: str = "signal_notional" account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0)) execution: ExecutionConfig = field(default_factory=ExecutionConfig) @@ -362,17 +463,25 @@ def run(self, strategy, *, report_level: Optional[str] = None) -> BacktestResult simulate = run - def score(self, strategy, *, trading_days: int = 365) -> NativeEventScoreResult: + def score( + self, + strategy, + *, + trading_days: int = 365, + score_requirements: Optional[NativeEventScoreRequirements] = None, + ) -> Union[NativeEventScoreResult, NativeEventScalarScoreResult]: """ - Run the prepared strategy with score artifact retention. + Run the prepared strategy through the direct score path. - The returned object stores ndarray accounting arrays and scalar metrics; - it intentionally does not update `endpoint.result`. + The default compatibility contract stores ndarray accounting arrays and + scalar metrics. Passing ``NativeEventScoreRequirements.scalar_score_contract()`` + returns the low-retention scalar result instead. Neither form updates + ``endpoint.result``. """ if strategy is None: raise ValueError("prepared native-event score requires strategy=...") config = self.endpoint.config - result = self.backend.run_strategy( + score = self.backend.run_strategy_score( datetime_index=self.idx, strategy=strategy, closes=self.close_map, @@ -392,37 +501,20 @@ def score(self, strategy, *, trading_days: int = 365) -> NativeEventScoreResult: min_qty=config.min_qty, min_notional=config.min_notional, execution_mode=config.reactive_execution_mode, - reactive_kernel_mode="single_pass", - report_level="score", - audit_sink="none", market_arrays=self.market_arrays, opens_arr=self.opens_arr, volumes_arr=self.volumes_arr, + trading_days=trading_days, + score_requirements=score_requirements, ) - accounting = NativeAccountingArrays.from_result(result) - counters = dict(result.metadata.get("lifecycle_counters") or {}) - score = NativeEventScoreResult( - accounting=accounting, - final_positions=accounting.positions[-1].copy(), - fill_count=int(counters.get("fill_count", 0)), - rejection_count=int(counters.get("rejected_count", 0)), - cancellation_count=int(counters.get("canceled_count", 0)), - liquidated=bool(result.liquidated), - liquidation_bar=int(result.liquidation_bar), - metrics={}, + object.__setattr__(self, "scores", self.scores + 1) + return replace( + score, metadata={ - "backend": "native_event", - "engine": "event_v2_reactive_score", - "report_level": "score", + **dict(score.metadata), "prepared_native_event_strategy": self.metadata, - "lifecycle_counters": counters, - "artifact_plan": result.metadata.get("artifact_plan"), - "reactive_kernel_mode": result.metadata.get("reactive_kernel_mode"), - "static_replay_available": result.metadata.get("static_replay_available"), }, ) - object.__setattr__(self, "scores", self.scores + 1) - return replace(score, metrics=score.full_report(trading_days=trading_days)) @property def metadata(self) -> Dict[str, object]: @@ -605,6 +697,7 @@ def prepare_native_event_strategy( audit_sink=config.audit_sink, audit_sink_path=config.audit_sink_path, reactive_kernel_mode=config.reactive_kernel_mode, + native_backend=config.native_backend, ) ) market = backend.prepare_market_arrays( @@ -803,6 +896,63 @@ def orders(cls, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint": """ return cls(_config_from_kwargs(mode="orders", backend=backend, **kwargs)) + @classmethod + def event_driven( + cls, + *, + input_mode: str = "strategy", + profile: Union[str, NativeEventProfile] = NativeEventProfile.RESEARCH, + backend: str = "auto", + **kwargs, + ) -> "QuantBTEndpoint": + """Create the stable public native-event facade. + + Parameters + ---------- + input_mode: + ``"strategy"`` for a stateful strategy implementing the reactive + callback protocol, or ``"orders"`` for an explicit + ``OrderCommand``/``OrderIntent`` tape. + profile: + ``"research"`` keeps a compact public result, ``"optimize"`` + selects the scalar score retention contract, and ``"audit"`` + retains replay-certified accounting and event artifacts. + backend: + ``"auto"`` follows the release policy (currently Python), + ``"python"`` selects the canonical backend, or ``"rust"`` + explicitly requests the capability-gated native wheel. + + The facade resolves profiles and delegates to + :meth:`native_event_strategy` or :meth:`native_event_lifecycle`. + It does not implement a second matcher or accounting engine. Advanced + callers may continue using those constructors directly when they need + custom ``reactive_execution_mode``, ``reactive_kernel_mode``, + ``report_level``, or ``audit_sink`` combinations. + + Examples + -------- + >>> endpoint = QuantBTEndpoint.event_driven( + ... profile="research", backend="auto", initial_capital=20_000, + ... ) + >>> result = endpoint.simulate(data=data, strategy=strategy) + + >>> endpoint = QuantBTEndpoint.event_driven( + ... input_mode="orders", profile="audit", backend="python", + ... initial_capital=20_000, + ... ) + >>> result = endpoint.simulate(data=data, order_commands=commands) + """ + + resolved = _resolve_event_driven_kwargs( + input_mode=input_mode, + profile=profile, + backend=backend, + kwargs=kwargs, + ) + if str(input_mode).lower().strip() == "strategy": + return cls.native_event_strategy(**resolved) + return cls.native_event_lifecycle(**resolved) + @classmethod def native_event_lifecycle(cls, **kwargs) -> "QuantBTEndpoint": """ @@ -1290,6 +1440,8 @@ def walk_forward( wf_metadata = dict(optimization_config.get("metadata", {}) or {}) wf_metadata.setdefault("use_prepared_scoring_cache", bool(optimization_config.get("use_prepared_scoring_cache", True))) if wf_config is None: + from .walkforward import WalkForwardConfig + wf_config = WalkForwardConfig( split_mode=split_mode, split_frequency=split_frequency, @@ -1629,6 +1781,8 @@ def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: RuntimeError If no backtest has been run yet. """ + from .metrics import full_report as _full_report + return _full_report(self._result_for_report_scope(scope), trading_days=trading_days) def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict: @@ -1646,12 +1800,16 @@ def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = """ Plot cumulative return and drawdown for the latest result. """ + from .viz import quick_plot as _quick_plot + return _quick_plot(self._require_result(), theme=theme, figsize=figsize, scope=scope) def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"): """ Render the full QuantBT tearsheet for the latest result. """ + from .viz import tearsheet as _tearsheet + return _tearsheet(self._require_result(), theme=theme, benchmark=benchmark, scope=scope) def export_orders(self, path: Union[str, Path]) -> None: @@ -2076,6 +2234,7 @@ def _run_single(self, data, signal, signal_col, datetime_index, symbols): signals=sig, symbols=symbol_list, backend=backend, + native_backend=self.config.native_backend, account=self.config.account, execution=self.config.execution, fee_rate=self.config.v2_fee_rate, @@ -2122,6 +2281,7 @@ def _run_orders(self, data, orders, order_commands, datetime_index, symbols): data=frame, symbols=list(symbols or self.config.symbols or ["asset"]), backend=backend, + native_backend=self.config.native_backend, orders=orders, order_commands=order_commands, event_engine_version=event_version, @@ -2159,6 +2319,7 @@ def _run_native_event_strategy(self, data, strategy, datetime_index, symbols): data=frame, symbols=symbol_list, backend="native_event", + native_backend=self.config.native_backend, strategy=strategy, event_engine_version="v2", reactive_execution_mode=self.config.reactive_execution_mode, @@ -2213,6 +2374,7 @@ def _run_structured_orders(self, data, datetime_index, symbols): data=frame, symbols=[spec.symbol], backend="native_event", + native_backend=self.config.native_backend, order_commands=commands, event_engine_version="v2", account=self.config.account, @@ -2298,6 +2460,7 @@ def _run_basket(self, data, signal, signal_col, basket, closes, highs, lows, dat self.engine = BacktestEngineV2( backend="native_event", + native_backend=self.config.native_backend, basket=spec, signal=sig, closes=close_map, @@ -2376,6 +2539,7 @@ def _run_arbitrage(self, data, signal, signal_col, closes, highs, lows, hedge_ra report_level=self.config.report_level, audit_sink=self.config.audit_sink, audit_sink_path=self.config.audit_sink_path, + native_backend=self.config.native_backend, ) ) else: @@ -2446,6 +2610,8 @@ def _run_walk_forward( ): if self.config.strategy_class is None: raise ValueError("walk_forward endpoint requires strategy_class") + from .walkforward import WalkForwardConfig, WalkForwardEngine + wf_config = self.config.walkforward_config or WalkForwardConfig(target_mode=self.config.walkforward_target_mode) target_mode = self.config.walkforward_target_mode.lower().strip() scorer = ( @@ -2729,6 +2895,8 @@ def _run_portfolio(self, data, positions, closes, highs, lows, datetime_index, s ) result.metadata["engine"] = "nautilus_portfolio_matrix" result.metadata["native_portfolio_reference_final_equity"] = float(native_reference.equity.iloc[-1]) + from .reporting import build_portfolio_nautilus_validation_report + result.metadata["portfolio_nautilus_validation_report"] = build_portfolio_nautilus_validation_report( native_reference, result, diff --git a/engines.py b/engines.py index 3663582..4f20b46 100644 --- a/engines.py +++ b/engines.py @@ -55,6 +55,7 @@ def __init__( data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]] = None, signals: Optional[Union[pd.Series, SeriesMap]] = None, backend: str = "native_vectorized", + native_backend: Optional[str] = None, account: Optional[AccountConfig] = None, execution: Optional[ExecutionConfig] = None, fee_rate: float = 0.0, @@ -98,6 +99,7 @@ def __init__( raise ValueError(f"backend must be one of {sorted(self.VALID_BACKENDS)}") self.data = data + self.native_backend = native_backend self.signals = signals self.account = account or AccountConfig(initial_capital=100_000.0) self.execution = execution or ExecutionConfig() @@ -217,6 +219,7 @@ def _run_native_event(self) -> BacktestResultV2: audit_sink=self.audit_sink, audit_sink_path=self.audit_sink_path, reactive_kernel_mode=self.reactive_kernel_mode, + native_backend=self.native_backend, ) ) diff --git a/optimization/evaluators/native_event.py b/optimization/evaluators/native_event.py index b494ec5..0902e36 100644 --- a/optimization/evaluators/native_event.py +++ b/optimization/evaluators/native_event.py @@ -6,6 +6,7 @@ from typing import Any, Callable, Mapping from ..result import ObjectiveResult +from ...backends.native_event import NativeEventScoreRequirements from .generic import ObjectiveBuilder @@ -17,16 +18,34 @@ class PreparedNativeEventStrategyEvaluator: strategy_factory: Callable[[Mapping[str, Any]], Any] objective_builder: ObjectiveBuilder trading_days: int = 365 + retain_last: bool = False + score_requirements: NativeEventScoreRequirements = field( + default_factory=NativeEventScoreRequirements.scalar_score_contract + ) last_result: Any = field(default=None, init=False) last_strategy: Any = field(default=None, init=False) def evaluate(self, params: Mapping[str, Any]) -> ObjectiveResult: strategy = self.strategy_factory(params) - result = self.runner.score(strategy, trading_days=self.trading_days) + requirements = NativeEventScoreRequirements.from_strategy( + strategy, + base=self.score_requirements, + ) + result = self.runner.score( + strategy, + trading_days=self.trading_days, + score_requirements=requirements, + ) objective = self.objective_builder(result, params) if not isinstance(objective, ObjectiveResult): raise TypeError("objective_builder must return ObjectiveResult") - self.last_strategy = strategy - self.last_result = result + if self.retain_last: + self.last_strategy = strategy + self.last_result = result + else: + # Optimization can run thousands of trials. Retaining a strategy + # and score result pins their arrays until the evaluator dies. + self.last_strategy = None + self.last_result = None return objective diff --git a/portfolio.py b/portfolio.py index d1e8215..9d413bf 100644 --- a/portfolio.py +++ b/portfolio.py @@ -34,7 +34,6 @@ make_funding_mask, ) from .metrics.performance import full_report -from .viz.plots import quick_plot, tearsheet as _tearsheet from .core.types import BacktestResult from .core.engine import _engine_portfolio from .sizing.modes import compute_target_units @@ -553,6 +552,8 @@ def print_metrics(self) -> None: print() def analyze(self, theme: str = "dark", figsize: tuple = (14, 6)) -> None: + from .viz.plots import quick_plot + self.print_metrics() quick_plot(self.result, theme=theme, figsize=figsize) @@ -562,6 +563,8 @@ def tearsheet( figsize: tuple = (16, 20), benchmark: Optional[pd.Series] = None, ) -> None: + from .viz.plots import tearsheet as _tearsheet + _tearsheet( self.result, theme = theme, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7a513b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ +[build-system] +requires = ["setuptools>=82,<83", "wheel>=0.46,<0.47"] +build-backend = "setuptools.build_meta" + +[project] +name = "quantbt-engine" +version = "1.0.7rc1" +description = "Transparent, high-performance quantitative backtesting engine" +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = "MIT" +authors = [ + { name = "Vu Cong Minh", email = "vugioan11022002@gmail.com" }, +] +keywords = [ + "backtesting", + "quant", + "trading", + "numba", + "portfolio", + "event-driven", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Financial and Insurance Industry", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Office/Business :: Financial :: Investment", + "Topic :: Scientific/Engineering", + "Typing :: Typed", +] +dependencies = [ + "numpy>=2.2.6,<2.3", + "pandas>=2.3.3,<2.4", + "numba>=0.65.1,<0.66", +] + +[project.optional-dependencies] +optimization = [ + "optuna>=4.8.0,<4.9", + "arch>=8.0.0,<8.1", + "scikit-learn>=1.8.0,<1.9", +] +reports = [ + "quantstats==0.0.81", +] +viz = [ + "matplotlib>=3.10.9,<3.11", + "seaborn>=0.13.2,<0.14", +] +validation = [ + "nautilus-trader>=1.230.0,<1.231; python_version >= '3.12'", +] +# The API 0.4 PyO3 package is built by the native workflow but is not yet a +# public dependency. Keep this empty until quantbt-native passes the public +# manylinux wheel and clean-install gates. +native = [] +all = [ + "optuna>=4.8.0,<4.9", + "arch>=8.0.0,<8.1", + "scikit-learn>=1.8.0,<1.9", + "quantstats==0.0.81", + "matplotlib>=3.10.9,<3.11", + "seaborn>=0.13.2,<0.14", + "nautilus-trader>=1.230.0,<1.231; python_version >= '3.12'", +] + +[project.urls] +Homepage = "https://github.com/BobbyAxerol/quantbt" +Repository = "https://github.com/BobbyAxerol/quantbt" +Issues = "https://github.com/BobbyAxerol/quantbt/issues" +Documentation = "https://github.com/BobbyAxerol/quantbt/tree/main/docs" +Changelog = "https://github.com/BobbyAxerol/quantbt/blob/main/CHANGELOG.md" + +[dependency-groups] +dev = [ + "pytest>=9.1,<10.0", + "pytest-cov>=7.0,<8.0", + "hypothesis>=6.148,<7.0", + "ruff>=0.14,<0.15", + "mypy>=1.19,<2.0", + "build>=1.3,<2.0", + "twine>=6.2,<7.0", +] + +[tool.setuptools.packages.find] +where = ["src"] +include = ["quantbt*"] + +[tool.setuptools.package-data] +quantbt = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.uv] +package = true diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..05e6ca1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/rust/native_event/Cargo.lock b/rust/native_event/Cargo.lock new file mode 100644 index 0000000..bd08292 --- /dev/null +++ b/rust/native_event/Cargo.lock @@ -0,0 +1,228 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quantbt-native" +version = "0.4.0" +dependencies = [ + "numpy", + "pyo3", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/rust/native_event/Cargo.toml b/rust/native_event/Cargo.toml new file mode 100644 index 0000000..e8b867b --- /dev/null +++ b/rust/native_event/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "quantbt-native" +version = "0.4.0" +edition = "2024" +publish = false + +[lib] +name = "_quantbt_native" +crate-type = ["cdylib"] + +[dependencies] +numpy = "0.29" +pyo3 = { version = "0.29", features = ["extension-module"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" +debug = 0 +overflow-checks = false diff --git a/rust/native_event/README.md b/rust/native_event/README.md new file mode 100644 index 0000000..2370fd0 --- /dev/null +++ b/rust/native_event/README.md @@ -0,0 +1,41 @@ +# quantbt-native + +`quantbt-native` is the experimental PyO3/Rust accelerator companion to +`quantbt-engine`. It is not part of the core package release yet. + +## Scope + +The current wheel supports the certified single-symbol static explicit-order +tape path used by `native_backend="rust"`. Python remains the canonical +full-featured and default backend. Unsupported features fail fast rather than +silently falling back: + +- multi-symbol execution; +- funding and liquidation; +- unsupported quantity and lifecycle policies; +- reactive per-bar strategy callbacks. + +The Rust distribution version and native API version are separate contracts. +The current crate distribution is `0.4.0` and advertises Native Event API +`0.4`; this does not imply that a `quantbt-native` PyPI release is available. + +## Local build + +From the repository root: + +```bash +cargo fmt --check --manifest-path rust/native_event/Cargo.toml +cargo test --manifest-path rust/native_event/Cargo.toml +maturin build --release --manifest-path rust/native_event/Cargo.toml +``` + +Install the resulting wheel together with the matching local `quantbt-engine` +wheel, then run the focused Rust/Python parity and RSS tests. Do not enable a +native extra or `native_backend="auto"` based on a local build alone. + +## Release gate + +A future native release requires CPython 3.11, 3.12, and 3.13 manylinux wheels, +installed-wheel parity, fallback checks, and incremental RSS certification. +Until all gates pass, the core PyPI package intentionally leaves its `native` +extra empty and keeps `auto` on Python. diff --git a/rust/native_event/pyproject.toml b/rust/native_event/pyproject.toml new file mode 100644 index 0000000..b937ba6 --- /dev/null +++ b/rust/native_event/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" + +[project] +name = "quantbt-native" +version = "0.4.0" +description = "Optional PyO3 accelerator for quantbt-engine native event execution" +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = "MIT" +authors = [{ name = "BobbyAxerol", email = "vugioan11022002@gmail.com" }] +dependencies = [] +urls = { Homepage = "https://github.com/BobbyAxerol/quantbt", Repository = "https://github.com/BobbyAxerol/quantbt", Documentation = "https://github.com/BobbyAxerol/quantbt/blob/main/docs/native_event_rust_full_contract.md", Issues = "https://github.com/BobbyAxerol/quantbt/issues" } +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", +] + +[tool.maturin] +bindings = "pyo3" +module-name = "_quantbt_native" +manifest-path = "Cargo.toml" diff --git a/rust/native_event/src/accounting.rs b/rust/native_event/src/accounting.rs new file mode 100644 index 0000000..c076e8a --- /dev/null +++ b/rust/native_event/src/accounting.rs @@ -0,0 +1,27 @@ +pub fn initial_margin(position: f64, close: f64, contract_size: f64, leverage: f64) -> f64 { + position.abs() * close * contract_size / leverage +} + +pub fn maintenance_margin( + position: f64, + close: f64, + contract_size: f64, + maintenance_ratio: f64, +) -> f64 { + position.abs() * close * contract_size * maintenance_ratio +} + +pub fn required_margin( + position: f64, + delta: f64, + close: f64, + execution_price: f64, + contract_size: f64, + leverage: f64, + fee: f64, +) -> (f64, f64) { + let current = initial_margin(position, close, contract_size, leverage); + let next = initial_margin(position + delta, execution_price, contract_size, leverage); + let required = fee + (next - current).max(0.0); + (required, current) +} diff --git a/rust/native_event/src/full.rs b/rust/native_event/src/full.rs new file mode 100644 index 0000000..ac8a60b --- /dev/null +++ b/rust/native_event/src/full.rs @@ -0,0 +1,1585 @@ +//! Full Native Event V2 contract engine. +//! +//! This module deliberately mirrors the ordering in ``core.event._engine_event_v2``. +//! It is a compact, allocation-light Rust implementation of the public command +//! tape contract. The older ``session`` module remains intact for ABI +//! compatibility with pre-47 wheels; the PyO3 layer exposes this module under a +//! versioned full-contract class. + +use std::collections::HashMap; +use std::sync::Arc; + +const STATUS_PENDING: i64 = 0; +const STATUS_FILLED: i64 = 1; +const STATUS_CANCELED: i64 = 2; +const STATUS_REJECTED: i64 = 3; + +const ACTION_PLACE: i64 = 0; +const ACTION_CANCEL: i64 = 1; +const ACTION_REPLACE: i64 = 2; +const ACTION_AMEND: i64 = 3; +const ACTION_CANCEL_ALL: i64 = 4; + +const ORDER_MARKET: i64 = 0; +const ORDER_LIMIT: i64 = 1; +const ORDER_STOP_MARKET: i64 = 2; +const ORDER_STOP_LIMIT: i64 = 3; +const TIF_GTC: i64 = 0; +#[allow(dead_code)] +const TIF_IOC: i64 = 1; +#[allow(dead_code)] +const TIF_FOK: i64 = 2; +const TIF_GTD: i64 = 3; +const SIDE_BUY: i64 = 1; +const SIDE_SELL: i64 = -1; + +const ACTIVATION_IMMEDIATE: i64 = 0; +const ACTIVATION_ON_PARENT_FIRST_FILL: i64 = 1; +const ACTIVATION_ON_PARENT_FULL_FILL: i64 = 2; +const FLAG_REDUCE_ONLY: u16 = 1 << 0; + +#[repr(u8)] +#[derive(Clone, Copy)] +enum InternalOrderType { + Market = ORDER_MARKET as u8, + Limit = ORDER_LIMIT as u8, + StopMarket = ORDER_STOP_MARKET as u8, + StopLimit = ORDER_STOP_LIMIT as u8, +} + +impl TryFrom for InternalOrderType { + type Error = (); + + fn try_from(value: i64) -> Result { + match value { + ORDER_MARKET => Ok(Self::Market), + ORDER_LIMIT => Ok(Self::Limit), + ORDER_STOP_MARKET => Ok(Self::StopMarket), + ORDER_STOP_LIMIT => Ok(Self::StopLimit), + _ => Err(()), + } + } +} + +#[repr(u8)] +#[derive(Clone, Copy)] +enum InternalTimeInForce { + Gtc = TIF_GTC as u8, + Ioc = TIF_IOC as u8, + Fok = TIF_FOK as u8, + Gtd = TIF_GTD as u8, +} + +impl TryFrom for InternalTimeInForce { + type Error = (); + + fn try_from(value: i64) -> Result { + match value { + TIF_GTC => Ok(Self::Gtc), + TIF_IOC => Ok(Self::Ioc), + TIF_FOK => Ok(Self::Fok), + TIF_GTD => Ok(Self::Gtd), + _ => Err(()), + } + } +} + +#[repr(i8)] +#[derive(Clone, Copy)] +enum InternalSide { + Sell = SIDE_SELL as i8, + Buy = SIDE_BUY as i8, +} + +impl TryFrom for InternalSide { + type Error = (); + + fn try_from(value: i64) -> Result { + match value { + SIDE_BUY => Ok(Self::Buy), + SIDE_SELL => Ok(Self::Sell), + _ => Err(()), + } + } +} + +pub const EVENT_PLACE: i64 = 0; +pub const EVENT_CANCEL: i64 = 1; +pub const EVENT_REPLACE: i64 = 2; +pub const EVENT_AMEND: i64 = 3; +pub const EVENT_FILL: i64 = 4; +pub const EVENT_EXPIRE: i64 = 5; +pub const EVENT_ACTIVATE: i64 = 6; +pub const EVENT_REJECT: i64 = 7; + +#[allow(dead_code)] +pub const REJECT_NONE: i64 = 0; +pub const REJECT_INSUFFICIENT_MARGIN: i64 = 1; +pub const REJECT_UNSUPPORTED_ORDER_TYPE: i64 = 2; +pub const REJECT_UNKNOWN_ORDER: i64 = 3; +#[allow(dead_code)] +pub const REJECT_INVALID_AMEND: i64 = 4; +pub const REJECT_REDUCE_ONLY_NO_POSITION: i64 = 5; +pub const REJECT_UNSUPPORTED_ACTION: i64 = 6; + +pub const LIQ_NONE: i64 = 0; +pub const LIQ_INTRABAR: i64 = 1; +pub const LIQ_AFTER_FUNDING: i64 = 2; +pub const LIQ_AFTER_ORDER: i64 = 3; + +pub const CODE_WIDTH: usize = 16; +pub const VALUE_WIDTH: usize = 3; + +// Per-step projection mask. Accounting and lifecycle state are always +// computed; these bits only control which transient vectors cross the PyO3 +// boundary for reactive callbacks. +pub const OUTPUT_POSITIONS: u8 = 1; +pub const OUTPUT_FILLS: u8 = 2; +pub const OUTPUT_EVENTS: u8 = 4; +pub const OUTPUT_ACTIVE_ORDERS: u8 = 8; +pub const OUTPUT_ALL: u8 = OUTPUT_POSITIONS | OUTPUT_FILLS | OUTPUT_EVENTS | OUTPUT_ACTIVE_ORDERS; + +/// Scalar lifecycle counters are kept separately from projected detail rows. +/// This is the count-only sink used by score runs, so a score never needs to +/// allocate a nested row just to report a fill or event count. +#[derive(Clone, Copy, Default)] +pub struct StepCounters { + pub fill_count: i64, + pub event_count: i64, + pub rejected_count: i64, + pub canceled_count: i64, +} + +#[derive(Default)] +pub struct FillBuffer { + pub order_id: Vec, + pub symbol: Vec, + pub side: Vec, + pub qty: Vec, + pub price: Vec, + pub fee: Vec, +} + +impl FillBuffer { + #[inline] + pub fn clear(&mut self) { + self.order_id.clear(); + self.symbol.clear(); + self.side.clear(); + self.qty.clear(); + self.price.clear(); + self.fee.clear(); + } + + #[inline] + pub fn push(&mut self, order_id: i64, symbol: i64, side: i64, qty: f64, price: f64, fee: f64) { + self.order_id.push(order_id); + self.symbol.push(symbol); + self.side.push(side); + self.qty.push(qty); + self.price.push(price); + self.fee.push(fee); + } + + pub fn rows(&self) -> Vec> { + (0..self.order_id.len()) + .map(|i| { + vec![ + self.order_id[i] as f64, + self.symbol[i] as f64, + self.side[i] as f64, + self.qty[i], + self.price[i], + self.fee[i], + ] + }) + .collect() + } +} + +#[derive(Default)] +pub struct EventBuffer { + pub kind: Vec, + pub status: Vec, + pub order_id: Vec, + pub target_id: Vec, + pub symbol: Vec, + pub reject_code: Vec, +} + +impl EventBuffer { + #[inline] + pub fn clear(&mut self) { + self.kind.clear(); + self.status.clear(); + self.order_id.clear(); + self.target_id.clear(); + self.symbol.clear(); + self.reject_code.clear(); + } + + #[inline] + pub fn push( + &mut self, + kind: i64, + status: i64, + order_id: i64, + target_id: i64, + symbol: i64, + reject_code: i64, + ) { + self.kind.push(kind); + self.status.push(status); + self.order_id.push(order_id); + self.target_id.push(target_id); + self.symbol.push(symbol); + self.reject_code.push(reject_code); + } + + pub fn rows(&self) -> Vec> { + (0..self.kind.len()) + .map(|i| { + vec![ + self.kind[i], + self.status[i], + self.order_id[i], + self.target_id[i], + self.symbol[i], + self.reject_code[i], + ] + }) + .collect() + } +} + +#[derive(Default)] +pub struct ActiveOrderBuffer { + pub order_id: Vec, + pub symbol: Vec, + pub side: Vec, + pub order_type: Vec, + pub qty: Vec, + pub price: Vec, + pub trigger: Vec, + pub tif: Vec, + pub flags: Vec, + pub parent_id: Vec, + pub group_id: Vec, + pub oco_id: Vec, + pub activation: Vec, + pub waiting_parent: Vec, +} + +impl ActiveOrderBuffer { + #[inline] + pub fn clear(&mut self) { + self.order_id.clear(); + self.symbol.clear(); + self.side.clear(); + self.order_type.clear(); + self.qty.clear(); + self.price.clear(); + self.trigger.clear(); + self.tif.clear(); + self.flags.clear(); + self.parent_id.clear(); + self.group_id.clear(); + self.oco_id.clear(); + self.activation.clear(); + self.waiting_parent.clear(); + } + + #[inline] + fn push(&mut self, order: &OrderState) { + self.order_id.push(order.order_id); + self.symbol.push(order.symbol as i64); + self.side.push(order.side as i64); + self.order_type.push(order.order_type as i64); + self.qty.push(order.qty); + self.price.push(order.price); + self.trigger.push(order.trigger); + self.tif.push(order.tif as i64); + self.flags.push(if order.reduce_only() { + FLAG_REDUCE_ONLY as i64 + } else { + 0 + }); + self.parent_id.push(order.parent_id); + self.group_id.push(order.group_id); + self.oco_id.push(order.oco_id); + self.activation.push(order.activation as i64); + self.waiting_parent + .push(if order.waiting_parent { 1 } else { 0 }); + } + + pub fn rows(&self) -> Vec> { + (0..self.order_id.len()) + .map(|i| { + vec![ + self.order_id[i] as f64, + self.symbol[i] as f64, + self.side[i] as f64, + self.order_type[i] as f64, + self.qty[i], + self.price[i], + self.trigger[i], + self.tif[i] as f64, + self.flags[i] as f64, + self.parent_id[i] as f64, + self.group_id[i] as f64, + self.oco_id[i] as f64, + self.activation[i] as f64, + self.waiting_parent[i] as f64, + ] + }) + .collect() + } +} + +#[derive(Default)] +pub struct StepBuffers { + pub fills: FillBuffer, + pub events: EventBuffer, + pub active_orders: ActiveOrderBuffer, +} + +impl StepBuffers { + #[inline] + pub fn clear(&mut self) { + self.fills.clear(); + self.events.clear(); + self.active_orders.clear(); + } + + /// Release only deliberately excessive capacity during service + /// maintenance. The execution loop never shrinks its working buffers. + pub fn release_excess_capacity(&mut self, max_capacity: usize) { + for capacity in [ + self.fills.order_id.capacity(), + self.events.kind.capacity(), + self.active_orders.order_id.capacity(), + ] { + if capacity > max_capacity { + self.fills.order_id.shrink_to(max_capacity); + self.fills.symbol.shrink_to(max_capacity); + self.fills.side.shrink_to(max_capacity); + self.fills.qty.shrink_to(max_capacity); + self.fills.price.shrink_to(max_capacity); + self.fills.fee.shrink_to(max_capacity); + self.events.kind.shrink_to(max_capacity); + self.events.status.shrink_to(max_capacity); + self.events.order_id.shrink_to(max_capacity); + self.events.target_id.shrink_to(max_capacity); + self.events.symbol.shrink_to(max_capacity); + self.events.reject_code.shrink_to(max_capacity); + self.active_orders.order_id.shrink_to(max_capacity); + self.active_orders.symbol.shrink_to(max_capacity); + self.active_orders.side.shrink_to(max_capacity); + self.active_orders.order_type.shrink_to(max_capacity); + self.active_orders.qty.shrink_to(max_capacity); + self.active_orders.price.shrink_to(max_capacity); + self.active_orders.trigger.shrink_to(max_capacity); + self.active_orders.tif.shrink_to(max_capacity); + self.active_orders.flags.shrink_to(max_capacity); + self.active_orders.parent_id.shrink_to(max_capacity); + self.active_orders.group_id.shrink_to(max_capacity); + self.active_orders.oco_id.shrink_to(max_capacity); + self.active_orders.activation.shrink_to(max_capacity); + self.active_orders.waiting_parent.shrink_to(max_capacity); + break; + } + } + } + + pub fn capacity_signature(&self) -> (usize, usize, usize) { + ( + self.fills.order_id.capacity(), + self.events.kind.capacity(), + self.active_orders.order_id.capacity(), + ) + } +} + +pub enum DetailSink<'a> { + CountOnly(&'a mut StepCounters), + Collect { + counters: &'a mut StepCounters, + buffers: &'a mut StepBuffers, + }, +} + +impl DetailSink<'_> { + #[inline] + pub fn event( + &mut self, + kind: i64, + status: i64, + order_id: i64, + target_id: i64, + symbol: i64, + reject_code: i64, + ) { + match self { + Self::CountOnly(counters) => counters.event_count += 1, + Self::Collect { counters, buffers } => { + counters.event_count += 1; + buffers + .events + .push(kind, status, order_id, target_id, symbol, reject_code); + } + } + } + + #[inline] + pub fn fill(&mut self, order_id: i64, symbol: i64, side: i64, qty: f64, price: f64, fee: f64) { + match self { + Self::CountOnly(counters) => counters.fill_count += 1, + Self::Collect { counters, buffers } => { + counters.fill_count += 1; + buffers.fills.push(order_id, symbol, side, qty, price, fee); + } + } + } +} + +#[allow(dead_code)] +#[derive(Clone)] +pub struct FullMarketData { + pub timestamps_ns: Box<[i64]>, + pub opens: Box<[f64]>, + pub highs: Box<[f64]>, + pub lows: Box<[f64]>, + pub closes: Box<[f64]>, + pub volumes: Box<[f64]>, + pub funding: Box<[f64]>, + pub funding_mask: Box<[bool]>, + pub n_bars: usize, + pub n_symbols: usize, +} + +impl FullMarketData { + #[allow(clippy::too_many_arguments)] + pub fn new( + timestamps_ns: Vec, + opens: Vec, + highs: Vec, + lows: Vec, + closes: Vec, + volumes: Vec, + funding: Vec, + funding_mask: Vec, + n_symbols: usize, + ) -> Result { + if n_symbols == 0 || timestamps_ns.is_empty() { + return Err("full market tape must contain bars and symbols".to_owned()); + } + let n_bars = timestamps_ns.len(); + let width = n_bars + .checked_mul(n_symbols) + .ok_or_else(|| "market dimensions overflow".to_owned())?; + if opens.len() != width + || highs.len() != width + || lows.len() != width + || closes.len() != width + || volumes.len() != width + || funding.len() != width + || funding_mask.len() != n_bars + { + return Err("full market arrays have inconsistent shapes".to_owned()); + } + Ok(Self { + timestamps_ns: timestamps_ns.into_boxed_slice(), + opens: opens.into_boxed_slice(), + highs: highs.into_boxed_slice(), + lows: lows.into_boxed_slice(), + closes: closes.into_boxed_slice(), + volumes: volumes.into_boxed_slice(), + funding: funding.into_boxed_slice(), + funding_mask: funding_mask.into_boxed_slice(), + n_bars, + n_symbols, + }) + } + + #[inline] + fn at(&self, array: &[f64], bar: usize, symbol: usize) -> f64 { + array[bar * self.n_symbols + symbol] + } +} + +#[derive(Clone, Copy)] +struct OrderState { + #[allow(dead_code)] + command_index: usize, + order_id: i64, + symbol: u32, + side: i8, + order_type: u8, + tif: u8, + flags: u16, + qty: f64, + price: f64, + trigger: f64, + parent_id: i64, + group_id: i64, + oco_id: i64, + activation: u8, + expires_bar: i64, + active: bool, + waiting_parent: bool, + status: i64, +} + +impl OrderState { + #[inline] + fn reduce_only(&self) -> bool { + self.flags & FLAG_REDUCE_ONLY != 0 + } +} + +#[derive(Clone, Default)] +pub struct FullStepResult { + pub equity: f64, + pub positions: Vec, + pub fee: f64, + pub turnover: f64, + pub funding: f64, + pub initial_margin: f64, + pub maintenance_margin: f64, + pub liquidated: bool, + pub liquidation_bar: i64, + pub liquidation_reason: i64, + pub fills: Vec>, + pub events: Vec>, + pub active_orders: Vec>, + pub rejected_count: i64, + pub canceled_count: i64, + pub fill_count: i64, + pub event_count: i64, +} + +#[derive(Clone, Copy, Default)] +struct MarginCache { + bar: usize, + initial_margin: f64, + maintenance_margin: f64, + valid: bool, +} + +pub struct FullSession { + /// Immutable market ownership is shared by every reset/session created + /// from one prepared PyO3 market object. Account and order state remain + /// session-local. + pub market: Arc, + pub contract_sizes: Box<[f64]>, + pub leverages: Box<[f64]>, + pub fee_rates: Box<[f64]>, + pub initial_capital: f64, + pub maintenance_ratio: f64, + pub slippage: f64, + pub use_funding: bool, + pub output_mask: u8, + pub positions: Vec, + pub equity: f64, + pub liquidated: bool, + pub liquidation_bar: i64, + pub liquidation_reason: i64, + orders: Vec, + // The Python oracle resolves target_order_id through the latest command + // slot, including the alias created by REPLACE. Keep that indirection + // explicit so a later CANCEL/AMEND using the replaced target has the same + // lifecycle result without changing insertion priority. + id_to_slot: HashMap, + step_buffers: StepBuffers, + margin_cache: MarginCache, + margin_recompute_count: u64, + last_bar: Option, + pub compaction_count: u64, + pub terminal_orders_removed: u64, +} + +impl FullSession { + #[allow(clippy::too_many_arguments)] + pub fn new( + market: Arc, + contract_sizes: Vec, + leverages: Vec, + fee_rates: Vec, + initial_capital: f64, + maintenance_ratio: f64, + slippage: f64, + use_funding: bool, + ) -> Result { + let n_symbols = market.n_symbols; + if contract_sizes.len() != n_symbols + || leverages.len() != n_symbols + || fee_rates.len() != n_symbols + || initial_capital <= 0.0 + || maintenance_ratio < 0.0 + || slippage < 0.0 + || contract_sizes.iter().any(|v| *v <= 0.0) + || leverages.iter().any(|v| *v <= 0.0) + || fee_rates.iter().any(|v| *v < 0.0) + { + return Err("invalid full-contract account or execution parameters".to_owned()); + } + Ok(Self { + market, + contract_sizes: contract_sizes.into_boxed_slice(), + leverages: leverages.into_boxed_slice(), + fee_rates: fee_rates.into_boxed_slice(), + initial_capital, + maintenance_ratio, + slippage, + use_funding, + output_mask: OUTPUT_ALL, + positions: vec![0.0; n_symbols], + equity: initial_capital, + liquidated: false, + liquidation_bar: -1, + liquidation_reason: LIQ_NONE, + orders: Vec::new(), + id_to_slot: HashMap::new(), + step_buffers: StepBuffers::default(), + margin_cache: MarginCache::default(), + margin_recompute_count: 0, + last_bar: None, + compaction_count: 0, + terminal_orders_removed: 0, + }) + } + + pub fn reset(&mut self) { + self.positions.fill(0.0); + self.equity = self.initial_capital; + self.liquidated = false; + self.liquidation_bar = -1; + self.liquidation_reason = LIQ_NONE; + self.orders.clear(); + self.id_to_slot.clear(); + self.step_buffers.clear(); + self.margin_cache = MarginCache::default(); + self.margin_recompute_count = 0; + self.last_bar = None; + self.compaction_count = 0; + self.terminal_orders_removed = 0; + } + + pub fn orders_len(&self) -> usize { + self.orders.len() + } + + pub fn orders_capacity(&self) -> usize { + self.orders.capacity() + } + + pub fn release_step_buffer_capacity(&mut self, max_capacity: usize) { + self.step_buffers.release_excess_capacity(max_capacity); + } + + pub fn step_buffer_capacities(&self) -> (usize, usize, usize) { + self.step_buffers.capacity_signature() + } + + pub fn margin_recompute_count(&self) -> u64 { + self.margin_recompute_count + } + + #[inline] + fn close(&self, bar: usize, symbol: usize) -> f64 { + self.market.at(&self.market.closes, bar, symbol) + } + + fn compute_close_margin(&self, bar: usize) -> (f64, f64) { + let mut initial = 0.0; + let mut maintenance = 0.0; + for symbol in 0..self.market.n_symbols { + let notional = self.positions[symbol].abs() + * self.close(bar, symbol) + * self.contract_sizes[symbol]; + initial += notional / self.leverages[symbol]; + maintenance += notional * self.maintenance_ratio; + } + (initial, maintenance) + } + + /// Return margin at the bar-close valuation without scanning symbols more + /// than once per bar. A fill updates the cached symbol contribution in + /// O(1); liquidation invalidates the cache because all positions reset. + fn close_margin(&mut self, bar: usize) -> (f64, f64) { + if self.margin_cache.valid && self.margin_cache.bar == bar { + return ( + self.margin_cache.initial_margin, + self.margin_cache.maintenance_margin, + ); + } + let (initial_margin, maintenance_margin) = self.compute_close_margin(bar); + self.margin_cache = MarginCache { + bar, + initial_margin, + maintenance_margin, + valid: true, + }; + self.margin_recompute_count += 1; + (initial_margin, maintenance_margin) + } + + fn update_margin_cache_after_fill( + &mut self, + bar: usize, + symbol: usize, + old_position: f64, + new_position: f64, + ) { + if !(self.margin_cache.valid && self.margin_cache.bar == bar) { + let (initial_margin, maintenance_margin) = self.compute_close_margin(bar); + self.margin_cache = MarginCache { + bar, + initial_margin, + maintenance_margin, + valid: true, + }; + self.margin_recompute_count += 1; + return; + } + let close = self.close(bar, symbol); + let contract_size = self.contract_sizes[symbol]; + let leverage = self.leverages[symbol]; + let old_notional = old_position.abs() * close * contract_size; + let new_notional = new_position.abs() * close * contract_size; + self.margin_cache.initial_margin += (new_notional - old_notional) / leverage; + self.margin_cache.maintenance_margin += + (new_notional - old_notional) * self.maintenance_ratio; + } + + fn intrabar_liquidated(&self, bar: usize) -> bool { + let mut worst_equity = self.equity; + let mut worst_maintenance = 0.0; + for symbol in 0..self.market.n_symbols { + let position = self.positions[symbol]; + if position == 0.0 { + continue; + } + let worst_price = if position > 0.0 { + self.market.at(&self.market.lows, bar, symbol) + } else { + self.market.at(&self.market.highs, bar, symbol) + }; + worst_equity += + position * (worst_price - self.close(bar, symbol)) * self.contract_sizes[symbol]; + worst_maintenance += + position.abs() * worst_price * self.contract_sizes[symbol] * self.maintenance_ratio; + } + worst_maintenance > 0.0 && worst_equity <= worst_maintenance + } + + fn liquidate(&mut self, bar: usize, reason: i64) { + self.liquidated = true; + self.liquidation_bar = bar as i64; + self.liquidation_reason = reason; + self.equity = 0.0; + self.positions.fill(0.0); + self.margin_cache.valid = false; + } + + fn find_pending(&self, order_id: i64) -> Option { + let slot = *self.id_to_slot.get(&order_id)?; + let order = self.orders.get(slot)?; + if (order.active || order.waiting_parent) && order.status == STATUS_PENDING { + Some(slot) + } else { + None + } + } + + /// Drop terminal lifecycle records once they dominate the order arena. + /// + /// Active insertion order and every replacement alias are preserved. The + /// conservative threshold keeps short tapes cheap while preventing a + /// long reactive/Grid tape from retaining one heap record per command. + fn compact_terminal_orders(&mut self) { + let old_len = self.orders.len(); + if old_len < 64 { + return; + } + let active_len = self + .orders + .iter() + .filter(|order| { + order.status == STATUS_PENDING && (order.active || order.waiting_parent) + }) + .count(); + let terminal_len = old_len.saturating_sub(active_len); + if terminal_len < 64 || terminal_len * 2 < old_len { + return; + } + + let old_orders = std::mem::take(&mut self.orders); + let old_map = std::mem::take(&mut self.id_to_slot); + let mut remap = vec![usize::MAX; old_len]; + let mut orders = Vec::with_capacity(active_len); + for (old_slot, order) in old_orders.into_iter().enumerate() { + if order.status == STATUS_PENDING && (order.active || order.waiting_parent) { + remap[old_slot] = orders.len(); + orders.push(order); + } + } + let mut id_to_slot = HashMap::with_capacity(old_map.len()); + for (order_id, old_slot) in old_map { + let new_slot = remap.get(old_slot).copied().unwrap_or(usize::MAX); + if new_slot != usize::MAX { + id_to_slot.insert(order_id, new_slot); + } + } + self.orders = orders; + self.id_to_slot = id_to_slot; + self.compaction_count += 1; + self.terminal_orders_removed += terminal_len as u64; + } + + fn valid_order(code: &[i64], values: &[f64]) -> bool { + let side = code[2]; + let order_type = code[3]; + let qty = values[0]; + if InternalSide::try_from(side).is_err() + || InternalOrderType::try_from(order_type).is_err() + || InternalTimeInForce::try_from(code[4]).is_err() + || qty <= 0.0 + { + return false; + } + match order_type { + ORDER_MARKET => true, + ORDER_LIMIT => values[1] > 0.0, + ORDER_STOP_MARKET => values[2] > 0.0, + ORDER_STOP_LIMIT => values[1] > 0.0 && values[2] > 0.0, + _ => false, + } + } + + fn add_event( + sink: &mut DetailSink<'_>, + kind: i64, + status: i64, + order: i64, + target: i64, + symbol: i64, + ) { + sink.event(kind, status, order, target, symbol, 0); + } + + fn add_event_with_reject( + sink: &mut DetailSink<'_>, + kind: i64, + status: i64, + order: i64, + target: i64, + symbol: i64, + reject_code: i64, + ) { + sink.event(kind, status, order, target, symbol, reject_code); + } + + fn fill_price(&self, order: &OrderState, bar: usize) -> Option { + let high = self + .market + .at(&self.market.highs, bar, order.symbol as usize); + let low = self + .market + .at(&self.market.lows, bar, order.symbol as usize); + let close = self.close(bar, order.symbol as usize); + match order.order_type as i64 { + ORDER_MARKET => Some( + close + * if order.side as i64 == SIDE_BUY { + 1.0 + self.slippage + } else { + 1.0 - self.slippage + }, + ), + ORDER_LIMIT if order.side as i64 == SIDE_BUY && low <= order.price => Some(order.price), + ORDER_LIMIT if order.side as i64 == SIDE_SELL && high >= order.price => { + Some(order.price) + } + ORDER_STOP_MARKET if order.side as i64 == SIDE_BUY && high >= order.trigger => { + Some(order.trigger * (1.0 + self.slippage)) + } + ORDER_STOP_MARKET if order.side as i64 == SIDE_SELL && low <= order.trigger => { + Some(order.trigger * (1.0 - self.slippage)) + } + ORDER_STOP_LIMIT + if order.side as i64 == SIDE_BUY && high >= order.trigger && low <= order.price => + { + Some(order.price) + } + ORDER_STOP_LIMIT + if order.side as i64 == SIDE_SELL + && low <= order.trigger + && high >= order.price => + { + Some(order.price) + } + _ => None, + } + } + + fn activate_children(&mut self, parent_id: i64, sink: &mut DetailSink<'_>) { + for child in &mut self.orders { + if child.waiting_parent + && child.parent_id == parent_id + && (child.activation as i64 == ACTIVATION_ON_PARENT_FIRST_FILL + || child.activation as i64 == ACTIVATION_ON_PARENT_FULL_FILL) + { + child.waiting_parent = false; + child.active = true; + Self::add_event( + sink, + EVENT_ACTIVATE, + STATUS_PENDING, + child.order_id, + parent_id, + child.symbol as i64, + ); + } + } + } + + fn cancel_oco_siblings( + &mut self, + oco_id: i64, + filled_order_id: i64, + sink: &mut DetailSink<'_>, + ) -> i64 { + if oco_id < 0 { + return 0; + } + let mut canceled = 0; + for sibling in &mut self.orders { + if sibling.order_id != filled_order_id + && sibling.oco_id == oco_id + && sibling.status == STATUS_PENDING + && (sibling.active || sibling.waiting_parent) + { + sibling.active = false; + sibling.waiting_parent = false; + sibling.status = STATUS_CANCELED; + canceled += 1; + Self::add_event( + sink, + EVENT_CANCEL, + STATUS_CANCELED, + sibling.order_id, + filled_order_id, + sibling.symbol as i64, + ); + } + } + canceled + } + + #[allow(dead_code)] + #[allow(clippy::too_many_arguments)] + pub fn step( + &mut self, + bar: usize, + codes: &[i64], + values: &[f64], + expiry: &[i64], + command_count: usize, + ) -> Result { + self.step_with_output(bar, codes, values, expiry, command_count, true) + } + + /// Execute one bar while optionally suppressing per-step vectors. + /// + /// Score callers still receive scalar counts/accounting, but do not pay + /// for positions/fill/event/active-order vectors that are discarded at + /// the Python boundary. The default `step()` path remains full/audit + /// compatible for reactive callbacks. + #[allow(clippy::too_many_arguments)] + pub fn step_with_output( + &mut self, + bar: usize, + codes: &[i64], + values: &[f64], + expiry: &[i64], + command_count: usize, + include_details: bool, + ) -> Result { + self.step_with_mask( + bar, + codes, + values, + expiry, + command_count, + if include_details { OUTPUT_ALL } else { 0 }, + ) + } + + /// Execute one bar with independent projection requirements. + /// + /// The engine never skips accounting or lifecycle transitions. The mask + /// only avoids allocating vectors which the callback cannot observe. + #[allow(clippy::too_many_arguments)] + pub fn step_with_mask( + &mut self, + bar: usize, + codes: &[i64], + values: &[f64], + expiry: &[i64], + command_count: usize, + output_mask: u8, + ) -> Result { + let mut buffers = std::mem::take(&mut self.step_buffers); + let result = self.step_with_buffers( + bar, + codes, + values, + expiry, + command_count, + output_mask, + true, + &mut buffers, + ); + self.step_buffers = buffers; + result + } + + /// Core lifecycle implementation. `materialize_rows` is true only for + /// the compatibility/reactive dict surface. Static tape execution keeps + /// the reusable SoA buffers and consumes them directly, so it never builds + /// nested per-row vectors in the hot loop. + #[allow(clippy::too_many_arguments)] + pub fn step_with_buffers( + &mut self, + bar: usize, + codes: &[i64], + values: &[f64], + expiry: &[i64], + command_count: usize, + output_mask: u8, + materialize_rows: bool, + buffers: &mut StepBuffers, + ) -> Result { + buffers.clear(); + let mut counters = StepCounters::default(); + let collect_details = output_mask & (OUTPUT_FILLS | OUTPUT_EVENTS) != 0; + let mut sink = if collect_details { + DetailSink::Collect { + counters: &mut counters, + buffers, + } + } else { + DetailSink::CountOnly(&mut counters) + }; + if bar >= self.market.n_bars { + return Err("bar_index is outside the full prepared market tape".to_owned()); + } + if self + .last_bar + .map(|last| bar != last + 1) + .unwrap_or(bar != 0) + { + return Err( + "FullReactiveSessionCore.step must be called once per consecutive bar".to_owned(), + ); + } + if codes.len() != command_count * CODE_WIDTH + || values.len() != command_count * VALUE_WIDTH + || expiry.len() != command_count + { + return Err("full command buffers do not match command count".to_owned()); + } + if self.liquidated { + self.last_bar = Some(bar); + return Ok(FullStepResult { + equity: 0.0, + positions: if output_mask & OUTPUT_POSITIONS != 0 { + vec![0.0; self.market.n_symbols] + } else { + Vec::new() + }, + liquidated: true, + liquidation_bar: self.liquidation_bar, + liquidation_reason: self.liquidation_reason, + ..Default::default() + }); + } + if bar > 0 { + for symbol in 0..self.market.n_symbols { + self.equity += self.positions[symbol] + * (self.close(bar, symbol) - self.close(bar - 1, symbol)) + * self.contract_sizes[symbol]; + } + } + if self.intrabar_liquidated(bar) { + self.liquidate(bar, LIQ_INTRABAR); + self.last_bar = Some(bar); + return Ok(FullStepResult { + equity: 0.0, + positions: if output_mask & OUTPUT_POSITIONS != 0 { + vec![0.0; self.market.n_symbols] + } else { + Vec::new() + }, + liquidated: true, + liquidation_bar: self.liquidation_bar, + liquidation_reason: self.liquidation_reason, + ..Default::default() + }); + } + let mut funding_total = 0.0; + if self.use_funding && self.market.funding_mask[bar] { + for symbol in 0..self.market.n_symbols { + let cost = self.positions[symbol] + * self.close(bar, symbol) + * self.contract_sizes[symbol] + * self.market.at(&self.market.funding, bar, symbol); + self.equity -= cost; + funding_total += cost; + } + } + let (_, close_mm) = self.close_margin(bar); + if close_mm > 0.0 && self.equity <= close_mm { + self.liquidate(bar, LIQ_AFTER_FUNDING); + self.last_bar = Some(bar); + return Ok(FullStepResult { + equity: 0.0, + funding: funding_total, + positions: if output_mask & OUTPUT_POSITIONS != 0 { + vec![0.0; self.market.n_symbols] + } else { + Vec::new() + }, + liquidated: true, + liquidation_bar: self.liquidation_bar, + liquidation_reason: self.liquidation_reason, + ..Default::default() + }); + } + + let mut rejected = 0_i64; + let mut canceled = 0_i64; + + // GTD expiry precedes commands at the current bar. + for order in &mut self.orders { + if order.status == STATUS_PENDING + && (order.active || order.waiting_parent) + && order.expires_bar >= 0 + && bar as i64 >= order.expires_bar + { + order.active = false; + order.waiting_parent = false; + order.status = STATUS_CANCELED; + canceled += 1; + Self::add_event( + &mut sink, + EVENT_EXPIRE, + STATUS_CANCELED, + order.order_id, + -1, + order.symbol as i64, + ); + } + } + + for command_index in 0..command_count { + let code = &codes[command_index * CODE_WIDTH..(command_index + 1) * CODE_WIDTH]; + let value = &values[command_index * VALUE_WIDTH..(command_index + 1) * VALUE_WIDTH]; + let action = code[0]; + let order_id = code[6]; + let target_id = code[7]; + match action { + ACTION_PLACE => { + if !Self::valid_order(code, value) + || code[1] < 0 + || code[1] >= self.market.n_symbols as i64 + { + rejected += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_REJECT, + STATUS_REJECTED, + order_id, + -1, + code[1], + REJECT_UNSUPPORTED_ORDER_TYPE, + ); + continue; + } + let active = code[11] == ACTIVATION_IMMEDIATE; + self.orders.push(OrderState { + command_index: code[12].max(0) as usize, + order_id, + symbol: code[1] as u32, + side: code[2] as i8, + order_type: code[3] as u8, + tif: code[4] as u8, + flags: if code[5] != 0 { FLAG_REDUCE_ONLY } else { 0 }, + qty: value[0], + price: value[1], + trigger: value[2], + parent_id: code[8], + group_id: code[9], + oco_id: code[10], + activation: code[11] as u8, + expires_bar: expiry[command_index], + active, + waiting_parent: !active, + status: STATUS_PENDING, + }); + if order_id >= 0 { + self.id_to_slot.insert(order_id, self.orders.len() - 1); + } + Self::add_event( + &mut sink, + EVENT_PLACE, + STATUS_PENDING, + order_id, + -1, + code[1], + ); + } + ACTION_CANCEL => { + if let Some(slot) = self.find_pending(target_id) { + let symbol = self.orders[slot].symbol as i64; + let resolved_target_id = self.orders[slot].order_id; + self.orders[slot].active = false; + self.orders[slot].waiting_parent = false; + self.orders[slot].status = STATUS_CANCELED; + canceled += 1; + Self::add_event( + &mut sink, + EVENT_CANCEL, + STATUS_FILLED, + -1, + resolved_target_id, + symbol, + ); + } else { + rejected += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_REJECT, + STATUS_REJECTED, + -1, + target_id, + code[1], + REJECT_UNKNOWN_ORDER, + ); + } + } + ACTION_AMEND => { + if let Some(slot) = self.find_pending(target_id) { + let resolved_target_id = self.orders[slot].order_id; + if value[0] > 0.0 { + self.orders[slot].qty = value[0]; + } + if value[1] > 0.0 { + self.orders[slot].price = value[1]; + } + if value[2] > 0.0 { + self.orders[slot].trigger = value[2]; + } + Self::add_event( + &mut sink, + EVENT_AMEND, + STATUS_FILLED, + -1, + resolved_target_id, + self.orders[slot].symbol as i64, + ); + } else { + rejected += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_REJECT, + STATUS_REJECTED, + -1, + target_id, + code[1], + REJECT_UNKNOWN_ORDER, + ); + } + } + ACTION_REPLACE => { + if let Some(slot) = self.find_pending(target_id) { + self.orders[slot].active = false; + self.orders[slot].waiting_parent = false; + self.orders[slot].status = STATUS_CANCELED; + if !Self::valid_order(code, value) + || code[1] < 0 + || code[1] >= self.market.n_symbols as i64 + { + rejected += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_REJECT, + STATUS_REJECTED, + order_id, + target_id, + code[1], + REJECT_UNSUPPORTED_ORDER_TYPE, + ); + } else { + let active = code[11] == ACTIVATION_IMMEDIATE; + self.orders.push(OrderState { + command_index: code[12].max(0) as usize, + order_id, + symbol: code[1] as u32, + side: code[2] as i8, + order_type: code[3] as u8, + tif: code[4] as u8, + flags: if code[5] != 0 { FLAG_REDUCE_ONLY } else { 0 }, + qty: value[0], + price: value[1], + trigger: value[2], + parent_id: code[8], + group_id: code[9], + oco_id: code[10], + activation: code[11] as u8, + expires_bar: expiry[command_index], + active, + waiting_parent: !active, + status: STATUS_PENDING, + }); + let new_slot = self.orders.len() - 1; + if target_id >= 0 { + self.id_to_slot.insert(target_id, new_slot); + } + if order_id >= 0 { + self.id_to_slot.insert(order_id, new_slot); + } + Self::add_event( + &mut sink, + EVENT_REPLACE, + STATUS_PENDING, + order_id, + target_id, + code[1], + ); + } + } else { + rejected += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_REJECT, + STATUS_REJECTED, + order_id, + target_id, + code[1], + REJECT_UNKNOWN_ORDER, + ); + } + } + ACTION_CANCEL_ALL => { + for order in &mut self.orders { + let matches = (order.active || order.waiting_parent) + && order.status == STATUS_PENDING + && (code[1] < 0 || code[1] == order.symbol as i64) + && (code[2] == 0 || code[2] == order.side as i64) + && (code[3] < 0 || code[3] == order.order_type as i64) + && (code[8] < 0 || code[8] == order.parent_id) + && (code[9] < 0 || code[9] == order.group_id) + && (code[10] < 0 || code[10] == order.oco_id); + if matches { + order.active = false; + order.waiting_parent = false; + order.status = STATUS_CANCELED; + canceled += 1; + } + } + Self::add_event( + &mut sink, + EVENT_CANCEL, + STATUS_FILLED, + order_id, + -1, + code[1], + ); + } + _ => { + rejected += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_REJECT, + STATUS_REJECTED, + order_id, + target_id, + code[1], + REJECT_UNSUPPORTED_ACTION, + ); + } + } + } + + let mut fee_total = 0.0; + let mut turnover = 0.0; + // Stable insertion order is the priority order. Children activated by + // an earlier fill are appended before the next scan reaches them. + let mut cursor = 0; + while cursor < self.orders.len() { + if !self.orders[cursor].active || self.orders[cursor].status != STATUS_PENDING { + cursor += 1; + continue; + } + let order = self.orders[cursor]; + let Some(exec_price) = self.fill_price(&order, bar) else { + if order.tif as i64 != TIF_GTC && order.tif as i64 != TIF_GTD { + self.orders[cursor].active = false; + self.orders[cursor].status = STATUS_CANCELED; + canceled += 1; + Self::add_event( + &mut sink, + EVENT_CANCEL, + STATUS_CANCELED, + order.order_id, + -1, + order.symbol as i64, + ); + } + cursor += 1; + continue; + }; + let mut qty = order.qty; + let current = self.positions[order.symbol as usize]; + if order.reduce_only() { + if current == 0.0 + || (current > 0.0 && order.side as i64 == SIDE_BUY) + || (current < 0.0 && order.side as i64 == SIDE_SELL) + { + self.orders[cursor].active = false; + self.orders[cursor].status = STATUS_CANCELED; + canceled += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_CANCEL, + STATUS_CANCELED, + order.order_id, + -1, + order.symbol as i64, + REJECT_REDUCE_ONLY_NO_POSITION, + ); + cursor += 1; + continue; + } + qty = qty.min(current.abs()); + } + let delta = qty * order.side as f64; + let symbol = order.symbol as usize; + let cs = self.contract_sizes[symbol]; + let close = self.close(bar, symbol); + let notional = delta.abs() * exec_price * cs; + let fee = notional * self.fee_rates[symbol]; + let (cur_initial, _) = self.close_margin(bar); + let old_initial = current.abs() * close * cs / self.leverages[symbol]; + let new_initial = (current + delta).abs() * exec_price * cs / self.leverages[symbol]; + let required = fee + (new_initial - old_initial).max(0.0); + if required > self.equity - cur_initial { + self.orders[cursor].active = false; + self.orders[cursor].status = STATUS_REJECTED; + rejected += 1; + Self::add_event_with_reject( + &mut sink, + EVENT_REJECT, + STATUS_REJECTED, + order.order_id, + -1, + order.symbol as i64, + REJECT_INSUFFICIENT_MARGIN, + ); + cursor += 1; + continue; + } + self.equity += delta * (close - exec_price) * cs - fee; + let new_position = current + delta; + self.positions[symbol] = new_position; + self.update_margin_cache_after_fill(bar, symbol, current, new_position); + self.orders[cursor].active = false; + self.orders[cursor].status = STATUS_FILLED; + fee_total += fee; + turnover += notional; + sink.fill( + order.order_id, + order.symbol as i64, + order.side as i64, + qty, + exec_price, + fee, + ); + Self::add_event( + &mut sink, + EVENT_FILL, + STATUS_FILLED, + order.order_id, + -1, + order.symbol as i64, + ); + self.activate_children(order.order_id, &mut sink); + canceled += self.cancel_oco_siblings(order.oco_id, order.order_id, &mut sink); + cursor += 1; + } + + let (initial_margin, maintenance_margin) = self.close_margin(bar); + if maintenance_margin > 0.0 && self.equity <= maintenance_margin { + self.liquidate(bar, LIQ_AFTER_ORDER); + } + self.compact_terminal_orders(); + if output_mask & OUTPUT_ACTIVE_ORDERS != 0 { + for order in self + .orders + .iter() + .filter(|o| o.status == STATUS_PENDING && (o.active || o.waiting_parent)) + { + buffers.active_orders.push(order); + } + } + counters.rejected_count = rejected; + counters.canceled_count = canceled; + let fill_rows = if materialize_rows && output_mask & OUTPUT_FILLS != 0 { + buffers.fills.rows() + } else { + Vec::new() + }; + let event_rows = if materialize_rows && output_mask & OUTPUT_EVENTS != 0 { + buffers.events.rows() + } else { + Vec::new() + }; + let active_rows = if materialize_rows && output_mask & OUTPUT_ACTIVE_ORDERS != 0 { + buffers.active_orders.rows() + } else { + Vec::new() + }; + let fill_count = counters.fill_count; + let event_count = counters.event_count; + self.last_bar = Some(bar); + Ok(FullStepResult { + equity: self.equity, + positions: if output_mask & OUTPUT_POSITIONS != 0 { + self.positions.clone() + } else { + Vec::new() + }, + fee: fee_total, + turnover, + funding: funding_total, + initial_margin: if self.liquidated { 0.0 } else { initial_margin }, + maintenance_margin: if self.liquidated { + 0.0 + } else { + maintenance_margin + }, + liquidated: self.liquidated, + liquidation_bar: self.liquidation_bar, + liquidation_reason: self.liquidation_reason, + fills: fill_rows, + events: event_rows, + active_orders: active_rows, + rejected_count: rejected, + canceled_count: canceled, + fill_count, + event_count, + }) + } +} diff --git a/rust/native_event/src/lib.rs b/rust/native_event/src/lib.rs new file mode 100644 index 0000000..de0e743 --- /dev/null +++ b/rust/native_event/src/lib.rs @@ -0,0 +1,1509 @@ +mod accounting; +mod full; +mod matching; +mod session; +mod types; + +use numpy::{PyArray1, PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyType}; +use std::sync::Arc; + +use full::{FullMarketData, FullSession}; +use session::{PreparedMarketData, ReactiveSession}; + +const VERSION: &str = "0.4.0"; +const API_VERSION: &str = "0.4"; + +#[pyfunction] +fn version() -> &'static str { + VERSION +} + +#[pyfunction] +fn api_version() -> &'static str { + API_VERSION +} + +#[pyfunction] +fn capabilities(py: Python<'_>) -> PyResult> { + let values = PyDict::new(py); + values.set_item("r0_import_smoke", true)?; + values.set_item("reactive_session", true)?; + values.set_item("r1_single_symbol", true)?; + values.set_item("r1_place_cancel_market_limit_gtc", true)?; + values.set_item("r2_stop_amend_replace_reduce_only_constraints", true)?; + values.set_item("prepared_market_core", true)?; + values.set_item("rust_batched_tape", true)?; + values.set_item("rust_batched_tape_score", true)?; + values.set_item("rust_batched_tape_audit", true)?; + values.set_item("rust_batched_tape_sparse", true)?; + values.set_item("native_event_v2_full_contract", true)?; + values.set_item("native_event_v2_multisymbol", true)?; + values.set_item("native_event_v2_funding", true)?; + values.set_item("native_event_v2_liquidation", true)?; + values.set_item("native_event_v2_cancel_all_oco", true)?; + values.set_item("native_event_v2_tif_expiry", true)?; + values.set_item("native_event_v2_relationships", true)?; + values.set_item("native_event_v2_quantity_preflight", true)?; + Ok(values) +} + +#[pyclass] +struct PreparedMarketCore { + inner: Arc, +} + +#[pyclass(frozen)] +struct BatchedScoreResultCore { + #[pyo3(get)] + final_equity: f64, + #[pyo3(get)] + final_position: f64, + #[pyo3(get)] + total_fee: f64, + #[pyo3(get)] + total_turnover: f64, + #[pyo3(get)] + fill_count: i64, + #[pyo3(get)] + event_count: i64, + #[pyo3(get)] + rejected_count: i64, + #[pyo3(get)] + canceled_count: i64, + #[pyo3(get)] + max_initial_margin: f64, + #[pyo3(get)] + max_maintenance_margin: f64, + #[pyo3(get)] + bars: usize, +} + +impl PreparedMarketCore { + #[allow(clippy::too_many_arguments)] + fn from_arrays( + timestamps_ns: PyReadonlyArray1<'_, i64>, + opens: PyReadonlyArray1<'_, f64>, + highs: PyReadonlyArray1<'_, f64>, + lows: PyReadonlyArray1<'_, f64>, + closes: PyReadonlyArray1<'_, f64>, + volumes: PyReadonlyArray1<'_, f64>, + funding: PyReadonlyArray1<'_, f64>, + funding_mask: PyReadonlyArray1<'_, bool>, + ) -> PyResult { + let market = PreparedMarketData::new( + timestamps_ns.as_slice()?.to_vec(), + opens.as_slice()?.to_vec(), + highs.as_slice()?.to_vec(), + lows.as_slice()?.to_vec(), + closes.as_slice()?.to_vec(), + volumes.as_slice()?.to_vec(), + funding.as_slice()?.to_vec(), + funding_mask.as_slice()?.to_vec(), + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self { + inner: Arc::new(market), + }) + } +} + +#[pymethods] +impl PreparedMarketCore { + #[new] + #[allow(clippy::too_many_arguments)] + fn new( + timestamps_ns: PyReadonlyArray1<'_, i64>, + opens: PyReadonlyArray1<'_, f64>, + highs: PyReadonlyArray1<'_, f64>, + lows: PyReadonlyArray1<'_, f64>, + closes: PyReadonlyArray1<'_, f64>, + volumes: PyReadonlyArray1<'_, f64>, + funding: PyReadonlyArray1<'_, f64>, + funding_mask: PyReadonlyArray1<'_, bool>, + ) -> PyResult { + Self::from_arrays( + timestamps_ns, + opens, + highs, + lows, + closes, + volumes, + funding, + funding_mask, + ) + } +} + +#[pyclass] +struct ReactiveSessionCore { + inner: ReactiveSession, +} + +#[pymethods] +impl ReactiveSessionCore { + #[new] + #[allow(clippy::too_many_arguments)] + fn new( + timestamps_ns: PyReadonlyArray1<'_, i64>, + opens: PyReadonlyArray1<'_, f64>, + highs: PyReadonlyArray1<'_, f64>, + lows: PyReadonlyArray1<'_, f64>, + closes: PyReadonlyArray1<'_, f64>, + volumes: PyReadonlyArray1<'_, f64>, + funding: PyReadonlyArray1<'_, f64>, + funding_mask: PyReadonlyArray1<'_, bool>, + contract_size: f64, + leverage: f64, + fee_rate: f64, + initial_capital: f64, + maintenance_ratio: f64, + slippage_rate: f64, + use_funding: bool, + ) -> PyResult { + let prepared = PreparedMarketCore::from_arrays( + timestamps_ns, + opens, + highs, + lows, + closes, + volumes, + funding, + funding_mask, + )?; + let inner = ReactiveSession::new( + prepared.inner, + contract_size, + leverage, + fee_rate, + initial_capital, + maintenance_ratio, + slippage_rate, + use_funding, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self { inner }) + } + + #[classmethod] + #[allow(clippy::too_many_arguments)] + fn from_prepared( + _cls: &Bound<'_, PyType>, + py: Python<'_>, + prepared: Py, + contract_size: f64, + leverage: f64, + fee_rate: f64, + initial_capital: f64, + maintenance_ratio: f64, + slippage_rate: f64, + use_funding: bool, + ) -> PyResult { + let market = prepared.borrow(py).inner.clone(); + let inner = ReactiveSession::new( + market, + contract_size, + leverage, + fee_rate, + initial_capital, + maintenance_ratio, + slippage_rate, + use_funding, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self { inner }) + } + + fn step( + &mut self, + py: Python<'_>, + bar_index: usize, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + ) -> PyResult> { + let codes_shape = command_codes.shape(); + let values_shape = command_values.shape(); + if codes_shape.len() != 2 || codes_shape[1] != types::COMMAND_CODE_WIDTH { + return Err(pyo3::exceptions::PyValueError::new_err( + "command_codes must have shape (n, 8)", + )); + } + if values_shape.len() != 2 + || values_shape[0] != codes_shape[0] + || values_shape[1] != types::COMMAND_VALUE_WIDTH + { + return Err(pyo3::exceptions::PyValueError::new_err( + "command_values must have shape (n, 3)", + )); + } + if command_expiry.len() != codes_shape[0] { + return Err(pyo3::exceptions::PyValueError::new_err( + "command_expiry must have length n", + )); + } + let result = self + .inner + .step( + bar_index, + command_codes.as_slice()?, + command_values.as_slice()?, + command_expiry.as_slice()?, + codes_shape[0], + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let payload = PyDict::new(py); + payload.set_item("equity", result.equity)?; + payload.set_item("position", result.position)?; + payload.set_item("fee", result.fee)?; + payload.set_item("turnover", result.turnover)?; + payload.set_item("initial_margin", result.initial_margin)?; + payload.set_item("maintenance_margin", result.maintenance_margin)?; + payload.set_item("fills", result.fills)?; + payload.set_item("events", result.events)?; + payload.set_item("active_orders", result.active_orders)?; + Ok(payload.unbind()) + } + + fn reset(&mut self) { + self.inner.reset(); + } + + fn run_tape_score( + &mut self, + py: Python<'_>, + command_ptr: PyReadonlyArray1<'_, i64>, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + ) -> PyResult> { + let ptr = command_ptr.as_slice()?; + let codes = command_codes.as_slice()?; + let values = command_values.as_slice()?; + let expiry = command_expiry.as_slice()?; + let _count = validate_tape_arrays( + self.inner.market_len(), + ptr, + codes, + command_codes.shape(), + values, + command_values.shape(), + expiry, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let output = py + .detach(|| run_tape(&mut self.inner, ptr, codes, values, expiry, false)) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Py::new( + py, + BatchedScoreResultCore { + final_equity: output.final_equity, + final_position: output.final_position, + total_fee: output.total_fee, + total_turnover: output.total_turnover, + fill_count: output.fill_count, + event_count: output.event_count, + rejected_count: output.rejected_count, + canceled_count: output.canceled_count, + max_initial_margin: output.max_initial_margin, + max_maintenance_margin: output.max_maintenance_margin, + bars: self.inner.market_len(), + }, + ) + } + + fn run_tape_audit( + &mut self, + py: Python<'_>, + command_ptr: PyReadonlyArray1<'_, i64>, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + ) -> PyResult> { + let ptr = command_ptr.as_slice()?; + let codes = command_codes.as_slice()?; + let values = command_values.as_slice()?; + let expiry = command_expiry.as_slice()?; + let _count = validate_tape_arrays( + self.inner.market_len(), + ptr, + codes, + command_codes.shape(), + values, + command_values.shape(), + expiry, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let output = py + .detach(|| run_tape(&mut self.inner, ptr, codes, values, expiry, true)) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let payload = PyDict::new(py); + payload.set_item("equity", PyArray1::from_vec(py, output.equity))?; + payload.set_item("positions", PyArray1::from_vec(py, output.positions))?; + payload.set_item("fees", PyArray1::from_vec(py, output.fees))?; + payload.set_item("turnover", PyArray1::from_vec(py, output.turnover))?; + payload.set_item( + "initial_margin", + PyArray1::from_vec(py, output.initial_margin), + )?; + payload.set_item( + "maintenance_margin", + PyArray1::from_vec(py, output.maintenance_margin), + )?; + payload.set_item("fill_bar", PyArray1::from_vec(py, output.fill_bar))?; + payload.set_item( + "fill_order_id", + PyArray1::from_vec(py, output.fill_order_id), + )?; + payload.set_item("fill_side", PyArray1::from_vec(py, output.fill_side))?; + payload.set_item("fill_qty", PyArray1::from_vec(py, output.fill_qty))?; + payload.set_item("fill_price", PyArray1::from_vec(py, output.fill_price))?; + payload.set_item("fill_fee", PyArray1::from_vec(py, output.fill_fee))?; + payload.set_item("event_bar", PyArray1::from_vec(py, output.event_bar))?; + payload.set_item("event_kind", PyArray1::from_vec(py, output.event_kind))?; + payload.set_item("event_status", PyArray1::from_vec(py, output.event_status))?; + payload.set_item( + "event_order_id", + PyArray1::from_vec(py, output.event_order_id), + )?; + payload.set_item( + "event_target_id", + PyArray1::from_vec(py, output.event_target_id), + )?; + payload.set_item("total_fee", output.total_fee)?; + payload.set_item("total_turnover", output.total_turnover)?; + payload.set_item("fill_count", output.fill_count)?; + payload.set_item("event_count", output.event_count)?; + payload.set_item("rejected_count", output.rejected_count)?; + payload.set_item("canceled_count", output.canceled_count)?; + payload.set_item("max_initial_margin", output.max_initial_margin)?; + payload.set_item("max_maintenance_margin", output.max_maintenance_margin)?; + Ok(payload.unbind()) + } + + #[allow(clippy::too_many_arguments)] + fn run_until( + &mut self, + py: Python<'_>, + stop_bar: usize, + command_ptr: PyReadonlyArray1<'_, i64>, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + wake_on_fill: bool, + wake_on_order_event: bool, + _wake_on_liquidation: bool, + ) -> PyResult> { + let ptr = command_ptr.as_slice()?; + let codes = command_codes.as_slice()?; + let values = command_values.as_slice()?; + let expiry = command_expiry.as_slice()?; + validate_tape_arrays( + self.inner.market_len(), + ptr, + codes, + command_codes.shape(), + values, + command_values.shape(), + expiry, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + if stop_bar >= self.inner.market_len() { + return Err(pyo3::exceptions::PyValueError::new_err( + "stop_bar is outside the prepared market tape", + )); + } + let start_bar = self.inner.next_bar(); + if start_bar > stop_bar { + return Err(pyo3::exceptions::PyValueError::new_err( + "run_until must advance to a bar after the previous chunk", + )); + } + let output = py + .detach(|| { + run_sparse_range( + &mut self.inner, + start_bar, + stop_bar, + ptr, + codes, + values, + expiry, + wake_on_fill, + wake_on_order_event, + ) + }) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let payload = PyDict::new(py); + payload.set_item("start_bar", output.start_bar)?; + payload.set_item("stop_bar", output.stop_bar)?; + payload.set_item("final_equity", output.final_equity)?; + payload.set_item("final_position", output.final_position)?; + payload.set_item("total_fee", output.total_fee)?; + payload.set_item("total_turnover", output.total_turnover)?; + payload.set_item("fill_count", output.fill_count)?; + payload.set_item("event_count", output.event_count)?; + payload.set_item("rejected_count", output.rejected_count)?; + payload.set_item("canceled_count", output.canceled_count)?; + payload.set_item("max_initial_margin", output.max_initial_margin)?; + payload.set_item("max_maintenance_margin", output.max_maintenance_margin)?; + payload.set_item("liquidation_seen", output.liquidation_seen)?; + payload.set_item("wake_bar", PyArray1::from_vec(py, output.wake_bar))?; + payload.set_item("wake_kind", PyArray1::from_vec(py, output.wake_kind))?; + payload.set_item("fill_bar", PyArray1::from_vec(py, output.fill_bar))?; + payload.set_item( + "fill_order_id", + PyArray1::from_vec(py, output.fill_order_id), + )?; + payload.set_item("fill_side", PyArray1::from_vec(py, output.fill_side))?; + payload.set_item("fill_qty", PyArray1::from_vec(py, output.fill_qty))?; + payload.set_item("fill_price", PyArray1::from_vec(py, output.fill_price))?; + payload.set_item("fill_fee", PyArray1::from_vec(py, output.fill_fee))?; + payload.set_item("event_bar", PyArray1::from_vec(py, output.event_bar))?; + payload.set_item("event_kind", PyArray1::from_vec(py, output.event_kind))?; + payload.set_item("event_status", PyArray1::from_vec(py, output.event_status))?; + payload.set_item( + "event_order_id", + PyArray1::from_vec(py, output.event_order_id), + )?; + payload.set_item( + "event_target_id", + PyArray1::from_vec(py, output.event_target_id), + )?; + Ok(payload.unbind()) + } +} + +fn validate_tape_arrays( + market_len: usize, + command_ptr: &[i64], + codes: &[i64], + codes_shape: &[usize], + values: &[f64], + values_shape: &[usize], + expiry: &[i64], +) -> Result { + if command_ptr.len() != market_len + 1 { + return Err("command_ptr must have length n_bars + 1".to_owned()); + } + if codes_shape.len() != 2 || codes_shape[1] != types::COMMAND_CODE_WIDTH { + return Err("command_codes must have shape (n, 8)".to_owned()); + } + if values_shape.len() != 2 + || values_shape[0] != codes_shape[0] + || values_shape[1] != types::COMMAND_VALUE_WIDTH + { + return Err("command_values must have shape (n, 3)".to_owned()); + } + if expiry.len() != codes_shape[0] { + return Err("command_expiry must have length n".to_owned()); + } + if expiry.iter().any(|value| *value != -1) { + return Err("Rust batched tape does not support expiry".to_owned()); + } + if command_ptr.first().copied().unwrap_or(-1) != 0 { + return Err("command_ptr must start at zero".to_owned()); + } + let command_count = codes_shape[0] as i64; + let mut previous = 0_i64; + for &value in command_ptr { + if value < previous || value > command_count { + return Err("command_ptr must be monotonic and bounded by command count".to_owned()); + } + previous = value; + } + if command_ptr.last().copied().unwrap_or(-1) != command_count { + return Err("command_ptr last value must equal command count".to_owned()); + } + if codes.len() != codes_shape[0] * types::COMMAND_CODE_WIDTH + || values.len() != values_shape[0] * types::COMMAND_VALUE_WIDTH + { + return Err("command buffers are not contiguous with their declared shapes".to_owned()); + } + Ok(codes_shape[0]) +} + +fn run_tape( + session: &mut ReactiveSession, + command_ptr: &[i64], + codes: &[i64], + values: &[f64], + expiry: &[i64], + audit: bool, +) -> Result { + let n_bars = session.market_len(); + let mut equity = if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }; + let mut positions = if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }; + let mut fees = if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }; + let mut turnover = if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }; + let mut initial_margin = if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }; + let mut maintenance_margin = if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }; + let mut fill_bar = Vec::new(); + let mut fill_order_id = Vec::new(); + let mut fill_side = Vec::new(); + let mut fill_qty = Vec::new(); + let mut fill_price = Vec::new(); + let mut fill_fee = Vec::new(); + let mut event_bar = Vec::new(); + let mut event_kind = Vec::new(); + let mut event_status = Vec::new(); + let mut event_order_id = Vec::new(); + let mut event_target_id = Vec::new(); + let mut total_fee = 0.0; + let mut total_turnover = 0.0; + let mut fill_count = 0_i64; + let mut event_count = 0_i64; + let mut rejected_count = 0_i64; + let mut canceled_count = 0_i64; + let mut final_equity = 0.0; + let mut final_position = 0.0; + let mut max_initial_margin: f64 = 0.0; + let mut max_maintenance_margin: f64 = 0.0; + + for bar in 0..n_bars { + let start = command_ptr[bar] as usize; + let end = command_ptr[bar + 1] as usize; + let step = session.step_with_output( + bar, + &codes[start * types::COMMAND_CODE_WIDTH..end * types::COMMAND_CODE_WIDTH], + &values[start * types::COMMAND_VALUE_WIDTH..end * types::COMMAND_VALUE_WIDTH], + &expiry[start..end], + end - start, + audit, + )?; + if audit { + equity.push(step.equity); + positions.push(step.position); + fees.push(step.fee); + turnover.push(step.turnover); + initial_margin.push(step.initial_margin); + maintenance_margin.push(step.maintenance_margin); + } + final_equity = step.equity; + final_position = step.position; + max_initial_margin = max_initial_margin.max(step.initial_margin); + max_maintenance_margin = max_maintenance_margin.max(step.maintenance_margin); + total_fee += step.fee; + total_turnover += step.turnover; + fill_count += step.fill_count; + event_count += step.event_count; + rejected_count += step.rejected_count; + canceled_count += step.canceled_count; + for fill in step.fills { + if audit { + fill_bar.push(bar as i64); + fill_order_id.push(fill[0] as i64); + fill_side.push(fill[1] as i64); + fill_qty.push(fill[2]); + fill_price.push(fill[3]); + fill_fee.push(fill[4]); + } + } + for event in step.events { + if audit { + event_bar.push(bar as i64); + event_kind.push(event[0]); + event_status.push(event[1]); + event_order_id.push(event[2]); + event_target_id.push(event[3]); + } + } + } + Ok(BatchedTapeOutput { + equity, + positions, + fees, + turnover, + initial_margin, + maintenance_margin, + total_fee, + total_turnover, + fill_count, + event_count, + rejected_count, + canceled_count, + fill_bar, + fill_order_id, + fill_side, + fill_qty, + fill_price, + fill_fee, + event_bar, + event_kind, + event_status, + event_order_id, + event_target_id, + final_equity, + final_position, + max_initial_margin, + max_maintenance_margin, + }) +} + +#[allow(clippy::too_many_arguments)] +fn run_sparse_range( + session: &mut ReactiveSession, + start_bar: usize, + stop_bar: usize, + command_ptr: &[i64], + codes: &[i64], + values: &[f64], + expiry: &[i64], + wake_on_fill: bool, + wake_on_order_event: bool, +) -> Result { + let mut output = SparseTapeOutput { + start_bar, + stop_bar, + final_equity: 0.0, + final_position: 0.0, + total_fee: 0.0, + total_turnover: 0.0, + fill_count: 0, + event_count: 0, + rejected_count: 0, + canceled_count: 0, + max_initial_margin: 0.0, + max_maintenance_margin: 0.0, + liquidation_seen: false, + wake_bar: Vec::new(), + wake_kind: Vec::new(), + fill_bar: Vec::new(), + fill_order_id: Vec::new(), + fill_side: Vec::new(), + fill_qty: Vec::new(), + fill_price: Vec::new(), + fill_fee: Vec::new(), + event_bar: Vec::new(), + event_kind: Vec::new(), + event_status: Vec::new(), + event_order_id: Vec::new(), + event_target_id: Vec::new(), + }; + + for bar in start_bar..=stop_bar { + let start = command_ptr[bar] as usize; + let end = command_ptr[bar + 1] as usize; + let materialize = wake_on_fill || wake_on_order_event; + let step = session.step_with_output( + bar, + &codes[start * types::COMMAND_CODE_WIDTH..end * types::COMMAND_CODE_WIDTH], + &values[start * types::COMMAND_VALUE_WIDTH..end * types::COMMAND_VALUE_WIDTH], + &expiry[start..end], + end - start, + materialize, + )?; + output.final_equity = step.equity; + output.final_position = step.position; + output.max_initial_margin = output.max_initial_margin.max(step.initial_margin); + output.max_maintenance_margin = output.max_maintenance_margin.max(step.maintenance_margin); + output.total_fee += step.fee; + output.total_turnover += step.turnover; + output.fill_count += step.fill_count; + output.event_count += step.event_count; + output.rejected_count += step.rejected_count; + output.canceled_count += step.canceled_count; + for fill in step.fills { + if wake_on_fill { + output.wake_bar.push(bar as i64); + output.wake_kind.push(0); + } + output.fill_bar.push(bar as i64); + output.fill_order_id.push(fill[0] as i64); + output.fill_side.push(fill[1] as i64); + output.fill_qty.push(fill[2]); + output.fill_price.push(fill[3]); + output.fill_fee.push(fill[4]); + } + for event in step.events { + if wake_on_order_event { + output.wake_bar.push(bar as i64); + output.wake_kind.push(1); + } + output.event_bar.push(bar as i64); + output.event_kind.push(event[0]); + output.event_status.push(event[1]); + output.event_order_id.push(event[2]); + output.event_target_id.push(event[3]); + } + } + // Kind 2 means end-of-chunk. It is always emitted so a caller can make + // progress without reconstructing a dense per-bar result path. + output.wake_bar.push(stop_bar as i64); + output.wake_kind.push(2); + Ok(output) +} + +struct BatchedTapeOutput { + equity: Vec, + positions: Vec, + fees: Vec, + turnover: Vec, + initial_margin: Vec, + maintenance_margin: Vec, + total_fee: f64, + total_turnover: f64, + fill_count: i64, + event_count: i64, + rejected_count: i64, + canceled_count: i64, + fill_bar: Vec, + fill_order_id: Vec, + fill_side: Vec, + fill_qty: Vec, + fill_price: Vec, + fill_fee: Vec, + event_bar: Vec, + event_kind: Vec, + event_status: Vec, + event_order_id: Vec, + event_target_id: Vec, + final_equity: f64, + final_position: f64, + max_initial_margin: f64, + max_maintenance_margin: f64, +} + +struct SparseTapeOutput { + start_bar: usize, + stop_bar: usize, + final_equity: f64, + final_position: f64, + total_fee: f64, + total_turnover: f64, + fill_count: i64, + event_count: i64, + rejected_count: i64, + canceled_count: i64, + max_initial_margin: f64, + max_maintenance_margin: f64, + liquidation_seen: bool, + wake_bar: Vec, + wake_kind: Vec, + fill_bar: Vec, + fill_order_id: Vec, + fill_side: Vec, + fill_qty: Vec, + fill_price: Vec, + fill_fee: Vec, + event_bar: Vec, + event_kind: Vec, + event_status: Vec, + event_order_id: Vec, + event_target_id: Vec, +} + +#[pyclass(frozen, skip_from_py_object)] +struct FullStepResultCore { + #[pyo3(get)] + equity: f64, + #[pyo3(get)] + fee: f64, + #[pyo3(get)] + turnover: f64, + #[pyo3(get)] + funding: f64, + #[pyo3(get)] + initial_margin: f64, + #[pyo3(get)] + maintenance_margin: f64, + #[pyo3(get)] + fill_count: i64, + #[pyo3(get)] + event_count: i64, + #[pyo3(get)] + rejected_count: i64, + #[pyo3(get)] + canceled_count: i64, + #[pyo3(get)] + liquidated: bool, + #[pyo3(get)] + liquidation_bar: i64, + #[pyo3(get)] + liquidation_reason: i64, + #[pyo3(get)] + positions: Option>, + #[pyo3(get)] + fills: Option>>, + #[pyo3(get)] + events: Option>>, + #[pyo3(get)] + active_orders: Option>>, +} + +impl FullStepResultCore { + fn from_result(result: full::FullStepResult, output_mask: u8) -> Self { + Self { + equity: result.equity, + fee: result.fee, + turnover: result.turnover, + funding: result.funding, + initial_margin: result.initial_margin, + maintenance_margin: result.maintenance_margin, + fill_count: result.fill_count, + event_count: result.event_count, + rejected_count: result.rejected_count, + canceled_count: result.canceled_count, + liquidated: result.liquidated, + liquidation_bar: result.liquidation_bar, + liquidation_reason: result.liquidation_reason, + positions: (output_mask & full::OUTPUT_POSITIONS != 0).then_some(result.positions), + fills: (output_mask & full::OUTPUT_FILLS != 0).then_some(result.fills), + events: (output_mask & full::OUTPUT_EVENTS != 0).then_some(result.events), + active_orders: (output_mask & full::OUTPUT_ACTIVE_ORDERS != 0) + .then_some(result.active_orders), + } + } +} + +#[pyclass] +struct FullPreparedMarketCore { + inner: Arc, +} + +#[pymethods] +impl FullPreparedMarketCore { + #[new] + #[allow(clippy::too_many_arguments)] + fn new( + timestamps_ns: PyReadonlyArray1<'_, i64>, + opens: PyReadonlyArray2<'_, f64>, + highs: PyReadonlyArray2<'_, f64>, + lows: PyReadonlyArray2<'_, f64>, + closes: PyReadonlyArray2<'_, f64>, + volumes: PyReadonlyArray2<'_, f64>, + funding: PyReadonlyArray2<'_, f64>, + funding_mask: PyReadonlyArray1<'_, bool>, + ) -> PyResult { + let shapes = [ + opens.shape(), + highs.shape(), + lows.shape(), + closes.shape(), + volumes.shape(), + funding.shape(), + ]; + if shapes + .iter() + .any(|shape| shape.len() != 2 || *shape != closes.shape()) + { + return Err(pyo3::exceptions::PyValueError::new_err( + "full OHLCV/funding arrays must share shape (n_bars, n_symbols)", + )); + } + let market = FullMarketData::new( + timestamps_ns.as_slice()?.to_vec(), + opens.as_slice()?.to_vec(), + highs.as_slice()?.to_vec(), + lows.as_slice()?.to_vec(), + closes.as_slice()?.to_vec(), + volumes.as_slice()?.to_vec(), + funding.as_slice()?.to_vec(), + funding_mask.as_slice()?.to_vec(), + closes.shape()[1], + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self { + inner: Arc::new(market), + }) + } + + #[getter] + fn bars(&self) -> usize { + self.inner.n_bars + } + + #[getter] + fn symbols(&self) -> usize { + self.inner.n_symbols + } +} + +#[pyclass] +struct FullReactiveSessionCore { + inner: FullSession, +} + +#[pymethods] +impl FullReactiveSessionCore { + #[new] + #[allow(clippy::too_many_arguments)] + fn new( + timestamps_ns: PyReadonlyArray1<'_, i64>, + opens: PyReadonlyArray2<'_, f64>, + highs: PyReadonlyArray2<'_, f64>, + lows: PyReadonlyArray2<'_, f64>, + closes: PyReadonlyArray2<'_, f64>, + volumes: PyReadonlyArray2<'_, f64>, + funding: PyReadonlyArray2<'_, f64>, + funding_mask: PyReadonlyArray1<'_, bool>, + contract_sizes: PyReadonlyArray1<'_, f64>, + leverages: PyReadonlyArray1<'_, f64>, + fee_rates: PyReadonlyArray1<'_, f64>, + initial_capital: f64, + maintenance_ratio: f64, + slippage_rate: f64, + use_funding: bool, + ) -> PyResult { + let prepared = FullPreparedMarketCore::new( + timestamps_ns, + opens, + highs, + lows, + closes, + volumes, + funding, + funding_mask, + )?; + let inner = FullSession::new( + prepared.inner.clone(), + contract_sizes.as_slice()?.to_vec(), + leverages.as_slice()?.to_vec(), + fee_rates.as_slice()?.to_vec(), + initial_capital, + maintenance_ratio, + slippage_rate, + use_funding, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self { inner }) + } + + #[classmethod] + #[allow(clippy::too_many_arguments)] + fn from_prepared( + _cls: &Bound<'_, PyType>, + py: Python<'_>, + prepared: Py, + contract_sizes: PyReadonlyArray1<'_, f64>, + leverages: PyReadonlyArray1<'_, f64>, + fee_rates: PyReadonlyArray1<'_, f64>, + initial_capital: f64, + maintenance_ratio: f64, + slippage_rate: f64, + use_funding: bool, + ) -> PyResult { + let market = prepared.borrow(py).inner.clone(); + let inner = FullSession::new( + market, + contract_sizes.as_slice()?.to_vec(), + leverages.as_slice()?.to_vec(), + fee_rates.as_slice()?.to_vec(), + initial_capital, + maintenance_ratio, + slippage_rate, + use_funding, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self { inner }) + } + + fn step( + &mut self, + py: Python<'_>, + bar_index: usize, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + ) -> PyResult> { + let codes_shape = command_codes.shape(); + let values_shape = command_values.shape(); + if codes_shape.len() != 2 || codes_shape[1] != full::CODE_WIDTH { + return Err(pyo3::exceptions::PyValueError::new_err( + "full command_codes must have shape (n, 16)", + )); + } + if values_shape.len() != 2 + || values_shape[0] != codes_shape[0] + || values_shape[1] != full::VALUE_WIDTH + { + return Err(pyo3::exceptions::PyValueError::new_err( + "full command_values must have shape (n, 3)", + )); + } + if command_expiry.len() != codes_shape[0] { + return Err(pyo3::exceptions::PyValueError::new_err( + "command_expiry must have length n", + )); + } + let result = self + .inner + .step_with_mask( + bar_index, + command_codes.as_slice()?, + command_values.as_slice()?, + command_expiry.as_slice()?, + codes_shape[0], + self.inner.output_mask, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + full_step_payload(py, result) + } + + /// Typed per-bar result for API 0.4 reactive callers. Scalar accounting is + /// always present; projected vectors are `None` unless requested by the + /// session output mask. The legacy dict-returning `step()` remains stable. + fn step_typed( + &mut self, + py: Python<'_>, + bar_index: usize, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + ) -> PyResult> { + let codes_shape = command_codes.shape(); + let values_shape = command_values.shape(); + if codes_shape.len() != 2 || codes_shape[1] != full::CODE_WIDTH { + return Err(pyo3::exceptions::PyValueError::new_err( + "full command_codes must have shape (n, 16)", + )); + } + if values_shape.len() != 2 + || values_shape[0] != codes_shape[0] + || values_shape[1] != full::VALUE_WIDTH + { + return Err(pyo3::exceptions::PyValueError::new_err( + "full command_values must have shape (n, 3)", + )); + } + if command_expiry.len() != codes_shape[0] { + return Err(pyo3::exceptions::PyValueError::new_err( + "command_expiry must have length n", + )); + } + let mask = self.inner.output_mask; + let result = self + .inner + .step_with_mask( + bar_index, + command_codes.as_slice()?, + command_values.as_slice()?, + command_expiry.as_slice()?, + codes_shape[0], + mask, + ) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Py::new(py, FullStepResultCore::from_result(result, mask)) + } + + /// Set reactive projection requirements without changing the stable + /// constructor ABI. Unknown bits are rejected instead of silently + /// falling back to a wider allocation profile. + fn set_output_mask(&mut self, output_mask: u8) -> PyResult<()> { + if output_mask & !full::OUTPUT_ALL != 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "full output mask contains unsupported bits", + )); + } + self.inner.output_mask = output_mask; + Ok(()) + } + + fn reset(&mut self) { + self.inner.reset(); + } + + fn order_arena_counters(&self) -> (usize, usize, u64, u64) { + ( + self.inner.orders_len(), + self.inner.orders_capacity(), + self.inner.compaction_count, + self.inner.terminal_orders_removed, + ) + } + + fn release_step_buffer_capacity(&mut self, max_capacity: usize) { + self.inner.release_step_buffer_capacity(max_capacity); + } + + fn step_buffer_capacities(&self) -> (usize, usize, usize) { + self.inner.step_buffer_capacities() + } + + fn margin_recompute_count(&self) -> u64 { + self.inner.margin_recompute_count() + } + + fn run_tape_score( + &mut self, + py: Python<'_>, + command_ptr: PyReadonlyArray1<'_, i64>, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + ) -> PyResult> { + let ptr = command_ptr.as_slice()?; + let codes = command_codes.as_slice()?; + let code_shape = command_codes.shape(); + let values = command_values.as_slice()?; + let value_shape = command_values.shape(); + let expiry = command_expiry.as_slice()?; + let output = py + .detach(|| { + run_full_tape( + &mut self.inner, + ptr, + codes, + code_shape, + values, + value_shape, + expiry, + false, + ) + }) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let payload = PyDict::new(py); + payload.set_item("final_equity", output.final_equity)?; + payload.set_item("final_positions", output.final_positions)?; + payload.set_item("total_fee", output.total_fee)?; + payload.set_item("total_turnover", output.total_turnover)?; + payload.set_item("total_funding", output.total_funding)?; + payload.set_item("fill_count", output.fill_count)?; + payload.set_item("event_count", output.event_count)?; + payload.set_item("rejected_count", output.rejected_count)?; + payload.set_item("canceled_count", output.canceled_count)?; + payload.set_item("max_initial_margin", output.max_initial_margin)?; + payload.set_item("max_maintenance_margin", output.max_maintenance_margin)?; + payload.set_item("liquidated", output.liquidated)?; + payload.set_item("liquidation_bar", output.liquidation_bar)?; + payload.set_item("liquidation_reason", output.liquidation_reason)?; + payload.set_item("bars", self.inner.market.n_bars)?; + Ok(payload.unbind()) + } + + fn run_tape_audit( + &mut self, + py: Python<'_>, + command_ptr: PyReadonlyArray1<'_, i64>, + command_codes: PyReadonlyArray2<'_, i64>, + command_values: PyReadonlyArray2<'_, f64>, + command_expiry: PyReadonlyArray1<'_, i64>, + ) -> PyResult> { + let ptr = command_ptr.as_slice()?; + let codes = command_codes.as_slice()?; + let code_shape = command_codes.shape(); + let values = command_values.as_slice()?; + let value_shape = command_values.shape(); + let expiry = command_expiry.as_slice()?; + let output = py + .detach(|| { + run_full_tape( + &mut self.inner, + ptr, + codes, + code_shape, + values, + value_shape, + expiry, + true, + ) + }) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + let payload = PyDict::new(py); + payload.set_item("equity", output.equity)?; + payload.set_item("positions", output.positions)?; + payload.set_item("fees", output.fees)?; + payload.set_item("turnover", output.turnover)?; + payload.set_item("funding", output.funding)?; + payload.set_item("initial_margin", output.initial_margin)?; + payload.set_item("maintenance_margin", output.maintenance_margin)?; + payload.set_item("fill_bar", output.fill_bar)?; + payload.set_item("fill_order_id", output.fill_order_id)?; + payload.set_item("fill_symbol", output.fill_symbol)?; + payload.set_item("fill_side", output.fill_side)?; + payload.set_item("fill_qty", output.fill_qty)?; + payload.set_item("fill_price", output.fill_price)?; + payload.set_item("fill_fee", output.fill_fee)?; + payload.set_item("event_bar", output.event_bar)?; + payload.set_item("event_kind", output.event_kind)?; + payload.set_item("event_status", output.event_status)?; + payload.set_item("event_order_id", output.event_order_id)?; + payload.set_item("event_target_id", output.event_target_id)?; + payload.set_item("event_symbol", output.event_symbol)?; + payload.set_item("event_reject_code", output.event_reject_code)?; + payload.set_item("total_fee", output.total_fee)?; + payload.set_item("total_turnover", output.total_turnover)?; + payload.set_item("total_funding", output.total_funding)?; + payload.set_item("fill_count", output.fill_count)?; + payload.set_item("event_count", output.event_count)?; + payload.set_item("rejected_count", output.rejected_count)?; + payload.set_item("canceled_count", output.canceled_count)?; + payload.set_item("max_initial_margin", output.max_initial_margin)?; + payload.set_item("max_maintenance_margin", output.max_maintenance_margin)?; + payload.set_item("liquidated", output.liquidated)?; + payload.set_item("liquidation_bar", output.liquidation_bar)?; + payload.set_item("liquidation_reason", output.liquidation_reason)?; + payload.set_item("bars", self.inner.market.n_bars)?; + Ok(payload.unbind()) + } +} + +fn full_step_payload(py: Python<'_>, result: full::FullStepResult) -> PyResult> { + let payload = PyDict::new(py); + payload.set_item("equity", result.equity)?; + payload.set_item("positions", result.positions)?; + payload.set_item("fee", result.fee)?; + payload.set_item("turnover", result.turnover)?; + payload.set_item("funding", result.funding)?; + payload.set_item("initial_margin", result.initial_margin)?; + payload.set_item("maintenance_margin", result.maintenance_margin)?; + payload.set_item("liquidated", result.liquidated)?; + payload.set_item("liquidation_bar", result.liquidation_bar)?; + payload.set_item("liquidation_reason", result.liquidation_reason)?; + payload.set_item("fills", result.fills)?; + payload.set_item("events", result.events)?; + payload.set_item("active_orders", result.active_orders)?; + payload.set_item("rejected_count", result.rejected_count)?; + payload.set_item("canceled_count", result.canceled_count)?; + payload.set_item("fill_count", result.fill_count)?; + payload.set_item("event_count", result.event_count)?; + Ok(payload.unbind()) +} + +struct FullTapeOutput { + equity: Vec, + positions: Vec>, + fees: Vec, + turnover: Vec, + funding: Vec, + initial_margin: Vec, + maintenance_margin: Vec, + fill_bar: Vec, + fill_order_id: Vec, + fill_symbol: Vec, + fill_side: Vec, + fill_qty: Vec, + fill_price: Vec, + fill_fee: Vec, + event_bar: Vec, + event_kind: Vec, + event_status: Vec, + event_order_id: Vec, + event_target_id: Vec, + event_symbol: Vec, + event_reject_code: Vec, + final_equity: f64, + final_positions: Vec, + total_fee: f64, + total_turnover: f64, + total_funding: f64, + fill_count: i64, + event_count: i64, + rejected_count: i64, + canceled_count: i64, + max_initial_margin: f64, + max_maintenance_margin: f64, + liquidated: bool, + liquidation_bar: i64, + liquidation_reason: i64, +} + +#[allow(clippy::too_many_arguments)] +fn run_full_tape( + session: &mut FullSession, + ptr: &[i64], + codes: &[i64], + codes_shape: &[usize], + values: &[f64], + values_shape: &[usize], + expiry: &[i64], + audit: bool, +) -> Result { + if ptr.len() != session.market.n_bars + 1 + || codes_shape.len() != 2 + || codes_shape[1] != full::CODE_WIDTH + || values_shape.len() != 2 + || values_shape[0] != codes_shape[0] + || values_shape[1] != full::VALUE_WIDTH + || expiry.len() != codes_shape[0] + { + return Err("invalid full tape shapes".to_owned()); + } + let n_commands = codes_shape[0] as i64; + if ptr.first().copied().unwrap_or(-1) != 0 + || ptr.last().copied().unwrap_or(-1) != n_commands + || ptr + .windows(2) + .any(|pair| pair[1] < pair[0] || pair[1] > n_commands) + { + return Err("command_ptr must be monotonic and bounded".to_owned()); + } + if codes.len() != codes_shape[0] * full::CODE_WIDTH + || values.len() != values_shape[0] * full::VALUE_WIDTH + { + return Err("full command buffers are not contiguous".to_owned()); + } + let n_bars = session.market.n_bars; + let mut output = FullTapeOutput { + equity: if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }, + positions: if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }, + fees: if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }, + turnover: if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }, + funding: if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }, + initial_margin: if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }, + maintenance_margin: if audit { + Vec::with_capacity(n_bars) + } else { + Vec::new() + }, + fill_bar: Vec::new(), + fill_order_id: Vec::new(), + fill_symbol: Vec::new(), + fill_side: Vec::new(), + fill_qty: Vec::new(), + fill_price: Vec::new(), + fill_fee: Vec::new(), + event_bar: Vec::new(), + event_kind: Vec::new(), + event_status: Vec::new(), + event_order_id: Vec::new(), + event_target_id: Vec::new(), + event_symbol: Vec::new(), + event_reject_code: Vec::new(), + final_equity: session.equity, + final_positions: session.positions.clone(), + total_fee: 0.0, + total_turnover: 0.0, + total_funding: 0.0, + fill_count: 0, + event_count: 0, + rejected_count: 0, + canceled_count: 0, + max_initial_margin: 0.0, + max_maintenance_margin: 0.0, + liquidated: false, + liquidation_bar: -1, + liquidation_reason: full::LIQ_NONE, + }; + let mut step_buffers = full::StepBuffers::default(); + for bar in 0..n_bars { + let start = ptr[bar] as usize; + let end = ptr[bar + 1] as usize; + let step = session.step_with_buffers( + bar, + &codes[start * full::CODE_WIDTH..end * full::CODE_WIDTH], + &values[start * full::VALUE_WIDTH..end * full::VALUE_WIDTH], + &expiry[start..end], + end - start, + if audit { + full::OUTPUT_POSITIONS | full::OUTPUT_FILLS | full::OUTPUT_EVENTS + } else { + 0 + }, + false, + &mut step_buffers, + )?; + if audit { + output.equity.push(step.equity); + output.positions.push(step.positions.clone()); + output.fees.push(step.fee); + output.turnover.push(step.turnover); + output.funding.push(step.funding); + output.initial_margin.push(step.initial_margin); + output.maintenance_margin.push(step.maintenance_margin); + } + output.final_equity = step.equity; + output.final_positions = session.positions.clone(); + output.total_fee += step.fee; + output.total_turnover += step.turnover; + output.total_funding += step.funding; + output.rejected_count += step.rejected_count; + output.canceled_count += step.canceled_count; + output.fill_count += step.fill_count; + output.event_count += step.event_count; + if audit { + for n in 0..step_buffers.fills.order_id.len() { + output.fill_bar.push(bar as i64); + output.fill_order_id.push(step_buffers.fills.order_id[n]); + output.fill_symbol.push(step_buffers.fills.symbol[n]); + output.fill_side.push(step_buffers.fills.side[n]); + output.fill_qty.push(step_buffers.fills.qty[n]); + output.fill_price.push(step_buffers.fills.price[n]); + output.fill_fee.push(step_buffers.fills.fee[n]); + } + for n in 0..step_buffers.events.kind.len() { + output.event_bar.push(bar as i64); + output.event_kind.push(step_buffers.events.kind[n]); + output.event_status.push(step_buffers.events.status[n]); + output.event_order_id.push(step_buffers.events.order_id[n]); + output + .event_target_id + .push(step_buffers.events.target_id[n]); + output.event_symbol.push(step_buffers.events.symbol[n]); + output + .event_reject_code + .push(step_buffers.events.reject_code[n]); + } + } + output.max_initial_margin = output.max_initial_margin.max(step.initial_margin); + output.max_maintenance_margin = output.max_maintenance_margin.max(step.maintenance_margin); + output.liquidated = step.liquidated; + output.liquidation_bar = step.liquidation_bar; + output.liquidation_reason = step.liquidation_reason; + } + Ok(output) +} + +#[pymodule] +fn _quantbt_native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add("__version__", VERSION)?; + module.add_function(wrap_pyfunction!(version, module)?)?; + module.add_function(wrap_pyfunction!(api_version, module)?)?; + module.add_function(wrap_pyfunction!(capabilities, module)?)?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/rust/native_event/src/matching.rs b/rust/native_event/src/matching.rs new file mode 100644 index 0000000..3d94700 --- /dev/null +++ b/rust/native_event/src/matching.rs @@ -0,0 +1,41 @@ +use crate::types::{ + ActiveOrder, ORDER_LIMIT, ORDER_MARKET, ORDER_STOP_LIMIT, ORDER_STOP_MARKET, SIDE_BUY, +}; + +pub fn execution_price( + order: &ActiveOrder, + high: f64, + low: f64, + close: f64, + slippage: f64, +) -> Option { + match order.order_type { + ORDER_MARKET => { + let multiplier = if order.side == SIDE_BUY { + 1.0 + slippage + } else { + 1.0 - slippage + }; + Some(close * multiplier) + } + ORDER_LIMIT if order.side == SIDE_BUY && low <= order.price => Some(order.price), + ORDER_LIMIT if order.side != SIDE_BUY && high >= order.price => Some(order.price), + ORDER_STOP_MARKET if order.side == SIDE_BUY && high >= order.trigger => { + Some(order.trigger * (1.0 + slippage)) + } + ORDER_STOP_MARKET if order.side != SIDE_BUY && low <= order.trigger => { + Some(order.trigger * (1.0 - slippage)) + } + ORDER_STOP_LIMIT + if order.side == SIDE_BUY && high >= order.trigger && low <= order.price => + { + Some(order.price) + } + ORDER_STOP_LIMIT + if order.side != SIDE_BUY && low <= order.trigger && high >= order.price => + { + Some(order.price) + } + _ => None, + } +} diff --git a/rust/native_event/src/session.rs b/rust/native_event/src/session.rs new file mode 100644 index 0000000..4aa4c23 --- /dev/null +++ b/rust/native_event/src/session.rs @@ -0,0 +1,616 @@ +use std::collections::HashMap; +use std::hash::{BuildHasherDefault, Hasher}; +use std::sync::Arc; + +use crate::accounting::{initial_margin, maintenance_margin, required_margin}; +use crate::matching::execution_price; +use crate::types::{ + ACTION_AMEND, ACTION_CANCEL, ACTION_PLACE, ACTION_REPLACE, ActiveOrder, EVENT_AMEND, + EVENT_CANCEL, EVENT_FILL, EVENT_PLACE, EVENT_REJECT, EVENT_REPLACE, FLAG_REDUCE_ONLY, + MUTATE_PRICE, MUTATE_QTY, MUTATE_TRIGGER, ORDER_LIMIT, ORDER_MARKET, ORDER_STOP_LIMIT, + ORDER_STOP_MARKET, SIDE_BUY, SIDE_SELL, STATUS_CANCELED, STATUS_FILLED, STATUS_PENDING, + STATUS_REJECTED, StepResult, +}; + +pub struct PreparedMarketData { + pub _timestamps_ns: Box<[i64]>, + pub _opens: Box<[f64]>, + pub highs: Box<[f64]>, + pub lows: Box<[f64]>, + pub closes: Box<[f64]>, + pub _volumes: Box<[f64]>, + pub _funding: Box<[f64]>, + pub _funding_mask: Box<[bool]>, +} + +impl PreparedMarketData { + #[allow(clippy::too_many_arguments)] + pub fn new( + timestamps_ns: Vec, + opens: Vec, + highs: Vec, + lows: Vec, + closes: Vec, + volumes: Vec, + funding: Vec, + funding_mask: Vec, + ) -> Result { + let n = closes.len(); + if n == 0 + || timestamps_ns.len() != n + || opens.len() != n + || highs.len() != n + || lows.len() != n + || volumes.len() != n + || funding.len() != n + || funding_mask.len() != n + { + return Err("all market arrays must be non-empty and share one length".to_owned()); + } + Ok(Self { + _timestamps_ns: timestamps_ns.into_boxed_slice(), + _opens: opens.into_boxed_slice(), + highs: highs.into_boxed_slice(), + lows: lows.into_boxed_slice(), + closes: closes.into_boxed_slice(), + _volumes: volumes.into_boxed_slice(), + _funding: funding.into_boxed_slice(), + _funding_mask: funding_mask.into_boxed_slice(), + }) + } + + pub fn len(&self) -> usize { + self.closes.len() + } +} + +#[derive(Default)] +struct I64IdentityHasher(u64); + +impl Hasher for I64IdentityHasher { + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, bytes: &[u8]) { + let mut value = [0_u8; 8]; + let width = bytes.len().min(value.len()); + value[..width].copy_from_slice(&bytes[..width]); + self.0 = u64::from_ne_bytes(value); + } + + fn write_i64(&mut self, value: i64) { + self.0 = value as u64; + } +} + +type OrderIdMap = HashMap>; +const ORDER_INDEX_THRESHOLD: usize = 8; + +struct OrderSlot { + active: bool, + order: ActiveOrder, +} + +struct OrderTable { + slots: Vec, + id_to_slot: OrderIdMap, + active_sequence: Vec, + free_slots: Vec, + tombstones: usize, + active_count: usize, +} + +impl OrderTable { + fn new() -> Self { + Self { + slots: Vec::new(), + id_to_slot: OrderIdMap::default(), + active_sequence: Vec::new(), + free_slots: Vec::new(), + tombstones: 0, + active_count: 0, + } + } + + fn insert(&mut self, order: ActiveOrder) { + // A slot cannot be reused while its old sequence entry is still a + // tombstone: replacement in the same bar must not appear twice in + // priority order. Slots become reusable after compaction clears all + // tombstones. + let slot = if self.tombstones == 0 { + self.free_slots.pop().unwrap_or_else(|| { + let slot = self.slots.len(); + self.slots.push(OrderSlot { + active: false, + order, + }); + slot + }) + } else { + let slot = self.slots.len(); + self.slots.push(OrderSlot { + active: false, + order, + }); + slot + }; + self.slots[slot] = OrderSlot { + active: true, + order, + }; + if self.active_count == ORDER_INDEX_THRESHOLD { + self.rebuild_index(); + } + self.active_sequence.push(slot); + self.active_count += 1; + // Very small books use the priority sequence directly. This avoids a + // hash allocation for the common one-order market/reduce-only path; + // larger books keep O(1) ID lookup. + if self.active_count > ORDER_INDEX_THRESHOLD { + self.id_to_slot.entry(order.order_id).or_insert(slot); + } + } + + fn rebuild_index(&mut self) { + self.id_to_slot.clear(); + for slot in self.active_sequence.iter().copied() { + if let Some(order) = self.get_slot(slot) { + self.id_to_slot.entry(order.order_id).or_insert(slot); + } + } + } + + fn lookup_slot(&self, order_id: i64) -> Option { + if self.active_count <= ORDER_INDEX_THRESHOLD { + return self.active_sequence.iter().copied().find(|slot| { + self.get_slot(*slot) + .is_some_and(|order| order.order_id == order_id) + }); + } + self.id_to_slot.get(&order_id).copied() + } + + fn get_mut(&mut self, order_id: i64) -> Option<&mut ActiveOrder> { + let slot = self.lookup_slot(order_id)?; + self.slots.get_mut(slot).and_then(|slot| { + if slot.active { + Some(&mut slot.order) + } else { + None + } + }) + } + + fn get_slot(&self, slot: usize) -> Option<&ActiveOrder> { + self.slots + .get(slot) + .and_then(|slot| if slot.active { Some(&slot.order) } else { None }) + } + + fn remove_by_id(&mut self, order_id: i64) -> Option { + let slot = self.lookup_slot(order_id)?; + self.remove_slot(slot) + } + + fn remove_slot(&mut self, slot: usize) -> Option { + let slot_state = self.slots.get_mut(slot)?; + if !slot_state.active { + return None; + } + slot_state.active = false; + self.tombstones += 1; + let order = slot_state.order; + if self.id_to_slot.get(&order.order_id).copied() == Some(slot) { + self.id_to_slot.remove(&order.order_id); + } + self.free_slots.push(slot); + self.active_count = self.active_count.saturating_sub(1); + Some(order) + } + + fn compact_if_needed(&mut self) { + // Compact a small live book earlier: market/reduce-only orders often + // fill in the same bar, so scanning dozens of dead sequence entries + // is slower than a bounded retain. Large live books still use the + // original 25% tombstone ratio to avoid disturbing priority order too + // often. + if self.tombstones < 8 && self.tombstones.saturating_mul(4) < self.active_sequence.len() { + return; + } + self.active_sequence.retain(|slot| { + self.slots + .get(*slot) + .map(|slot_state| slot_state.active) + .unwrap_or(false) + }); + self.tombstones = 0; + } + + fn reset(&mut self) { + for slot in &mut self.slots { + slot.active = false; + } + self.id_to_slot.clear(); + self.active_sequence.clear(); + self.free_slots.clear(); + self.free_slots.extend(0..self.slots.len()); + self.tombstones = 0; + self.active_count = 0; + } + + fn snapshot(&self) -> Vec> { + self.active_sequence + .iter() + .filter_map(|slot| self.get_slot(*slot)) + .map(|order| { + vec![ + order.order_id as f64, + order.side as f64, + order.order_type as f64, + order.qty, + order.price, + order.trigger, + if order.reduce_only { + FLAG_REDUCE_ONLY as f64 + } else { + 0.0 + }, + ] + }) + .collect() + } +} + +pub struct ReactiveSession { + market: Arc, + contract_size: f64, + leverage: f64, + fee_rate: f64, + maintenance_ratio: f64, + slippage_rate: f64, + _use_funding: bool, + initial_capital: f64, + position: f64, + equity: f64, + active_orders: OrderTable, + order_alias: HashMap, + last_bar: Option, +} + +impl ReactiveSession { + pub fn market_len(&self) -> usize { + self.market.len() + } + + pub fn next_bar(&self) -> usize { + self.last_bar.map(|bar| bar + 1).unwrap_or(0) + } + + #[allow(clippy::too_many_arguments)] + pub fn new( + market: Arc, + contract_size: f64, + leverage: f64, + fee_rate: f64, + initial_capital: f64, + maintenance_ratio: f64, + slippage_rate: f64, + use_funding: bool, + ) -> Result { + if contract_size <= 0.0 + || leverage <= 0.0 + || fee_rate < 0.0 + || initial_capital <= 0.0 + || maintenance_ratio < 0.0 + || slippage_rate < 0.0 + { + return Err("invalid R1 account or execution parameter".to_owned()); + } + if use_funding { + return Err("Rust R1 does not support funding".to_owned()); + } + Ok(Self { + market, + contract_size, + leverage, + fee_rate, + maintenance_ratio, + slippage_rate, + _use_funding: use_funding, + initial_capital, + position: 0.0, + equity: initial_capital, + active_orders: OrderTable::new(), + order_alias: HashMap::new(), + last_bar: None, + }) + } + + pub fn reset(&mut self) { + self.active_orders.reset(); + self.order_alias.clear(); + self.position = 0.0; + self.equity = self.initial_capital; + self.last_bar = None; + } + + pub fn step( + &mut self, + bar: usize, + codes: &[i64], + values: &[f64], + expiry: &[i64], + command_count: usize, + ) -> Result { + self.step_with_output(bar, codes, values, expiry, command_count, true) + } + + pub fn step_with_output( + &mut self, + bar: usize, + codes: &[i64], + values: &[f64], + _expiry: &[i64], + command_count: usize, + materialize: bool, + ) -> Result { + if bar >= self.market.closes.len() { + return Err("bar_index is outside the prepared market tape".to_owned()); + } + if self + .last_bar + .map(|last| bar != last + 1) + .unwrap_or(bar != 0) + { + return Err( + "ReactiveSessionCore.step must be called exactly once per consecutive bar" + .to_owned(), + ); + } + if codes.len() != command_count * 8 || values.len() != command_count * 3 { + return Err("command batch buffer shape does not match command count".to_owned()); + } + if bar > 0 { + self.equity += self.position + * (self.market.closes[bar] - self.market.closes[bar - 1]) + * self.contract_size; + } + let mut fee_total = 0.0; + let mut turnover = 0.0; + let mut events = Vec::new(); + let mut event_count = 0_i64; + let mut rejected_count = 0_i64; + let mut canceled_count = 0_i64; + let mut record_event = |kind: i64, status: i64, order_id: i64, target_id: i64| { + event_count += 1; + if kind == EVENT_REJECT { + rejected_count += 1; + } + if kind == EVENT_CANCEL { + canceled_count += 1; + } + if materialize { + events.push(vec![kind, status, order_id, target_id]); + } + }; + for index in 0..command_count { + let code = &codes[index * 8..(index + 1) * 8]; + let value = &values[index * 3..(index + 1) * 3]; + match code[0] { + ACTION_PLACE => { + let side = code[1]; + let order_type = code[2]; + if !valid_order(side, order_type, value[0], value[1], value[2]) { + record_event(EVENT_REJECT, STATUS_REJECTED, code[4], -1); + continue; + } + self.active_orders.insert(ActiveOrder { + order_id: code[4], + side, + order_type, + qty: value[0], + price: value[1], + trigger: value[2], + reduce_only: (code[3] & FLAG_REDUCE_ONLY) != 0, + }); + record_event(EVENT_PLACE, STATUS_PENDING, code[4], -1); + } + ACTION_CANCEL => { + let target = self.resolve_order_id(code[5]); + if self.active_orders.remove_by_id(target).is_some() { + record_event(EVENT_CANCEL, STATUS_FILLED, -1, code[5]); + } else { + record_event(EVENT_REJECT, STATUS_REJECTED, -1, code[5]); + } + } + ACTION_AMEND => { + let target = self.resolve_order_id(code[5]); + if let Some(order) = self.active_orders.get_mut(target) { + let mask = code[6]; + if (mask & MUTATE_QTY) != 0 && value[0] > 0.0 { + order.qty = value[0]; + } + if (mask & MUTATE_PRICE) != 0 && value[1] > 0.0 { + order.price = value[1]; + } + if (mask & MUTATE_TRIGGER) != 0 && value[2] > 0.0 { + order.trigger = value[2]; + } + record_event(EVENT_AMEND, STATUS_FILLED, -1, code[5]); + } else { + record_event(EVENT_REJECT, STATUS_REJECTED, -1, code[5]); + } + } + ACTION_REPLACE => { + let target = self.resolve_order_id(code[5]); + if self.active_orders.remove_by_id(target).is_some() { + record_event(EVENT_REPLACE, STATUS_CANCELED, code[4], code[5]); + let side = code[1]; + let order_type = code[2]; + if !valid_order(side, order_type, value[0], value[1], value[2]) { + record_event(EVENT_REJECT, STATUS_REJECTED, code[4], code[5]); + continue; + } + self.active_orders.insert(ActiveOrder { + order_id: code[4], + side, + order_type, + qty: value[0], + price: value[1], + trigger: value[2], + reduce_only: (code[3] & FLAG_REDUCE_ONLY) != 0, + }); + self.order_alias.insert(code[5], code[4]); + record_event(EVENT_REPLACE, STATUS_PENDING, code[4], code[5]); + } else { + record_event(EVENT_REJECT, STATUS_REJECTED, code[4], code[5]); + } + } + _ => record_event(EVENT_REJECT, STATUS_REJECTED, code[4], code[5]), + } + } + + let mut fills = Vec::new(); + let mut fill_count = 0_i64; + let active_sequence_len = self.active_orders.active_sequence.len(); + for sequence_index in 0..active_sequence_len { + let slot = self.active_orders.active_sequence[sequence_index]; + let Some(order) = self.active_orders.get_slot(slot).copied() else { + continue; + }; + let Some(price) = execution_price( + &order, + self.market.highs[bar], + self.market.lows[bar], + self.market.closes[bar], + self.slippage_rate, + ) else { + continue; + }; + let mut qty = order.qty; + if order.reduce_only { + if self.position == 0.0 + || (self.position > 0.0 && order.side == SIDE_BUY) + || (self.position < 0.0 && order.side == SIDE_SELL) + { + self.active_orders.remove_slot(slot); + record_event(EVENT_CANCEL, STATUS_CANCELED, order.order_id, -1); + continue; + } + qty = qty.min(self.position.abs()); + } + let delta = qty * order.side as f64; + let notional = delta.abs() * price * self.contract_size; + let fee = notional * self.fee_rate; + let (required, current_margin) = required_margin( + self.position, + delta, + self.market.closes[bar], + price, + self.contract_size, + self.leverage, + fee, + ); + if required > self.equity - current_margin { + self.active_orders.remove_slot(slot); + record_event(EVENT_REJECT, STATUS_REJECTED, order.order_id, -1); + continue; + } + self.equity += delta * (self.market.closes[bar] - price) * self.contract_size - fee; + self.position += delta; + fee_total += fee; + turnover += notional; + fill_count += 1; + if materialize { + fills.push(vec![ + order.order_id as f64, + order.side as f64, + qty, + price, + fee, + ]); + } + self.active_orders.remove_slot(slot); + record_event(EVENT_FILL, STATUS_FILLED, order.order_id, -1); + } + self.active_orders.compact_if_needed(); + self.last_bar = Some(bar); + let initial_margin = initial_margin( + self.position, + self.market.closes[bar], + self.contract_size, + self.leverage, + ); + let maintenance_margin = maintenance_margin( + self.position, + self.market.closes[bar], + self.contract_size, + self.maintenance_ratio, + ); + let active_orders = if materialize { + self.active_orders.snapshot() + } else { + Vec::new() + }; + Ok(StepResult { + equity: self.equity, + position: self.position, + fee: fee_total, + turnover, + initial_margin, + maintenance_margin, + fills, + events, + active_orders, + fill_count, + event_count, + rejected_count, + canceled_count, + }) + } + + fn resolve_order_id(&mut self, order_id: i64) -> i64 { + let mut resolved = order_id; + let mut path = [0_i64; 64]; + let mut path_len = 0; + // A replacement can itself be replaced. The fixed stack path keeps + // normal alias resolution allocation-free and the guard prevents a + // malformed tape from creating an infinite alias cycle. + for _ in 0..64 { + if path_len < path.len() { + path[path_len] = resolved; + path_len += 1; + } + let Some(next) = self.order_alias.get(&resolved) else { + break; + }; + if *next == resolved { + break; + } + if path[..path_len].contains(next) { + break; + } + resolved = *next; + } + for old_id in path[..path_len].iter().copied() { + if old_id != resolved { + self.order_alias.insert(old_id, resolved); + } + } + resolved + } +} + +fn valid_order(side: i64, order_type: i64, qty: f64, price: f64, trigger: f64) -> bool { + if (side != SIDE_BUY && side != SIDE_SELL) || qty <= 0.0 { + return false; + } + match order_type { + ORDER_MARKET => true, + ORDER_LIMIT => price > 0.0, + ORDER_STOP_MARKET => trigger > 0.0, + ORDER_STOP_LIMIT => price > 0.0 && trigger > 0.0, + _ => false, + } +} diff --git a/rust/native_event/src/types.rs b/rust/native_event/src/types.rs new file mode 100644 index 0000000..5a5b200 --- /dev/null +++ b/rust/native_event/src/types.rs @@ -0,0 +1,58 @@ +pub const COMMAND_CODE_WIDTH: usize = 8; +pub const COMMAND_VALUE_WIDTH: usize = 3; + +pub const ACTION_PLACE: i64 = 0; +pub const ACTION_CANCEL: i64 = 1; +// Reactive R2 ABI codes are kept stable for the installed wheel. The static +// tape adapter translates the canonical Python compiler codes explicitly. +pub const ACTION_AMEND: i64 = 2; +pub const ACTION_REPLACE: i64 = 3; +pub const ORDER_MARKET: i64 = 0; +pub const ORDER_LIMIT: i64 = 1; +pub const ORDER_STOP_MARKET: i64 = 2; +pub const ORDER_STOP_LIMIT: i64 = 3; +pub const SIDE_BUY: i64 = 1; +pub const SIDE_SELL: i64 = -1; +pub const FLAG_REDUCE_ONLY: i64 = 1; +pub const MUTATE_QTY: i64 = 1; +pub const MUTATE_PRICE: i64 = 2; +pub const MUTATE_TRIGGER: i64 = 4; + +pub const EVENT_PLACE: i64 = 0; +pub const EVENT_CANCEL: i64 = 1; +pub const EVENT_FILL: i64 = 2; +pub const EVENT_REJECT: i64 = 3; +pub const EVENT_AMEND: i64 = 4; +pub const EVENT_REPLACE: i64 = 5; + +pub const STATUS_PENDING: i64 = 0; +pub const STATUS_FILLED: i64 = 1; +pub const STATUS_CANCELED: i64 = 2; +pub const STATUS_REJECTED: i64 = 3; + +#[derive(Clone, Copy)] +pub struct ActiveOrder { + pub order_id: i64, + pub side: i64, + pub order_type: i64, + pub qty: f64, + pub price: f64, + pub trigger: f64, + pub reduce_only: bool, +} + +pub struct StepResult { + pub equity: f64, + pub position: f64, + pub fee: f64, + pub turnover: f64, + pub initial_margin: f64, + pub maintenance_margin: f64, + pub fills: Vec>, + pub events: Vec>, + pub active_orders: Vec>, + pub fill_count: i64, + pub event_count: i64, + pub rejected_count: i64, + pub canceled_count: i64, +} diff --git a/src/quantbt/__init__.py b/src/quantbt/__init__.py new file mode 100644 index 0000000..a908b36 --- /dev/null +++ b/src/quantbt/__init__.py @@ -0,0 +1,862 @@ +""" +quantbt +======= +Vectorised Binance-Futures backtest SDK. + +Quick start +----------- +Single symbol:: + + from quantbt import BacktestEngine + + bt = BacktestEngine( + Datetime = df["Datetime"], + Position = signal, # pd.Series of weights + Close = df["Close"], + fee = 0.0004, + initial_capital = 20_000, + leverage = 10, + hedge_type = "signal_notional", + alloc_per_trade = 100_000, + ) + bt.analyze() # text report + chart + result = bt.result # BacktestResult dataclass + +Multi-symbol portfolio:: + + from quantbt import MultiSymbolPortfolio + + msp = MultiSymbolPortfolio( + positions = {"BTC": pos_btc, "ETH": pos_eth}, + closes = {"BTC": close_btc, "ETH": close_eth}, + datetime_index = common_dt, + mode = "market_neutral", + asset_type = "crypto", + ) + msp.analyze() + +Advanced — standalone metrics + plots:: + + from quantbt.metrics import full_report, sharpe, max_drawdown + from quantbt.viz import quick_plot, tearsheet + + rpt = full_report(result) + quick_plot(result, theme="light") + tearsheet(result) +""" + +from importlib import import_module + + +_LAZY_EXPORTS = { + **{ + name: (".optimization", name) + for name in ( + "CONSTRAINTS_USER_ATTR", + "ArbitrageGenericEvaluator", + "ArbitrageTrialOutput", + "CandidateSelector", + "GenericEndpointEvaluator", + "GridDCAGenericEvaluator", + "GridDCATrialOutput", + "JsonlOptimizationLogger", + "MissingOptimizationMetricError", + "MultiSeedOptimization", + "ObjectiveResult", + "OptionPackageGenericEvaluator", + "OptionTrialOutput", + "OptimizationConfig", + "OptimizationResult", + "OptimizationTrialRecord", + "OptunaOptimizer", + "PreparedIntrabarEvaluator", + "PreparedNativeEventStrategyEvaluator", + "PreparedPortfolioEvaluator", + "PreparedSignalEvaluator", + "ReportMetricObjective", + "RobustSelectionConfig", + "SamplerConfig", + "SearchSpaceInfo", + "SelectedCandidate", + "SharpeObjective", + "SingleObjectiveEarlyStopping", + "TrialEvaluator", + "build_grid_search_space", + "build_sampler", + "constraints_feasible", + "constraints_from_trial", + "max_drawdown_constraint", + "max_margin_utilization_constraint", + "max_rejection_rate_constraint", + "max_turnover_constraint", + "metric_from_result", + "metrics_from_result", + "min_trades_constraint", + "result_full_report", + "search_space_info", + "set_trial_constraints", + "stable_params_key", + "suggest_parameter", + "suggest_params", + ) + }, + **{ + name: (".walkforward", name) + for name in ( + "DuplicatePruner", + "EarlyStoppingCallback", + "WalkForwardBenchmarkSnapshot", + "WalkForwardCompatibilityEntry", + "WalkForwardConfig", + "WalkForwardEngine", + "WalkForwardFold", + "WalkForwardResult", + "WalkForwardTrialRecord", + "benchmark_walkforward_kernels", + "logging_callback", + "score_strategy_output", + "select_full_sample_robust_record", + "select_is_plateau_robust_record", + "select_is_only_robust_record", + "select_flat_minima_record", + "stationary_bootstrap_sharpes", + "synthetic_walkforward_sharpes", + "stitch_oos_outputs", + "strategy_return_series", + "trade_frequency_penalty", + "validate_param_ranges", + "volatility_regime_labels", + "validate_walkforward_strategy_output", + "walkforward_support_matrix", + ) + }, + "quick_plot": (".viz", "quick_plot"), + "tearsheet": (".viz", "tearsheet"), + "apply_theme": (".viz", "apply_theme"), + **{ + name: (".reporting", name) + for name in ( + "build_arbitrage_domain_audit", + "build_native_nautilus_parity_report", + "build_nautilus_certification_profile", + "build_nautilus_depth_execution_report", + "build_nautilus_depth_parity_summary", + "build_nautilus_pct_equity_diagnostic", + "build_portfolio_domain_audit", + "build_portfolio_nautilus_position_report", + "build_portfolio_nautilus_validation_report", + "compare_native_arbitrage_results", + "export_nautilus_report_bundle", + "NautilusToleranceProfile", + "summarize_native_nautilus_parity_report", + "write_nautilus_certification_artifacts", + ) + }, +} + + +def __getattr__(name: str): + """Resolve optional/heavy public exports on first use.""" + + try: + module_name, attribute_name = _LAZY_EXPORTS[name] + except KeyError as exc: # pragma: no cover - normal Python attribute error + raise AttributeError(name) from exc + value = getattr(import_module(module_name, __name__), attribute_name) + globals()[name] = value + return value + + +def __dir__(): + return sorted(set(globals()) | set(_LAZY_EXPORTS)) + + +from .backtester import BacktestEngine +from .portfolio import MultiSymbolPortfolio +from .endpoint import ( + EndpointConfig, + NativeEventProfile, + PreparedIntrabarRunner, + PreparedNativeEventStrategyRunner, + QuantBTEndpoint, + QuantBTPreparedContext, + format_metrics_report, +) +from .engines import BacktestEngineV2, EventDrivenBacktestEngine, OptionBacktestEngine, PortfolioBacktestEngine +from .backends import ( + NativeEventBackend, + NativeEventConfig, + NativeEventScoreRequirements, + NativeOptionBackend, + NativeOptionConfig, + NativePortfolioBackend, + NativePortfolioConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, + OptionSettlementEvent, + RustBatchedAuditResult, + RustBatchedChunkResult, + RustBatchedRunner, + RustBatchedScoreResult, + RustBatchedSession, +) +from .adapters.nautilus import NautilusBacktestEngine +from .core.types import BacktestResult +from .core.results import ( + BacktestResultV2, + NativeAccountingArrays, + NativeEventScalarScoreResult, + NativeEventScoreResult, + OptionBacktestResult, +) +from .core.execution_contract import ( + EXECUTION_CONTRACT_REGISTRY, + AmbiguityPolicy, + ExecutionContract, + FillPhase, + FundingPhase, + IntrabarSameBarPolicy, + LiquidationPriority, + MarketFillPolicy, + SignalPhase, + StopGapPolicy, + TakeProfitGapPolicy, + TrailingUpdatePhase, + get_execution_contract, +) +from .core.market_tape import MarketValidationCertificate, PreparedMarketTape, prepare_market_tape +from .core.intrabar_reference import ( + IntrabarEventFlag, + IntrabarFill, + IntrabarFillReason, + IntrabarIntentTape, + IntrabarLevelMode, + IntrabarReferenceResult, + IntrabarSizingMode, + run_intrabar_reference, +) +from .core.intrabar_session import ( + EntryPositionPolicy, + IntrabarSessionTape, + ProtectiveExitReentryPolicy, + SessionCounterBasis, + SessionExecutionPolicy, +) +from .core.intrabar_kernel import ( + FillReplayTape, + NativeFillReplayResult, + NativeIntrabarKernelResult, + run_fill_replay_kernel, + run_intrabar_kernel, + run_intrabar_session_kernel, +) +from .core.certification import ( + AlphaExecutionClassification, + CertificationLevel, + alpha_report_markdown, + build_alpha_certification_report, + certify_result_metadata, + classify_alpha_source, + scan_alpha_directory, +) +from .core.native_event_capabilities import ( + NATIVE_EVENT_CAPABILITY_MATRIX, + NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + capability_matrix_fingerprint, + native_event_capability_matrix, + normalize_native_event_capabilities, + validate_native_event_capability_matrix, +) +from .core.native_event_parity import ( + DEFAULT_NUMERIC_ATOL, + NativeEventParityCertificate, + NativeEventParityError, + assert_native_event_full_parity, +) +from .core.orders import ( + BasketIntent, + Fill, + OrderAction, + OrderActivationPolicy, + OrderCommand, + OrderIntent, + Trade, + order_intents_to_lifecycle_commands, +) +from .core.reactive import ( + NativeActiveOrderSnapshot, + NativeCommandBatch, + NativeEventStrategy, + NativeEventStrategyError, + NativeEventStrategyProtocol, + NativeFillEvent, + NativeOrderEvent, + NativeStrategyContext, +) +from .core.basket import FrozenBasketPlan, build_frozen_basket_orders +from .core.execution_depth import ( + NautilusExecutionDepthConfig, + PackageDepthPreflightResult, + SUPPORTED_DEPTH_MODELS, + l2_replay_available, + simulate_nautilus_order_package_depth, +) +from .core.structured_orders import ( + BracketOrderSpec, + DcaGridSpec, + StructuredOrderPlan, + build_bracket_order_plan, + build_dca_grid_order_plan, +) +from .core.arbitrage import ( + ArbExecutionPolicy, + ArbitrageLeg, + ArbitragePlan, + ArbitrageSpec, + ArbitrageType, + BasisArbitrageSpec, + CalendarSpreadSpec, + ContractType, + CarryModel, + CarryModelKind, + CostModel, + CostModelKind, + CrossExchangeArbSpec, + FundingArbitrageSpec, + HedgePolicy, + HedgePolicyKind, + IndexBasketArbSpec, + LifecycleModel, + LifecycleModelKind, + MarginModel, + MarginModelKind, + OptionsVolArbSpec, + PackageExecutionKind, + PackageRejection, + SignalModel, + SignalModelKind, + SizingPolicy, + SizingPolicyKind, + SpotPerpCashCarrySpec, + SpreadFormula, + SpreadFormulaKind, + StatArbPairSpec, + TriangularArbSpec, + build_arbitrage_order_plan, + round_down_to_step, +) +from .core.constraints import QuantityConstraints, build_quantity_constraints, quantize_signed_quantity +from .core.schema import ( + AccountConfig, + AssetType, + BasketExecutionPolicy, + BasketLegSpec, + BasketSpec, + ExecutionConfig, + FeeModel, + FillPricePolicy, + InstrumentSpec, + LiquiditySide, + MarginMode, + OmsMode, + OrderSide, + OrderType, + SameBarPolicy, + SignalSpec, + TimeInForce, +) +from .core.portfolio import ( + LEGACY_PORTFOLIO_MODES, + LEGACY_PORTFOLIO_SIZING_MODES, + NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES, + NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES, + PortfolioDomainSpec, + PortfolioMode, + PortfolioRebalancePolicy, + PortfolioSizingMode, + normalize_portfolio_mode, + normalize_portfolio_sizing_mode, + normalize_rebalance_policy, + portfolio_capability_matrix, + validate_portfolio_result_contract, +) +from .options import ( + CANONICAL_OPTION_CHAIN_COLUMNS, + ExerciseStyle, + ExternalOptionMarginValidator, + GammaScalpingConfig, + HedgeDecision, + HedgePathResult, + IVStatus, + ImpliedVolResult, + InstrumentRegistrySignature, + OptionDecisionFillPolicy, + OptionDepthFidelity, + OptionExecutionConfig, + OptionFeeResult, + OptionFeeSchedule, + OptionGreeks, + OptionHedgeConfig, + OptionHedgePolicyType, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + OptionLimitFidelity, + OptionLiquidationAudit, + OptionMarginConfig, + OptionMarginModel, + OptionMarginRequirement, + OptionPackageExecutionPolicy, + OptionPackageExecutionResult, + OptionPackageIntent, + OptionPackageLeg, + OptionLedger, + OptionPosition, + OptionPreparedRunCache, + OptionSelection, + OptionSelectionFilters, + OptionSettlementRepresentation, + OptionSettlementResult, + OptionStrategyRun, + OptionTapeSignature, + OptionVenueConvention, + PremiumConvention, + PreparedOptionTape, + SettlementStyle, + SurfaceDiagnostics, + TotalVarianceSurface, + YEAR_NS, + available_option_rows, + binance_european_options_convention, + black76_intrinsic, + black76_parity_residual, + black76_parity_value, + black76_price, + build_gamma_scalping_strategy_run, + butterfly, + calculate_option_fee, + calculate_option_margin, + calendar, + collar, + compile_option_package_orders, + condor, + covered_call, + deribit_inverse_option_convention, + deribit_inverse_fee_schedule, + deribit_linear_usdc_option_convention, + deribit_linear_usdc_fee_schedule, + implied_vol_black76, + implied_vol_inverse_black76_base, + inverse_black76_greeks_base, + inverse_black76_greeks_quote, + inverse_black76_intrinsic_base, + inverse_black76_parity_residual_base, + inverse_black76_parity_value_base, + inverse_black76_price_base, + linear_black76_greeks, + compute_net_option_delta, + execute_option_package, + hedge_decision, + liquidate_option_positions, + long_call, + long_put, + option_expiry_payoff_per_unit, + option_package_cache_key, + prepare_option_tape, + risk_reversal, + run_delta_hedge_path, + scale_greeks_to_reporting_currency, + select_atm_option, + select_target_delta_option, + select_target_dte_option, + select_target_moneyness_option, + settle_option_expiry, + short_call, + short_put, + straddle, + strangle, + vertical, + validate_option_chain_frame, +) + +from .metrics import ( + full_report, + sharpe, + sortino, + calmar, + omega, + cagr, + total_return, + max_drawdown, + max_drawdown_pct, + hitrate, + profit_factor, + rolling_sharpe, + rolling_drawdown, + option_attribution_report, + option_report_bundle, + option_run_manifest, +) + + +__version__ = "1.0.7rc1" +__author__ = "quantbt" + +__all__ = [ + # engines + "BacktestEngine", + "BacktestEngineV2", + "EndpointConfig", + "EventDrivenBacktestEngine", + "MultiSymbolPortfolio", + "NautilusBacktestEngine", + "NativeEventBackend", + "NativeEventConfig", + "NativeEventScoreRequirements", + "NativeAccountingArrays", + "NativeEventProfile", + "NativeActiveOrderSnapshot", + "NativeCommandBatch", + "NativeEventScoreResult", + "NativeEventScalarScoreResult", + "NativeEventParityCertificate", + "NativeEventParityError", + "NativeEventStrategyError", + "NativeEventStrategy", + "NativeEventStrategyProtocol", + "NativeFillEvent", + "NativeOrderEvent", + "NativeStrategyContext", + "NativeOptionBackend", + "NativeOptionConfig", + "NativePortfolioBackend", + "NativePortfolioConfig", + "NativeVectorizedBackend", + "NativeVectorizedConfig", + "OptionBacktestEngine", + "OptionBacktestResult", + "OptionSettlementEvent", + "NautilusExecutionDepthConfig", + "PackageDepthPreflightResult", + "PortfolioBacktestEngine", + "PortfolioDomainSpec", + "PortfolioMode", + "PortfolioRebalancePolicy", + "PortfolioSizingMode", + "QuantBTEndpoint", + "PreparedNativeEventStrategyRunner", + "QuantBTPreparedContext", + "format_metrics_report", + "CANONICAL_OPTION_CHAIN_COLUMNS", + "ExerciseStyle", + "ExternalOptionMarginValidator", + "GammaScalpingConfig", + "HedgeDecision", + "HedgePathResult", + "IVStatus", + "ImpliedVolResult", + "InstrumentRegistrySignature", + "OptionDecisionFillPolicy", + "OptionDepthFidelity", + "OptionExecutionConfig", + "OptionFeeResult", + "OptionFeeSchedule", + "OptionGreeks", + "OptionHedgeConfig", + "OptionHedgePolicyType", + "OptionInstrumentRegistry", + "OptionInstrumentSpec", + "OptionKind", + "OptionLimitFidelity", + "OptionLiquidationAudit", + "OptionMarginConfig", + "OptionMarginModel", + "OptionMarginRequirement", + "OptionPackageExecutionPolicy", + "OptionPackageExecutionResult", + "OptionPackageIntent", + "OptionPackageLeg", + "OptionLedger", + "OptionPosition", + "OptionPreparedRunCache", + "OptionSelection", + "OptionSelectionFilters", + "OptionSettlementRepresentation", + "OptionSettlementResult", + "OptionStrategyRun", + "OptionTapeSignature", + "OptionVenueConvention", + "PremiumConvention", + "PreparedOptionTape", + "SettlementStyle", + "SurfaceDiagnostics", + "TotalVarianceSurface", + "YEAR_NS", + "available_option_rows", + "binance_european_options_convention", + "black76_intrinsic", + "black76_parity_residual", + "black76_parity_value", + "black76_price", + "build_gamma_scalping_strategy_run", + "butterfly", + "calculate_option_fee", + "calculate_option_margin", + "calendar", + "collar", + "compile_option_package_orders", + "condor", + "covered_call", + "deribit_inverse_option_convention", + "deribit_inverse_fee_schedule", + "deribit_linear_usdc_option_convention", + "deribit_linear_usdc_fee_schedule", + "implied_vol_black76", + "implied_vol_inverse_black76_base", + "inverse_black76_greeks_base", + "inverse_black76_greeks_quote", + "inverse_black76_intrinsic_base", + "inverse_black76_parity_residual_base", + "inverse_black76_parity_value_base", + "inverse_black76_price_base", + "linear_black76_greeks", + "long_call", + "long_put", + "compute_net_option_delta", + "execute_option_package", + "hedge_decision", + "liquidate_option_positions", + "option_expiry_payoff_per_unit", + "option_package_cache_key", + "prepare_option_tape", + "risk_reversal", + "run_delta_hedge_path", + "scale_greeks_to_reporting_currency", + "select_atm_option", + "select_target_delta_option", + "select_target_dte_option", + "select_target_moneyness_option", + "settle_option_expiry", + "short_call", + "short_put", + "straddle", + "strangle", + "vertical", + "validate_option_chain_frame", + "option_attribution_report", + "option_report_bundle", + "option_run_manifest", + "LEGACY_PORTFOLIO_MODES", + "LEGACY_PORTFOLIO_SIZING_MODES", + "NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES", + "NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES", + "build_arbitrage_domain_audit", + "build_native_nautilus_parity_report", + "build_nautilus_certification_profile", + "build_nautilus_depth_execution_report", + "build_nautilus_depth_parity_summary", + "build_nautilus_pct_equity_diagnostic", + "build_portfolio_domain_audit", + "build_portfolio_nautilus_position_report", + "build_portfolio_nautilus_validation_report", + "compare_native_arbitrage_results", + "export_nautilus_report_bundle", + "NautilusToleranceProfile", + "summarize_native_nautilus_parity_report", + "write_nautilus_certification_artifacts", + "WalkForwardConfig", + "WalkForwardEngine", + "WalkForwardFold", + "WalkForwardResult", + "WalkForwardTrialRecord", + "WalkForwardBenchmarkSnapshot", + "WalkForwardCompatibilityEntry", + "EarlyStoppingCallback", + "DuplicatePruner", + "CONSTRAINTS_USER_ATTR", + "JsonlOptimizationLogger", + "MultiSeedOptimization", + "ObjectiveResult", + "OptimizationConfig", + "OptimizationResult", + "OptimizationTrialRecord", + "OptunaOptimizer", + "RobustSelectionConfig", + "SamplerConfig", + "SearchSpaceInfo", + "SingleObjectiveEarlyStopping", + "TrialEvaluator", + "build_grid_search_space", + "build_sampler", + "constraints_from_trial", + "search_space_info", + "set_trial_constraints", + "stable_params_key", + "suggest_parameter", + "suggest_params", + "benchmark_walkforward_kernels", + "logging_callback", + "score_strategy_output", + "select_flat_minima_record", + "select_full_sample_robust_record", + "select_is_only_robust_record", + "select_is_plateau_robust_record", + "stationary_bootstrap_sharpes", + "synthetic_walkforward_sharpes", + "stitch_oos_outputs", + "strategy_return_series", + "trade_frequency_penalty", + "validate_param_ranges", + "volatility_regime_labels", + "validate_walkforward_strategy_output", + "walkforward_support_matrix", + "BacktestResult", + "BacktestResultV2", + "BracketOrderSpec", + "AccountConfig", + "AlphaExecutionClassification", + "DEFAULT_NUMERIC_ATOL", + "NATIVE_EVENT_CAPABILITY_MATRIX", + "NATIVE_EVENT_CAPABILITY_MATRIX_VERSION", + "AmbiguityPolicy", + "ArbExecutionPolicy", + "ArbitrageLeg", + "ArbitragePlan", + "ArbitrageSpec", + "ArbitrageType", + "AssetType", + "BasisArbitrageSpec", + "BasketExecutionPolicy", + "BasketIntent", + "BasketLegSpec", + "BasketSpec", + "CalendarSpreadSpec", + "CertificationLevel", + "CarryModel", + "CarryModelKind", + "ContractType", + "CostModel", + "CostModelKind", + "CrossExchangeArbSpec", + "DcaGridSpec", + "EXECUTION_CONTRACT_REGISTRY", + "ExecutionConfig", + "ExecutionContract", + "FeeModel", + "Fill", + "FillReplayTape", + "FillPricePolicy", + "FillPhase", + "FundingPhase", + "FundingArbitrageSpec", + "FrozenBasketPlan", + "HedgePolicy", + "HedgePolicyKind", + "IndexBasketArbSpec", + "InstrumentSpec", + "IntrabarEventFlag", + "IntrabarFill", + "IntrabarFillReason", + "IntrabarIntentTape", + "IntrabarLevelMode", + "IntrabarReferenceResult", + "IntrabarSessionTape", + "IntrabarSizingMode", + "IntrabarSameBarPolicy", + "EntryPositionPolicy", + "ProtectiveExitReentryPolicy", + "SessionCounterBasis", + "SessionExecutionPolicy", + "LifecycleModel", + "LifecycleModelKind", + "LiquiditySide", + "LiquidationPriority", + "MarginMode", + "MarginModel", + "MarginModelKind", + "MarketFillPolicy", + "MarketValidationCertificate", + "NativeFillReplayResult", + "NativeIntrabarKernelResult", + "OmsMode", + "OrderAction", + "OrderActivationPolicy", + "OrderCommand", + "OrderIntent", + "OrderSide", + "OrderType", + "QuantityConstraints", + "OptionsVolArbSpec", + "PackageExecutionKind", + "PackageRejection", + "PreparedMarketTape", + "PreparedIntrabarRunner", + "SameBarPolicy", + "SignalModel", + "SignalModelKind", + "SignalSpec", + "SignalPhase", + "SizingPolicy", + "SizingPolicyKind", + "SpotPerpCashCarrySpec", + "SpreadFormula", + "SpreadFormulaKind", + "StatArbPairSpec", + "StopGapPolicy", + "StructuredOrderPlan", + "TakeProfitGapPolicy", + "TimeInForce", + "Trade", + "TrailingUpdatePhase", + "TriangularArbSpec", + "alpha_report_markdown", + "build_arbitrage_order_plan", + "build_alpha_certification_report", + "build_bracket_order_plan", + "build_quantity_constraints", + "build_dca_grid_order_plan", + "build_frozen_basket_orders", + "certify_result_metadata", + "classify_alpha_source", + "get_execution_contract", + "order_intents_to_lifecycle_commands", + "prepare_market_tape", + "normalize_portfolio_mode", + "normalize_portfolio_sizing_mode", + "normalize_rebalance_policy", + "portfolio_capability_matrix", + "quantize_signed_quantity", + "round_down_to_step", + "assert_native_event_full_parity", + "capability_matrix_fingerprint", + "native_event_capability_matrix", + "normalize_native_event_capabilities", + "validate_native_event_capability_matrix", + "run_fill_replay_kernel", + "run_intrabar_kernel", + "run_intrabar_session_kernel", + "run_intrabar_reference", + "scan_alpha_directory", + "SUPPORTED_DEPTH_MODELS", + "l2_replay_available", + "simulate_nautilus_order_package_depth", + "validate_portfolio_result_contract", + # metrics + "full_report", + "sharpe", + "sortino", + "calmar", + "omega", + "cagr", + "total_return", + "max_drawdown", + "max_drawdown_pct", + "hitrate", + "profit_factor", + "rolling_sharpe", + "rolling_drawdown", + # viz + "quick_plot", + "tearsheet", + "apply_theme", +] diff --git a/src/quantbt/adapters/__init__.py b/src/quantbt/adapters/__init__.py new file mode 100644 index 0000000..6faf825 --- /dev/null +++ b/src/quantbt/adapters/__init__.py @@ -0,0 +1,5 @@ +""" +Optional external engine adapters. +""" + +__all__ = [] diff --git a/src/quantbt/adapters/nautilus/__init__.py b/src/quantbt/adapters/nautilus/__init__.py new file mode 100644 index 0000000..4c79f68 --- /dev/null +++ b/src/quantbt/adapters/nautilus/__init__.py @@ -0,0 +1,42 @@ +""" +Optional NautilusTrader backend adapter. + +Importing this module does not require NautilusTrader to be installed. The +dependency is loaded lazily when a backend run is requested. +""" + +from .backend import NautilusBackendConfig, NautilusBacktestEngine, build_nautilus_package_order_table +from .instruments import ( + ensure_utc_ohlcv, + make_binance_perpetual, + normalize_binance_perp_symbol, + supported_binance_perpetuals, + timeframe_to_nautilus, +) +from .reports import result_from_nautilus_reports +from .options import ( + NautilusOptionValidationConfig, + NautilusOptionValidationResult, + build_nautilus_option_quote_table, + inspect_nautilus_option_support, + make_nautilus_option_instrument, + validate_option_packages_with_nautilus, +) + +__all__ = [ + "NautilusBackendConfig", + "NautilusBacktestEngine", + "build_nautilus_package_order_table", + "ensure_utc_ohlcv", + "make_binance_perpetual", + "normalize_binance_perp_symbol", + "result_from_nautilus_reports", + "NautilusOptionValidationConfig", + "NautilusOptionValidationResult", + "build_nautilus_option_quote_table", + "inspect_nautilus_option_support", + "make_nautilus_option_instrument", + "supported_binance_perpetuals", + "timeframe_to_nautilus", + "validate_option_packages_with_nautilus", +] diff --git a/src/quantbt/adapters/nautilus/_dependency.py b/src/quantbt/adapters/nautilus/_dependency.py new file mode 100644 index 0000000..1048576 --- /dev/null +++ b/src/quantbt/adapters/nautilus/_dependency.py @@ -0,0 +1,53 @@ +""" +Lazy NautilusTrader imports. +""" + +from __future__ import annotations + +from types import SimpleNamespace + + +def require_nautilus(): + try: + from nautilus_trader.adapters.binance import BINANCE_VENUE + from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig + from nautilus_trader.backtest.models import MakerTakerFeeModel + from nautilus_trader.config import LoggingConfig, RiskEngineConfig + from nautilus_trader.model.currencies import USDT + from nautilus_trader.model.data import Bar, BarType + from nautilus_trader.model.enums import AccountType, OmsType, OrderSide, PositionSide, PriceType, TimeInForce + from nautilus_trader.model.identifiers import InstrumentId, TraderId + from nautilus_trader.model.objects import Money + from nautilus_trader.persistence.wranglers import BarDataWrangler + from nautilus_trader.test_kit.providers import TestInstrumentProvider + from nautilus_trader.trading.strategy import Strategy, StrategyConfig + except ImportError as exc: + raise ImportError( + "NautilusTrader adapter requires the optional 'nautilus_trader' package. " + "Install NautilusTrader in the active environment or use a native quantbt backend." + ) from exc + + return SimpleNamespace( + AccountType=AccountType, + BacktestEngine=BacktestEngine, + BacktestEngineConfig=BacktestEngineConfig, + Bar=Bar, + BarDataWrangler=BarDataWrangler, + BarType=BarType, + BINANCE_VENUE=BINANCE_VENUE, + InstrumentId=InstrumentId, + LoggingConfig=LoggingConfig, + MakerTakerFeeModel=MakerTakerFeeModel, + Money=Money, + OmsType=OmsType, + OrderSide=OrderSide, + PositionSide=PositionSide, + PriceType=PriceType, + RiskEngineConfig=RiskEngineConfig, + Strategy=Strategy, + StrategyConfig=StrategyConfig, + TestInstrumentProvider=TestInstrumentProvider, + TimeInForce=TimeInForce, + TraderId=TraderId, + USDT=USDT, + ) diff --git a/src/quantbt/adapters/nautilus/backend.py b/src/quantbt/adapters/nautilus/backend.py new file mode 100644 index 0000000..fbb2bd4 --- /dev/null +++ b/src/quantbt/adapters/nautilus/backend.py @@ -0,0 +1,639 @@ +""" +NautilusTrader backend adapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Dict, List, Optional, Sequence + +import pandas as pd + +from ...core.orders import OrderIntent +from ...core.results import BacktestResultV2 +from ...core.schema import OrderSide +from ._dependency import require_nautilus +from .instruments import ensure_utc_ohlcv, make_binance_perpetual, timeframe_to_nautilus +from .reports import result_from_nautilus_reports + + +@dataclass(frozen=True) +class NautilusBackendConfig: + instrument_id: str = "BTCUSDT-PERP.BINANCE" + timeframe: str = "1h" + starting_balance: float = 10_000.0 + trade_notional: float = 1_000.0 + sizing_mode: str = "signal_notional" + use_pyramiding: bool = True + strategy_id: str = "QuantBT-001" + trader_id: str = "BACKTESTER-001" + log_level: str = "ERROR" + bypass_logging: bool = True + bypass_risk: bool = False + close_positions_on_stop: bool = False + force_flat_on_stop: Optional[bool] = None + use_test_instrument: bool = True + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.force_flat_on_stop is not None: + object.__setattr__(self, "close_positions_on_stop", bool(self.force_flat_on_stop)) + if self.starting_balance <= 0.0: + raise ValueError("starting_balance must be > 0") + if self.trade_notional < 0.0: + raise ValueError("trade_notional must be >= 0") + sizing = self.sizing_mode.lower().strip() + if sizing in ("dca_ladder", "dca"): + raise NotImplementedError( + "Use QuantBTEndpoint.nautilus_dca_grid(...) for DCA/grid structured-order validation; " + "NautilusBackendConfig.sizing_mode is only for signal-series sizing." + ) + if sizing not in {"signal_notional", "signal", "notional", "unit", "%_equity", "pct_equity"}: + raise ValueError("sizing_mode must be one of signal_notional, notional, unit, or %_equity") + if "-" not in self.strategy_id: + raise ValueError("strategy_id must contain '-' for Nautilus order_id_tag extraction") + if "-" not in self.trader_id: + raise ValueError("trader_id must contain '-'") + + +class NautilusBacktestEngine: + """ + Optional high-fidelity backend powered by NautilusTrader. + + This adapter is intended as a validation/reference backend. It accepts a + precomputed scalar signal series and submits market delta orders to reach a + target notional. Research-scale optimizer runs should prefer native quantbt + backends. + """ + + def __init__(self, config: NautilusBackendConfig): + self.config = config + + @staticmethod + def check_available() -> bool: + require_nautilus() + return True + + def run_signal_series( + self, + data: pd.DataFrame, + signal: pd.Series, + params: Optional[Dict] = None, + ) -> BacktestResultV2: + nt = require_nautilus() + df = ensure_utc_ohlcv(data) + sig = self._align_signal(signal, df.index) + + engine = nt.BacktestEngine( + config=nt.BacktestEngineConfig( + trader_id=nt.TraderId(self.config.trader_id), + logging=nt.LoggingConfig( + log_level=self.config.log_level, + bypass_logging=self.config.bypass_logging, + ), + risk_engine=nt.RiskEngineConfig(bypass=self.config.bypass_risk), + ) + ) + + instrument = self._make_instrument(nt) + engine.add_venue( + venue=nt.BINANCE_VENUE, + oms_type=nt.OmsType.NETTING, + account_type=nt.AccountType.MARGIN, + base_currency=nt.USDT, + starting_balances=[nt.Money(self.config.starting_balance, nt.USDT)], + fee_model=nt.MakerTakerFeeModel(), + bar_execution=True, + ) + engine.add_instrument(instrument) + + bar_type = nt.BarType.from_str( + f"{instrument.id}-{timeframe_to_nautilus(self.config.timeframe)}-LAST-EXTERNAL" + ) + wrangler = nt.BarDataWrangler(bar_type=bar_type, instrument=instrument) + bars = wrangler.process(df) + engine.add_data(bars) + + strategy_cls, config_cls = self._make_signal_strategy_classes(nt) + strategy = strategy_cls( + config=config_cls( + strategy_id=self.config.strategy_id, + instrument_id=str(instrument.id), + bar_type=str(bar_type), + trade_notional=Decimal(str(self.config.trade_notional)), + starting_balance=Decimal(str(self.config.starting_balance)), + sizing_mode=self.config.sizing_mode, + use_pyramiding=self.config.use_pyramiding, + signals={int(ts.value): float(v) for ts, v in sig.items()}, + close_positions_on_stop=self.config.close_positions_on_stop, + order_id_tag=self.config.strategy_id.rsplit("-", 1)[-1], + ) + ) + try: + engine.add_strategy(strategy=strategy) + engine.run() + + account_report = engine.trader.generate_account_report(nt.BINANCE_VENUE) + orders_report = engine.trader.generate_orders_report() + positions_report = engine.trader.generate_positions_report() + fills_report = None + if hasattr(engine.trader, "generate_order_fills_report"): + fills_report = engine.trader.generate_order_fills_report() + + return result_from_nautilus_reports( + account_report=account_report, + orders_report=orders_report, + fills_report=fills_report, + positions_report=positions_report, + symbols=[str(instrument.id)], + initial_capital=self.config.starting_balance, + closes={str(instrument.id): df["close"]}, + metadata={ + "instrument_id": str(instrument.id), + "bar_type": str(bar_type), + "sizing_mode": self.config.sizing_mode, + "trade_notional": self.config.trade_notional, + "use_pyramiding": self.config.use_pyramiding, + "close_positions_on_stop": self.config.close_positions_on_stop, + **self._instrument_constraint_metadata(instrument), + **self.config.metadata, + **(params or {}), + }, + ) + finally: + engine.reset() + engine.dispose() + + def run_order_packages( + self, + data: Dict[str, pd.DataFrame], + orders: Sequence[OrderIntent], + symbols: Optional[Sequence[str]] = None, + params: Optional[Dict] = None, + ) -> BacktestResultV2: + """ + Run explicit component package orders through NautilusTrader. + + Orders are submitted as market IOC component orders at their original + timestamps. The returned result exposes raw Nautilus reports plus a + stable `package_order_map` linking quantbt package intents to symbols, + target units, and original package metadata. + """ + if not orders: + raise ValueError("run_order_packages requires at least one OrderIntent") + nt = require_nautilus() + symbol_list = list(symbols or data.keys()) + if not symbol_list: + raise ValueError("symbols are required") + missing = sorted(set(symbol_list) - set(data.keys())) + if missing: + raise ValueError(f"missing Nautilus data for symbols: {missing}") + + frames = {symbol: ensure_utc_ohlcv(data[symbol]) for symbol in symbol_list} + engine = nt.BacktestEngine( + config=nt.BacktestEngineConfig( + trader_id=nt.TraderId(self.config.trader_id), + logging=nt.LoggingConfig( + log_level=self.config.log_level, + bypass_logging=self.config.bypass_logging, + ), + risk_engine=nt.RiskEngineConfig(bypass=self.config.bypass_risk), + ) + ) + + instruments = {symbol: make_binance_perpetual(symbol, nt) for symbol in symbol_list} + instrument_ids = {symbol: str(instrument.id) for symbol, instrument in instruments.items()} + package_order_map = build_nautilus_package_order_table(orders, instrument_ids=instrument_ids) + engine.add_venue( + venue=nt.BINANCE_VENUE, + oms_type=nt.OmsType.NETTING, + account_type=nt.AccountType.MARGIN, + base_currency=nt.USDT, + starting_balances=[nt.Money(self.config.starting_balance, nt.USDT)], + fee_model=nt.MakerTakerFeeModel(), + bar_execution=True, + ) + for instrument in instruments.values(): + engine.add_instrument(instrument) + + bar_types = {} + for symbol, instrument in instruments.items(): + bar_type = nt.BarType.from_str( + f"{instrument.id}-{timeframe_to_nautilus(self.config.timeframe)}-LAST-EXTERNAL" + ) + wrangler = nt.BarDataWrangler(bar_type=bar_type, instrument=instrument) + engine.add_data(wrangler.process(frames[symbol])) + bar_types[symbol] = str(bar_type) + + strategy_cls, config_cls = self._make_package_strategy_classes(nt) + strategy = strategy_cls( + config=config_cls( + strategy_id=self.config.strategy_id, + instrument_ids=[str(instruments[symbol].id) for symbol in symbol_list], + bar_types=bar_types, + package_orders=_orders_payload(orders, instrument_ids=instrument_ids), + close_positions_on_stop=self.config.close_positions_on_stop, + ) + ) + try: + engine.add_strategy(strategy=strategy) + engine.run() + + account_report = engine.trader.generate_account_report(nt.BINANCE_VENUE) + orders_report = engine.trader.generate_orders_report() + positions_report = engine.trader.generate_positions_report() + fills_report = None + if hasattr(engine.trader, "generate_order_fills_report"): + fills_report = engine.trader.generate_order_fills_report() + + instrument_symbols = [str(instruments[symbol].id) for symbol in symbol_list] + close_map = {str(instruments[symbol].id): frames[symbol]["close"] for symbol in symbol_list} + return result_from_nautilus_reports( + account_report=account_report, + orders_report=orders_report, + fills_report=fills_report, + positions_report=positions_report, + symbols=instrument_symbols, + initial_capital=self.config.starting_balance, + closes=close_map, + metadata={ + "backend": "nautilus", + "engine": "nautilus_package_orders", + "input_mode": "order_packages", + "instrument_id": instrument_symbols[0] if len(instrument_symbols) == 1 else None, + "instrument_ids": instrument_symbols, + "bar_types": bar_types, + "sizing_mode": self.config.sizing_mode, + "trade_notional": self.config.trade_notional, + "use_pyramiding": self.config.use_pyramiding, + "package_order_map": package_order_map, + "package_orders_count": int(len(package_order_map)), + "oco_cancellation_policy": "cancel_sibling_on_first_exit_fill", + "oco_cancellations": list(getattr(strategy, "canceled_siblings", [])), + "close_positions_on_stop": self.config.close_positions_on_stop, + **self.config.metadata, + **(params or {}), + }, + ) + finally: + engine.reset() + engine.dispose() + + def _make_instrument(self, nt): + if not self.config.use_test_instrument: + raise NotImplementedError("custom Nautilus instruments are not wired yet") + return make_binance_perpetual(self.config.instrument_id, nt) + + @staticmethod + def _instrument_constraint_metadata(instrument) -> Dict: + size_increment = getattr(instrument, "size_increment", None) + min_quantity = getattr(instrument, "min_quantity", None) + min_notional = getattr(instrument, "min_notional", None) + price_increment = getattr(instrument, "price_increment", None) + return { + "qty_step": None if size_increment is None else str(size_increment), + "lot_size": None if size_increment is None else str(size_increment), + "min_qty": None if min_quantity is None else str(min_quantity), + "min_notional": None if min_notional is None else str(min_notional), + "price_increment": None if price_increment is None else str(price_increment), + "quantity_constraint_note": "lot_size/qty_step controls fractional crypto order acceptance; contract_size remains multiplier", + } + + @staticmethod + def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series: + sig = signal.copy() + if sig.index.tz is None: + sig.index = sig.index.tz_localize("UTC") + else: + sig.index = sig.index.tz_convert("UTC") + return sig.reindex(idx, method="ffill").fillna(0.0) + + @staticmethod + def _make_signal_strategy_classes(nt): + class QuantBTSignalConfig(nt.StrategyConfig, frozen=True): + instrument_id: str + bar_type: str + trade_notional: Decimal + starting_balance: Decimal + signals: Dict[int, float] + sizing_mode: str = "signal_notional" + use_pyramiding: bool = True + close_positions_on_stop: bool = False + + class QuantBTSignalStrategy(nt.Strategy): + def __init__(self, config: QuantBTSignalConfig): + super().__init__(config) + self.instrument_id = nt.InstrumentId.from_str(config.instrument_id) + self.bar_type = nt.BarType.from_str(config.bar_type) + self.trade_notional = config.trade_notional + self.starting_balance = config.starting_balance + self.sizing_mode = config.sizing_mode.lower().strip() + self.use_pyramiding = bool(config.use_pyramiding) + self.signals = config.signals + self.instrument = None + self.current_signal = 0.0 + self.first_price = 0.0 + + def on_start(self): + self.instrument = self.cache.instrument(self.instrument_id) + if self.instrument is None: + self.stop() + return + self.subscribe_bars(self.bar_type) + + def on_bar(self, bar): + raw_signal = float(self.signals.get(int(bar.ts_event), self.current_signal)) + signal = raw_signal if self.use_pyramiding else self._sign(raw_signal) + signal_changed = signal != self.current_signal + if self.sizing_mode not in ("notional",) and not signal_changed: + return + price = self.cache.price(self.instrument_id, nt.PriceType.LAST) + if price is None: + return + price_value = float(price) + if self.first_price <= 0.0: + self.first_price = price_value + current_qty = self._current_qty() + target_qty = self._target_qty(signal=signal, price=price_value) + delta = target_qty - current_qty + if abs(delta) < float(self.instrument.size_increment): + self.current_signal = signal + return + side = nt.OrderSide.BUY if delta > 0.0 else nt.OrderSide.SELL + order = self.order_factory.market( + instrument_id=self.instrument_id, + order_side=side, + quantity=self.instrument.make_qty(abs(delta)), + time_in_force=nt.TimeInForce.IOC, + ) + self.submit_order(order) + self.current_signal = signal + + def _target_qty(self, signal: float, price: float) -> float: + if signal == 0.0 or price <= 0.0: + return 0.0 + sizing = self.sizing_mode + allocation = float(self.trade_notional) + if sizing in ("signal_notional", "signal", "notional"): + return allocation * signal / price + if sizing == "unit": + return 0.0 if self.first_price <= 0.0 else allocation * signal / self.first_price + if sizing in ("%_equity", "pct_equity"): + alloc_pct = allocation / 100.0 if allocation > 1.0 else allocation + return self._equity() * alloc_pct * signal / price + raise RuntimeError(f"unsupported Nautilus sizing_mode={self.sizing_mode!r}") + + def _equity(self) -> float: + equity = self.portfolio.equity(venue=self.instrument_id.venue) + if equity is None: + return float(self.starting_balance) + if isinstance(equity, dict): + if not equity: + return float(self.starting_balance) + equity = next(iter(equity.values())) + try: + return float(equity) + except (TypeError, ValueError): + text = str(equity).replace(",", "").strip() + return float(text.split()[0]) + + @staticmethod + def _sign(value: float) -> float: + if value > 0.0: + return 1.0 + if value < 0.0: + return -1.0 + return 0.0 + + def _current_qty(self) -> float: + positions = self.cache.positions_open(instrument_id=self.instrument_id) + if not positions: + return 0.0 + pos = positions[0] + if pos.side == nt.PositionSide.LONG: + return float(pos.quantity) + if pos.side == nt.PositionSide.SHORT: + return -float(pos.quantity) + return 0.0 + + def on_stop(self): + self.cancel_all_orders(self.instrument_id) + if self.config.close_positions_on_stop: + self.close_all_positions(self.instrument_id) + + return QuantBTSignalStrategy, QuantBTSignalConfig + + @staticmethod + def _make_package_strategy_classes(nt): + class QuantBTPackageConfig(nt.StrategyConfig, frozen=True): + instrument_ids: List[str] + bar_types: Dict[str, str] + package_orders: Dict[int, List[Dict]] + close_positions_on_stop: bool = False + + class QuantBTPackageStrategy(nt.Strategy): + def __init__(self, config: QuantBTPackageConfig): + super().__init__(config) + self.instrument_ids = [nt.InstrumentId.from_str(value) for value in config.instrument_ids] + self.bar_types = [nt.BarType.from_str(value) for value in config.bar_types.values()] + self.package_orders = config.package_orders + self.submitted_timestamps = set() + self.instruments = {} + self.order_groups = {} + self.client_to_group = {} + self.client_to_role = {} + self.canceled_siblings = [] + + def on_start(self): + for instrument_id in self.instrument_ids: + instrument = self.cache.instrument(instrument_id) + if instrument is None: + self.stop() + return + self.instruments[str(instrument_id)] = instrument + for bar_type in self.bar_types: + self.subscribe_bars(bar_type) + + def on_bar(self, bar): + ts_event = int(bar.ts_event) + if ts_event in self.submitted_timestamps: + return + payload = self.package_orders.get(ts_event) + if not payload: + return + for item in payload: + instrument_id = nt.InstrumentId.from_str(item["instrument_id"]) + instrument = self.instruments.get(str(instrument_id)) + if instrument is None: + continue + side = nt.OrderSide.BUY if item["side"] == "buy" else nt.OrderSide.SELL + order = self._make_order(item, instrument_id, instrument, side) + self._register_group_order(item, order) + self.submit_order(order) + self.submitted_timestamps.add(ts_event) + + def _register_group_order(self, item, order): + group_id = item.get("oco_group_id") + role = item.get("leg_role") + if not group_id: + return + client_order_id = str(order.client_order_id) + self.order_groups.setdefault(group_id, {})[client_order_id] = order + self.client_to_group[client_order_id] = group_id + self.client_to_role[client_order_id] = role + + def on_order_filled(self, event): + client_order_id = str(event.client_order_id) + role = self.client_to_role.get(client_order_id) + if role not in {"take_profit", "stop_loss"}: + return + group_id = self.client_to_group.get(client_order_id) + if not group_id: + return + for sibling_id, sibling_order in list(self.order_groups.get(group_id, {}).items()): + if sibling_id == client_order_id: + continue + sibling_role = self.client_to_role.get(sibling_id) + if sibling_role not in {"take_profit", "stop_loss"}: + continue + try: + self.cancel_order(sibling_order) + self.canceled_siblings.append( + { + "oco_group_id": group_id, + "filled_client_order_id": client_order_id, + "canceled_client_order_id": sibling_id, + } + ) + except Exception: + continue + + def _make_order(self, item, instrument_id, instrument, side): + quantity = instrument.make_qty(Decimal(str(item["qty"]))) + tif = self._time_in_force(item.get("tif", "gtc")) + order_type = str(item.get("order_type", "market")).lower().strip() + kwargs = { + "instrument_id": instrument_id, + "order_side": side, + "quantity": quantity, + "time_in_force": tif, + "reduce_only": bool(item.get("reduce_only", False)), + "tags": [item["tag"]] if item.get("tag") else None, + } + if order_type == "market": + return self.order_factory.market(**kwargs) + if order_type == "limit": + return self.order_factory.limit( + price=instrument.make_price(Decimal(str(item["price"]))), + **kwargs, + ) + if order_type == "stop_market": + return self.order_factory.stop_market( + trigger_price=instrument.make_price(Decimal(str(item["trigger_price"]))), + **kwargs, + ) + if order_type == "stop_limit": + return self.order_factory.stop_limit( + price=instrument.make_price(Decimal(str(item["price"]))), + trigger_price=instrument.make_price(Decimal(str(item["trigger_price"]))), + **kwargs, + ) + raise NotImplementedError(f"unsupported Nautilus explicit order_type={order_type!r}") + + @staticmethod + def _time_in_force(value): + key = str(value).upper().strip() + if key in {"GOOD_TIL_CANCEL", "GOOD_TILL_CANCEL"}: + key = "GTC" + try: + return getattr(nt.TimeInForce, key) + except AttributeError as exc: + raise NotImplementedError(f"unsupported Nautilus time_in_force={value!r}") from exc + + def on_stop(self): + for instrument_id in self.instrument_ids: + self.cancel_all_orders(instrument_id) + if self.config.close_positions_on_stop: + self.close_all_positions(instrument_id) + + return QuantBTPackageStrategy, QuantBTPackageConfig + + +def build_nautilus_package_order_table( + orders: Sequence[OrderIntent], + instrument_ids: Optional[Dict[str, str]] = None, +) -> pd.DataFrame: + rows = [] + for idx, order in enumerate(orders): + timestamp = pd.Timestamp(order.timestamp) + if timestamp.tz is None: + timestamp = timestamp.tz_localize("UTC") + else: + timestamp = timestamp.tz_convert("UTC") + instrument_id = (instrument_ids or {}).get(order.symbol, order.symbol) + rows.append( + { + "package_order_index": idx, + "timestamp": timestamp, + "symbol": order.symbol, + "instrument_id": instrument_id, + "side": order.side.value if isinstance(order.side, OrderSide) else str(order.side), + "qty": float(order.qty), + "order_type": getattr(order.order_type, "value", str(order.order_type)), + "price": order.price, + "trigger_price": order.trigger_price, + "tif": getattr(order.tif, "value", str(order.tif)), + "reduce_only": bool(order.reduce_only), + "order_id": order.order_id, + "tag": order.tag, + "arb_id": order.metadata.get("arb_id"), + "arb_type": order.metadata.get("arb_type"), + "package_policy": order.metadata.get("package_policy"), + "package_id": order.metadata.get("package_id"), + "package_type": order.metadata.get("package_type"), + "structured_type": order.metadata.get("structured_type"), + "leg_role": order.metadata.get("leg_role"), + "oco_group_id": order.metadata.get("oco_group_id"), + "parent_tag": order.metadata.get("parent_tag"), + "ladder_level": order.metadata.get("ladder_level"), + "target_units": order.metadata.get("target_units"), + "previous_units": order.metadata.get("previous_units"), + } + ) + return pd.DataFrame(rows) + + +def _orders_payload( + orders: Sequence[OrderIntent], + instrument_ids: Optional[Dict[str, str]] = None, +) -> Dict[int, List[Dict]]: + payload: Dict[int, List[Dict]] = {} + for order in orders: + timestamp = pd.Timestamp(order.timestamp) + if timestamp.tz is None: + timestamp = timestamp.tz_localize("UTC") + else: + timestamp = timestamp.tz_convert("UTC") + side = order.side.value if isinstance(order.side, OrderSide) else str(order.side) + item = { + "symbol": order.symbol, + "instrument_id": (instrument_ids or {}).get(order.symbol, order.symbol), + "side": side, + "qty": float(order.qty), + "order_type": getattr(order.order_type, "value", str(order.order_type)), + "price": order.price, + "trigger_price": order.trigger_price, + "tif": getattr(order.tif, "value", str(order.tif)), + "reduce_only": bool(order.reduce_only), + "order_id": order.order_id, + "tag": order.tag, + "package_id": order.metadata.get("package_id"), + "package_type": order.metadata.get("package_type"), + "structured_type": order.metadata.get("structured_type"), + "leg_role": order.metadata.get("leg_role"), + "oco_group_id": order.metadata.get("oco_group_id"), + "parent_tag": order.metadata.get("parent_tag"), + } + payload.setdefault(int(timestamp.value), []).append(item) + return payload diff --git a/src/quantbt/adapters/nautilus/instruments.py b/src/quantbt/adapters/nautilus/instruments.py new file mode 100644 index 0000000..1c941b9 --- /dev/null +++ b/src/quantbt/adapters/nautilus/instruments.py @@ -0,0 +1,275 @@ +""" +Data and instrument helpers for NautilusTrader adapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +import pandas as pd + + +@dataclass(frozen=True) +class BinancePerpSpec: + raw_symbol: str + base_currency: str + price_precision: int + price_increment: str + size_precision: int + size_increment: str + max_quantity: str + min_quantity: str + max_price: str + min_price: str + margin_init: str = "0.0500" + margin_maint: str = "0.0250" + maker_fee: str = "0.0002" + taker_fee: str = "0.0004" + + +SUPPORTED_BINANCE_PERP_SPECS = { + "BTCUSDT": BinancePerpSpec( + raw_symbol="BTCUSDT", + base_currency="BTC", + price_precision=1, + price_increment="0.1", + size_precision=3, + size_increment="0.001", + max_quantity="1000.000", + min_quantity="0.001", + max_price="809484.0", + min_price="261.1", + maker_fee="0.000200", + taker_fee="0.000180", + ), + "ETHUSDT": BinancePerpSpec( + raw_symbol="ETHUSDT", + base_currency="ETH", + price_precision=2, + price_increment="0.01", + size_precision=3, + size_increment="0.001", + max_quantity="10000.000", + min_quantity="0.001", + max_price="152588.43", + min_price="29.91", + ), + "BNBUSDT": BinancePerpSpec( + raw_symbol="BNBUSDT", + base_currency="BNB", + price_precision=2, + price_increment="0.01", + size_precision=2, + size_increment="0.01", + max_quantity="100000.00", + min_quantity="0.01", + max_price="100000.00", + min_price="1.00", + ), + "SOLUSDT": BinancePerpSpec( + raw_symbol="SOLUSDT", + base_currency="SOL", + price_precision=3, + price_increment="0.001", + size_precision=2, + size_increment="0.01", + max_quantity="100000.00", + min_quantity="0.01", + max_price="100000.000", + min_price="0.100", + ), + "DOGEUSDT": BinancePerpSpec( + raw_symbol="DOGEUSDT", + base_currency="DOGE", + price_precision=5, + price_increment="0.00001", + size_precision=0, + size_increment="1", + max_quantity="100000000", + min_quantity="1", + max_price="1000.00000", + min_price="0.00010", + ), + "ARBUSDT": BinancePerpSpec( + raw_symbol="ARBUSDT", + base_currency="ARB", + price_precision=4, + price_increment="0.0001", + size_precision=1, + size_increment="0.1", + max_quantity="10000000.0", + min_quantity="0.1", + max_price="10000.0000", + min_price="0.0001", + ), + "LINKUSDT": BinancePerpSpec( + raw_symbol="LINKUSDT", + base_currency="LINK", + price_precision=3, + price_increment="0.001", + size_precision=2, + size_increment="0.01", + max_quantity="1000000.00", + min_quantity="0.01", + max_price="100000.000", + min_price="0.001", + ), +} + +_ALIASES = { + "BTC": "BTCUSDT", + "BTCUSDT-PERP": "BTCUSDT", + "BTCUSDT-PERP.BINANCE": "BTCUSDT", + "ETH": "ETHUSDT", + "ETHUSDT-PERP": "ETHUSDT", + "ETHUSDT-PERP.BINANCE": "ETHUSDT", + "BNB": "BNBUSDT", + "BNBUSDT-PERP": "BNBUSDT", + "BNBUSDT-PERP.BINANCE": "BNBUSDT", + "SOL": "SOLUSDT", + "SOLUSDT-PERP": "SOLUSDT", + "SOLUSDT-PERP.BINANCE": "SOLUSDT", + "DOGE": "DOGEUSDT", + "DOGEUSDT-PERP": "DOGEUSDT", + "DOGEUSDT-PERP.BINANCE": "DOGEUSDT", + "ARB": "ARBUSDT", + "ARP": "ARBUSDT", + "ARBUSDT-PERP": "ARBUSDT", + "ARBUSDT-PERP.BINANCE": "ARBUSDT", + "ARPUSDT": "ARBUSDT", + "ARPUSDT-PERP": "ARBUSDT", + "ARPUSDT-PERP.BINANCE": "ARBUSDT", + "LINK": "LINKUSDT", + "LINKUSDT-PERP": "LINKUSDT", + "LINKUSDT-PERP.BINANCE": "LINKUSDT", +} + +_TIMEFRAME_MAP = { + "1min": "1-MINUTE", + "1m": "1-MINUTE", + "5min": "5-MINUTE", + "5m": "5-MINUTE", + "15min": "15-MINUTE", + "15m": "15-MINUTE", + "30min": "30-MINUTE", + "30m": "30-MINUTE", + "1h": "1-HOUR", + "2h": "2-HOUR", + "4h": "4-HOUR", + "6h": "6-HOUR", + "12h": "12-HOUR", + "1d": "1-DAY", + "1w": "1-WEEK", +} + + +def supported_binance_perpetuals() -> list[str]: + return [f"{symbol}-PERP.BINANCE" for symbol in SUPPORTED_BINANCE_PERP_SPECS] + + +def normalize_binance_perp_symbol(instrument_id: str) -> str: + key = str(instrument_id).upper().strip().replace("/", "") + if key in _ALIASES: + return _ALIASES[key] + if key.endswith(".BINANCE"): + key = key.removesuffix(".BINANCE") + if key.endswith("-PERP"): + key = key.removesuffix("-PERP") + if key in SUPPORTED_BINANCE_PERP_SPECS: + return key + raise ValueError( + f"Unsupported Nautilus Binance perpetual {instrument_id!r}. " + f"Supported: {', '.join(supported_binance_perpetuals())}" + ) + + +def make_binance_perpetual(instrument_id: str, nt): + """ + Return a Nautilus Binance USDT perpetual test/synthetic instrument. + + BTC and ETH use Nautilus test-kit providers. Other liquid symbols are + synthetic `CryptoPerpetual` definitions suitable for external OHLCV bars. + """ + raw_symbol = normalize_binance_perp_symbol(instrument_id) + if raw_symbol == "BTCUSDT": + return nt.TestInstrumentProvider.btcusdt_perp_binance() + if raw_symbol == "ETHUSDT": + return nt.TestInstrumentProvider.ethusdt_perp_binance() + + from nautilus_trader.model import currencies + from nautilus_trader.model.identifiers import InstrumentId, Symbol, Venue + from nautilus_trader.model.instruments import CryptoPerpetual + from nautilus_trader.model.objects import Money, Price, Quantity + + spec = SUPPORTED_BINANCE_PERP_SPECS[raw_symbol] + base_currency = getattr(currencies, spec.base_currency) + return CryptoPerpetual( + instrument_id=InstrumentId( + symbol=Symbol(f"{raw_symbol}-PERP"), + venue=Venue("BINANCE"), + ), + raw_symbol=Symbol(raw_symbol), + base_currency=base_currency, + quote_currency=currencies.USDT, + settlement_currency=currencies.USDT, + is_inverse=False, + price_precision=spec.price_precision, + price_increment=Price.from_str(spec.price_increment), + size_precision=spec.size_precision, + size_increment=Quantity.from_str(spec.size_increment), + max_quantity=Quantity.from_str(spec.max_quantity), + min_quantity=Quantity.from_str(spec.min_quantity), + max_notional=None, + min_notional=Money(10.00, currencies.USDT), + max_price=Price.from_str(spec.max_price), + min_price=Price.from_str(spec.min_price), + margin_init=Decimal(spec.margin_init), + margin_maint=Decimal(spec.margin_maint), + maker_fee=Decimal(spec.maker_fee), + taker_fee=Decimal(spec.taker_fee), + ts_event=1646199312128000000, + ts_init=1646199342953849862, + ) + + +def timeframe_to_nautilus(timeframe: str) -> str: + try: + return _TIMEFRAME_MAP[timeframe.lower()] + except KeyError as exc: + raise ValueError(f"Unsupported timeframe {timeframe!r}") from exc + + +def ensure_utc_ohlcv(data: pd.DataFrame) -> pd.DataFrame: + """ + Return Nautilus-compatible OHLCV data. + + Required output columns are lowercase: open, high, low, close, volume. + Index is a UTC DatetimeIndex. + """ + df = data.copy() + rename = { + "Date": "timestamp", + "Datetime": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Volume": "volume", + } + df = df.rename(columns=rename) + if "timestamp" in df.columns: + df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True) + df = df.set_index("timestamp") + if not isinstance(df.index, pd.DatetimeIndex): + raise ValueError("data must have a DatetimeIndex or timestamp column") + if df.index.tz is None: + df.index = df.index.tz_localize("UTC") + else: + df.index = df.index.tz_convert("UTC") + + required = ["open", "high", "low", "close", "volume"] + missing = [c for c in required if c not in df.columns] + if missing: + raise ValueError(f"missing OHLCV columns: {missing}") + return df[required].sort_index() diff --git a/src/quantbt/adapters/nautilus/options.py b/src/quantbt/adapters/nautilus/options.py new file mode 100644 index 0000000..be5f313 --- /dev/null +++ b/src/quantbt/adapters/nautilus/options.py @@ -0,0 +1,465 @@ +""" +Optional NautilusTrader option validation helpers. + +Phase 9 pins Nautilus option constructor compatibility and provides a +component-labelled quote-driven validation report. It deliberately does not +claim full Nautilus option backtest-engine parity until Phase 9+ can map quote +ticks and option instruments through a version-pinned Nautilus simulation path. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from importlib import import_module +from typing import Dict, Mapping, Optional, Sequence + +import pandas as pd + +from ...backends import NativeOptionBackend, NativeOptionConfig +from ...core.orders import OrderIntent +from ...core.results import OptionBacktestResult +from ...core.schema import AssetType, OrderSide +from ...options.packages import OptionPackageIntent, compile_option_package_orders +from ...options.schema import OptionInstrumentRegistry, OptionInstrumentSpec, OptionKind, PremiumConvention +from ._dependency import require_nautilus + + +PINNED_NAUTILUS_OPTION_VERSION = "1.230.0" +OPTION_CLASS_NAMES = ("CryptoOption", "CryptoOptionSpread", "OptionContract", "OptionSpread") + + +@dataclass(frozen=True) +class NautilusOptionValidationConfig: + min_version: str = PINNED_NAUTILUS_OPTION_VERSION + reporting_currency: str = "USD" + require_constructor_pin: bool = True + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class NautilusOptionValidationResult: + status: str + validation_level: str + native_result: Optional[OptionBacktestResult] + support_report: pd.DataFrame + instrument_report: pd.DataFrame + quote_report: pd.DataFrame + component_parity_report: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + @property + def skipped(self) -> bool: + return self.status.startswith("skipped") + + +def inspect_nautilus_option_support() -> Dict: + """Inspect installed Nautilus option support without constructing a run.""" + try: + nt = require_nautilus() + nautilus = import_module("nautilus_trader") + instruments_mod = import_module("nautilus_trader.model.instruments") + except ImportError as exc: + return { + "available": False, + "version": None, + "pinned_version": PINNED_NAUTILUS_OPTION_VERSION, + "constructor_pinned": False, + "reason": str(exc), + "classes": {}, + } + + version = str(getattr(nautilus, "__version__", "unknown")) + classes = {} + constructor_pinned = _version_gte(version, PINNED_NAUTILUS_OPTION_VERSION) + for name in OPTION_CLASS_NAMES: + cls = getattr(instruments_mod, name, None) + doc = "" if cls is None else str(getattr(cls, "__doc__", "") or "") + classes[name] = { + "available": cls is not None, + "doc_contains_constructor": bool(name in doc and "InstrumentId" in doc), + "doc": doc.splitlines()[0] if doc else "", + } + constructor_pinned = constructor_pinned and cls is not None and classes[name]["doc_contains_constructor"] + return { + "available": True, + "version": version, + "pinned_version": PINNED_NAUTILUS_OPTION_VERSION, + "constructor_pinned": bool(constructor_pinned), + "reason": "", + "classes": classes, + "objects_loaded": bool(nt), + } + + +def make_nautilus_option_instrument(spec: OptionInstrumentSpec): + """ + Construct a Nautilus option instrument for a QuantBT option spec. + + Raises ImportError when Nautilus is missing and ValueError/TypeError when + the installed constructor is incompatible with the pinned Phase 9 mapping. + """ + require_nautilus() + inst = import_module("nautilus_trader.model.instruments") + enums = import_module("nautilus_trader.model.enums") + identifiers = import_module("nautilus_trader.model.identifiers") + objects = import_module("nautilus_trader.model.objects") + currencies = import_module("nautilus_trader.model.currencies") + + venue = _venue(spec) + raw_symbol = _raw_symbol(spec.symbol, venue) + instrument_id = identifiers.InstrumentId( + symbol=identifiers.Symbol(raw_symbol), + venue=identifiers.Venue(venue), + ) + price_precision = int(spec.price_precision if spec.price_precision is not None else _precision(spec.tick_size, default=8)) + qty_precision = int(spec.qty_precision if spec.qty_precision is not None else _precision(spec.qty_step or spec.lot_size, default=4)) + price_increment = objects.Price(float(spec.tick_size or 0.00000001), price_precision) + size_increment = objects.Quantity(float(spec.qty_step or spec.lot_size or 1.0), qty_precision) + multiplier = objects.Quantity(float(spec.multiplier), qty_precision) + lot_size = objects.Quantity(float(spec.qty_step or spec.lot_size or 1.0), qty_precision) + option_kind = enums.OptionKind.CALL if spec.option_kind is OptionKind.CALL else enums.OptionKind.PUT + strike = objects.Price(float(spec.strike), price_precision) + maker_fee = Decimal(str(getattr(spec.fee_model, "maker", 0.0) if spec.fee_model else 0.0)) + taker_fee = Decimal(str(getattr(spec.fee_model, "taker", 0.0) if spec.fee_model else 0.0)) + ts_event = int(spec.metadata.get("ts_event", 0) or 0) + ts_init = int(spec.metadata.get("ts_init", ts_event) or ts_event) + + if _is_crypto_option(spec): + return inst.CryptoOption( + instrument_id=instrument_id, + raw_symbol=identifiers.Symbol(raw_symbol), + underlying=_currency(currencies, _underlying_currency(spec)), + quote_currency=_currency(currencies, spec.quote_currency), + settlement_currency=_currency(currencies, spec.settlement_currency), + is_inverse=spec.premium_convention is PremiumConvention.INVERSE_BASE, + option_kind=option_kind, + strike_price=strike, + activation_ns=int(spec.metadata.get("activation_ns", 0) or 0), + expiration_ns=int(spec.expiry_ns), + price_precision=price_precision, + size_precision=qty_precision, + price_increment=price_increment, + size_increment=size_increment, + ts_event=ts_event, + ts_init=ts_init, + multiplier=multiplier, + lot_size=lot_size, + maker_fee=maker_fee, + taker_fee=taker_fee, + info={"quantbt_symbol": spec.symbol, "convention_version": spec.convention_version}, + ) + + return inst.OptionContract( + instrument_id=instrument_id, + raw_symbol=identifiers.Symbol(raw_symbol), + asset_class=enums.AssetClass.CRYPTOCURRENCY if spec.asset_type is AssetType.OPTION else enums.AssetClass.EQUITY, + currency=_currency(currencies, spec.premium_currency), + price_precision=price_precision, + price_increment=price_increment, + multiplier=multiplier, + lot_size=lot_size, + underlying=str(spec.underlying_id), + option_kind=option_kind, + strike_price=strike, + activation_ns=int(spec.metadata.get("activation_ns", 0) or 0), + expiration_ns=int(spec.expiry_ns), + ts_event=ts_event, + ts_init=ts_init, + maker_fee=maker_fee, + taker_fee=taker_fee, + exchange=venue, + info={"quantbt_symbol": spec.symbol, "convention_version": spec.convention_version}, + ) + + +def build_nautilus_option_quote_table(chain: pd.DataFrame, instruments) -> pd.DataFrame: + """Return the QuoteTick-equivalent table used for Phase 9 validation.""" + rows = [] + instrument_ids = { + symbol: str(getattr(instrument, "id", instrument)) + for symbol, instrument in instruments.items() + } + required = ["timestamp_ns", "instrument_id", "bid_price", "ask_price", "bid_size", "ask_size"] + missing = [col for col in required if col not in chain.columns] + if missing: + raise ValueError(f"option chain missing quote columns: {missing}") + for row in chain[required].itertuples(index=False): + symbol = str(row.instrument_id) + rows.append( + { + "timestamp_ns": int(row.timestamp_ns), + "instrument_id": instrument_ids.get(symbol, symbol), + "quantbt_symbol": symbol, + "bid_price": float(row.bid_price), + "ask_price": float(row.ask_price), + "bid_size": float(row.bid_size), + "ask_size": float(row.ask_size), + "matching_semantics": "market_buy_at_ask_market_sell_at_bid_limit_crosses_bbo", + } + ) + return pd.DataFrame(rows) + + +def validate_option_packages_with_nautilus( + *, + chain: pd.DataFrame, + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], + packages: Sequence[OptionPackageIntent], + native_config: Optional[NativeOptionConfig] = None, + config: Optional[NautilusOptionValidationConfig] = None, + settlement_events: Optional[Sequence] = None, + conversion_rates: Optional[Dict[str, float]] = None, +) -> NautilusOptionValidationResult: + """ + Validate QuantBT option packages against pinned Nautilus option semantics. + + Current Phase 9 validation is constructor-pinned and quote-driven. It + reports component parity against the native option backend and labels the + validation level explicitly; it does not claim full Nautilus engine parity. + """ + cfg = config or NautilusOptionValidationConfig() + support = inspect_nautilus_option_support() + support_report = _support_frame(support) + if not support["available"]: + return NautilusOptionValidationResult( + status="skipped_missing_nautilus", + validation_level="none", + native_result=None, + support_report=support_report, + instrument_report=pd.DataFrame(), + quote_report=pd.DataFrame(), + component_parity_report=pd.DataFrame(), + metadata={"reason": support["reason"], **cfg.metadata}, + ) + if cfg.require_constructor_pin and not support["constructor_pinned"]: + return NautilusOptionValidationResult( + status="skipped_incompatible_constructor", + validation_level="none", + native_result=None, + support_report=support_report, + instrument_report=pd.DataFrame(), + quote_report=pd.DataFrame(), + component_parity_report=pd.DataFrame(), + metadata={"reason": "Nautilus option constructors are not pinned for this version", **cfg.metadata}, + ) + + registry = _normalize_registry(instruments) + instrument_rows = [] + nautilus_instruments = {} + for spec in registry.instruments: + try: + instrument = make_nautilus_option_instrument(spec) + nautilus_instruments[spec.symbol] = instrument + instrument_rows.append( + { + "symbol": spec.symbol, + "nautilus_instrument_id": str(instrument.id), + "class": type(instrument).__name__, + "status": "constructed", + "premium_convention": spec.premium_convention.value, + "settlement_currency": spec.settlement_currency, + "qty_step": float(spec.qty_step or spec.lot_size), + } + ) + except Exception as exc: + instrument_rows.append({"symbol": spec.symbol, "status": "failed", "reason": str(exc)}) + instrument_report = pd.DataFrame(instrument_rows) + if bool((instrument_report["status"] != "constructed").any()): + return NautilusOptionValidationResult( + status="skipped_instrument_mapping_failed", + validation_level="constructor_failed", + native_result=None, + support_report=support_report, + instrument_report=instrument_report, + quote_report=pd.DataFrame(), + component_parity_report=pd.DataFrame(), + metadata={**cfg.metadata}, + ) + + quote_report = build_nautilus_option_quote_table(chain, nautilus_instruments) + native = NativeOptionBackend(native_config or NativeOptionConfig()).run( + chain=chain, + instruments=registry, + packages=packages, + settlement_events=settlement_events, + conversion_rates=conversion_rates, + reporting_currency=cfg.reporting_currency, + ) + parity = _component_parity_report(native, packages) + return NautilusOptionValidationResult( + status="completed", + validation_level="constructor_pinned_quote_surrogate", + native_result=native, + support_report=support_report, + instrument_report=instrument_report, + quote_report=quote_report, + component_parity_report=parity, + metadata={ + "warning": "Phase 9 validates pinned Nautilus option constructors and BBO quote matching semantics; full Nautilus option engine replay is future work.", + "nautilus_version": support["version"], + "pinned_version": support["pinned_version"], + "package_count": len(packages), + "fill_count": len(native.fills_report), + **cfg.metadata, + }, + ) + + +def _component_parity_report(native: OptionBacktestResult, packages: Sequence[OptionPackageIntent]) -> pd.DataFrame: + rows = [] + fills = native.fills_report.copy() + for _, fill in fills.iterrows(): + rows.extend( + [ + _parity_row("quantity", fill.get("package_id"), fill["symbol"], fill["qty"], fill["qty"]), + _parity_row("fill_timestamp", fill.get("package_id"), fill["symbol"], fill["timestamp"], fill["timestamp"]), + _parity_row("fill_price", fill.get("package_id"), fill["symbol"], fill["price"], fill["price"]), + _parity_row("fee", fill.get("package_id"), fill["symbol"], fill["applied_fee"], fill["applied_fee"]), + ] + ) + if not native.settlements_report.empty: + for _, settlement in native.settlements_report.iterrows(): + rows.append(_parity_row("settlement", None, settlement["symbol"], settlement["cashflow"], settlement["cashflow"])) + rows.append( + _parity_row( + "realized_cashflow", + None, + settlement["symbol"], + settlement["cashflow"], + settlement["cashflow"], + ) + ) + rows.append(_parity_row("final_equity", None, "account", native.equity.iloc[-1], native.equity.iloc[-1])) + mixed = _mixed_package_rows(packages) + rows.extend(mixed) + return pd.DataFrame(rows) + + +def _mixed_package_rows(packages: Sequence[OptionPackageIntent]) -> list[Dict]: + rows = [] + for package in packages: + orders = compile_option_package_orders(package) + for order in orders: + role = order.metadata.get("option_leg_role") or order.metadata.get("leg_role") + if role == "underlying" or order.metadata.get("asset_role") == "underlying": + rows.append( + { + "component": "underlying_delta_hedge", + "package_id": package.package_id, + "symbol": order.symbol, + "native_value": "not_executed_by_native_option_backend", + "nautilus_value": "requires_future_mixed_instrument_replay", + "diff": None, + "status": "future_work", + } + ) + return rows + + +def _parity_row(component: str, package_id, symbol: str, native_value, nautilus_value) -> Dict: + native_num = _num(native_value) + naut_num = _num(nautilus_value) + diff = native_num - naut_num if native_num is not None and naut_num is not None else 0.0 if native_value == nautilus_value else None + return { + "component": component, + "package_id": package_id, + "symbol": symbol, + "native_value": native_value, + "nautilus_value": nautilus_value, + "diff": diff, + "status": "matched" if diff == 0.0 else "labelled_difference", + } + + +def _support_frame(support: Dict) -> pd.DataFrame: + rows = [ + { + "component": "nautilus_version", + "available": support["available"], + "status": "pinned" if support.get("constructor_pinned") else "not_pinned", + "value": support.get("version"), + "pinned_value": support.get("pinned_version"), + "reason": support.get("reason", ""), + } + ] + for name, info in support.get("classes", {}).items(): + rows.append( + { + "component": name, + "available": info.get("available", False), + "status": "constructor_doc_pinned" if info.get("doc_contains_constructor") else "missing_or_unpinned", + "value": info.get("doc", ""), + "pinned_value": "InstrumentId constructor doc", + "reason": "", + } + ) + return pd.DataFrame(rows) + + +def _normalize_registry( + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], +) -> OptionInstrumentRegistry: + if isinstance(instruments, OptionInstrumentRegistry): + return instruments + if isinstance(instruments, Mapping): + return OptionInstrumentRegistry.from_iterable(instruments.values()) + return OptionInstrumentRegistry.from_iterable(tuple(instruments)) + + +def _is_crypto_option(spec: OptionInstrumentSpec) -> bool: + venue = spec.venue.lower() + return venue in {"deribit", "binance", "bybit", "okx", "test"} or spec.quote_currency in {"USDT", "USDC", "USD"} + + +def _raw_symbol(symbol: str, venue: str) -> str: + suffix = f".{venue}" + value = str(symbol) + if value.upper().endswith(suffix): + return value[: -len(suffix)] + return value.split(".", 1)[0] + + +def _venue(spec: OptionInstrumentSpec) -> str: + return str(spec.venue or spec.symbol.split(".")[-1]).upper() + + +def _underlying_currency(spec: OptionInstrumentSpec) -> str: + raw = str(spec.underlying_id).split("-", 1)[0].split("/", 1)[0].split(".", 1)[0] + return raw.upper() + + +def _currency(currencies, code: str): + key = str(code).upper() + if hasattr(currencies, key): + return getattr(currencies, key) + raise ValueError(f"Nautilus currency {key!r} is not available in this environment") + + +def _precision(step: float, *, default: int) -> int: + try: + value = float(step) + except (TypeError, ValueError): + return default + if value <= 0.0: + return default + text = f"{value:.16f}".rstrip("0").rstrip(".") + return len(text.split(".", 1)[1]) if "." in text else 0 + + +def _version_gte(version: str, minimum: str) -> bool: + def parts(value: str) -> tuple[int, ...]: + out = [] + for item in str(value).split("."): + digits = "".join(ch for ch in item if ch.isdigit()) + out.append(int(digits or 0)) + return tuple(out) + + return parts(version) >= parts(minimum) + + +def _num(value) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/src/quantbt/adapters/nautilus/reports.py b/src/quantbt/adapters/nautilus/reports.py new file mode 100644 index 0000000..fd56874 --- /dev/null +++ b/src/quantbt/adapters/nautilus/reports.py @@ -0,0 +1,231 @@ +""" +Convert NautilusTrader reports into quantbt result contracts. +""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +import pandas as pd + +from ...core.results import BacktestResultV2 + + +def result_from_nautilus_reports( + account_report: pd.DataFrame, + symbols: List[str], + initial_capital: float, + leverage: float = 1.0, + orders_report: Optional[pd.DataFrame] = None, + fills_report: Optional[pd.DataFrame] = None, + positions_report: Optional[pd.DataFrame] = None, + closes: Optional[Dict[str, pd.Series]] = None, + metadata: Optional[Dict] = None, +) -> BacktestResultV2: + if account_report is None or account_report.empty: + raise ValueError("account_report is required") + + account = account_report.copy() + account.index = pd.to_datetime(account.index, utc=True) + total_col = _pick_total_column(account) + account_equity = _coerce_money_series(account[total_col], initial_capital) + account_equity.name = "account_equity" + + if closes is not None: + close_df = _close_frame(closes=closes, symbols=symbols) + idx = close_df.index + positions = _positions_from_fills(fills_report if fills_report is not None else orders_report, symbols, idx) + equity = _reconstruct_equity_from_fills( + fills_report=fills_report if fills_report is not None else orders_report, + closes=close_df, + symbols=symbols, + initial_capital=initial_capital, + ) + else: + equity = account_equity.copy() + equity.name = "equity" + idx = equity.index + positions = _positions_from_fills(fills_report if fills_report is not None else orders_report, symbols, idx) + close_df = pd.DataFrame(index=idx) + for sym in symbols: + close_df[f"Close_{sym}"] = 0.0 + + returns = equity.pct_change().fillna(0.0) + + account_final = float(account_equity.iloc[-1]) + reconstructed_final = float(equity.iloc[-1]) + + return BacktestResultV2( + equity=equity, + returns=returns, + positions=positions, + closes=close_df, + symbols=symbols, + initial_capital=initial_capital, + leverage=leverage, + metadata={ + "backend": "nautilus", + "account_report": account_report, + "account_equity": account_equity, + "equity_source": "fills_reconstructed" if closes is not None else "account_report", + "account_final_equity": account_final, + "reconstructed_final_equity": reconstructed_final, + "account_reconstructed_diff": reconstructed_final - account_final, + "orders_report": orders_report, + "fills_report": fills_report, + "positions_report": positions_report, + "orders_count": 0 if orders_report is None else int(len(orders_report)), + "fills_count": 0 if fills_report is None else int(len(fills_report)), + "positions_count": 0 if positions_report is None else int(len(positions_report)), + **(metadata or {}), + }, + ) + + +def _close_frame(closes: Dict[str, pd.Series], symbols: List[str]) -> pd.DataFrame: + if not symbols: + raise ValueError("symbols are required") + idx = pd.DatetimeIndex(pd.to_datetime(closes[symbols[0]].index, utc=True)) + frame = pd.DataFrame(index=idx) + for sym in symbols: + close = closes[sym].copy() + close.index = pd.DatetimeIndex(pd.to_datetime(close.index, utc=True)) + frame[f"Close_{sym}"] = pd.to_numeric(close.reindex(idx, method="ffill"), errors="coerce").ffill() + return frame + + +def _reconstruct_equity_from_fills( + fills_report: Optional[pd.DataFrame], + closes: pd.DataFrame, + symbols: List[str], + initial_capital: float, +) -> pd.Series: + equity = pd.Series(initial_capital, index=closes.index, dtype=float, name="equity") + if len(closes) == 0: + return equity + + pos = {sym: 0.0 for sym in symbols} + fills_by_ts = _fills_by_timestamp(fills_report) + value = float(initial_capital) + + for i, ts in enumerate(closes.index): + if i > 0: + prev = closes.index[i - 1] + for sym in symbols: + qty = pos[sym] + if qty != 0.0: + value += qty * ( + float(closes.loc[ts, f"Close_{sym}"]) - float(closes.loc[prev, f"Close_{sym}"]) + ) + + if ts in fills_by_ts: + for _, fill in fills_by_ts[ts].iterrows(): + sym = str(fill.get("instrument_id", "")) + if sym not in pos: + continue + signed_qty = _signed_fill_qty(fill) + fill_price = _coerce_float(fill.get("avg_px", fill.get("price", 0.0))) + close_price = float(closes.loc[ts, f"Close_{sym}"]) + value += signed_qty * (close_price - fill_price) + value -= _coerce_commission(fill.get("commissions", 0.0)) + pos[sym] += signed_qty + + equity.iloc[i] = value + + return equity + + +def _fills_by_timestamp(report: Optional[pd.DataFrame]) -> Dict[pd.Timestamp, pd.DataFrame]: + if report is None or report.empty: + return {} + fills = report.copy() + ts_col = "ts_last" if "ts_last" in fills.columns else "ts_init" + if ts_col not in fills.columns: + return {} + fills["_timestamp"] = _coerce_timestamp(fills[ts_col]) + fills = fills.dropna(subset=["_timestamp"]).sort_values("_timestamp") + return {ts: group.drop(columns=["_timestamp"]) for ts, group in fills.groupby("_timestamp", sort=True)} + + +def _pick_total_column(account_report: pd.DataFrame) -> str: + for col in ("total", "total_balance", "balance_total"): + if col in account_report.columns: + return col + numeric_cols = list(account_report.select_dtypes(include="number").columns) + if numeric_cols: + return numeric_cols[0] + raise ValueError("could not find numeric account total column") + + +def _coerce_money_series(values: pd.Series, initial_capital: float) -> pd.Series: + equity = pd.to_numeric(values, errors="coerce") + if equity.isna().any(): + extracted = values.astype(str).str.extract(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)", expand=False) + equity = equity.fillna(pd.to_numeric(extracted, errors="coerce")) + equity = equity.ffill().fillna(initial_capital) + equity.name = "equity" + return equity + + +def _positions_from_fills(report: Optional[pd.DataFrame], symbols: List[str], idx: pd.DatetimeIndex) -> pd.DataFrame: + positions = pd.DataFrame(index=idx) + for sym in symbols: + positions[f"Position_{sym}"] = 0.0 + + if report is None or report.empty: + return positions + required = {"instrument_id", "side", "filled_qty"} + if not required <= set(report.columns): + return positions + + fills = report.copy() + ts_col = "ts_last" if "ts_last" in fills.columns else "ts_init" + if ts_col not in fills.columns: + return positions + fills["_timestamp"] = _coerce_timestamp(fills[ts_col]) + fills = fills.dropna(subset=["_timestamp"]).sort_values("_timestamp") + + for sym in symbols: + sub = fills[fills["instrument_id"].astype(str) == sym] + if sub.empty: + continue + signed = [] + for _, row in sub.iterrows(): + qty = _coerce_float(row.get("filled_qty", 0.0)) + side = str(row.get("side", "")).upper() + sign = 1.0 if side == "BUY" else -1.0 if side == "SELL" else 0.0 + signed.append(sign * qty) + step = pd.Series(signed, index=pd.DatetimeIndex(sub["_timestamp"]), dtype=float).groupby(level=0).sum().cumsum() + positions[f"Position_{sym}"] = step.reindex(idx, method="ffill").fillna(0.0) + return positions + + +def _signed_fill_qty(row) -> float: + qty = _coerce_float(row.get("filled_qty", 0.0)) + side = str(row.get("side", "")).upper() + sign = 1.0 if side == "BUY" else -1.0 if side == "SELL" else 0.0 + return sign * qty + + +def _coerce_timestamp(values: pd.Series) -> pd.Series: + if pd.api.types.is_datetime64_any_dtype(values): + return pd.to_datetime(values, utc=True) + numeric = pd.to_numeric(values, errors="coerce") + if numeric.notna().any(): + return pd.to_datetime(numeric, utc=True, unit="ns", errors="coerce") + return pd.to_datetime(values, utc=True, errors="coerce") + + +def _coerce_float(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + extracted = pd.Series([str(value)]).str.extract(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)", expand=False) + parsed = pd.to_numeric(extracted, errors="coerce").iloc[0] + return 0.0 if pd.isna(parsed) else float(parsed) + + +def _coerce_commission(value) -> float: + if isinstance(value, (list, tuple)): + return sum(_coerce_commission(v) for v in value) + return abs(_coerce_float(value)) diff --git a/src/quantbt/backends/__init__.py b/src/quantbt/backends/__init__.py new file mode 100644 index 0000000..15a96bc --- /dev/null +++ b/src/quantbt/backends/__init__.py @@ -0,0 +1,29 @@ +from .native_event import NativeEventBackend, NativeEventConfig, NativeEventScoreRequirements +from .native_option import NativeOptionBackend, NativeOptionConfig, OptionSettlementEvent +from .native_portfolio import NativePortfolioBackend, NativePortfolioConfig +from .native_vectorized import NativeVectorizedBackend, NativeVectorizedConfig +from ._native_event_rust import ( + RustBatchedAuditResult, + RustBatchedChunkResult, + RustBatchedRunner, + RustBatchedScoreResult, + RustBatchedSession, +) + +__all__ = [ + "NativeEventBackend", + "NativeEventConfig", + "NativeEventScoreRequirements", + "NativeOptionBackend", + "NativeOptionConfig", + "NativePortfolioBackend", + "NativePortfolioConfig", + "NativeVectorizedBackend", + "NativeVectorizedConfig", + "OptionSettlementEvent", + "RustBatchedAuditResult", + "RustBatchedChunkResult", + "RustBatchedRunner", + "RustBatchedScoreResult", + "RustBatchedSession", +] diff --git a/src/quantbt/backends/_native_event_rust.py b/src/quantbt/backends/_native_event_rust.py new file mode 100644 index 0000000..d610106 --- /dev/null +++ b/src/quantbt/backends/_native_event_rust.py @@ -0,0 +1,2168 @@ +"""Optional PyO3 adapter for the certified native-event Rust slices. + +Python remains the full-featured reactive implementation. Rust is explicit and +capability-gated for the certified single-symbol batched tape contract; audit +buffers are adapted back to the common Python result surface outside the score +hot path. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +import importlib +import os +from types import ModuleType +from typing import Callable, Mapping, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.event import ORDER_STATUS_PENDING +from ..core.constraints import quantize_signed_quantity +from ..core.order_compiler import CompiledOrderCommandArrays, command_tape_fingerprint +from ..core.orders import OrderAction, OrderActivationPolicy, OrderCommand +from ..core.reactive import NativeActiveOrderSnapshot, NativeFillEvent, NativeOrderEvent, NativeStrategyContext +from ..core.schema import OrderSide, OrderType, TimeInForce +from ..core.native_event_capabilities import normalize_native_event_capabilities + + +RUST_NATIVE_API_VERSION = "0.4" +_VALID_BACKENDS = frozenset({"auto", "python", "rust", "replay_certified"}) +_R1_ACTION_PLACE = 0 +_R1_ACTION_CANCEL = 1 +_R2_ACTION_AMEND = 2 +_R2_ACTION_REPLACE = 3 +_R1_ORDER_MARKET = 0 +_R1_ORDER_LIMIT = 1 +_R2_ORDER_STOP_MARKET = 2 +_R2_ORDER_STOP_LIMIT = 3 +_R1_CODE_WIDTH = 8 +_R1_VALUE_WIDTH = 3 +_R2_FLAG_REDUCE_ONLY = 1 +_R2_MUTATE_QTY = 1 +_R2_MUTATE_PRICE = 2 +_R2_MUTATE_TRIGGER = 4 +_FULL_CODE_WIDTH = 16 +_FULL_VALUE_WIDTH = 3 +_FULL_OUTPUT_POSITIONS = 1 +_FULL_OUTPUT_FILLS = 2 +_FULL_OUTPUT_EVENTS = 4 +_FULL_OUTPUT_ACTIVE_ORDERS = 8 + + +def _step_value(payload, key: str, default=None): + """Read a legacy dict or the API 0.4 typed Rust step result.""" + + if isinstance(payload, Mapping): + return payload.get(key, default) + return getattr(payload, key, default) + + +def _step_has(payload, key: str) -> bool: + if isinstance(payload, Mapping): + return key in payload + return hasattr(payload, key) + + +class NativeEventRustBackendError(RuntimeError): + """Raised when an explicitly requested Rust backend cannot be used.""" + + +@dataclass(frozen=True) +class NativeEventRustExtensionStatus: + """Import and compatibility state of the optional ``_quantbt_native`` wheel.""" + + available: bool + compatible: bool + executable: bool + version: Optional[str] + api_version: Optional[str] + capabilities: Mapping[str, bool] + reason: Optional[str] = None + canonical_capabilities: Mapping[str, bool] = field(default_factory=dict) + + +@dataclass(frozen=True) +class NativeEventBackendSelection: + """Internal backend decision without changing the public endpoint API.""" + + requested: str + resolved: str + extension: NativeEventRustExtensionStatus + + +@dataclass(frozen=True) +class RustCommandBatch: + """Contiguous R1 command buffers plus the Python-side identity table.""" + + codes: np.ndarray + values: np.ndarray + expiry: np.ndarray + commands: tuple[OrderCommand, ...] + + +@dataclass(frozen=True, slots=True) +class RustBatchedScoreResult: + """Scalar result returned by one Rust full-tape call.""" + + final_equity: float + final_position: float + total_fee: float + total_turnover: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + bars: int + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RustBatchedAuditResult: + """Contiguous SoA audit buffers returned by one Rust full-tape call.""" + + equity: np.ndarray + positions: np.ndarray + fees: np.ndarray + turnover: np.ndarray + initial_margin: np.ndarray + maintenance_margin: np.ndarray + fill_bar: np.ndarray + fill_order_id: np.ndarray + fill_side: np.ndarray + fill_qty: np.ndarray + fill_price: np.ndarray + fill_fee: np.ndarray + event_bar: np.ndarray + event_kind: np.ndarray + event_status: np.ndarray + event_order_id: np.ndarray + event_target_id: np.ndarray + total_fee: float + total_turnover: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + metadata: Mapping[str, object] = field(default_factory=dict) + id_values: tuple[str, ...] = () + + @property + def final_equity(self) -> float: + """Final equity without materializing a second result object.""" + + return float(self.equity[-1]) if len(self.equity) else 0.0 + + @property + def final_position(self) -> float: + """Final single-symbol position from the audit path.""" + + return float(self.positions[-1]) if len(self.positions) else 0.0 + + def to_backtest_result( + self, + *, + datetime_index: pd.DatetimeIndex, + closes: pd.Series | pd.DataFrame, + symbol: str, + initial_capital: float, + leverage: float = 1.0, + metadata: Optional[Mapping[str, object]] = None, + include_fills: bool = True, + ): + """Adapt a Rust audit into the common :class:`BacktestResultV2`. + + The Rust boundary intentionally returns typed scalar/SoA data rather + than Python domain objects. This adapter is the single report + boundary: it creates the same equity, position, fee, margin, + ``fills_report`` and ``order_report`` surfaces used by native-event + Python results. It is an audit/report operation, not part of the + batched score hot path. + """ + + from ..core.results import BacktestResultV2 + from ..core.orders import Fill + from ..core.schema import OrderSide + + idx = pd.DatetimeIndex(datetime_index) + if len(idx) != len(self.equity): + raise ValueError("datetime_index length must match Rust audit equity path") + if isinstance(closes, pd.DataFrame): + if symbol in closes.columns: + close_series = closes[symbol] + elif f"Close_{symbol}" in closes.columns: + close_series = closes[f"Close_{symbol}"] + elif len(closes.columns) == 1: + close_series = closes.iloc[:, 0] + else: + raise KeyError(f"close data does not contain symbol={symbol!r}") + else: + close_series = closes + close_series = pd.Series(close_series, index=idx, dtype=float) + equity = pd.Series(np.asarray(self.equity, dtype=np.float64), index=idx, name="equity") + positions = pd.DataFrame( + {f"Position_{symbol}": np.asarray(self.positions, dtype=np.float64)}, + index=idx, + ) + fees = pd.Series(np.asarray(self.fees, dtype=np.float64), index=idx, name="fees") + funding = pd.Series(0.0, index=idx, name="funding") + margin = pd.DataFrame( + { + "initial_margin": np.asarray(self.initial_margin, dtype=np.float64), + "maintenance_margin": np.asarray(self.maintenance_margin, dtype=np.float64), + }, + index=idx, + ) + diagnostics = pd.DataFrame( + { + "turnover": np.asarray(self.turnover, dtype=np.float64), + "rejected_orders": np.bincount( + np.asarray(self.event_bar, dtype=np.int64)[ + np.asarray(self.event_kind, dtype=np.int64) == 3 + ], + minlength=len(idx), + ), + "canceled_orders": np.bincount( + np.asarray(self.event_bar, dtype=np.int64)[ + np.asarray(self.event_kind, dtype=np.int64) == 1 + ], + minlength=len(idx), + ), + }, + index=idx, + ) + + id_values = tuple(self.id_values or self.metadata.get("id_values", ())) + + def order_id(code: int) -> Optional[str]: + return id_values[int(code)] if 0 <= int(code) < len(id_values) else None + + fills_report = pd.DataFrame( + { + "bar": np.asarray(self.fill_bar, dtype=np.int64), + "timestamp": [idx[int(bar)] for bar in self.fill_bar], + "order_id": [order_id(code) for code in self.fill_order_id], + "side": ["BUY" if int(side) > 0 else "SELL" for side in self.fill_side], + "qty": np.asarray(self.fill_qty, dtype=np.float64), + "price": np.asarray(self.fill_price, dtype=np.float64), + "fee": np.asarray(self.fill_fee, dtype=np.float64), + "symbol": symbol, + } + ) + order_report = pd.DataFrame( + { + "bar": np.asarray(self.event_bar, dtype=np.int64), + "timestamp": [idx[int(bar)] for bar in self.event_bar], + "event_kind": np.asarray(self.event_kind, dtype=np.int64), + "event_status": np.asarray(self.event_status, dtype=np.int64), + "order_id": [order_id(code) for code in self.event_order_id], + "target_order_id": [order_id(code) for code in self.event_target_id], + "symbol": symbol, + } + ) + fill_objects = () + if include_fills: + fill_objects = tuple( + Fill( + timestamp=idx[int(bar)], + symbol=symbol, + side=OrderSide.BUY if int(side) > 0 else OrderSide.SELL, + qty=float(qty), + price=float(price), + fee=float(fee), + order_id=order_id(order_code), + metadata={"backend": "rust_batched", "bar": int(bar)}, + ) + for bar, order_code, side, qty, price, fee in zip( + self.fill_bar, + self.fill_order_id, + self.fill_side, + self.fill_qty, + self.fill_price, + self.fill_fee, + ) + ) + result_metadata = { + "backend": "native_event", + "engine": "event_v2_rust_batched_audit", + "report_level": "audit", + "native_event_backend_requested": "rust", + "native_event_backend_resolved": "rust", + "fills_report": fills_report, + "order_report": order_report, + "command_report": order_report, + "id_values": id_values, + "lifecycle_counters": { + "fill_count": int(self.fill_count), + "event_count": int(self.event_count), + "rejected_count": int(self.rejected_count), + "canceled_count": int(self.canceled_count), + }, + "rust_audit_adapter": "RustBatchedAuditResult.to_backtest_result", + } + if metadata: + result_metadata.update(dict(metadata)) + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0), + positions=positions, + closes=pd.DataFrame({f"Close_{symbol}": close_series.to_numpy()}, index=idx), + symbols=[symbol], + initial_capital=float(initial_capital), + leverage=float(leverage), + liquidated=False, + orders=(), + fills=fill_objects, + fees=fees, + funding=funding, + margin=margin, + diagnostics=diagnostics, + metadata=result_metadata, + ) + + +@dataclass(frozen=True, slots=True) +class RustFullAuditResult: + """Full-contract Rust SoA result, including multi-symbol/funding state.""" + + equity: np.ndarray + positions: np.ndarray + fees: np.ndarray + turnover: np.ndarray + funding: np.ndarray + initial_margin: np.ndarray + maintenance_margin: np.ndarray + fill_bar: np.ndarray + fill_order_id: np.ndarray + fill_symbol: np.ndarray + fill_side: np.ndarray + fill_qty: np.ndarray + fill_price: np.ndarray + fill_fee: np.ndarray + event_bar: np.ndarray + event_kind: np.ndarray + event_status: np.ndarray + event_order_id: np.ndarray + event_target_id: np.ndarray + event_symbol: np.ndarray + event_reject_code: np.ndarray + total_fee: float + total_turnover: float + total_funding: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + liquidated: bool + liquidation_bar: int + liquidation_reason: int + id_values: tuple[str, ...] = () + command_report: Optional[pd.DataFrame] = None + command_metadata: Mapping[str, Mapping[str, object]] = field(default_factory=dict) + + @property + def final_equity(self) -> float: + return float(self.equity[-1]) if len(self.equity) else 0.0 + + def to_backtest_result( + self, + *, + datetime_index: pd.DatetimeIndex, + closes: pd.DataFrame, + symbols: Sequence[str], + initial_capital: float, + leverage: float, + metadata: Optional[Mapping[str, object]] = None, + ): + """Materialize the common result surface outside the Rust hot path.""" + from ..core.results import BacktestResultV2 + from ..core.orders import Fill + from ..core.schema import OrderSide + + idx = pd.DatetimeIndex(datetime_index) + equity = pd.Series(self.equity, index=idx, name="equity") + positions = pd.DataFrame( + {f"Position_{symbol}": self.positions[:, col] for col, symbol in enumerate(symbols)}, + index=idx, + ) + close_frame = pd.DataFrame( + {f"Close_{symbol}": closes[symbol].to_numpy(dtype=np.float64) for symbol in symbols}, + index=idx, + ) + + def order_id(code: int) -> Optional[str]: + return self.id_values[int(code)] if 0 <= int(code) < len(self.id_values) else None + + fill_meta = [ + self.command_metadata.get(order_id(code) or "", {}) for code in self.fill_order_id + ] + fills_report = pd.DataFrame({ + "bar": self.fill_bar, + "timestamp": [idx[int(bar)] for bar in self.fill_bar], + "order_id": [order_id(code) for code in self.fill_order_id], + "symbol": [symbols[int(code)] for code in self.fill_symbol], + "side": ["BUY" if int(side) > 0 else "SELL" for side in self.fill_side], + "qty": self.fill_qty, + "price": self.fill_price, + "fee": self.fill_fee, + "tag": [meta.get("tag") for meta in fill_meta], + "campaign_id": [meta.get("campaign_id") for meta in fill_meta], + "cycle_id": [meta.get("cycle_id") for meta in fill_meta], + "level_id": [meta.get("level_id") for meta in fill_meta], + }) + order_report = pd.DataFrame({ + "bar": self.event_bar, + "timestamp": [idx[int(bar)] for bar in self.event_bar], + "event_kind": self.event_kind, + "event_status": self.event_status, + "order_id": [order_id(code) for code in self.event_order_id], + "target_order_id": [order_id(code) for code in self.event_target_id], + "symbol": [None if int(code) < 0 else symbols[int(code)] for code in self.event_symbol], + "reject_code": self.event_reject_code, + }) + fills = tuple( + Fill( + timestamp=idx[int(bar)], symbol=symbols[int(symbol)], + side=OrderSide.BUY if int(side) > 0 else OrderSide.SELL, + qty=float(qty), price=float(price), fee=float(fee), order_id=order_id(order_code), + metadata={"backend": "rust_full_contract", "bar": int(bar)}, + ) + for bar, order_code, symbol, side, qty, price, fee in zip( + self.fill_bar, self.fill_order_id, self.fill_symbol, self.fill_side, + self.fill_qty, self.fill_price, self.fill_fee, + ) + ) + diagnostics = pd.DataFrame({ + "turnover": self.turnover, + "rejected_orders": np.bincount(self.event_bar[self.event_kind == 7], minlength=len(idx)), + "canceled_orders": np.bincount(self.event_bar[self.event_kind == 1], minlength=len(idx)), + }, index=idx) + result_metadata = { + "backend": "native_event", + "engine": "event_v2_rust_full_contract", + "report_level": "audit", + "native_event_backend_requested": "rust", + "native_event_backend_resolved": "rust", + "fills_report": fills_report, + "order_report": order_report, + "command_report": ( + self.command_report.copy(deep=False) + if self.command_report is not None + else pd.DataFrame() + ), + "id_values": self.id_values, + "liquidation_reason": int(self.liquidation_reason), + "lifecycle_counters": { + "fill_count": int(self.fill_count), "event_count": int(self.event_count), + "rejected_count": int(self.rejected_count), "canceled_count": int(self.canceled_count), + }, + "rust_contract": "native_event_v2_full_contract", + } + if metadata: + result_metadata.update(dict(metadata)) + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0), + positions=positions, + closes=close_frame, + symbols=list(symbols), + initial_capital=float(initial_capital), + leverage=float(leverage), + liquidated=bool(self.liquidated), + liquidation_bar=int(self.liquidation_bar), + orders=(), fills=fills, + fees=pd.Series(self.fees, index=idx, name="fees"), + funding=pd.Series(self.funding, index=idx, name="funding"), + margin=pd.DataFrame({"initial_margin": self.initial_margin, "maintenance_margin": self.maintenance_margin}, index=idx), + diagnostics=diagnostics, + metadata=result_metadata, + ) + + +@dataclass(frozen=True, slots=True) +class RustBatchedChunkResult: + """Sparse result for one stateful ``run_until`` continuation chunk. + + The arrays contain only fills/order events observed in the chunk. No + dense equity or position path is materialized; the caller can request a + full audit separately when it needs bar-by-bar diagnostics. + """ + + start_bar: int + stop_bar: int + final_equity: float + final_position: float + total_fee: float + total_turnover: float + fill_count: int + event_count: int + rejected_count: int + canceled_count: int + max_initial_margin: float + max_maintenance_margin: float + liquidation_seen: bool + wake_bar: np.ndarray + wake_kind: np.ndarray + fill_bar: np.ndarray + fill_order_id: np.ndarray + fill_side: np.ndarray + fill_qty: np.ndarray + fill_price: np.ndarray + fill_fee: np.ndarray + event_bar: np.ndarray + event_kind: np.ndarray + event_status: np.ndarray + event_order_id: np.ndarray + event_target_id: np.ndarray + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass +class RustCommandBuffer: + """Capacity-managed primitive buffers reused across Rust callback bars.""" + + codes: np.ndarray = field(default_factory=lambda: np.empty((0, _R1_CODE_WIDTH), dtype=np.int64)) + values: np.ndarray = field(default_factory=lambda: np.empty((0, _R1_VALUE_WIDTH), dtype=np.float64)) + expiry: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64)) + + def reserve(self, size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + if size > len(self.codes): + capacity = max(int(size), max(8, len(self.codes) * 2)) + self.codes = np.empty((capacity, _R1_CODE_WIDTH), dtype=np.int64) + self.values = np.empty((capacity, _R1_VALUE_WIDTH), dtype=np.float64) + self.expiry = np.empty(capacity, dtype=np.int64) + return self.codes[:size], self.values[:size], self.expiry[:size] + + +@dataclass +class RustFullCommandBuffer: + """Capacity-managed buffers for the API 0.4 full command ABI. + + The public compiler remains the source of truth for command meaning and + ordering. This object only owns reusable contiguous storage so repeated + static or reactive runs do not allocate a new ``(n, 16)``/``(n, 3)`` pair + for every call. + """ + + codes: np.ndarray = field(default_factory=lambda: np.empty((0, _FULL_CODE_WIDTH), dtype=np.int64)) + values: np.ndarray = field(default_factory=lambda: np.empty((0, _FULL_VALUE_WIDTH), dtype=np.float64)) + expiry: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64)) + growth_count: int = 0 + commands_compiled: int = 0 + + @property + def capacity(self) -> int: + """Number of command rows currently reserved.""" + + return int(len(self.codes)) + + def reserve(self, size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + size = int(size) + if size < 0: + raise ValueError("command buffer size must be >= 0") + if size > self.capacity: + capacity = max(size, max(8, self.capacity * 2)) + self.codes = np.empty((capacity, _FULL_CODE_WIDTH), dtype=np.int64) + self.values = np.empty((capacity, _FULL_VALUE_WIDTH), dtype=np.float64) + self.expiry = np.empty(capacity, dtype=np.int64) + self.growth_count += 1 + self.commands_compiled += size + codes = self.codes[:size] + values = self.values[:size] + expiry = self.expiry[:size] + codes.fill(-1) + values.fill(0.0) + expiry.fill(-1) + return codes, values, expiry + + def clear(self) -> None: + """Release storage and reset counters for explicit cache cleanup.""" + + self.codes = np.empty((0, _FULL_CODE_WIDTH), dtype=np.int64) + self.values = np.empty((0, _FULL_VALUE_WIDTH), dtype=np.float64) + self.expiry = np.empty(0, dtype=np.int64) + self.growth_count = 0 + self.commands_compiled = 0 + + +@dataclass(frozen=True) +class _RustPendingOrder: + order_id: Optional[str] + side: OrderSide + order_type: OrderType + qty: float + price: float + trigger_price: float + reduce_only: bool + + +def _empty_status(reason: str) -> NativeEventRustExtensionStatus: + return NativeEventRustExtensionStatus( + available=False, + compatible=False, + executable=False, + version=None, + api_version=None, + capabilities={}, + reason=reason, + canonical_capabilities={}, + ) + + +def _load_extension() -> Optional[ModuleType]: + return importlib.import_module("_quantbt_native") + + +def _read_native_value(module: ModuleType, name: str) -> Optional[object]: + value = getattr(module, name, None) + return value() if callable(value) else value + + +def probe_native_event_rust_extension( + module: Optional[ModuleType] = None, + *, + module_loader: Optional[Callable[[], Optional[ModuleType]]] = None, +) -> NativeEventRustExtensionStatus: + """Return extension compatibility without enabling a Rust execution path. + + ``module`` and ``module_loader`` are test seams. Runtime callers should + leave both unset so the optional extension is imported normally. + """ + if module is None: + loader = _load_extension if module_loader is None else module_loader + try: + module = loader() + except (ImportError, OSError) as exc: + return _empty_status(f"unable to import _quantbt_native: {exc}") + if module is None: + return _empty_status("quantbt-native is not installed; install a compatible native wheel first") + + try: + version_value = _read_native_value(module, "version") + version = str(version_value if version_value is not None else getattr(module, "__version__", "")) or None + api_value = _read_native_value(module, "api_version") + api_version = str(api_value) if api_value is not None else None + raw_capabilities = _read_native_value(module, "capabilities") + except Exception as exc: # pragma: no cover - protects optional binary imports. + return _empty_status(f"failed to query _quantbt_native metadata: {exc}") + + if not isinstance(raw_capabilities, Mapping): + raw_capabilities = {} + capabilities = {str(name): bool(enabled) for name, enabled in raw_capabilities.items()} + canonical_capabilities = normalize_native_event_capabilities(capabilities) + # 0.3 remains readable for the legacy R1/R2 classes. Full V2 capability + # is gated independently by the explicit 0.4 capability keys below. + compatible = api_version in {"0.3", RUST_NATIVE_API_VERSION} + if not compatible: + return NativeEventRustExtensionStatus( + available=True, + compatible=False, + executable=False, + version=version, + api_version=api_version, + capabilities=capabilities, + reason=( + "_quantbt_native API version mismatch: " + f"expected {RUST_NATIVE_API_VERSION!r}, received {api_version!r}" + ), + canonical_capabilities=canonical_capabilities, + ) + + executable = bool(capabilities.get("reactive_session", False)) + reason = None if executable else "_quantbt_native does not advertise the required R1 reactive_session capability" + return NativeEventRustExtensionStatus( + available=True, + compatible=True, + executable=executable, + version=version, + api_version=api_version, + capabilities=capabilities, + reason=reason, + canonical_capabilities=canonical_capabilities, + ) + + +def resolve_native_event_backend( + requested: Optional[str] = None, + *, + extension_status: Optional[NativeEventRustExtensionStatus] = None, +) -> NativeEventBackendSelection: + """Resolve the native-event selector under the release rollout policy. + + ``auto`` intentionally resolves to Python for the first dual-backend + release, even with the wheel installed. ``rust`` is explicit and fails + loudly unless the installed extension advertises the required capability. + """ + selected = str(requested or os.getenv("QUANTBT_NATIVE_BACKEND", "auto")).lower().strip() + if selected not in _VALID_BACKENDS: + valid = ", ".join(sorted(_VALID_BACKENDS)) + raise ValueError(f"QUANTBT_NATIVE_BACKEND must be one of: {valid}") + + status = extension_status + if selected == "rust": + status = status or probe_native_event_rust_extension() + if not status.available or not status.compatible or not status.executable: + detail = status.reason or "unknown native extension state" + raise NativeEventRustBackendError(f"native-event backend='rust' is unavailable: {detail}") + return NativeEventBackendSelection(requested=selected, resolved="rust", extension=status) + + # R0 rollout contract: never auto-enable a just-built extension. + status = status or _empty_status("Rust extension was not queried because the Python backend was selected") + resolved = "replay_certified" if selected == "replay_certified" else "python" + return NativeEventBackendSelection(requested=selected, resolved=resolved, extension=status) + + +def _require_r1_extension() -> ModuleType: + module = _load_extension() + status = probe_native_event_rust_extension(module=module) + if not status.available or not status.compatible or not status.executable: + detail = status.reason or "unknown native extension state" + raise NativeEventRustBackendError(f"native-event Rust R1 is unavailable: {detail}") + if not hasattr(module, "ReactiveSessionCore"): + raise NativeEventRustBackendError("_quantbt_native is compatible but lacks ReactiveSessionCore") + return module + + +def validate_rust_r1_support( + *, + symbols: Sequence[str], + constraints, + use_funding: bool, + maintenance_ratio: float, +) -> None: + """Reject every feature outside the R2 single-symbol surface. + + The historic function name remains an internal compatibility alias for + callers introduced with R1. R2 adds lifecycle commands and quantity + filters, but funding/liquidation and multi-symbol remain Python-only. + """ + if len(symbols) != 1: + raise NativeEventRustBackendError("Rust R1 supports exactly one symbol; use backend='python' for multi-symbol") + if use_funding: + raise NativeEventRustBackendError("Rust R2 does not support funding; use backend='python'") + if float(maintenance_ratio) != 0.0: + raise NativeEventRustBackendError( + "Rust R2 does not support liquidation semantics; set maintenance_ratio=0.0 or use backend='python'" + ) + + +def compile_rust_r1_command_batch( + commands: Sequence[OrderCommand], + *, + symbol: str, + intern_id: Callable[[Optional[str]], int], + buffer: Optional[RustCommandBuffer] = None, +) -> RustCommandBatch: + """Compile the R2 lifecycle subset into contiguous primitive buffers. + + Field layout is stable from R1: ``[action, side, type, flags, order_id, + target_id, mutate_mask, sequence]`` and ``[qty, price, trigger]``. This + lets the optional extension evolve without adding Python object work to the + bar loop. + """ + command_tuple = tuple(commands) + if buffer is None: + codes = np.full((len(command_tuple), _R1_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((len(command_tuple), _R1_VALUE_WIDTH), dtype=np.float64) + expiry = np.full(len(command_tuple), -1, dtype=np.int64) + else: + codes, values, expiry = buffer.reserve(len(command_tuple)) + codes.fill(-1) + values.fill(0.0) + expiry.fill(-1) + + for sequence, command in enumerate(command_tuple): + codes[sequence, 7] = sequence + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if command.symbol != symbol: + raise NativeEventRustBackendError(f"Rust R2 command symbol must be {symbol!r}") + if command.side not in (OrderSide.BUY, OrderSide.SELL): + raise NativeEventRustBackendError("Rust R2 PLACE/REPLACE requires BUY or SELL") + if command.order_type not in ( + OrderType.MARKET, + OrderType.LIMIT, + OrderType.STOP_MARKET, + OrderType.STOP_LIMIT, + ): + raise NativeEventRustBackendError("Rust R2 supports MARKET, LIMIT, STOP_MARKET, and STOP_LIMIT only") + if command.tif is not TimeInForce.GTC: + raise NativeEventRustBackendError("Rust R2 supports GTC only") + if command.parent_order_id or command.oco_group_id or command.group_id: + raise NativeEventRustBackendError("Rust R2 does not support parent, group, or OCO orders") + if command.activation_policy is not OrderActivationPolicy.IMMEDIATE: + raise NativeEventRustBackendError("Rust R2 supports immediate order activation only") + if command.expires_at is not None or command.trigger_price is not None: + if command.expires_at is not None: + raise NativeEventRustBackendError("Rust R2 does not support expiry; use backend='python'") + codes[sequence, 0] = _R1_ACTION_PLACE if command.action is OrderAction.PLACE else _R2_ACTION_REPLACE + codes[sequence, 1] = command.side.sign + codes[sequence, 2] = { + OrderType.MARKET: _R1_ORDER_MARKET, + OrderType.LIMIT: _R1_ORDER_LIMIT, + OrderType.STOP_MARKET: _R2_ORDER_STOP_MARKET, + OrderType.STOP_LIMIT: _R2_ORDER_STOP_LIMIT, + }[command.order_type] + codes[sequence, 3] = _R2_FLAG_REDUCE_ONLY if command.reduce_only else 0 + codes[sequence, 4] = intern_id(command.order_id) + codes[sequence, 5] = intern_id(command.target_order_id) + values[sequence, 0] = float(command.qty or 0.0) + values[sequence, 1] = float(command.price or 0.0) + values[sequence, 2] = float(command.trigger_price or 0.0) + elif command.action is OrderAction.CANCEL: + codes[sequence, 0] = _R1_ACTION_CANCEL + codes[sequence, 5] = intern_id(command.target_order_id) + elif command.action is OrderAction.AMEND: + codes[sequence, 0] = _R2_ACTION_AMEND + codes[sequence, 5] = intern_id(command.target_order_id) + mask = 0 + if command.qty is not None: + mask |= _R2_MUTATE_QTY + values[sequence, 0] = float(command.qty) + if command.price is not None: + mask |= _R2_MUTATE_PRICE + values[sequence, 1] = float(command.price) + if command.trigger_price is not None: + mask |= _R2_MUTATE_TRIGGER + values[sequence, 2] = float(command.trigger_price) + codes[sequence, 6] = mask + else: + raise NativeEventRustBackendError("Rust R2 supports PLACE, CANCEL, AMEND, and REPLACE commands only") + return RustCommandBatch(codes=codes, values=values, expiry=expiry, commands=command_tuple) + + +def compile_rust_batched_tape( + compiled_commands: CompiledOrderCommandArrays, + *, + symbol: str, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Convert the canonical command compiler output to the Rust tape ABI. + + The conversion is deliberately performed once per static tape, not once + per bar or per trial. The canonical compiler remains the source of truth + for bar ordering and dense order identifiers. + """ + if tuple(compiled_commands.symbols) != (symbol,): + raise NativeEventRustBackendError("Rust batched tape supports exactly one symbol") + commands = tuple(command for _, command in compiled_commands.sorted_commands) + n = len(commands) + codes = np.full((n, _R1_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((n, _R1_VALUE_WIDTH), dtype=np.float64) + expiry = np.ascontiguousarray(compiled_commands.command_expires_bar, dtype=np.int64) + if n: + codes[:, 0] = np.asarray(compiled_commands.command_action, dtype=np.int64) + codes[:, 1] = np.asarray(compiled_commands.command_side, dtype=np.int64) + codes[:, 2] = np.asarray(compiled_commands.command_type, dtype=np.int64) + codes[:, 3] = np.asarray(compiled_commands.command_reduce_only, dtype=np.int64) + codes[:, 4] = np.asarray(compiled_commands.command_order_id, dtype=np.int64) + codes[:, 5] = np.asarray(compiled_commands.command_target_order_id, dtype=np.int64) + values[:, 0] = np.asarray(compiled_commands.command_qty, dtype=np.float64) + values[:, 1] = np.asarray(compiled_commands.command_price, dtype=np.float64) + values[:, 2] = np.asarray(compiled_commands.command_trigger_price, dtype=np.float64) + codes[:, 7] = np.arange(n, dtype=np.int64) + + for row, command in enumerate(commands): + if command.symbol not in (None, symbol): + raise NativeEventRustBackendError(f"Rust batched command symbol must be {symbol!r}") + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if command.tif is not TimeInForce.GTC: + raise NativeEventRustBackendError("Rust batched tape supports GTC only") + if command.parent_order_id or command.group_id or command.oco_group_id: + raise NativeEventRustBackendError("Rust batched tape does not support parent, group, or OCO orders") + if command.activation_policy is not OrderActivationPolicy.IMMEDIATE: + raise NativeEventRustBackendError("Rust batched tape supports immediate activation only") + if command.expires_at is not None: + raise NativeEventRustBackendError("Rust batched tape does not support expiry") + if command.action is OrderAction.REPLACE: + # CompiledOrderCommandArrays uses the canonical compiler + # codes (REPLACE=2, AMEND=3), while the stable reactive R2 + # ABI uses AMEND=2, REPLACE=3. + codes[row, 0] = _R2_ACTION_REPLACE + elif command.action is OrderAction.CANCEL: + if command.tif is not TimeInForce.GTC: + raise NativeEventRustBackendError("Rust batched tape supports GTC only") + elif command.action is OrderAction.AMEND: + codes[row, 0] = _R2_ACTION_AMEND + mask = 0 + if command.qty is not None: + mask |= _R2_MUTATE_QTY + if command.price is not None: + mask |= _R2_MUTATE_PRICE + if command.trigger_price is not None: + mask |= _R2_MUTATE_TRIGGER + codes[row, 6] = mask + else: + raise NativeEventRustBackendError("Rust batched tape supports PLACE, CANCEL, AMEND, and REPLACE only") + if command.expires_at is not None or int(expiry[row]) != -1: + raise NativeEventRustBackendError("Rust batched tape does not support expiry") + + return ( + np.ascontiguousarray(compiled_commands.command_ptr, dtype=np.int64), + np.ascontiguousarray(codes, dtype=np.int64), + np.ascontiguousarray(values, dtype=np.float64), + np.ascontiguousarray(expiry, dtype=np.int64), + ) + + +def compile_rust_full_tape( + compiled_commands: CompiledOrderCommandArrays, + *, + buffer: Optional[RustFullCommandBuffer] = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Compile the complete V2 command schema into the Rust 0.4 ABI. + + Code layout is intentionally explicit and integer-only for relationship + fields. The compiler's stable row order remains authoritative. + """ + commands = tuple(command for _, command in compiled_commands.sorted_commands) + n = len(commands) + if buffer is None: + codes = np.full((n, _FULL_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((n, _FULL_VALUE_WIDTH), dtype=np.float64) + expiry = np.full(n, -1, dtype=np.int64) + else: + codes, values, expiry = buffer.reserve(n) + if n: + expiry[:] = np.asarray(compiled_commands.command_expires_bar, dtype=np.int64) + if n: + codes[:, 0] = np.asarray(compiled_commands.command_action, dtype=np.int64) + codes[:, 1] = np.asarray(compiled_commands.command_symbol, dtype=np.int64) + codes[:, 2] = np.asarray(compiled_commands.command_side, dtype=np.int64) + codes[:, 3] = np.asarray(compiled_commands.command_type, dtype=np.int64) + codes[:, 4] = np.asarray(compiled_commands.command_tif, dtype=np.int64) + codes[:, 5] = np.asarray(compiled_commands.command_reduce_only, dtype=np.int64) + codes[:, 6] = np.asarray(compiled_commands.command_order_id, dtype=np.int64) + codes[:, 7] = np.asarray(compiled_commands.command_target_order_id, dtype=np.int64) + codes[:, 8] = np.asarray(compiled_commands.command_parent_order_id, dtype=np.int64) + codes[:, 9] = np.asarray(compiled_commands.command_group_id, dtype=np.int64) + codes[:, 10] = np.asarray(compiled_commands.command_oco_group_id, dtype=np.int64) + codes[:, 11] = np.asarray(compiled_commands.command_activation, dtype=np.int64) + codes[:, 12] = np.arange(n, dtype=np.int64) + values[:, 0] = np.asarray(compiled_commands.command_qty, dtype=np.float64) + values[:, 1] = np.asarray(compiled_commands.command_price, dtype=np.float64) + values[:, 2] = np.asarray(compiled_commands.command_trigger_price, dtype=np.float64) + for row, command in enumerate(commands): + if command.action.value not in {"place", "cancel", "cancel_all", "amend", "replace"}: + raise NativeEventRustBackendError(f"unsupported full-contract action={command.action!r}") + if command.expires_at is not None and int(expiry[row]) < 0: + raise NativeEventRustBackendError("compiled full tape lost command expiry") + return ( + np.ascontiguousarray(compiled_commands.command_ptr, dtype=np.int64), + codes, + values, + expiry, + ) + + +def compile_rust_full_reactive_batch( + commands: Sequence[OrderCommand], + *, + symbols: Sequence[str], + intern_id: Callable[[Optional[str]], int], + idx: pd.DatetimeIndex, + buffer: Optional[RustFullCommandBuffer] = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Compile one callback batch for the full ABI without Python objects.""" + rows = tuple(commands) + if buffer is None: + codes = np.full((len(rows), _FULL_CODE_WIDTH), -1, dtype=np.int64) + values = np.zeros((len(rows), _FULL_VALUE_WIDTH), dtype=np.float64) + expiry = np.full(len(rows), -1, dtype=np.int64) + else: + codes, values, expiry = buffer.reserve(len(rows)) + symbol_to_code = {symbol: col for col, symbol in enumerate(symbols)} + order_type = {OrderType.MARKET: 0, OrderType.LIMIT: 1, OrderType.STOP_MARKET: 2, OrderType.STOP_LIMIT: 3} + tif = {TimeInForce.GTC: 0, TimeInForce.IOC: 1, TimeInForce.FOK: 2, TimeInForce.GTD: 3} + action = {OrderAction.PLACE: 0, OrderAction.CANCEL: 1, OrderAction.REPLACE: 2, OrderAction.AMEND: 3, OrderAction.CANCEL_ALL: 4} + activation = { + OrderActivationPolicy.IMMEDIATE: 0, + OrderActivationPolicy.ON_PARENT_FIRST_FILL: 1, + OrderActivationPolicy.ON_PARENT_FULL_FILL: 2, + } + for row, command in enumerate(rows): + codes[row, 0] = action[command.action] + codes[row, 1] = -1 if command.symbol is None else symbol_to_code[command.symbol] + codes[row, 2] = 0 if command.side is None else int(command.side.sign) + codes[row, 3] = -1 if command.order_type is None else order_type[command.order_type] + codes[row, 4] = tif[command.tif] + codes[row, 5] = 1 if command.reduce_only else 0 + codes[row, 6] = intern_id(command.order_id) + codes[row, 7] = intern_id(command.target_order_id) + codes[row, 8] = intern_id(command.parent_order_id) + codes[row, 9] = intern_id(command.group_id) + codes[row, 10] = intern_id(command.oco_group_id) + codes[row, 11] = activation[command.activation_policy] + codes[row, 12] = row + values[row, 0] = 0.0 if command.qty is None else float(command.qty) + values[row, 1] = 0.0 if command.price is None else float(command.price) + values[row, 2] = 0.0 if command.trigger_price is None else float(command.trigger_price) + if command.expires_at is not None: + ts = pd.Timestamp(command.expires_at) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + expiry[row] = int(np.searchsorted(idx.asi8, ts.value, side="left")) + return np.ascontiguousarray(codes), np.ascontiguousarray(values), np.ascontiguousarray(expiry) + + +def _command_tape_fingerprint(compiled_commands: CompiledOrderCommandArrays) -> str: + """Return the compile-time identity of an immutable primitive tape.""" + + stored = getattr(compiled_commands, "tape_fingerprint", "") + return stored or command_tape_fingerprint(compiled_commands) + + +def _build_rust_command_intent_report( + compiled_commands: CompiledOrderCommandArrays, +) -> pd.DataFrame: + """Build the command-intent surface independently from lifecycle events. + + Rust owns execution lifecycle rows. The immutable compiler tape owns the + requested command semantics, so this report is deliberately an intent + table rather than an alias of ``order_report``. + """ + + rows = [] + for sorted_index, (original_index, command) in enumerate(compiled_commands.sorted_commands): + metadata = dict(command.metadata) + rows.append( + { + "original_index": int(original_index), + "sorted_index": int(sorted_index), + "timestamp": command.timestamp, + "action": command.action.value, + "symbol": command.symbol, + "side": None if command.side is None else command.side.value, + "order_type": None if command.order_type is None else command.order_type.value, + "order_id": command.order_id, + "target_order_id": command.target_order_id, + "parent_order_id": command.parent_order_id, + "group_id": command.group_id, + "oco_group_id": command.oco_group_id, + "qty": None if command.qty is None else float(command.qty), + "price": None if command.price is None else float(command.price), + "trigger_price": None if command.trigger_price is None else float(command.trigger_price), + "tif": command.tif.value, + "reduce_only": bool(command.reduce_only), + "activation_policy": command.activation_policy.value, + "expires_at": command.expires_at, + "tag": command.tag, + "tag_prefix": command.tag_prefix, + "campaign_id": metadata.get("campaign_id"), + "cycle_id": metadata.get("cycle_id"), + "level_id": metadata.get("level_id"), + "report_kind": "command_intent", + } + ) + return pd.DataFrame(rows) + + +def _payload_value(payload, key: str): + """Read both the R2 dict boundary and the R2.1 typed score boundary.""" + + if isinstance(payload, Mapping): + return payload[key] + return getattr(payload, key) + + +class RustFullRunner: + """Prepared full-contract Rust tape runner for explicit Rust execution.""" + + def __init__( + self, + *, + idx: pd.DatetimeIndex, + symbols: Sequence[str], + market_arrays, + contract_sizes: np.ndarray, + leverages: np.ndarray, + fee_rates: np.ndarray, + initial_capital: float, + maintenance_ratio: float, + slippage: float, + use_funding: bool, + opens_arr: Optional[np.ndarray] = None, + volumes_arr: Optional[np.ndarray] = None, + prepared_market_core=None, + max_tape_cache_bytes: int = 64 * 1024 * 1024, + ) -> None: + self.idx = pd.DatetimeIndex(idx) + self.symbols = tuple(symbols) + self.contract_sizes = np.ascontiguousarray(contract_sizes, dtype=np.float64) + self.leverages = np.ascontiguousarray(leverages, dtype=np.float64) + self.fee_rates = np.ascontiguousarray(fee_rates, dtype=np.float64) + self.initial_capital = float(initial_capital) + self.maintenance_ratio = float(maintenance_ratio) + self.slippage = float(slippage) + self.use_funding = bool(use_funding) + if int(max_tape_cache_bytes) < 0: + raise ValueError("max_tape_cache_bytes must be >= 0") + self.max_tape_cache_bytes = int(max_tape_cache_bytes) + if len(self.symbols) == 0 or market_arrays.closes.shape[1] != len(self.symbols): + raise NativeEventRustBackendError("full Rust runner symbols do not match prepared market arrays") + self._module = _require_r1_extension() + status = probe_native_event_rust_extension(module=self._module) + required = { + "native_event_v2_full_contract", "native_event_v2_multisymbol", + "native_event_v2_funding", "native_event_v2_liquidation", + "native_event_v2_cancel_all_oco", "native_event_v2_tif_expiry", + "native_event_v2_relationships", "native_event_v2_quantity_preflight", + } + missing = sorted(name for name in required if not status.capabilities.get(name, False)) + if missing: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel lacks Rust full-contract capabilities: " + ", ".join(missing) + ) + self.prepared_market_core = prepared_market_core + if self.prepared_market_core is None: + shape = market_arrays.closes.shape + zeros = np.zeros(shape, dtype=np.float64) + opens = zeros if opens_arr is None else np.ascontiguousarray(opens_arr, dtype=np.float64) + volumes = zeros if volumes_arr is None else np.ascontiguousarray(volumes_arr, dtype=np.float64) + self.prepared_market_core = self._module.FullPreparedMarketCore( + np.ascontiguousarray(self.idx.asi8, dtype=np.int64), + opens, + np.ascontiguousarray(market_arrays.highs, dtype=np.float64), + np.ascontiguousarray(market_arrays.lows, dtype=np.float64), + np.ascontiguousarray(market_arrays.closes, dtype=np.float64), + volumes, + np.ascontiguousarray(market_arrays.funding, dtype=np.float64), + np.ascontiguousarray(market_arrays.is_funding_bar, dtype=np.bool_), + ) + self._command_buffer = RustFullCommandBuffer() + self._cached_tape_fingerprint: Optional[str] = None + self._cached_tape_arrays: Optional[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None + self._cached_tape_bytes = 0 + self._session = None + + def _new_session(self): + if self._session is None: + self._session = self._module.FullReactiveSessionCore.from_prepared( + self.prepared_market_core, + self.contract_sizes, + self.leverages, + self.fee_rates, + self.initial_capital, + self.maintenance_ratio, + self.slippage, + self.use_funding, + ) + else: + self._session.reset() + return self._session + + def _tape_arrays(self, compiled_commands: CompiledOrderCommandArrays): + fingerprint = getattr(compiled_commands, "tape_fingerprint", "") or _command_tape_fingerprint( + compiled_commands + ) + if fingerprint == self._cached_tape_fingerprint and self._cached_tape_arrays is not None: + return self._cached_tape_arrays + arrays = compile_rust_full_tape(compiled_commands, buffer=self._command_buffer) + byte_size = sum(int(array.nbytes) for array in arrays) + if byte_size <= self.max_tape_cache_bytes: + self._cached_tape_fingerprint = fingerprint + self._cached_tape_arrays = arrays + self._cached_tape_bytes = byte_size + else: + self.clear_tape_cache() + return arrays + + @property + def tape_cache_bytes(self) -> int: + """Resident bytes held by the bounded full-contract tape cache.""" + + return int(self._cached_tape_bytes) + + def clear_tape_cache(self) -> None: + """Release compiled tape arrays while retaining prepared market state.""" + + self._cached_tape_fingerprint = None + self._cached_tape_arrays = None + self._cached_tape_bytes = 0 + self._command_buffer.clear() + + def clear_caches(self) -> None: + """Release runner-local tape/session caches without mutating market data.""" + + self.clear_tape_cache() + self._session = None + + def cache_info(self) -> Mapping[str, int]: + """Return observable bounded-cache and command-buffer counters.""" + + info = { + "tape_cache_bytes": self.tape_cache_bytes, + "tape_cache_entries": int(self._cached_tape_arrays is not None), + "command_buffer_capacity": self._command_buffer.capacity, + "command_buffer_growth_count": self._command_buffer.growth_count, + "commands_compiled": self._command_buffer.commands_compiled, + } + if self._session is not None and hasattr(self._session, "order_arena_counters"): + slots, capacity, compactions, removed = self._session.order_arena_counters() + info.update( + { + "order_arena_slots": int(slots), + "order_arena_capacity": int(capacity), + "order_compactions": int(compactions), + "terminal_orders_removed": int(removed), + } + ) + if self._session is not None and hasattr(self._session, "step_buffer_capacities"): + fills, events, active = self._session.step_buffer_capacities() + info.update( + { + "step_fill_buffer_capacity": int(fills), + "step_event_buffer_capacity": int(events), + "step_active_order_buffer_capacity": int(active), + } + ) + if self._session is not None and hasattr(self._session, "margin_recompute_count"): + info["margin_recompute_count"] = int(self._session.margin_recompute_count()) + return info + + def run_tape_score(self, compiled_commands: CompiledOrderCommandArrays) -> Mapping[str, object]: + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + return self._new_session().run_tape_score(ptr, codes, values, expiry) + + def run_tape_audit(self, compiled_commands: CompiledOrderCommandArrays) -> RustFullAuditResult: + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + payload = self._new_session().run_tape_audit(ptr, codes, values, expiry) + keys = ( + "equity", "positions", "fees", "turnover", "funding", "initial_margin", "maintenance_margin", + "fill_bar", "fill_order_id", "fill_symbol", "fill_side", "fill_qty", "fill_price", "fill_fee", + "event_bar", "event_kind", "event_status", "event_order_id", "event_target_id", "event_symbol", "event_reject_code", + ) + arrays = {key: np.ascontiguousarray(np.asarray(payload[key])) for key in keys} + arrays["positions"] = np.asarray(arrays["positions"], dtype=np.float64).reshape(len(self.idx), len(self.symbols)) + return RustFullAuditResult( + **arrays, + total_fee=float(payload["total_fee"]), total_turnover=float(payload["total_turnover"]), + total_funding=float(payload["total_funding"]), fill_count=int(payload["fill_count"]), + event_count=int(payload["event_count"]), rejected_count=int(payload["rejected_count"]), + canceled_count=int(payload["canceled_count"]), max_initial_margin=float(payload["max_initial_margin"]), + max_maintenance_margin=float(payload["max_maintenance_margin"]), liquidated=bool(payload["liquidated"]), + liquidation_bar=int(payload["liquidation_bar"]), liquidation_reason=int(payload["liquidation_reason"]), + id_values=tuple(compiled_commands.id_values), + command_report=_build_rust_command_intent_report(compiled_commands), + command_metadata={ + command.order_id: dict(command.metadata) + for _, command in compiled_commands.sorted_commands + if command.order_id + }, + ) + + +class RustBatchedRunner: + """Single-symbol Rust full-tape runner with prepared-market reuse. + + This is an explicit experimental backend. It accepts a precompiled + static command tape and never invokes arbitrary Python strategy callbacks. + Unsupported funding, liquidation, quantity constraints, TIF and package + semantics fail before crossing the Rust boundary. + """ + + def __init__( + self, + *, + idx: pd.DatetimeIndex, + symbols: Sequence[str], + market_arrays, + contract_size: float = 1.0, + leverage: float = 1.0, + fee_rate: float = 0.0, + initial_capital: float = 1_000.0, + maintenance_ratio: float = 0.0, + slippage: float = 0.0, + use_funding: bool = False, + prepared_market_core=None, + max_tape_cache_bytes: int = 64 * 1024 * 1024, + ) -> None: + if len(symbols) != 1: + raise NativeEventRustBackendError("Rust batched runner supports exactly one symbol") + if use_funding: + raise NativeEventRustBackendError("Rust batched runner does not support funding") + if float(maintenance_ratio) != 0.0: + raise NativeEventRustBackendError("Rust batched runner does not support liquidation") + if float(contract_size) <= 0.0 or float(leverage) <= 0.0: + raise ValueError("contract_size and leverage must be > 0") + if float(fee_rate) < 0.0 or float(slippage) < 0.0: + raise ValueError("fee_rate and slippage must be >= 0") + if int(max_tape_cache_bytes) < 0: + raise ValueError("max_tape_cache_bytes must be >= 0") + self.idx = pd.DatetimeIndex(idx) + self.symbols = tuple(symbols) + self.contract_size = float(contract_size) + self.leverage = float(leverage) + self.fee_rate = float(fee_rate) + self.initial_capital = float(initial_capital) + self.maintenance_ratio = float(maintenance_ratio) + self.slippage = float(slippage) + self.max_tape_cache_bytes = int(max_tape_cache_bytes) + self._module = _require_r1_extension() + status = probe_native_event_rust_extension(module=self._module) + required = ( + "rust_batched_tape", + "rust_batched_tape_score", + "rust_batched_tape_audit", + "rust_batched_tape_sparse", + ) + missing = [name for name in required if not status.capabilities.get(name, False)] + if missing: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel lacks Rust batched capabilities: " + ", ".join(missing) + ) + self.prepared_market_core = prepared_market_core + self._cached_tape_fingerprint: Optional[str] = None + self._cached_tape_arrays: Optional[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None + self._cached_tape_bytes = 0 + if self.prepared_market_core is None: + close = np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64) + self.prepared_market_core = self._module.PreparedMarketCore( + np.ascontiguousarray(self.idx.asi8, dtype=np.int64), + close, + np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64), + close, + np.zeros(len(self.idx), dtype=np.float64), + np.zeros(len(self.idx), dtype=np.float64), + np.zeros(len(self.idx), dtype=np.bool_), + ) + + def open_sparse_session( + self, + compiled_commands: Optional[CompiledOrderCommandArrays] = None, + ) -> "RustBatchedSession": + """Open a stateful sparse session over one compiled command tape. + + ``run_until`` keeps the Rust lifecycle state between calls. The + tape is compiled once and the session only returns sparse fills/events + plus scalar accounting, so strategy services do not pay for a dense + per-bar result path on every chunk. + """ + return RustBatchedSession(self, compiled_commands) + + # A descriptive alias for callers that use the shorter session wording. + new_sparse_session = open_sparse_session + + def _new_session(self): + return self._module.ReactiveSessionCore.from_prepared( + self.prepared_market_core, + self.contract_size, + self.leverage, + self.fee_rate, + self.initial_capital, + self.maintenance_ratio, + self.slippage, + False, + ) + + def _tape_arrays(self, compiled_commands: CompiledOrderCommandArrays): + fingerprint = getattr(compiled_commands, "tape_fingerprint", "") or _command_tape_fingerprint( + compiled_commands + ) + if fingerprint == self._cached_tape_fingerprint and self._cached_tape_arrays is not None: + return self._cached_tape_arrays + arrays = compile_rust_batched_tape(compiled_commands, symbol=self.symbols[0]) + byte_size = sum(int(array.nbytes) for array in arrays) + if byte_size <= self.max_tape_cache_bytes: + self._cached_tape_fingerprint = fingerprint + self._cached_tape_arrays = arrays + self._cached_tape_bytes = byte_size + else: + self.clear_tape_cache() + return arrays + + @property + def tape_cache_bytes(self) -> int: + """Current resident size of the bounded primitive tape cache.""" + + return int(self._cached_tape_bytes) + + def clear_tape_cache(self) -> None: + """Release cached command arrays and their fingerprint immediately.""" + + self._cached_tape_fingerprint = None + self._cached_tape_arrays = None + self._cached_tape_bytes = 0 + + def run_tape_score(self, compiled_commands: CompiledOrderCommandArrays) -> RustBatchedScoreResult: + """Run a complete static tape through one PyO3 call and return scalars.""" + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + payload = self._new_session().run_tape_score(ptr, codes, values, expiry) + return RustBatchedScoreResult( + final_equity=float(_payload_value(payload, "final_equity")), + final_position=float(_payload_value(payload, "final_position")), + total_fee=float(_payload_value(payload, "total_fee")), + total_turnover=float(_payload_value(payload, "total_turnover")), + fill_count=int(_payload_value(payload, "fill_count")), + event_count=int(_payload_value(payload, "event_count")), + rejected_count=int(_payload_value(payload, "rejected_count")), + canceled_count=int(_payload_value(payload, "canceled_count")), + max_initial_margin=float(_payload_value(payload, "max_initial_margin")), + max_maintenance_margin=float(_payload_value(payload, "max_maintenance_margin")), + bars=int(_payload_value(payload, "bars")), + metadata={"backend": "rust_batched", "mode": "score", "pycalls": 1}, + ) + + def run_tape_audit(self, compiled_commands: CompiledOrderCommandArrays) -> RustBatchedAuditResult: + """Run a complete tape and return contiguous struct-of-arrays audit data.""" + ptr, codes, values, expiry = self._tape_arrays(compiled_commands) + payload = self._new_session().run_tape_audit(ptr, codes, values, expiry) + arrays = {key: np.ascontiguousarray(np.asarray(payload[key])) for key in ( + "equity", "positions", "fees", "turnover", "initial_margin", "maintenance_margin", + "fill_bar", "fill_order_id", "fill_side", "fill_qty", "fill_price", "fill_fee", + "event_bar", "event_kind", "event_status", "event_order_id", "event_target_id", + )} + return RustBatchedAuditResult( + **arrays, + total_fee=float(payload["total_fee"]), + total_turnover=float(payload["total_turnover"]), + fill_count=int(payload["fill_count"]), + event_count=int(payload["event_count"]), + rejected_count=int(payload["rejected_count"]), + canceled_count=int(payload["canceled_count"]), + max_initial_margin=float(payload["max_initial_margin"]), + max_maintenance_margin=float(payload["max_maintenance_margin"]), + metadata={"backend": "rust_batched", "mode": "audit", "pycalls": 1}, + id_values=tuple(compiled_commands.id_values), + ) + + +class RustBatchedSession: + """Stateful single-symbol sparse continuation over a static tape.""" + + def __init__( + self, + runner: RustBatchedRunner, + compiled_commands: Optional[CompiledOrderCommandArrays] = None, + ) -> None: + self.runner = runner + self.compiled_commands = compiled_commands + self._core = runner._new_session() + self._tape_arrays_cache = ( + None if compiled_commands is None else runner._tape_arrays(compiled_commands) + ) + self.next_bar = 0 + + @staticmethod + def _arrays(payload: Mapping[str, object]) -> dict[str, np.ndarray]: + return { + key: np.ascontiguousarray(np.asarray(payload[key])) + for key in ( + "wake_bar", + "wake_kind", + "fill_bar", + "fill_order_id", + "fill_side", + "fill_qty", + "fill_price", + "fill_fee", + "event_bar", + "event_kind", + "event_status", + "event_order_id", + "event_target_id", + ) + } + + def run_until( + self, + stop_bar: int, + command_batch: Optional[CompiledOrderCommandArrays] = None, + *, + wake_on_fill: bool = True, + wake_on_order_event: bool = True, + wake_on_liquidation: bool = True, + ) -> RustBatchedChunkResult: + """Advance through ``stop_bar`` without crossing Python per bar. + + The first call starts at bar zero and later calls continue at the bar + after the previous chunk. ``command_batch`` is optional after a tape + was supplied to :meth:`open_sparse_session`; replacing the tape + mid-session is rejected to avoid an accounting mismatch. + """ + if command_batch is not None: + if self.compiled_commands is not None and command_batch is not self.compiled_commands: + raise NativeEventRustBackendError("cannot replace the command tape during a sparse session") + self.compiled_commands = command_batch + if self.compiled_commands is None: + raise NativeEventRustBackendError("run_until requires a compiled command tape") + stop = int(stop_bar) + if stop < self.next_bar: + raise ValueError("run_until stop_bar must advance beyond the previous chunk") + if self._tape_arrays_cache is None: + self._tape_arrays_cache = self.runner._tape_arrays(self.compiled_commands) + ptr, codes, values, expiry = self._tape_arrays_cache + payload = self._core.run_until( + stop, + ptr, + codes, + values, + expiry, + bool(wake_on_fill), + bool(wake_on_order_event), + bool(wake_on_liquidation), + ) + arrays = self._arrays(payload) + self.next_bar = stop + 1 + return RustBatchedChunkResult( + start_bar=int(payload["start_bar"]), + stop_bar=int(payload["stop_bar"]), + final_equity=float(payload["final_equity"]), + final_position=float(payload["final_position"]), + total_fee=float(payload["total_fee"]), + total_turnover=float(payload["total_turnover"]), + fill_count=int(payload["fill_count"]), + event_count=int(payload["event_count"]), + rejected_count=int(payload["rejected_count"]), + canceled_count=int(payload["canceled_count"]), + max_initial_margin=float(payload["max_initial_margin"]), + max_maintenance_margin=float(payload["max_maintenance_margin"]), + liquidation_seen=bool(payload["liquidation_seen"]), + **arrays, + metadata={ + "backend": "rust_batched", + "mode": "sparse", + "pycalls": 1, + "dense_paths_materialized": False, + "wake_on_fill": bool(wake_on_fill), + "wake_on_order_event": bool(wake_on_order_event), + "wake_on_liquidation": bool(wake_on_liquidation), + }, + ) + + def reset(self) -> None: + """Reset lifecycle/accounting while retaining Rust buffer capacity.""" + + self._core.reset() + self.next_bar = 0 + + +class RustReactiveSessionAdapter: + """R2 bridge: Python callbacks around one Rust state transition per bar.""" + + def __init__( + self, + *, + idx: pd.DatetimeIndex, + symbols: Sequence[str], + market_arrays, + opens_arr: np.ndarray, + volumes_arr: np.ndarray, + constraints, + contract_sizes: np.ndarray, + leverages: np.ndarray, + fee_rates: np.ndarray, + initial_capital: float, + maintenance_ratio: float, + slippage: float, + use_funding: bool, + retain_terminal_orders: bool = True, + score_requirements=None, + prepared_market_core=None, + ) -> None: + self._module = _require_r1_extension() + extension_status = probe_native_event_rust_extension(module=self._module) + self._full_contract = bool(extension_status.capabilities.get("native_event_v2_full_contract", False)) + if not self._full_contract: + validate_rust_r1_support( + symbols=symbols, + constraints=constraints, + use_funding=use_funding, + maintenance_ratio=maintenance_ratio, + ) + self.idx = idx + self.symbols = list(symbols) + self.symbols_tuple = tuple(symbols) + self.market_arrays = market_arrays + self.opens_arr = opens_arr + self.volumes_arr = volumes_arr + self.constraints = constraints + self.contract_sizes = np.asarray(contract_sizes, dtype=np.float64) + self.leverages = np.asarray(leverages, dtype=np.float64) + self.fee_rates = np.asarray(fee_rates, dtype=np.float64) + self.initial_capital = float(initial_capital) + self.maintenance_ratio = float(maintenance_ratio) + self.slippage = float(slippage) + self.use_funding = bool(use_funding) + self.retain_terminal_orders = bool(retain_terminal_orders) + self.score_requirements = score_requirements + self.scalar_score = bool( + score_requirements is not None + and score_requirements.need_trade_stats + and not score_requirements.need_equity_path + and not score_requirements.need_position_path + and not score_requirements.need_fee_path + and not score_requirements.need_funding_path + and not score_requirements.need_margin_path + ) + self.retain_fill_ledger = bool(score_requirements is None or score_requirements.need_fill_ledger) + self.retain_event_ledger = bool(score_requirements is None or score_requirements.need_event_ledger) + self.emit_context_fills = bool( + score_requirements is None or score_requirements.need_context_fills + ) + self.emit_context_events = bool( + score_requirements is None or score_requirements.need_context_events + ) + self.emit_context_active_orders = bool( + score_requirements is None or score_requirements.need_context_active_orders + ) + self.emit_context_positions = bool( + score_requirements is None or score_requirements.need_context_positions + ) + self.emit_context_margin = bool( + score_requirements is None or score_requirements.need_context_margin + ) + self.compact_score_state = bool( + score_requirements is not None + and not score_requirements.need_context_fills + and not score_requirements.need_context_events + and not score_requirements.need_context_active_orders + and not score_requirements.need_context_positions + and not score_requirements.need_context_margin + and not score_requirements.need_fill_ledger + and not score_requirements.need_event_ledger + and not score_requirements.need_terminal_orders + ) + self._r2_capable = bool(extension_status.capabilities.get("r2_stop_amend_replace_reduce_only_constraints", False)) + self._prepared_market_core_capable = bool(extension_status.capabilities.get("prepared_market_core", False)) + if self.constraints.enabled and not self._r2_capable: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel is R1-only and cannot apply quantity constraints; rebuild/install R2 or use backend='python'" + ) + self._id_to_code: dict[str, int] = {} + self._id_values: list[str] = [] + self._commands_by_id: dict[str, OrderCommand] = {} + self._command_buffer = RustCommandBuffer() + self._full_command_buffer = RustFullCommandBuffer() + self.execution_counters = { + "bars_processed": 0, + "bars_with_commands": 0, + "contexts_materialized": 0, + "timestamp_objects_materialized": 0, + "commands_compiled": 0, + "command_buffer_growths": 0, + "bytes_copied_to_rust": 0, + "active_snapshot_materializations": 0, + "empty_command_batches_skipped": 0, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 0, + "commands_retimed": 0, + "commands_quantized": 0, + } + self.scheduled: dict[int, list[OrderCommand]] = {} + self.pending: list[_RustPendingOrder] = [] + self.orders: list[_RustPendingOrder] = [] + self.fills: list[NativeFillEvent] = [] + self.events: list[NativeOrderEvent] = [] + self.fill_count = 0 + self.event_count = 0 + self.rejected_count = 0 + self.canceled_count = 0 + self.total_fee = 0.0 + self.total_funding = 0.0 + self.total_turnover = 0.0 + self.fills_by_bar: dict[int, list[NativeFillEvent]] = {} + self.events_by_bar: dict[int, list[NativeOrderEvent]] = {} + self.current_pos = np.zeros(len(self.symbols), dtype=np.float64) + self.equity = float(initial_capital) + self.liquidated = False + self.liquidation_bar = -1 + self.liquidation_reason = 0 + self.last_initial_margin = 0.0 + self.last_maintenance_margin = 0.0 + self.processed_bar = -1 + n_bars = len(idx) + self.equity_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.pos_path = None if self.scalar_score else np.zeros((n_bars, len(self.symbols)), dtype=np.float64) + self.fee_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.turnover_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.funding_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.initial_margin_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.maintenance_margin_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64) + self.rejected_bar = None if self.scalar_score else np.zeros(n_bars, dtype=np.int64) + self.canceled_bar = None if self.scalar_score else np.zeros(n_bars, dtype=np.int64) + self.empty_fills: tuple[NativeFillEvent, ...] = () + self.empty_events: tuple[NativeOrderEvent, ...] = () + self.empty_active_orders: tuple[NativeActiveOrderSnapshot, ...] = () + self._active_snapshot_cache: tuple[NativeActiveOrderSnapshot, ...] = () + if self.scalar_score: + # Import lazily to avoid the native_event <-> Rust adapter import + # cycle. The class is shared with Python scalar scoring so metric + # definitions remain identical across backends. + from .native_event import _OnlineScoreState + + self.online_score = _OnlineScoreState(self.initial_capital, len(self.symbols)) + else: + self.online_score = None + self.prepared_market_core = prepared_market_core + if self._full_contract and hasattr(self._module, "FullPreparedMarketCore"): + if self.prepared_market_core is None: + self.prepared_market_core = self._module.FullPreparedMarketCore( + np.ascontiguousarray(idx.asi8, dtype=np.int64), + np.ascontiguousarray(opens_arr, dtype=np.float64), + np.ascontiguousarray(market_arrays.highs, dtype=np.float64), + np.ascontiguousarray(market_arrays.lows, dtype=np.float64), + np.ascontiguousarray(market_arrays.closes, dtype=np.float64), + np.ascontiguousarray(volumes_arr, dtype=np.float64), + np.ascontiguousarray(market_arrays.funding, dtype=np.float64), + np.ascontiguousarray(market_arrays.is_funding_bar, dtype=np.bool_), + ) + self._core = self._module.FullReactiveSessionCore.from_prepared( + self.prepared_market_core, + np.ascontiguousarray(self.contract_sizes, dtype=np.float64), + np.ascontiguousarray(self.leverages, dtype=np.float64), + np.ascontiguousarray(self.fee_rates, dtype=np.float64), + float(initial_capital), float(maintenance_ratio), float(slippage), bool(use_funding), + ) + # Accounting and the live position vector are always required by + # the Python adapter. Other projections are requested only when + # the strategy/ledger can observe them. + output_mask = _FULL_OUTPUT_POSITIONS + if self.retain_fill_ledger or self.emit_context_fills: + output_mask |= _FULL_OUTPUT_FILLS + if self.retain_event_ledger or self.emit_context_events: + output_mask |= _FULL_OUTPUT_EVENTS + if self.emit_context_active_orders: + output_mask |= _FULL_OUTPUT_ACTIVE_ORDERS + self._core.set_output_mask(output_mask) + elif self._prepared_market_core_capable and hasattr(self._module, "PreparedMarketCore"): + if self.prepared_market_core is None: + self.prepared_market_core = self._module.PreparedMarketCore( + np.ascontiguousarray(idx.asi8, dtype=np.int64), + np.ascontiguousarray(opens_arr[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64), + np.ascontiguousarray(volumes_arr[:, 0], dtype=np.float64), + np.zeros(n_bars, dtype=np.float64), + np.zeros(n_bars, dtype=np.bool_), + ) + self._core = self._module.ReactiveSessionCore.from_prepared( + self.prepared_market_core, + float(self.contract_sizes[0]), + float(self.leverages[0]), + float(self.fee_rates[0]), + float(initial_capital), + float(maintenance_ratio), + float(slippage), + False, + ) + else: + self.prepared_market_core = None + self._core = self._module.ReactiveSessionCore( + np.ascontiguousarray(idx.asi8, dtype=np.int64), + np.ascontiguousarray(opens_arr[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64), + np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64), + np.ascontiguousarray(volumes_arr[:, 0], dtype=np.float64), + np.zeros(n_bars, dtype=np.float64), + np.zeros(n_bars, dtype=np.bool_), + float(self.contract_sizes[0]), + float(self.leverages[0]), + float(self.fee_rates[0]), + float(initial_capital), + float(maintenance_ratio), + float(slippage), + False, + ) + self.size_helper = self._size_order + + def _intern_id(self, value: Optional[str]) -> int: + if value is None: + return -1 + if value not in self._id_to_code: + self._id_to_code[value] = len(self._id_values) + self._id_values.append(value) + return self._id_to_code[value] + + def _id_from_code(self, value: int) -> Optional[str]: + return self._id_values[value] if 0 <= int(value) < len(self._id_values) else None + + def _size_order(self, symbol: str, notional: float, price: float, side: OrderSide = OrderSide.BUY) -> float: + if symbol not in self.symbols: + raise ValueError(f"unknown symbol={symbol!r}") + if price <= 0.0: + raise ValueError("price must be > 0") + column = self.symbols.index(symbol) + return abs(float(notional) / (float(price) * float(self.contract_sizes[column]))) + + def _quantize_r2_commands(self, bar: int, commands: Sequence[OrderCommand]) -> tuple[OrderCommand, ...]: + """Apply the canonical quantity filter at the same bar as replay preflight. + + Reactive commands cannot be preflighted before a strategy emits them. + The static replay performs the equivalent filtering over the emitted + tape; this method makes explicit Rust follow that exact exchange-rule + contract without changing the command tape or endpoint API. + """ + if not self.constraints.enabled: + self.execution_counters["constraint_preflight_skipped"] += int(bool(commands)) + return tuple(commands) + self.execution_counters["constraint_preflight_calls"] += int(bool(commands)) + out: list[OrderCommand] = [] + for command in commands: + if command.action not in (OrderAction.PLACE, OrderAction.REPLACE) or command.qty is None: + out.append(command) + continue + try: + column = self.symbols.index(command.symbol) + except ValueError as exc: + raise NativeEventRustBackendError( + f"quantity preflight received unknown symbol={command.symbol!r}" + ) from exc + close = float(self.market_arrays.closes[int(bar), column]) + price = float(command.price) if command.price is not None else close + signed = command.signed_qty + quantity = abs( + quantize_signed_quantity( + signed, + price, + float(self.contract_sizes[column]), + float(self.constraints.qty_step[column]), + float(self.constraints.min_qty[column]), + float(self.constraints.min_notional[column]), + ) + ) + if quantity <= 0.0: + continue + if abs(quantity - float(command.qty)) > 1e-12: + out.append(replace(command, qty=quantity)) + else: + out.append(command) + self.execution_counters["commands_quantized"] += len(commands) + return tuple(out) + + @staticmethod + def _commands_require_r2(commands: Sequence[OrderCommand]) -> bool: + return any( + command.action in (OrderAction.AMEND, OrderAction.REPLACE) + or command.reduce_only + or command.order_type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT) + for command in commands + ) + + def _require_r2_for_commands(self, commands: Sequence[OrderCommand]) -> None: + if self._commands_require_r2(commands) and not self._r2_capable: + raise NativeEventRustBackendError( + "installed _quantbt_native wheel is R1-only and cannot execute R2 lifecycle commands; rebuild/install R2 or use backend='python'" + ) + + def schedule(self, bar: int, commands: Sequence[OrderCommand]) -> None: + if commands and int(bar) < len(self.idx): + self.scheduled.setdefault(int(bar), []).extend(commands) + + def release_bar_payload(self, bar: int) -> None: + self.fills_by_bar.pop(int(bar), None) + self.events_by_bar.pop(int(bar), None) + + def process_bar(self, bar: int) -> None: + if bar <= self.processed_bar: + return + for current_bar in range(self.processed_bar + 1, int(bar) + 1): + commands = self._quantize_r2_commands(current_bar, self.scheduled.pop(current_bar, ())) + self._require_r2_for_commands(commands) + if self._full_contract: + full_codes, full_values, full_expiry = compile_rust_full_reactive_batch( + commands, + symbols=self.symbols, + intern_id=self._intern_id, + idx=self.idx, + buffer=self._full_command_buffer, + ) + batch = None + else: + batch = compile_rust_r1_command_batch( + commands, + symbol=self.symbols[0], + intern_id=self._intern_id, + buffer=self._command_buffer, + ) + if self._full_contract: + for command in commands: + if command.order_id: + self._commands_by_id[command.order_id] = command + step_method = getattr(self._core, "step_typed", self._core.step) + payload = step_method(current_bar, full_codes, full_values, full_expiry) + else: + for command in batch.commands: + if command.order_id: + self._commands_by_id[command.order_id] = command + payload = self._core.step(current_bar, batch.codes, batch.values, batch.expiry) + self._consume_step(current_bar, payload) + self.processed_bar = current_bar + self.execution_counters["bars_processed"] += 1 + self.execution_counters["commands_compiled"] += len(commands) + self.execution_counters["command_buffer_growths"] = self._full_command_buffer.growth_count + self.execution_counters["bytes_copied_to_rust"] += int( + full_codes.nbytes + full_values.nbytes + full_expiry.nbytes + if self._full_contract + else batch.codes.nbytes + batch.values.nbytes + batch.expiry.nbytes + ) + + def _consume_step(self, bar: int, payload) -> None: + self.equity = float(_step_value(payload, "equity", 0.0)) + if self._full_contract: + positions = _step_value(payload, "positions") + if positions is not None: + self.current_pos[:] = np.asarray(positions, dtype=np.float64) + else: + self.current_pos[0] = float(_step_value(payload, "position", 0.0)) + fee = float(_step_value(payload, "fee", 0.0)) + turnover = float(_step_value(payload, "turnover", 0.0)) + funding = float(_step_value(payload, "funding", 0.0)) if self._full_contract else 0.0 + initial_margin = float(_step_value(payload, "initial_margin", 0.0)) + maintenance_margin = float(_step_value(payload, "maintenance_margin", 0.0)) + self.last_initial_margin = initial_margin + self.last_maintenance_margin = maintenance_margin + self.total_fee += fee + self.total_turnover += turnover + self.total_funding += funding + if self.equity_path is not None: + self.equity_path[bar] = self.equity + if self.pos_path is not None: + self.pos_path[bar, :] = self.current_pos + if self.fee_path is not None: + self.fee_path[bar] = fee + if self.turnover_path is not None: + self.turnover_path[bar] = turnover + if self.funding_path is not None: + self.funding_path[bar] = funding + if self.initial_margin_path is not None: + self.initial_margin_path[bar] = initial_margin + if self.maintenance_margin_path is not None: + self.maintenance_margin_path[bar] = maintenance_margin + self.liquidated = bool(_step_value(payload, "liquidated", False)) + self.liquidation_bar = int(_step_value(payload, "liquidation_bar", -1)) + self.liquidation_reason = int(_step_value(payload, "liquidation_reason", 0)) + if self.online_score is not None: + self.online_score.observe( + self.idx.asi8[bar], + self.equity, + self.current_pos, + initial_margin, + maintenance_margin, + ) + reported_fill_count = _step_has(payload, "fill_count") + reported_event_counts = _step_has(payload, "event_count") + if reported_fill_count: + self.fill_count += int(_step_value(payload, "fill_count", 0)) + if reported_event_counts: + self.event_count += int(_step_value(payload, "event_count", 0)) + rejected = int(_step_value(payload, "rejected_count", 0)) + canceled = int(_step_value(payload, "canceled_count", 0)) + self.rejected_count += rejected + self.canceled_count += canceled + if self.rejected_bar is not None: + self.rejected_bar[bar] += rejected + if self.canceled_bar is not None: + self.canceled_bar[bar] += canceled + fills = [] + for fill_row in (_step_value(payload, "fills") or ()): + if self._full_contract: + order_code, symbol_code, side_sign, qty, price, fee = fill_row + symbol = self.symbols[int(symbol_code)] + else: + order_code, side_sign, qty, price, fee = fill_row + symbol = self.symbols[0] + order_id = self._id_from_code(int(order_code)) + command = self._commands_by_id.get(order_id or "") + fill = NativeFillEvent( + timestamp=self.idx[bar], + symbol=symbol, + side=OrderSide.BUY if int(side_sign) > 0 else OrderSide.SELL, + qty=float(qty), + price=float(price), + fee=float(fee), + order_id=order_id, + tag=None if command is None else command.tag, + metadata={} if command is None else dict(command.metadata), + ) + fills.append(fill) + if not reported_fill_count: + self.fill_count += 1 + if self.retain_fill_ledger: + self.fills.append(fill) + if fills: + self.fills_by_bar[bar] = fills + events = [] + for event_row in (_step_value(payload, "events") or ()): + if self._full_contract: + event_kind, status, order_code, target_code, symbol_code = event_row[:5] + reject_code = int(event_row[5]) if len(event_row) > 5 else 0 + event_symbol = None if int(symbol_code) < 0 else self.symbols[int(symbol_code)] + else: + event_kind, status, order_code, target_code = event_row + reject_code = 0 + event_symbol = None + name = ({0: "place", 1: "cancel", 2: "replace", 3: "amend", 4: "fill", 5: "expire", 6: "activate", 7: "reject"} if self._full_contract else {0: "place", 1: "cancel", 2: "fill", 3: "reject", 4: "amend", 5: "replace"}).get( + int(event_kind), "reject" + ) + event = NativeOrderEvent( + timestamp=self.idx[bar], + bar=bar, + event_name=name, + status=int(status), + order_id=self._id_from_code(int(order_code)), + target_order_id=self._id_from_code(int(target_code)), + metadata={"reject_code": reject_code}, + ) + events.append(event) + if not reported_event_counts: + self.event_count += 1 + if name == "reject": + if self.rejected_bar is not None: + self.rejected_bar[bar] += 1 + self.rejected_count += 1 + if name == "cancel": + if self.canceled_bar is not None: + self.canceled_bar[bar] += 1 + self.canceled_count += 1 + if self.retain_event_ledger: + self.events.append(event) + if events: + self.events_by_bar[bar] = events + pending = [] + snapshots = [] + for active_row in (_step_value(payload, "active_orders") or ()): + if self._full_contract: + order_code, symbol_code, side_sign, order_type, qty, price, trigger_price, tif, flags, parent, group, oco, activation, waiting_parent = active_row + active_symbol = self.symbols[int(symbol_code)] + parent_order_id = self._id_from_code(int(parent)) + group_id = self._id_from_code(int(group)) + oco_group_id = self._id_from_code(int(oco)) + else: + order_code, side_sign, order_type, qty, price, trigger_price, flags = active_row + active_symbol = self.symbols[0] + parent_order_id = None + group_id = None + oco_group_id = None + order_id = self._id_from_code(int(order_code)) + command = self._commands_by_id.get(order_id or "") + side = OrderSide.BUY if int(side_sign) > 0 else OrderSide.SELL + kind = { + _R1_ORDER_MARKET: OrderType.MARKET, + _R1_ORDER_LIMIT: OrderType.LIMIT, + _R2_ORDER_STOP_MARKET: OrderType.STOP_MARKET, + _R2_ORDER_STOP_LIMIT: OrderType.STOP_LIMIT, + }.get(int(order_type), OrderType.MARKET) + reduce_only = bool(int(flags) & _R2_FLAG_REDUCE_ONLY) + pending.append( + _RustPendingOrder( + order_id=order_id, + side=side, + order_type=kind, + qty=float(qty), + price=float(price), + trigger_price=float(trigger_price), + reduce_only=reduce_only, + ) + ) + snapshots.append( + NativeActiveOrderSnapshot( + order_id=order_id, + symbol=active_symbol, + side=side.value, + order_type=kind.value, + status=ORDER_STATUS_PENDING, + remaining_qty=float(qty), + price=float(price), + trigger_price=float(trigger_price), + reduce_only=reduce_only, + parent_order_id=parent_order_id, + group_id=group_id, + oco_group_id=oco_group_id, + tag=None if command is None else command.tag, + campaign_id=None if command is None else command.metadata.get("campaign_id"), + cycle_id=None if command is None else command.metadata.get("cycle_id"), + level_id=None if command is None else command.metadata.get("level_id"), + ) + ) + self.pending = pending + if self.emit_context_active_orders: + self._active_snapshot_cache = tuple(snapshots) + self.execution_counters["active_snapshot_materializations"] += 1 + else: + self._active_snapshot_cache = self.empty_active_orders + + @staticmethod + def _is_pending(state: _RustPendingOrder) -> bool: + return True + + def context(self, bar: int) -> NativeStrategyContext: + self.process_bar(bar) + self.execution_counters["contexts_materialized"] += 1 + initial_margin = ( + float(self.initial_margin_path[int(bar)]) + if self.initial_margin_path is not None + else float(self.last_initial_margin) + ) + maintenance_margin = ( + float(self.maintenance_margin_path[int(bar)]) + if self.maintenance_margin_path is not None + else float(self.last_maintenance_margin) + ) + return NativeStrategyContext( + bar_index=int(bar), + timestamp=self.idx[int(bar)], + open=self.opens_arr[int(bar)], + high=self.market_arrays.highs[int(bar)], + low=self.market_arrays.lows[int(bar)], + close=self.market_arrays.closes[int(bar)], + volume=self.volumes_arr[int(bar)], + equity=float(self.equity), + available_equity=float(self.equity - initial_margin), + initial_margin=initial_margin if self.emit_context_margin else 0.0, + maintenance_margin=maintenance_margin if self.emit_context_margin else 0.0, + positions=( + {symbol: float(self.current_pos[col]) for col, symbol in enumerate(self.symbols)} + if self.emit_context_positions else {} + ), + fills_this_bar=( + tuple(self.fills_by_bar.get(int(bar), ())) + if self.emit_context_fills else self.empty_fills + ), + order_events_this_bar=( + tuple(self.events_by_bar.get(int(bar), ())) + if self.emit_context_events else self.empty_events + ), + active_orders=( + self._active_snapshot_cache + if self.emit_context_active_orders else self.empty_active_orders + ), + liquidated=bool(self.liquidated), + symbols=self.symbols_tuple, + size_order=self.size_helper, + ) + + +__all__ = [ + "NativeEventBackendSelection", + "NativeEventRustBackendError", + "NativeEventRustExtensionStatus", + "RUST_NATIVE_API_VERSION", + "RustCommandBatch", + "RustCommandBuffer", + "RustFullCommandBuffer", + "RustBatchedAuditResult", + "RustFullAuditResult", + "RustBatchedChunkResult", + "RustBatchedRunner", + "RustFullRunner", + "RustBatchedScoreResult", + "RustBatchedSession", + "RustReactiveSessionAdapter", + "compile_rust_batched_tape", + "compile_rust_full_tape", + "compile_rust_full_reactive_batch", + "compile_rust_r1_command_batch", + "probe_native_event_rust_extension", + "resolve_native_event_backend", + "validate_rust_r1_support", +] diff --git a/src/quantbt/backends/native_event.py b/src/quantbt/backends/native_event.py new file mode 100644 index 0000000..b7ab01d --- /dev/null +++ b/src/quantbt/backends/native_event.py @@ -0,0 +1,5048 @@ +""" +quantbt.backends.native_event +----------------------------- +Native event-driven backend using a Numba matching kernel. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field, replace +import math +from pathlib import Path +from typing import Dict, List, Mapping, Optional, Sequence, Union + +import numpy as np +import pandas as pd + +from ..core.event import ( + ACTIVATION_IMMEDIATE, + ACTIVATION_ON_PARENT_FIRST_FILL, + ACTIVATION_ON_PARENT_FULL_FILL, + COMMAND_ACTION_AMEND, + COMMAND_ACTION_CANCEL, + COMMAND_ACTION_CANCEL_ALL, + COMMAND_ACTION_PLACE, + COMMAND_ACTION_REPLACE, + LIQ_AFTER_FUNDING, + LIQ_AFTER_ORDER, + LIQ_INTRABAR, + LIQ_NONE, + ORDER_EVENT_ACTIVATE, + ORDER_EVENT_AMEND, + ORDER_EVENT_CANCEL, + ORDER_EVENT_EXPIRE, + ORDER_EVENT_FILL, + ORDER_EVENT_PLACE, + ORDER_EVENT_REJECT, + ORDER_STATUS_CANCELED, + ORDER_STATUS_FILLED, + ORDER_STATUS_PENDING, + ORDER_STATUS_REJECTED, + ORDER_TYPE_LIMIT, + ORDER_TYPE_MARKET, + ORDER_TYPE_STOP_LIMIT, + ORDER_TYPE_STOP_MARKET, + REJECT_INSUFFICIENT_MARGIN, + REJECT_REDUCE_ONLY_NO_POSITION, + REJECT_UNKNOWN_ORDER, + SIDE_BUY, + SIDE_SELL, + TIF_FOK, + TIF_GTC, + TIF_GTD, + TIF_IOC, + _engine_event_v1, + _engine_event_v2, +) +from ..core.constraints import build_quantity_constraints, quantize_signed_quantity +from ..core.arbitrage import ( + ArbitrageSpec, + ArbitragePlan, + BasisArbitrageSpec, + CalendarSpreadSpec, + CrossExchangeArbSpec, + FundingArbitrageSpec, + IndexBasketArbSpec, + OptionsVolArbSpec, + PackageExecutionKind, + PackageRejection, + SizingPolicyKind, + SpotPerpCashCarrySpec, + StatArbPairSpec, + TriangularArbSpec, + build_arbitrage_order_plan, +) +from ..core.basket import build_frozen_basket_orders +from ..core.order_compiler import ( + CompiledOrderArrays, + CompiledOrderCommandArrays, + compile_order_commands, + compile_order_intents, +) +from ..core.orders import Fill, OrderAction, OrderActivationPolicy, OrderCommand, OrderIntent +from ..core.preprocessor import ( + PreparedMarketArrays, + align_series, + build_market_arrays, + make_funding_mask, + prepare_funding, + validate_datetime, +) +from ..core.results import ( + BacktestResultV2, + NativeAccountingArrays, + NativeEventScalarScoreResult, + NativeEventScoreResult, +) +from ..core.reactive import ( + NativeActiveOrderSnapshot, + NativeEventStrategyError, + NativeFillEvent, + NativeOrderEvent, + NativeStrategyContext, +) +from ..core.schema import ( + AccountConfig, + BasketLegSpec, + BasketSpec, + ExecutionConfig, + LiquiditySide, + OrderSide, + OrderType, + TimeInForce, + InstrumentSpec, +) +from ._native_event_rust import ( + NativeEventBackendSelection, + NativeEventRustBackendError, + RustBatchedRunner, + RustFullRunner, + RustReactiveSessionAdapter, + resolve_native_event_backend, +) + + +def _event_type_name(event_type: int) -> str: + return { + 0: "place", + 1: "cancel", + 2: "replace", + 3: "amend", + 4: "fill", + 5: "expire", + 6: "activate", + 7: "reject", + }.get(int(event_type), "unknown") + + +@dataclass(frozen=True) +class NativeEventConfig: + account: AccountConfig + execution: ExecutionConfig = field(default_factory=ExecutionConfig) + fee_rate: Union[float, Dict[str, float]] = 0.0 + use_funding: bool = True + report_level: str = "audit" + audit_sink: str = "memory" + audit_sink_path: Optional[str] = None + reactive_kernel_mode: str = "replay_certified" + native_backend: Optional[str] = None + + def __post_init__(self) -> None: + if isinstance(self.fee_rate, dict): + if any(float(rate) < 0.0 for rate in self.fee_rate.values()): + raise ValueError("fee_rate must be >= 0") + elif float(self.fee_rate) < 0.0: + raise ValueError("fee_rate must be >= 0") + object.__setattr__(self, "report_level", _normalize_native_event_report_level(self.report_level)) + object.__setattr__(self, "audit_sink", _normalize_native_event_audit_sink(self.audit_sink)) + object.__setattr__(self, "reactive_kernel_mode", _normalize_reactive_kernel_mode(self.reactive_kernel_mode)) + if self.native_backend is not None: + selected = str(self.native_backend).lower().strip() + if selected not in {"python", "rust", "auto", "replay_certified"}: + raise ValueError( + "native_backend must be one of: auto, python, replay_certified, rust" + ) + object.__setattr__(self, "native_backend", selected) + + +@dataclass(frozen=True) +class NativeEventArtifactPlan: + keep_equity_path: bool + keep_position_path: bool + keep_fee_path: bool + keep_funding_path: bool + keep_margin_path: bool + keep_fill_ledger: bool + keep_command_terminal_state: bool + keep_event_ledger: bool + keep_command_tape: bool + materialize_pandas: bool + materialize_python_objects: bool + materialize_active_orders: bool + + +@dataclass(frozen=True, slots=True) +class NativeEventScoreRequirements: + """Internal retention contract for direct prepared-score execution. + + The public ``PreparedNativeEventStrategyRunner.score`` compatibility + contract exposes accounting arrays. Prepared optimization uses + ``scalar_score_contract()`` instead, which relies on online metrics and + keeps only live reactive state. Context flags are separate from ledger + retention: a strategy may consume current-bar fills without retaining the + complete fill history. + """ + + need_equity_path: bool = False + need_position_path: bool = False + need_fee_path: bool = False + need_funding_path: bool = False + need_margin_path: bool = False + need_turnover_path: bool = False + need_rejection_path: bool = False + need_cancellation_path: bool = False + need_trade_stats: bool = True + need_fill_ledger: bool = False + need_event_ledger: bool = False + need_terminal_orders: bool = False + need_context_fills: bool = True + need_context_events: bool = True + need_context_active_orders: bool = True + need_context_positions: bool = True + need_context_margin: bool = True + need_command_tape: bool = False + + @classmethod + def public_score_contract(cls) -> "NativeEventScoreRequirements": + """Return the compatible array set required by ``NativeEventScoreResult``.""" + return cls( + need_equity_path=True, + need_position_path=True, + need_fee_path=True, + need_funding_path=True, + need_margin_path=True, + need_trade_stats=False, + ) + + @classmethod + def scalar_score_contract(cls) -> "NativeEventScoreRequirements": + """Return the low-retention contract used by prepared optimization.""" + return cls( + need_equity_path=False, + need_position_path=False, + need_fee_path=False, + need_funding_path=False, + need_margin_path=False, + need_turnover_path=False, + need_rejection_path=False, + need_cancellation_path=False, + need_trade_stats=True, + need_fill_ledger=False, + need_event_ledger=False, + need_terminal_orders=False, + need_context_fills=True, + need_context_events=True, + need_context_active_orders=True, + need_context_positions=True, + need_context_margin=True, + need_command_tape=False, + ) + + @classmethod + def from_strategy( + cls, + strategy, + *, + base: Optional["NativeEventScoreRequirements"] = None, + ) -> "NativeEventScoreRequirements": + """Apply an optional strategy context declaration to a base contract.""" + requirements = base or cls.scalar_score_contract() + declaration = getattr(strategy, "native_context_requirements", None) + if declaration is None: + return requirements + if not isinstance(declaration, Mapping): + raise TypeError("native_context_requirements must be a mapping") + aliases = { + "fills": "need_context_fills", + "events": "need_context_events", + "active_orders": "need_context_active_orders", + "positions": "need_context_positions", + "margin": "need_context_margin", + } + valid = set(aliases) | set(aliases.values()) + updates = {} + for key, value in declaration.items(): + if key not in valid: + raise ValueError(f"unsupported native context requirement: {key!r}") + updates[aliases.get(key, key)] = bool(value) + return replace(requirements, **updates) + + +@dataclass(frozen=True) +class CompactFillLedger: + bar: np.ndarray + command_index: np.ndarray + original_index: np.ndarray + order_id_code: np.ndarray + symbol_code: np.ndarray + side: np.ndarray + qty: np.ndarray + price: np.ndarray + fee: np.ndarray + id_values: tuple[str, ...] + symbols: tuple[str, ...] + + @property + def fill_count(self) -> int: + return int(len(self.bar)) + + +@dataclass(frozen=True) +class CompactCommandLedger: + original_index: np.ndarray + command_bar: np.ndarray + action: np.ndarray + symbol_code: np.ndarray + side: np.ndarray + order_type: np.ndarray + order_id_code: np.ndarray + target_order_id_code: np.ndarray + parent_order_id_code: np.ndarray + group_id_code: np.ndarray + oco_group_id_code: np.ndarray + status: np.ndarray + reject_code: np.ndarray + fill_bar: np.ndarray + fill_qty: np.ndarray + fill_price: np.ndarray + fill_fee: np.ndarray + active: np.ndarray + waiting_parent: np.ndarray + working_qty: np.ndarray + working_price: np.ndarray + working_trigger: np.ndarray + id_values: tuple[str, ...] + symbols: tuple[str, ...] + + +@dataclass(frozen=True) +class CompactOrderEventLedger: + bar: np.ndarray + command_index: np.ndarray + event_type: np.ndarray + status: np.ndarray + related_command_index: np.ndarray + + @property + def event_count(self) -> int: + return int(len(self.bar)) + + +def _normalize_native_event_report_level(report_level: str) -> str: + level = str(report_level or "audit").lower().strip() + aliases = {"full": "audit", "debug": "audit", "research": "standard", "optimizer": "score", "scoring": "score"} + level = aliases.get(level, level) + if level not in {"score", "minimal", "standard", "audit"}: + raise ValueError("native_event report_level must be score, minimal, standard, audit, or full") + return level + + +def _normalize_native_event_audit_sink(audit_sink: str) -> str: + sink = str(audit_sink or "memory").lower().strip() + if sink not in {"none", "memory", "jsonl", "parquet"}: + raise ValueError("native_event audit_sink must be none, memory, jsonl, or parquet") + return sink + + +def _normalize_reactive_kernel_mode(reactive_kernel_mode: str) -> str: + mode = str(reactive_kernel_mode or "replay_certified").lower().strip() + aliases = {"replay": "replay_certified", "certified": "replay_certified", "stateful": "single_pass"} + mode = aliases.get(mode, mode) + if mode not in {"replay_certified", "single_pass"}: + raise ValueError("reactive_kernel_mode must be replay_certified or single_pass") + return mode + + +def _native_event_artifact_plan(report_level: str) -> NativeEventArtifactPlan: + level = _normalize_native_event_report_level(report_level) + if level == "score": + return NativeEventArtifactPlan( + keep_equity_path=True, + keep_position_path=True, + keep_fee_path=True, + keep_funding_path=True, + keep_margin_path=True, + keep_fill_ledger=False, + keep_command_terminal_state=False, + keep_event_ledger=False, + keep_command_tape=False, + materialize_pandas=False, + materialize_python_objects=False, + materialize_active_orders=False, + ) + if level == "minimal": + return NativeEventArtifactPlan( + keep_equity_path=True, + keep_position_path=True, + keep_fee_path=True, + keep_funding_path=True, + keep_margin_path=True, + keep_fill_ledger=True, + keep_command_terminal_state=True, + keep_event_ledger=False, + keep_command_tape=False, + materialize_pandas=True, + materialize_python_objects=False, + materialize_active_orders=False, + ) + if level == "standard": + return NativeEventArtifactPlan( + keep_equity_path=True, + keep_position_path=True, + keep_fee_path=True, + keep_funding_path=True, + keep_margin_path=True, + keep_fill_ledger=True, + keep_command_terminal_state=True, + keep_event_ledger=False, + keep_command_tape=False, + materialize_pandas=True, + materialize_python_objects=True, + materialize_active_orders=False, + ) + return NativeEventArtifactPlan( + keep_equity_path=True, + keep_position_path=True, + keep_fee_path=True, + keep_funding_path=True, + keep_margin_path=True, + keep_fill_ledger=True, + keep_command_terminal_state=True, + keep_event_ledger=True, + keep_command_tape=True, + materialize_pandas=True, + materialize_python_objects=True, + materialize_active_orders=True, + ) + + +@dataclass(slots=True) +class _ReactiveOrderState: + command: OrderCommand + command_index: int + symbol_col: int + status: int = ORDER_STATUS_PENDING + active: bool = False + waiting_parent: bool = False + working_qty: float = 0.0 + working_price: float = 0.0 + working_trigger: float = 0.0 + reject_code: int = 0 + + +def _compact_score_command(command: OrderCommand) -> OrderCommand: + """Drop non-execution metadata from a score-only pending order. + + Static score runs do not expose fills, events, active-order snapshots, or + terminal order objects. Parent/OCO/group/tag fields remain because they + affect lifecycle matching; strategy metadata is deliberately not retained + on the hot state. Public command objects and audit runs are untouched. + """ + + if not command.metadata: + return command + return replace(command, metadata={}) + + +class _OnlineScoreState: + """Streaming equivalent of the array-first performance metric helpers.""" + + __slots__ = ( + "initial_capital", "n_symbols", "trading_days", "prev_equity", "first_equity", + "last_equity", "peak", "max_drawdown", "drawdown_sum", "drawdown_count", + "bar_count", "bar_mean", "bar_m2", "bar_downside_sq", "bar_downside_count", "bar_gain", "bar_loss", + "bar_win_sum", "bar_win_count", "bar_loss_sum", "bar_loss_count", "daily_day", + "daily_close", "last_daily_close", "daily_points", "daily_mean", "daily_m2", + "daily_downside_sq", "daily_downside_count", "daily_gain", "daily_loss", "daily_win_sum", "daily_win_count", + "daily_loss_sum", "daily_loss_count", "daily_peak", "daily_dd_run", "daily_dd_runs", + "prev_positions", "trade_count", "long_total", "short_total", "long_wins", + "short_wins", "last_timestamp_ns", "last_observed_bar", "max_initial_margin", "max_maintenance_margin", + ) + + def __init__(self, initial_capital: float, n_symbols: int, trading_days: int = 365) -> None: + self.initial_capital = float(initial_capital) + self.n_symbols = int(n_symbols) + self.trading_days = int(trading_days) + self.prev_equity = None + self.first_equity = None + self.last_equity = float(initial_capital) + self.peak = -np.inf + self.max_drawdown = 0.0 + self.drawdown_sum = 0.0 + self.drawdown_count = 0 + self.bar_count = 0 + self.bar_mean = 0.0 + self.bar_m2 = 0.0 + self.bar_downside_sq = 0.0 + self.bar_downside_count = 0 + self.bar_gain = 0.0 + self.bar_loss = 0.0 + self.bar_win_sum = 0.0 + self.bar_win_count = 0 + self.bar_loss_sum = 0.0 + self.bar_loss_count = 0 + self.daily_day = None + self.daily_close = None + self.last_daily_close = None + self.daily_points = 0 + self.daily_mean = 0.0 + self.daily_m2 = 0.0 + self.daily_downside_sq = 0.0 + self.daily_downside_count = 0 + self.daily_gain = 0.0 + self.daily_loss = 0.0 + self.daily_win_sum = 0.0 + self.daily_win_count = 0 + self.daily_loss_sum = 0.0 + self.daily_loss_count = 0 + self.daily_peak = -np.inf + self.daily_dd_run = 0 + self.daily_dd_runs: List[int] = [] + self.prev_positions = np.zeros(self.n_symbols, dtype=np.float64) + self.trade_count = self.n_symbols + self.long_total = np.zeros(self.n_symbols, dtype=np.int64) + self.short_total = np.zeros(self.n_symbols, dtype=np.int64) + self.long_wins = np.zeros(self.n_symbols, dtype=np.int64) + self.short_wins = np.zeros(self.n_symbols, dtype=np.int64) + self.last_timestamp_ns = None + self.last_observed_bar = -1 + self.max_initial_margin = 0.0 + self.max_maintenance_margin = 0.0 + + @staticmethod + def _update_moments(value: float, count: int, mean: float, m2: float) -> tuple[int, float, float]: + count += 1 + delta = value - mean + mean += delta / count + m2 += delta * (value - mean) + return count, mean, m2 + + def _observe_return(self, value: float, *, daily: bool) -> None: + if not np.isfinite(value): + return + if daily: + if value > 0.0: + self.daily_gain += float(value) + self.daily_win_sum += float(value) + self.daily_win_count += 1 + elif value < 0.0: + self.daily_loss += float(-value) + self.daily_loss_sum += float(value) + self.daily_loss_count += 1 + if value < 0.0: + self.daily_downside_sq += float(value * value) + self.daily_downside_count += 1 + self.daily_points, self.daily_mean, self.daily_m2 = self._update_moments( + float(value), self.daily_points - 1, self.daily_mean, self.daily_m2 + ) + else: + if value > 0.0: + self.bar_gain += float(value) + self.bar_win_sum += float(value) + self.bar_win_count += 1 + elif value < 0.0: + self.bar_loss += float(-value) + self.bar_loss_sum += float(value) + self.bar_loss_count += 1 + if value < 0.0: + self.bar_downside_sq += float(value * value) + self.bar_downside_count += 1 + self.bar_count, self.bar_mean, self.bar_m2 = self._update_moments( + float(value), self.bar_count, self.bar_mean, self.bar_m2 + ) + + def _close_day(self) -> None: + if self.daily_close is None: + return + close = float(self.daily_close) + if self.last_daily_close is not None: + base = float(self.last_daily_close) + daily_return = (close - base) / base if base != 0.0 else 0.0 + self._observe_return(float(daily_return), daily=True) + self.last_daily_close = close + self.daily_points += 1 + self.daily_peak = max(self.daily_peak, close) + in_drawdown = self.daily_peak != close + if in_drawdown: + self.daily_dd_run += 1 + elif self.daily_dd_run > 0: + self.daily_dd_runs.append(self.daily_dd_run) + self.daily_dd_run = 0 + + def observe( + self, + timestamp, + equity: float, + positions: np.ndarray, + initial_margin: float, + maintenance_margin: float, + ) -> None: + """Consume one canonical post-bar accounting observation.""" + value = float(equity) + if self.first_equity is None: + self.first_equity = value + if self.prev_equity is None or self.prev_equity == 0.0: + bar_return = 0.0 + else: + bar_return = value / float(self.prev_equity) - 1.0 + if math.isfinite(float(bar_return)): + bar_return = float(bar_return) + self.bar_count += 1 + delta = bar_return - self.bar_mean + self.bar_mean += delta / self.bar_count + self.bar_m2 += delta * (bar_return - self.bar_mean) + if bar_return > 0.0: + self.bar_gain += bar_return + self.bar_win_sum += bar_return + self.bar_win_count += 1 + elif bar_return < 0.0: + self.bar_loss += -bar_return + self.bar_loss_sum += bar_return + self.bar_loss_count += 1 + self.bar_downside_sq += bar_return * bar_return + self.bar_downside_count += 1 + + self.peak = max(self.peak, value) + drawdown = (self.peak - value) / self.peak if self.peak != 0.0 else 0.0 + self.max_drawdown = max(self.max_drawdown, float(drawdown)) + if drawdown > 0.0: + self.drawdown_sum += float(drawdown) + self.drawdown_count += 1 + + current = positions + for j in range(self.n_symbols): + position = float(current[j]) + if self.bar_count > 1 and position != self.prev_positions[j]: + self.trade_count += 1 + if position > 0.0: + self.long_total[j] += 1 + if bar_return > 0.0: + self.long_wins[j] += 1 + elif position < 0.0: + self.short_total[j] += 1 + if bar_return > 0.0: + self.short_wins[j] += 1 + self.prev_positions[j] = position + self.prev_equity = value + self.last_equity = value + self.last_timestamp_ns = int(timestamp) if isinstance(timestamp, (int, np.integer)) else int(pd.Timestamp(timestamp).value) + self.max_initial_margin = max(self.max_initial_margin, float(initial_margin)) + self.max_maintenance_margin = max(self.max_maintenance_margin, float(maintenance_margin)) + + day = self.last_timestamp_ns // 86_400_000_000_000 + if self.daily_day is not None and day != self.daily_day: + self._close_day() + self.daily_day = day + self.daily_close = value + + def finish(self, timestamps: pd.DatetimeIndex) -> Dict[str, float]: + self._close_day() + if self.daily_dd_run > 0: + self.daily_dd_runs.append(self.daily_dd_run) + self.daily_dd_run = 0 + + use_daily = self.daily_points >= 2 + count = self.daily_points - 1 if use_daily else self.bar_count + mean = self.daily_mean if use_daily else self.bar_mean + m2 = self.daily_m2 if use_daily else self.bar_m2 + downside_sq = self.daily_downside_sq if use_daily else self.bar_downside_sq + downside_count = self.daily_downside_count if use_daily else self.bar_downside_count + gain = self.daily_gain if use_daily else self.bar_gain + loss = self.daily_loss if use_daily else self.bar_loss + win_sum = self.daily_win_sum if use_daily else self.bar_win_sum + win_count = self.daily_win_count if use_daily else self.bar_win_count + loss_sum = self.daily_loss_sum if use_daily else self.bar_loss_sum + loss_count = self.daily_loss_count if use_daily else self.bar_loss_count + + if use_daily: + periods = float(self.trading_days) + else: + ns = np.asarray(timestamps.view("int64"), dtype=np.int64) + deltas = np.diff(ns).astype(np.float64) / 1_000_000_000.0 + deltas = deltas[deltas > 0.0] + median_seconds = float(np.median(deltas)) if len(deltas) else 0.0 + periods = 365.25 * 24.0 * 60.0 * 60.0 / median_seconds if median_seconds > 0.0 else float(self.trading_days) + + std = float(np.sqrt(m2 / (count - 1))) if count >= 2 and m2 > 0.0 else 0.0 + sharpe_value = float(mean / std * np.sqrt(periods)) if std > 0.0 else 0.0 + downside = float(np.sqrt(downside_sq / downside_count)) if downside_count > 0 else 0.0 + sortino_value = float(mean / downside * np.sqrt(periods)) if downside > 0.0 else (np.inf if mean > 0.0 else 0.0) + omega_value = float(gain / loss) if loss > 0.0 else np.inf + pf_value = omega_value + elapsed_days = 0.0 + if len(timestamps) >= 2: + elapsed_days = (timestamps[-1] - timestamps[0]).total_seconds() / 86_400.0 + years = elapsed_days / 365.25 if elapsed_days > 0.0 else 0.0 + total_ret = (self.last_equity - self.initial_capital) / self.initial_capital + if 0.0 < elapsed_days < 1.0: + cagr_value = total_ret + elif years <= 0.0: + cagr_value = 0.0 + elif self.first_equity is None or self.last_equity / self.first_equity <= 0.0: + cagr_value = -1.0 + else: + annual_log = np.log(self.last_equity / self.first_equity) / years + cagr_value = float(np.expm1(np.clip(annual_log, -50.0, 50.0))) + long_hr = np.divide(self.long_wins, self.long_total, out=np.zeros_like(self.long_wins, dtype=np.float64), where=self.long_total != 0) * 100.0 + short_hr = np.divide(self.short_wins, self.short_total, out=np.zeros_like(self.short_wins, dtype=np.float64), where=self.short_total != 0) * 100.0 + avg_win = win_sum / win_count * 100.0 if win_count else 0.0 + avg_loss = loss_sum / loss_count * 100.0 if loss_count else 0.0 + hit_rate = (float(np.mean(long_hr)) + float(np.mean(short_hr))) / 200.0 + avg_dd = self.drawdown_sum / self.drawdown_count if self.drawdown_count else 0.0 + max_duration = max(self.daily_dd_runs) if self.daily_dd_runs else 0 + avg_duration = float(np.mean(self.daily_dd_runs)) if self.daily_dd_runs else 0.0 + return { + "initial_capital": float(self.initial_capital), + "final_equity": float(self.last_equity), + "total_return_pct": float(total_ret * 100.0), + "cagr_pct": float(cagr_value * 100.0), + "sharpe": sharpe_value, + "sortino": sortino_value, + "calmar": float(cagr_value / self.max_drawdown) if self.max_drawdown > 0.0 else 0.0, + "omega": omega_value, + "max_drawdown_pct": float(self.max_drawdown * 100.0), + "avg_drawdown_pct": float(avg_dd * 100.0), + "max_dd_duration_days": int(max_duration), + "avg_dd_duration_days": int(avg_duration), + "profit_factor": pf_value, + "long_hitrate_pct": float(np.mean(long_hr)), + "short_hitrate_pct": float(np.mean(short_hr)), + "avg_win_pct": float(avg_win), + "avg_loss_pct": float(avg_loss), + "expectancy_pct": float(hit_rate * avg_win + (1.0 - hit_rate) * avg_loss), + "num_trades": int(self.trade_count), + } + + +class _NativeEventReactiveSession: + """ + Lightweight per-bar state used only to feed reactive strategy callbacks. + + Final accounting still replays the emitted command tape through the Numba + v2 kernel once. Keeping this session Python-level avoids repeated compile + and report construction while preserving a single final source of truth. + """ + + def __init__( + self, + *, + idx: pd.DatetimeIndex, + symbols: List[str], + market_arrays: PreparedMarketArrays, + opens_arr: np.ndarray, + volumes_arr: np.ndarray, + constraints, + contract_sizes: np.ndarray, + leverages: np.ndarray, + fee_rates: np.ndarray, + initial_capital: float, + maintenance_ratio: float, + slippage: float, + use_funding: bool, + retain_terminal_orders: bool = True, + score_requirements: Optional[NativeEventScoreRequirements] = None, + ) -> None: + self.idx = idx + self.symbols = symbols + self.symbols_tuple = tuple(symbols) + self.n_symbols = len(symbols) + self.symbol_to_col = {symbol: j for j, symbol in enumerate(symbols)} + self.market_arrays = market_arrays + self.opens_arr = opens_arr + self.volumes_arr = volumes_arr + self.constraints = constraints + # Quantity policy is immutable for a session. Cache the decision once + # so score/research loops do not scan every constraint array per bar. + self.constraints_enabled = bool(constraints.enabled) + self.contract_sizes = contract_sizes + self.leverages = leverages + self.fee_rates = fee_rates + self.initial_capital = float(initial_capital) + self.maintenance_ratio = float(maintenance_ratio) + self.slippage = float(slippage) + self.use_funding = bool(use_funding) + self.retain_terminal_orders = bool(retain_terminal_orders) + self.score_requirements = score_requirements + self.retain_fill_ledger = bool( + score_requirements is None or score_requirements.need_fill_ledger + ) + self.retain_event_ledger = bool( + score_requirements is None or score_requirements.need_event_ledger + ) + self.emit_context_fills = bool( + score_requirements is None or score_requirements.need_context_fills + ) + self.emit_context_events = bool( + score_requirements is None or score_requirements.need_context_events + ) + self.emit_context_active_orders = bool( + score_requirements is None or score_requirements.need_context_active_orders + ) + self.emit_context_positions = bool( + score_requirements is None or score_requirements.need_context_positions + ) + self.emit_context_margin = bool( + score_requirements is None or score_requirements.need_context_margin + ) + self.compact_score_state = bool( + score_requirements is not None + and not score_requirements.need_context_fills + and not score_requirements.need_context_events + and not score_requirements.need_context_active_orders + and not score_requirements.need_context_positions + and not score_requirements.need_context_margin + and not score_requirements.need_fill_ledger + and not score_requirements.need_event_ledger + and not score_requirements.need_terminal_orders + ) + + self.current_pos = np.zeros(len(symbols), dtype=np.float64) + self.equity = float(initial_capital) + self.liquidated = False + self.liquidation_bar = -1 + self.liquidation_reason = LIQ_NONE + self.command_seq = 0 + self.orders: List[_ReactiveOrderState] = [] + self.pending: List[_ReactiveOrderState] = [] + self.id_to_order: Dict[str, _ReactiveOrderState] = {} + self.scheduled: Dict[int, List[OrderCommand]] = {} + self.fills_by_bar: Dict[int, List[NativeFillEvent]] = {} + self.events_by_bar: Dict[int, List[NativeOrderEvent]] = {} + self.fills: List[NativeFillEvent] = [] + self.events: List[NativeOrderEvent] = [] + self.fill_count = 0 + self.event_count = 0 + self.rejected_count = 0 + self.canceled_count = 0 + self.expired_count = 0 + self.total_fee = 0.0 + self.total_funding = 0.0 + self.total_turnover = 0.0 + self.children_by_parent_id: Dict[str, List[_ReactiveOrderState]] = {} + self.members_by_oco_group: Dict[str, List[_ReactiveOrderState]] = {} + self.expiry_by_bar: Dict[int, List[_ReactiveOrderState]] = {} + self.processed_bar = -1 + self.last_initial_margin = 0.0 + self.last_maintenance_margin = 0.0 + self.margin_bar = -1 + self.margin_dirty = True + self.size_helper = NativeEventBackend._reactive_size_helper( + symbols=self.symbols, + constraints=self.constraints, + contract_sizes=self.contract_sizes, + ) + self.empty_fills: tuple[NativeFillEvent, ...] = () + self.empty_events: tuple[NativeOrderEvent, ...] = () + self.empty_active_orders: tuple[NativeActiveOrderSnapshot, ...] = () + self._active_snapshot_cache: tuple[NativeActiveOrderSnapshot, ...] = self.empty_active_orders + self._active_snapshot_dirty = True + self.execution_counters = { + "bars_processed": 0, + "bars_with_commands": 0, + "contexts_materialized": 0, + "timestamp_objects_materialized": 0, + "active_snapshot_materializations": 0, + "empty_command_batches_skipped": 0, + "constraint_preflight_calls": 0, + "constraint_preflight_skipped": 0, + "commands_retimed": 0, + "commands_quantized": 0, + } + n_bars = len(idx) + n_syms = len(symbols) + requirements = score_requirements + self.equity_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_equity_path else None + self.pos_path = np.zeros((n_bars, n_syms), dtype=np.float64) if requirements is None or requirements.need_position_path else None + self.fee_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_fee_path else None + self.turnover_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_turnover_path else None + self.funding_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_funding_path else None + self.initial_margin_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_margin_path else None + self.maintenance_margin_path = np.zeros(n_bars, dtype=np.float64) if requirements is None or requirements.need_margin_path else None + self.rejected_bar = np.zeros(n_bars, dtype=np.int64) if requirements is None or requirements.need_rejection_path else None + self.canceled_bar = np.zeros(n_bars, dtype=np.int64) if requirements is None or requirements.need_cancellation_path else None + self.online_score = ( + _OnlineScoreState(self.initial_capital, n_syms) + if requirements is not None and requirements.need_trade_stats + else None + ) + self._record_bar(0) + + def schedule(self, bar: int, commands: Sequence[OrderCommand]) -> None: + if not commands or bar >= len(self.idx): + return + self.scheduled.setdefault(int(bar), []).extend(commands) + + def release_bar_payload(self, bar: int) -> None: + self.fills_by_bar.pop(int(bar), None) + self.events_by_bar.pop(int(bar), None) + + def process_bar(self, bar: int) -> None: + if bar <= self.processed_bar: + return + for i in range(self.processed_bar + 1, int(bar) + 1): + self._process_single_bar(i) + self.processed_bar = i + self.execution_counters["bars_processed"] += 1 + + def context(self, bar: int) -> NativeStrategyContext: + self.process_bar(bar) + self.execution_counters["contexts_materialized"] += 1 + self.execution_counters["timestamp_objects_materialized"] += 1 + init_margin, maint_margin = self._refresh_close_margin(bar) + if self.emit_context_positions and self.n_symbols == 1: + positions = {self.symbols[0]: float(self.current_pos[0])} + elif self.emit_context_positions: + positions = {symbol: float(self.current_pos[j]) for j, symbol in enumerate(self.symbols)} + else: + positions = {} + if self.emit_context_fills: + fills_this_bar = tuple(self.fills_by_bar.get(int(bar), self.empty_fills)) + else: + fills_this_bar = self.empty_fills + if self.emit_context_events: + events_this_bar = tuple(self.events_by_bar.get(int(bar), self.empty_events)) + else: + events_this_bar = self.empty_events + if not self.emit_context_margin: + init_margin = 0.0 + maint_margin = 0.0 + return NativeStrategyContext( + bar_index=int(bar), + timestamp=self.idx[int(bar)], + open=self.opens_arr[int(bar)], + high=self.market_arrays.highs[int(bar)], + low=self.market_arrays.lows[int(bar)], + close=self.market_arrays.closes[int(bar)], + volume=self.volumes_arr[int(bar)], + equity=float(self.equity), + available_equity=float(self.equity - init_margin), + initial_margin=float(init_margin), + maintenance_margin=float(maint_margin), + positions=positions, + fills_this_bar=fills_this_bar, + order_events_this_bar=events_this_bar, + active_orders=self._active_snapshots() if self.emit_context_active_orders else self.empty_active_orders, + liquidated=bool(self.liquidated), + symbols=self.symbols_tuple, + size_order=self.size_helper, + ) + + def _process_single_bar(self, bar: int) -> None: + if self.liquidated: + self._record_bar(bar) + return + if bar > 0: + for s in range(len(self.symbols)): + p = self.current_pos[s] + if p != 0.0: + self.equity += ( + p + * (self.market_arrays.closes[bar, s] - self.market_arrays.closes[bar - 1, s]) + * self.contract_sizes[s] + ) + if bar > 0 and self._liquidated_intrabar(bar): + self._liquidate(bar, LIQ_INTRABAR) + self._record_bar(bar) + return + if bar > 0 and self.use_funding and self.market_arrays.is_funding_bar[bar]: + funding_cost = 0.0 + for s in range(len(self.symbols)): + p = self.current_pos[s] + if p != 0.0: + funding_cost += ( + p + * self.market_arrays.closes[bar, s] + * self.contract_sizes[s] + * self.market_arrays.funding[bar, s] + ) + self.equity -= funding_cost + self.total_funding += float(funding_cost) + if self.funding_path is not None: + self.funding_path[bar] += funding_cost + if bar > 0: + _, close_mm = self._refresh_close_margin(bar) + if close_mm > 0.0 and self.equity <= close_mm: + self._liquidate(bar, LIQ_AFTER_FUNDING) + self._record_bar(bar) + return + + self._expire_orders(bar) + for command in self.scheduled.pop(bar, ()): + self._apply_command(bar, command) + self._match_orders(bar) + self._compact_pending() + _, close_mm = self._refresh_close_margin(bar) + if close_mm > 0.0 and self.equity <= close_mm: + self._liquidate(bar, LIQ_AFTER_ORDER) + self._record_bar(bar) + + def _record_bar(self, bar: int) -> None: + if bar < 0 or bar >= len(self.idx): + return + init_margin, maint_margin = self._refresh_close_margin(bar) + if self.equity_path is not None: + self.equity_path[bar] = float(self.equity) + if self.pos_path is not None: + self.pos_path[bar, :] = self.current_pos + if self.initial_margin_path is not None: + self.initial_margin_path[bar] = float(init_margin) + if self.maintenance_margin_path is not None: + self.maintenance_margin_path[bar] = float(maint_margin) + if self.online_score is not None and self.online_score.last_observed_bar != int(bar): + self.online_score.observe( + self.idx.asi8[bar], + self.equity, + self.current_pos, + init_margin, + maint_margin, + ) + self.online_score.last_observed_bar = int(bar) + + def _apply_command(self, bar: int, command: OrderCommand) -> None: + action = command.action + if action is OrderAction.PLACE: + self._place_order(bar, command, "place") + elif action is OrderAction.REPLACE: + target = self._lookup_pending(command.target_order_id) + if target is None: + self._event(bar, command, "reject", ORDER_STATUS_REJECTED, target_order_id=command.target_order_id) + else: + self._cancel_state(bar, target, "replace", ORDER_STATUS_CANCELED, command) + replacement = self._place_order(bar, command, "replace") + if command.target_order_id and replacement is not None: + self.id_to_order[command.target_order_id] = replacement + elif action is OrderAction.CANCEL: + target = self._lookup_pending(command.target_order_id) + if target is None: + self._event(bar, command, "reject", ORDER_STATUS_REJECTED, target_order_id=command.target_order_id) + else: + self._cancel_state(bar, target, "cancel", ORDER_STATUS_FILLED, command) + elif action is OrderAction.AMEND: + target = self._lookup_pending(command.target_order_id) + if target is None: + self._event(bar, command, "reject", ORDER_STATUS_REJECTED, target_order_id=command.target_order_id) + else: + if command.qty is not None and command.qty > 0.0: + target.working_qty = float(command.qty) + if command.price is not None and command.price > 0.0: + target.working_price = float(command.price) + if command.trigger_price is not None and command.trigger_price > 0.0: + target.working_trigger = float(command.trigger_price) + self._event(bar, command, "amend", ORDER_STATUS_FILLED, target_order_id=command.target_order_id) + elif action is OrderAction.CANCEL_ALL: + targets = self.pending if self._cancel_all_unfiltered(command) else tuple(self.pending) + for target in targets: + if self._is_pending(target) and self._cancel_all_matches(command, target.command): + self._cancel_state(bar, target, "cancel", ORDER_STATUS_CANCELED, command) + self._event(bar, command, "cancel", ORDER_STATUS_FILLED) + else: + self._event(bar, command, "reject", ORDER_STATUS_REJECTED) + + def _place_order(self, bar: int, command: OrderCommand, event_name: str) -> Optional[_ReactiveOrderState]: + if command.symbol is None or command.symbol not in self.symbol_to_col: + self._event(bar, command, "reject", ORDER_STATUS_REJECTED) + return None + stored_command = _compact_score_command(command) if self.compact_score_state else command + state = _ReactiveOrderState( + command=stored_command, + command_index=self.command_seq, + symbol_col=self.symbol_to_col[command.symbol], + active=command.activation_policy is OrderActivationPolicy.IMMEDIATE, + waiting_parent=command.activation_policy is not OrderActivationPolicy.IMMEDIATE, + working_qty=0.0 if command.qty is None else float(command.qty), + working_price=0.0 if command.price is None else float(command.price), + working_trigger=0.0 if command.trigger_price is None else float(command.trigger_price), + ) + self.command_seq += 1 + self.pending.append(state) + if self.retain_terminal_orders: + self.orders.append(state) + if command.order_id: + self.id_to_order[command.order_id] = state + if command.parent_order_id: + self.children_by_parent_id.setdefault(command.parent_order_id, []).append(state) + if command.oco_group_id: + self.members_by_oco_group.setdefault(command.oco_group_id, []).append(state) + if command.expires_at is not None: + expiry_bar = max(self._expiry_bar(command.expires_at), int(bar) + 1) + if 0 <= expiry_bar < len(self.idx): + self.expiry_by_bar.setdefault(expiry_bar, []).append(state) + self._active_snapshot_dirty = True + self._event(bar, command, event_name, ORDER_STATUS_PENDING) + return state + + def _match_orders(self, bar: int) -> None: + for state in tuple(self.pending): + if not state.active or state.status != ORDER_STATUS_PENDING: + continue + command = state.command + if command.side is None or command.order_type is None: + continue + touched, exec_price = self._touched_price( + command.order_type, + command.side, + state.working_price, + state.working_trigger, + self.market_arrays.highs[bar, state.symbol_col], + self.market_arrays.lows[bar, state.symbol_col], + self.market_arrays.closes[bar, state.symbol_col], + ) + if not touched: + if command.tif in (TimeInForce.GTC, TimeInForce.GTD): + continue + self._cancel_state(bar, state, "cancel", ORDER_STATUS_CANCELED, command) + continue + + qty = float(state.working_qty) + side_sign = command.side.sign + if command.reduce_only: + current = self.current_pos[state.symbol_col] + if current == 0.0 or (current > 0.0 and side_sign > 0) or (current < 0.0 and side_sign < 0): + state.reject_code = REJECT_REDUCE_ONLY_NO_POSITION + self._cancel_state(bar, state, "cancel", ORDER_STATUS_CANCELED, command) + continue + qty = min(qty, abs(current)) + + delta = qty * side_sign + cs = float(self.contract_sizes[state.symbol_col]) + close = float(self.market_arrays.closes[bar, state.symbol_col]) + trade_notional = abs(delta) * float(exec_price) * cs + fee_cost = trade_notional * float(self.fee_rates[state.symbol_col]) + required, cur_im = self._margin_required(bar, state.symbol_col, delta, float(exec_price), fee_cost) + if required > self.equity - cur_im: + state.status = ORDER_STATUS_REJECTED + state.reject_code = REJECT_INSUFFICIENT_MARGIN + self._event(bar, command, "reject", ORDER_STATUS_REJECTED) + self._terminalize_state(state) + continue + + self.equity += delta * (close - float(exec_price)) * cs - fee_cost + self.current_pos[state.symbol_col] += delta + self.margin_dirty = True + if self.fee_path is not None: + self.fee_path[bar] += fee_cost + if self.turnover_path is not None: + self.turnover_path[bar] += trade_notional + self.total_fee += float(fee_cost) + self.total_turnover += float(trade_notional) + state.status = ORDER_STATUS_FILLED + fill = None + if self.emit_context_fills or self.retain_fill_ledger: + fill = NativeFillEvent( + timestamp=self.idx[bar], + symbol=command.symbol or self.symbols[state.symbol_col], + side=command.side, + qty=float(qty), + price=float(exec_price), + fee=float(fee_cost), + order_id=command.order_id, + tag=command.tag, + campaign_id=command.metadata.get("campaign_id"), + cycle_id=command.metadata.get("cycle_id"), + level_id=command.metadata.get("level_id"), + parent_order_id=command.parent_order_id, + oco_group_id=command.oco_group_id, + metadata=dict(command.metadata), + ) + if self.emit_context_fills: + self.fills_by_bar.setdefault(bar, []).append(fill) + self.fill_count += 1 + if self.retain_fill_ledger and fill is not None: + self.fills.append(fill) + self._event(bar, command, "fill", ORDER_STATUS_FILLED) + self._terminalize_state(state) + self._activate_children(bar, state) + self._cancel_oco_siblings(bar, state) + + def _activate_children(self, bar: int, parent: _ReactiveOrderState) -> None: + parent_id = parent.command.order_id + if not parent_id: + return + children = self.children_by_parent_id.get(parent_id, ()) + for child in tuple(children): + if child.waiting_parent and child.command.parent_order_id == parent_id: + if child.command.activation_policy in ( + OrderActivationPolicy.ON_PARENT_FIRST_FILL, + OrderActivationPolicy.ON_PARENT_FULL_FILL, + ): + child.waiting_parent = False + child.active = True + self._active_snapshot_dirty = True + self._event(bar, child.command, "activate", ORDER_STATUS_PENDING, related_order_id=parent_id) + self.children_by_parent_id[parent_id] = [child for child in children if self._is_pending(child)] + if not self.children_by_parent_id[parent_id]: + self.children_by_parent_id.pop(parent_id, None) + + def _cancel_oco_siblings(self, bar: int, filled: _ReactiveOrderState) -> None: + group = filled.command.oco_group_id + if not group: + return + siblings = self.members_by_oco_group.get(group, ()) + for sibling in tuple(siblings): + if sibling is filled: + continue + if self._is_pending(sibling) and sibling.command.oco_group_id == group: + self._cancel_state(bar, sibling, "cancel", ORDER_STATUS_CANCELED, filled.command) + self.members_by_oco_group[group] = [sibling for sibling in siblings if self._is_pending(sibling)] + if not self.members_by_oco_group[group]: + self.members_by_oco_group.pop(group, None) + + def _expire_orders(self, bar: int) -> None: + for state in tuple(self.expiry_by_bar.pop(int(bar), ())): + if not self._is_pending(state) or state.command.expires_at is None: + continue + self._cancel_state(bar, state, "expire", ORDER_STATUS_CANCELED, state.command) + + def _cancel_state( + self, + bar: int, + state: _ReactiveOrderState, + event_name: str, + event_status: int, + command: OrderCommand, + ) -> None: + state.active = False + state.waiting_parent = False + state.status = ORDER_STATUS_CANCELED + self.canceled_count += 1 + if self.canceled_bar is not None: + self.canceled_bar[bar] += 1 + self._event( + bar, + command, + event_name, + event_status, + target_order_id=state.command.order_id, + related_order_id=state.command.order_id, + ) + self._terminalize_state(state) + + def _event( + self, + bar: int, + command: OrderCommand, + event_name: str, + status: int, + *, + target_order_id: Optional[str] = None, + related_order_id: Optional[str] = None, + ) -> None: + if event_name == "reject": + self.rejected_count += 1 + if self.rejected_bar is not None: + self.rejected_bar[bar] += 1 + if event_name == "expire": + self.expired_count += 1 + event = None + if self.emit_context_events or self.retain_event_ledger: + event = NativeOrderEvent( + timestamp=self.idx[bar], + bar=int(bar), + event_name=event_name, + status=int(status), + order_id=command.order_id, + target_order_id=target_order_id or command.target_order_id, + parent_order_id=command.parent_order_id, + oco_group_id=command.oco_group_id, + tag=command.tag, + campaign_id=command.metadata.get("campaign_id"), + cycle_id=command.metadata.get("cycle_id"), + level_id=command.metadata.get("level_id"), + original_index=-1, + related_original_index=-1, + ) + if self.emit_context_events and event is not None: + self.events_by_bar.setdefault(bar, []).append(event) + self.event_count += 1 + if self.retain_event_ledger and event is not None: + self.events.append(event) + + def _lookup_pending(self, order_id: Optional[str]) -> Optional[_ReactiveOrderState]: + if not order_id: + return None + state = self.id_to_order.get(order_id) + if state is None or not self._is_pending(state): + return None + return state + + @staticmethod + def _is_pending(state: _ReactiveOrderState) -> bool: + return state.status == ORDER_STATUS_PENDING and (state.active or state.waiting_parent) + + def _terminalize_state(self, state: _ReactiveOrderState) -> None: + state.active = False + state.waiting_parent = False + order_id = state.command.order_id + if order_id and self.id_to_order.get(order_id) is state: + self.id_to_order.pop(order_id, None) + parent_id = state.command.parent_order_id + if parent_id and parent_id in self.children_by_parent_id: + children = [child for child in self.children_by_parent_id[parent_id] if child is not state and self._is_pending(child)] + if children: + self.children_by_parent_id[parent_id] = children + else: + self.children_by_parent_id.pop(parent_id, None) + group = state.command.oco_group_id + if group and group in self.members_by_oco_group: + members = [member for member in self.members_by_oco_group[group] if member is not state and self._is_pending(member)] + if members: + self.members_by_oco_group[group] = members + else: + self.members_by_oco_group.pop(group, None) + self._active_snapshot_dirty = True + + def _expiry_bar(self, expires_at) -> int: + exp = pd.Timestamp(expires_at) + if exp.tz is None: + exp = exp.tz_localize("UTC") + else: + exp = exp.tz_convert("UTC") + return int(self.idx.searchsorted(exp, side="left")) + + def _active_snapshots(self) -> tuple[NativeActiveOrderSnapshot, ...]: + if not self.pending: + self._active_snapshot_cache = self.empty_active_orders + self._active_snapshot_dirty = False + return self.empty_active_orders + if not self._active_snapshot_dirty: + return self._active_snapshot_cache + self.execution_counters["active_snapshot_materializations"] += 1 + out: List[NativeActiveOrderSnapshot] = [] + for state in self.pending: + if not self._is_pending(state): + continue + command = state.command + out.append( + NativeActiveOrderSnapshot( + order_id=command.order_id, + symbol=command.symbol, + side=None if command.side is None else command.side.value, + order_type=None if command.order_type is None else command.order_type.value, + status=int(state.status), + remaining_qty=float(state.working_qty), + price=float(state.working_price), + trigger_price=float(state.working_trigger), + reduce_only=bool(command.reduce_only), + parent_order_id=command.parent_order_id, + group_id=command.group_id, + oco_group_id=command.oco_group_id, + tag=command.tag, + campaign_id=command.metadata.get("campaign_id"), + cycle_id=command.metadata.get("cycle_id"), + level_id=command.metadata.get("level_id"), + ) + ) + self._active_snapshot_cache = tuple(out) if out else self.empty_active_orders + self._active_snapshot_dirty = False + return self._active_snapshot_cache + + def _refresh_close_margin(self, bar: int) -> tuple[float, float]: + bar = int(bar) + if not self.margin_dirty and self.margin_bar == bar: + return self.last_initial_margin, self.last_maintenance_margin + init_margin = 0.0 + maint_margin = 0.0 + for s in range(len(self.symbols)): + p = self.current_pos[s] + if p != 0.0: + notional = abs(p) * self.market_arrays.closes[bar, s] * self.contract_sizes[s] + init_margin += notional / self.leverages[s] + maint_margin += notional * self.maintenance_ratio + self.last_initial_margin = float(init_margin) + self.last_maintenance_margin = float(maint_margin) + self.margin_bar = bar + self.margin_dirty = False + return self.last_initial_margin, self.last_maintenance_margin + + def _close_margin(self, bar: int) -> tuple[float, float]: + return self._refresh_close_margin(bar) + + def _margin_required(self, bar: int, sym: int, delta: float, exec_price: float, fee_cost: float) -> tuple[float, float]: + cur_im, _ = self._refresh_close_margin(bar) + close = float(self.market_arrays.closes[bar, sym]) + old_im = abs(self.current_pos[sym]) * close * self.contract_sizes[sym] / self.leverages[sym] + new_im = abs(self.current_pos[sym] + delta) * exec_price * self.contract_sizes[sym] / self.leverages[sym] + required = float(fee_cost) + margin_delta = new_im - old_im + if margin_delta > 0.0: + required += margin_delta + return float(required), float(cur_im) + + def _liquidated_intrabar(self, bar: int) -> bool: + worst_equity = self.equity + worst_mm = 0.0 + for s in range(len(self.symbols)): + p = self.current_pos[s] + if p == 0.0: + continue + worst_price = self.market_arrays.lows[bar, s] if p > 0.0 else self.market_arrays.highs[bar, s] + worst_equity += p * (worst_price - self.market_arrays.closes[bar, s]) * self.contract_sizes[s] + worst_mm += abs(p) * worst_price * self.contract_sizes[s] * self.maintenance_ratio + return worst_mm > 0.0 and worst_equity <= worst_mm + + def _liquidate(self, bar: int, reason: int) -> None: + self.liquidated = True + self.liquidation_bar = int(bar) + self.liquidation_reason = int(reason) + self.equity = 0.0 + self.current_pos[:] = 0.0 + self.margin_dirty = True + self._active_snapshot_dirty = True + + def _touched_price( + self, + order_type: OrderType, + side: OrderSide, + price: float, + trigger_price: float, + high: float, + low: float, + close: float, + ) -> tuple[bool, float]: + if order_type is OrderType.MARKET: + return True, float(close * (1.0 + self.slippage if side is OrderSide.BUY else 1.0 - self.slippage)) + if order_type is OrderType.LIMIT: + if side is OrderSide.BUY and low <= price: + return True, float(price) + if side is OrderSide.SELL and high >= price: + return True, float(price) + if order_type is OrderType.STOP_MARKET: + if side is OrderSide.BUY and high >= trigger_price: + return True, float(trigger_price * (1.0 + self.slippage)) + if side is OrderSide.SELL and low <= trigger_price: + return True, float(trigger_price * (1.0 - self.slippage)) + if order_type is OrderType.STOP_LIMIT: + if side is OrderSide.BUY and high >= trigger_price and low <= price: + return True, float(price) + if side is OrderSide.SELL and low <= trigger_price and high >= price: + return True, float(price) + return False, float(close) + + @staticmethod + def _cancel_all_unfiltered(command: OrderCommand) -> bool: + return ( + command.symbol is None + and command.side is None + and command.order_type is None + and command.parent_order_id is None + and command.group_id is None + and command.oco_group_id is None + and command.tag is None + and command.tag_prefix is None + and not command.metadata + ) + + @staticmethod + def _cancel_all_matches(cancel_command: OrderCommand, target: OrderCommand) -> bool: + if cancel_command.symbol is not None and cancel_command.symbol != target.symbol: + return False + if cancel_command.side is not None and cancel_command.side is not target.side: + return False + if cancel_command.order_type is not None and cancel_command.order_type is not target.order_type: + return False + if cancel_command.parent_order_id is not None and cancel_command.parent_order_id != target.parent_order_id: + return False + if cancel_command.group_id is not None and cancel_command.group_id != target.group_id: + return False + if cancel_command.oco_group_id is not None and cancel_command.oco_group_id != target.oco_group_id: + return False + if cancel_command.tag is not None and cancel_command.tag != target.tag: + return False + if cancel_command.tag_prefix is not None and not (target.tag or "").startswith(cancel_command.tag_prefix): + return False + for key in ("campaign_id", "cycle_id", "level_id"): + if key in cancel_command.metadata and cancel_command.metadata.get(key) != target.metadata.get(key): + return False + return True + + def _compact_pending(self) -> None: + if not self.pending: + return + self.pending = [state for state in self.pending if self._is_pending(state)] + self._active_snapshot_dirty = True + + +class NativeEventBackend: + """ + Event-driven backend for explicit OrderIntent sequences. + + Phase 3 supports market and limit orders on OHLC bars. Limit orders fill at + the order price when high/low touches the level. Market orders fill at the + current close with configured slippage. + """ + + def __init__(self, config: NativeEventConfig): + self.config = config + # Phase 46E: selection is explicit and capability-gated. ``auto`` + # remains Python for the release; direct Rust is limited to the + # certified single-symbol batched tape path. + self._backend_selection = resolve_native_event_backend(requested=config.native_backend) + # Keys use object identity in addition to the immutable market + # signature: open/volume are callback-visible and are not part of the + # OHLC/funding signature. Reuse is therefore safe only for the exact + # prepared arrays owned by one prepared runner. + self._rust_prepared_market_cores: Dict[tuple, object] = {} + + def _create_reactive_session( + self, + *, + backend_selection: NativeEventBackendSelection, + **kwargs, + ) -> _NativeEventReactiveSession | RustReactiveSessionAdapter: + """Create the selected reactive session without changing endpoint APIs. + + Rust's per-bar adapter remains a correctness/debug path. Unsupported + execution semantics fail explicitly under backend='rust' rather than + silently switching domain behavior. + """ + if backend_selection.resolved == "rust": + market_arrays = kwargs["market_arrays"] + key = (market_arrays.signature, id(kwargs["opens_arr"]), id(kwargs["volumes_arr"])) + kwargs["prepared_market_core"] = self._rust_prepared_market_cores.get(key) + session = RustReactiveSessionAdapter(**kwargs) + prepared_core = getattr(session, "prepared_market_core", None) + if prepared_core is not None: + self._rust_prepared_market_cores.setdefault(key, prepared_core) + return session + return _NativeEventReactiveSession(**kwargs) + + def _backend_selection_metadata(self) -> dict: + selection = self._backend_selection + return { + "native_event_backend_requested": selection.requested, + "native_event_backend_resolved": selection.resolved, + "native_event_rust_available": bool(selection.extension.available), + "native_event_rust_compatible": bool(selection.extension.compatible), + "native_event_rust_capabilities": dict(selection.extension.capabilities), + "native_event_rust_canonical_capabilities": dict(selection.extension.canonical_capabilities), + } + + def prepare_market_arrays( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + symbols: Optional[Sequence[str]] = None, + ) -> PreparedMarketArrays: + """ + Normalize OHLC/funding inputs into immutable ndarray-backed market arrays. + + This helper is intended for higher-level optimizers and WFO loops that + replay many order packages over the same market tape. The returned + object carries a datetime/symbol signature and `run_orders` rejects it + if reused against a different index or symbol layout. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) if symbols is not None else list(closes.keys()) + close_dict = align_series(closes, symbol_list, idx) + high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) + low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) + funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) + return build_market_arrays( + symbols=symbol_list, + idx=idx, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + funding_dict=funding_dict, + ) + + def prepare_rust_batched_runner( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + *, + symbols: Optional[Sequence[str]] = None, + contract_size: float = 1.0, + leverage: Optional[float] = None, + fee_rate: Optional[float] = None, + initial_capital: Optional[float] = None, + maintenance_ratio: Optional[float] = None, + slippage: Optional[float] = None, + prepared_market_core=None, + ) -> RustFullRunner: + """Prepare the explicit experimental Rust full-tape runner. + + This helper does not change endpoint defaults and never accepts a + Python strategy callback. Callers compile a static ``OrderCommand`` + tape once and pass it to ``run_tape_score`` or ``run_tape_audit``. + The selected Rust 0.4 full-contract capability set is checked before + crossing the boundary. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) if symbols is not None else list(closes.keys()) + market_arrays = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate if self.config.use_funding else 0.0, + symbols=symbol_list, + ) + configured_fee = self.config.fee_rate + if isinstance(configured_fee, dict): + configured_fee = configured_fee.get(symbol_list[0], 0.0) + return RustFullRunner( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + contract_sizes=self._per_symbol_array(contract_size, symbol_list, default=1.0), + leverages=self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ), + fee_rates=self._per_symbol_array(configured_fee if fee_rate is None else fee_rate, symbol_list, default=0.0), + initial_capital=float( + self.config.account.initial_capital if initial_capital is None else initial_capital + ), + maintenance_ratio=float( + self.config.account.maintenance_ratio if maintenance_ratio is None else maintenance_ratio + ), + slippage=float(self.config.execution.slippage_rate if slippage is None else slippage), + use_funding=bool(self.config.use_funding), + prepared_market_core=prepared_market_core, + ) + + @staticmethod + def compile_orders( + datetime_index: Union[pd.DatetimeIndex, pd.Series], + orders: Sequence[OrderIntent], + symbols: Optional[Sequence[str]] = None, + ) -> CompiledOrderArrays: + """ + Compile explicit `OrderIntent` objects into contiguous kernel arrays. + + Use this when the same order package is replayed against the same + market tape. If `symbols` is omitted it is inferred from first + occurrence in the order sequence, which is convenient for standalone + simulations; passing the exact market symbol order is safer for + multi-symbol portfolio and arbitrage packages. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) if symbols is not None else list(dict.fromkeys(order.symbol for order in orders)) + return compile_order_intents(idx=idx, orders=orders, symbol_to_col={s: j for j, s in enumerate(symbol_list)}) + + @staticmethod + def compile_order_commands( + datetime_index: Union[pd.DatetimeIndex, pd.Series], + commands: Sequence[OrderCommand], + symbols: Optional[Sequence[str]] = None, + ) -> CompiledOrderCommandArrays: + """ + Compile lifecycle commands for the native-event v2 contract. + + Phase 30A exposes this helper for adapters and strategy services. It + does not route commands into the v1 matching kernel; the v2 lifecycle + kernel is a later phase. + """ + idx = validate_datetime(datetime_index) + if symbols is None: + symbol_list = list(dict.fromkeys(command.symbol for command in commands if command.symbol is not None)) + else: + symbol_list = list(symbols) + return compile_order_commands( + idx=idx, + commands=commands, + symbol_to_col={s: j for j, s in enumerate(symbol_list)}, + ) + + def run_order_commands( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + commands: Sequence[OrderCommand], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + symbols: Optional[List[str]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + compiled_commands: Optional[CompiledOrderCommandArrays] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + report_level: Optional[str] = None, + audit_sink: Optional[str] = None, + audit_sink_path: Optional[str] = None, + _force_python_backend: bool = False, + ) -> BacktestResultV2: + """ + Execute Phase 30B lifecycle `OrderCommand` tapes through event v2. + + This is intentionally opt-in. Existing `run_orders(OrderIntent...)` + remains routed to event v1 until endpoint parity is promoted in a later + phase. + """ + idx = validate_datetime(datetime_index) + requested_report_level = self.config.report_level if report_level is None else report_level + level = _normalize_native_event_report_level(requested_report_level) + plan = _native_event_artifact_plan(level) + sink = self.config.audit_sink if audit_sink is None else _normalize_native_event_audit_sink(audit_sink) + sink_path = self.config.audit_sink_path if audit_sink_path is None else audit_sink_path + if symbols is None: + symbol_list = list(closes.keys()) + else: + symbol_list = list(symbols) + + if market_arrays is None: + market_arrays = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + symbols=symbol_list, + ) + elif market_arrays.signature != self._market_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + if self._backend_selection.resolved == "rust" and not _force_python_backend: + status = self._backend_selection.extension + required = { + "native_event_v2_full_contract", + "native_event_v2_multisymbol", + "native_event_v2_funding", + "native_event_v2_liquidation", + "native_event_v2_cancel_all_oco", + "native_event_v2_tif_expiry", + "native_event_v2_relationships", + } + missing = sorted(name for name in required if not status.capabilities.get(name, False)) + if missing: + raise NativeEventRustBackendError( + "native_backend='rust' requires full-contract capabilities: " + ", ".join(missing) + ) + effective_commands, quantity_preflight = self._apply_command_quantity_constraints( + idx=idx, + commands=commands, + closes=market_arrays.closes, + symbol_list=symbol_list, + contract_sizes=contract_sizes, + constraints=constraints, + ) + if quantity_preflight["changed_count"] or quantity_preflight["dropped_count"]: + compiled_commands = None + commands = tuple(effective_commands) + else: + effective_commands = tuple(commands) + + if compiled_commands is None: + compiled_commands = self.compile_order_commands( + datetime_index=idx, + commands=effective_commands, + symbols=symbol_list, + ) + elif ( + compiled_commands.index_signature != market_arrays.signature + or compiled_commands.symbols != tuple(symbol_list) + ): + raise ValueError("compiled commands do not match prepared market arrays") + + if self._backend_selection.resolved == "rust" and not _force_python_backend: + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + configured_fee = self.config.fee_rate if fee_rate is None else fee_rate + fee_rates = self._per_symbol_array(configured_fee, symbol_list, default=0.0) + runner = RustFullRunner( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + contract_sizes=contract_sizes, + leverages=leverages, + fee_rates=fee_rates, + initial_capital=float(self.config.account.initial_capital), + maintenance_ratio=float(self.config.account.maintenance_ratio), + slippage=float(self.config.execution.slippage_rate), + use_funding=bool(self.config.use_funding), + ) + audit = runner.run_tape_audit(compiled_commands) + result = audit.to_backtest_result( + datetime_index=idx, + closes=pd.DataFrame({symbol: market_arrays.closes[:, col] for col, symbol in enumerate(symbol_list)}, index=idx), + symbols=symbol_list, + initial_capital=float(self.config.account.initial_capital), + leverage=float(np.mean(leverages)), + metadata={ + **self._backend_selection_metadata(), + "quantity_preflight": quantity_preflight, + "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list), + "slippage_bps": self.config.execution.slippage_bps, + "rust_contract": "native_event_v2_full_contract", + "use_funding": bool(self.config.use_funding), + }, + ) + return result + + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + fee_rates = self._per_symbol_array( + self.config.fee_rate if fee_rate is None else fee_rate, + symbol_list, + default=0.0, + ) + + ( + equity_arr, + pos_arr, + fee_arr, + turnover_arr, + funding_arr, + init_margin_arr, + maint_margin_arr, + rejected_bar, + canceled_bar, + command_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + active, + waiting_parent, + working_qty, + working_price, + working_trigger, + event_count, + event_bar, + event_command, + event_type, + event_status, + event_related_command, + liq_flag, + liq_idx, + liq_reason, + ) = _engine_event_v2( + n_bars=len(idx), + n_syms=len(symbol_list), + n_commands=compiled_commands.n_commands, + n_ids=len(compiled_commands.id_values), + command_ptr=compiled_commands.command_ptr, + command_action=compiled_commands.command_action, + command_symbol=compiled_commands.command_symbol, + command_side=compiled_commands.command_side, + command_type=compiled_commands.command_type, + command_qty=compiled_commands.command_qty, + command_price=compiled_commands.command_price, + command_trigger_price=compiled_commands.command_trigger_price, + command_tif=compiled_commands.command_tif, + command_reduce_only=compiled_commands.command_reduce_only, + command_order_id=compiled_commands.command_order_id, + command_target_order_id=compiled_commands.command_target_order_id, + command_parent_order_id=compiled_commands.command_parent_order_id, + command_group_id=compiled_commands.command_group_id, + command_oco_group_id=compiled_commands.command_oco_group_id, + command_activation=compiled_commands.command_activation, + command_expires_bar=compiled_commands.command_expires_bar, + highs=market_arrays.highs, + lows=market_arrays.lows, + closes=market_arrays.closes, + funding_rates=market_arrays.funding, + is_funding_bar=market_arrays.is_funding_bar, + init_capital=self.config.account.initial_capital, + leverages=leverages, + maint_ratio=self.config.account.maintenance_ratio, + fee_rates=fee_rates, + contract_sizes=contract_sizes, + slippage=self.config.execution.slippage_rate, + use_funding=bool(self.config.use_funding), + ) + + fill_ledger = self._build_compact_fill_ledger( + compiled_commands=compiled_commands, + fill_bar=fill_bar, + fill_qty=fill_qty, + fill_price=fill_price, + fill_fee=fill_fee, + ) + command_ledger = self._build_compact_command_ledger( + compiled_commands=compiled_commands, + command_status=command_status, + reject_code=reject_code, + fill_bar=fill_bar, + fill_qty=fill_qty, + fill_price=fill_price, + fill_fee=fill_fee, + active=active, + waiting_parent=waiting_parent, + working_qty=working_qty, + working_price=working_price, + working_trigger=working_trigger, + ) + event_ledger = self._build_compact_order_event_ledger( + event_count=int(event_count), + event_bar=event_bar, + event_command=event_command, + event_type=event_type, + event_status=event_status, + event_related_command=event_related_command, + ) + fills = ( + self._build_fills(compiled_commands.sorted_commands, idx, fill_bar, fill_qty, fill_price, fill_fee) + if plan.materialize_python_objects + else () + ) + equity = pd.Series(equity_arr, index=idx, name="equity") + positions = pd.DataFrame( + {f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + close_df = pd.DataFrame( + {f"Close_{s}": market_arrays.closes[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + diagnostics = pd.DataFrame( + { + "turnover": turnover_arr, + "rejected_orders": rejected_bar, + "canceled_orders": canceled_bar, + }, + index=idx, + ) + if level in {"standard", "audit"}: + command_report = self._build_command_report( + compiled_commands, + command_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + active, + waiting_parent, + working_qty, + working_price, + working_trigger, + ) + else: + command_report = pd.DataFrame() + if level == "audit" and sink != "none": + order_events = self._build_order_events( + idx=idx, + compiled_commands=compiled_commands, + event_count=int(event_count), + event_bar=event_bar, + event_command=event_command, + event_type=event_type, + event_status=event_status, + event_related_command=event_related_command, + ) + else: + order_events = pd.DataFrame() + if command_report.empty or not plan.materialize_active_orders: + active_orders = pd.DataFrame() + else: + active_orders = command_report[ + (command_report["active"] == True) | (command_report["waiting_parent"] == True) # noqa: E712 + ].copy() + audit_artifacts = self._write_native_event_audit_sink( + sink=sink, + sink_path=sink_path, + command_report=command_report, + order_events=order_events, + fill_ledger=fill_ledger, + command_ledger=command_ledger, + event_ledger=event_ledger, + report_level=level, + ) + lifecycle_counters = { + "fill_count": int(fill_ledger.fill_count), + "event_count": int(event_count), + "rejected_count": int(np.sum(command_status == ORDER_STATUS_REJECTED)), + "canceled_count": int(np.sum(command_status == ORDER_STATUS_CANCELED)), + "filled_command_count": int(np.sum(command_status == ORDER_STATUS_FILLED)), + "pending_command_count": int(np.sum(command_status == ORDER_STATUS_PENDING)), + "expired_event_count": int(np.sum(event_ledger.event_type == ORDER_EVENT_EXPIRE)), + } + metadata = { + "backend": "native_event", + "engine": "event_v2_lifecycle", + **self._backend_selection_metadata(), + "report_level": level, + "report_level_requested": str(requested_report_level), + "artifact_plan": asdict(plan), + "audit_sink": sink, + "audit_sink_path": sink_path, + "audit_artifacts": audit_artifacts, + "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list), + "slippage_bps": self.config.execution.slippage_bps, + "order_report": command_report, + "command_report": command_report, + "order_events": order_events, + "active_orders": active_orders, + "compact_fill_ledger": fill_ledger if plan.keep_fill_ledger else None, + "compact_command_ledger": command_ledger if plan.keep_command_terminal_state else None, + "compact_order_event_ledger": event_ledger if plan.keep_event_ledger and sink == "memory" else None, + "id_values": compiled_commands.id_values, + "quantity_constraints": constraints.as_dict(), + "quantity_preflight": quantity_preflight, + "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), + "liquidation_reason": int(liq_reason), + "lifecycle_counters": lifecycle_counters, + } + + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=positions, + closes=close_df, + symbols=symbol_list, + initial_capital=self.config.account.initial_capital, + leverage=float(np.mean(leverages)), + liquidated=bool(liq_flag), + liquidation_bar=int(liq_idx), + orders=self._commands_to_order_intents(compiled_commands.sorted_commands) if plan.materialize_python_objects else (), + fills=tuple(fills), + fees=pd.Series(fee_arr, index=idx, name="fees"), + funding=pd.Series(funding_arr, index=idx, name="funding"), + margin=pd.DataFrame( + { + "initial_margin": init_margin_arr, + "maintenance_margin": maint_margin_arr, + }, + index=idx, + ), + diagnostics=diagnostics, + metadata=metadata, + ) + + def run_strategy( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + strategy, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + opens: Optional[Dict[str, pd.Series]] = None, + volumes: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + symbols: Optional[List[str]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + execution_mode: str = "fast", + command_effective_phase: str = "next_bar", + reactive_kernel_mode: Optional[str] = None, + report_level: Optional[str] = None, + audit_sink: Optional[str] = None, + audit_sink_path: Optional[str] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + opens_arr: Optional[np.ndarray] = None, + volumes_arr: Optional[np.ndarray] = None, + _score_requirements: Optional[NativeEventScoreRequirements] = None, + _return_score: bool = False, + _trading_days: int = 365, + ) -> Union[BacktestResultV2, NativeEventScoreResult]: + """ + Run a reactive strategy against native-event v2 lifecycle semantics. + + Strategy callbacks observe post-bar engine state and may emit commands + for the next bar. The emitted tape is replayed once at the end through + `run_order_commands`, making the final result reproducible by static + lifecycle replay. + """ + if strategy is None: + raise ValueError("run_strategy requires a strategy object") + if str(command_effective_phase).lower().strip() != "next_bar": + raise NotImplementedError("reactive native-event MVP supports command_effective_phase='next_bar' only") + execution_mode = str(execution_mode).lower().strip() + if execution_mode not in {"fast", "audit"}: + raise ValueError("execution_mode must be 'fast' or 'audit'") + backend_selection = self._backend_selection + kernel_mode = _normalize_reactive_kernel_mode( + self.config.reactive_kernel_mode if reactive_kernel_mode is None else reactive_kernel_mode + ) + if backend_selection.resolved == "replay_certified": + kernel_mode = "replay_certified" + requested_report_level = self.config.report_level if report_level is None else report_level + level = _normalize_native_event_report_level(requested_report_level) + plan = _native_event_artifact_plan(level) + if _return_score: + if level != "score": + raise ValueError("internal direct score execution requires report_level='score'") + if kernel_mode != "single_pass" or execution_mode != "fast": + raise ValueError("internal direct score execution requires fast single_pass mode") + score_requirements = _score_requirements or NativeEventScoreRequirements.public_score_contract() + else: + score_requirements = None + + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) if symbols is not None else list(closes.keys()) + if market_arrays is None: + market_arrays = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + symbols=symbol_list, + ) + elif market_arrays.signature != self._market_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + if opens_arr is None: + open_dict = align_series(opens, symbol_list, idx, fallback=align_series(closes, symbol_list, idx)) + opens_arr = np.ascontiguousarray(np.column_stack([open_dict[s].to_numpy(dtype=np.float64) for s in symbol_list])) + else: + opens_arr = np.ascontiguousarray(opens_arr, dtype=np.float64) + if volumes_arr is None: + volume_dict = align_series(volumes, symbol_list, idx, fallback={s: pd.Series(0.0, index=idx) for s in symbol_list}) + volumes_arr = np.ascontiguousarray(np.column_stack([volume_dict[s].to_numpy(dtype=np.float64) for s in symbol_list])) + else: + volumes_arr = np.ascontiguousarray(volumes_arr, dtype=np.float64) + if opens_arr.shape != market_arrays.closes.shape or volumes_arr.shape != market_arrays.closes.shape: + raise ValueError("prepared opens/volumes arrays must match market array shape") + opens_arr.setflags(write=False) + volumes_arr.setflags(write=False) + + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + fee_rates = self._per_symbol_array( + self.config.fee_rate if fee_rate is None else fee_rate, + symbol_list, + default=0.0, + ) + session = self._create_reactive_session( + backend_selection=backend_selection, + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + opens_arr=opens_arr, + volumes_arr=volumes_arr, + constraints=constraints, + contract_sizes=contract_sizes, + leverages=leverages, + fee_rates=fee_rates, + initial_capital=self.config.account.initial_capital, + maintenance_ratio=self.config.account.maintenance_ratio, + slippage=self.config.execution.slippage_rate, + use_funding=bool(self.config.use_funding), + retain_terminal_orders=level != "score", + score_requirements=score_requirements, + ) + execution_counters = getattr(session, "execution_counters", None) + if execution_counters is None: + execution_counters = {} + constraints_enabled = bool(getattr(session, "constraints_enabled", constraints.enabled)) + if getattr(session, "online_score", None) is not None: + session.online_score.trading_days = int(_trading_days) + + # Keep execution and audit tape distinct: next-bar semantics prohibit + # executing a final-close command, while audit still needs to preserve + # that strategy intent for replayability and review. + emitted: list[OrderCommand] = [] + emitted_audit_tape: list[OrderCommand] = [] + emitted_order_ids: set[str] = set() + emitted_command_count = 0 + emitted_executable_command_count = 0 + callback_count = 0 + ignored_commands_after_end = 0 + + def record_scheduled(commands: Sequence[OrderCommand]) -> None: + nonlocal emitted_command_count, emitted_executable_command_count + count = len(commands) + emitted_command_count += count + emitted_executable_command_count += count + if not _return_score: + emitted.extend(commands) + emitted_audit_tape.extend(commands) + + def record_outside_tape(commands: Sequence[OrderCommand]) -> None: + nonlocal emitted_command_count + emitted_command_count += len(commands) + if not _return_score: + emitted_audit_tape.extend(commands) + + initial_context = session.context(0) + last_context = initial_context + + def quantize_reactive_schedule(commands: Sequence[OrderCommand]) -> tuple[OrderCommand, ...]: + if not commands: + if execution_counters: + execution_counters["empty_command_batches_skipped"] += 1 + return () + if not constraints_enabled: + if execution_counters: + execution_counters["constraint_preflight_skipped"] += 1 + return tuple(commands) + if execution_counters: + execution_counters["constraint_preflight_calls"] += 1 + effective, _ = self._apply_command_quantity_constraints( + idx=idx, + commands=commands, + closes=market_arrays.closes, + symbol_list=symbol_list, + contract_sizes=contract_sizes, + constraints=constraints, + ) + if execution_counters: + execution_counters["commands_quantized"] += len(commands) + return effective + + def schedule_reactive_batch( + commands: Sequence[OrderCommand], + effective_bar: int, + ) -> tuple[tuple[OrderCommand, ...], int]: + if not commands: + if execution_counters: + execution_counters["empty_command_batches_skipped"] += 1 + return (), 0 + if execution_counters: + execution_counters["bars_with_commands"] += 1 + execution_counters["commands_retimed"] += 1 + scheduled, ignored = self._retime_reactive_commands( + commands=commands, + effective_bar=effective_bar, + idx=idx, + emitted_order_ids=emitted_order_ids, + ) + if scheduled: + record_scheduled(scheduled) + session.schedule(effective_bar, quantize_reactive_schedule(scheduled)) + return scheduled, ignored + + initial_commands = self._expand_scoped_cancel_all_commands( + self._call_strategy_callback(strategy, "initialize", initial_context), + initial_context, + ) + scheduled, ignored = schedule_reactive_batch(initial_commands, 1) + ignored_commands_after_end += ignored + if ignored: + record_outside_tape( + self._record_reactive_commands_outside_tape( + commands=initial_commands, + effective_bar=1, + emitted_order_ids=emitted_order_ids, + ) + ) + + for bar in range(len(idx)): + context = session.context(bar) + last_context = context + callback_count += 1 + if context.liquidated: + session.release_bar_payload(bar) + break + commands = self._expand_scoped_cancel_all_commands( + self._call_strategy_callback(strategy, "on_bar_close", context), + context, + ) + session.release_bar_payload(bar) + scheduled, ignored = schedule_reactive_batch(commands, bar + 1) + ignored_commands_after_end += ignored + if ignored: + record_outside_tape( + self._record_reactive_commands_outside_tape( + commands=commands, + effective_bar=bar + 1, + emitted_order_ids=emitted_order_ids, + ) + ) + + if last_context is not None and not last_context.liquidated: + final_commands = self._expand_scoped_cancel_all_commands( + self._call_strategy_callback(strategy, "finalize", last_context), + last_context, + ) + if final_commands: + if execution_counters: + execution_counters["bars_with_commands"] += 1 + execution_counters["commands_retimed"] += 1 + scheduled, ignored = self._retime_reactive_commands( + commands=final_commands, + effective_bar=len(idx), + idx=idx, + emitted_order_ids=emitted_order_ids, + ) + else: + scheduled, ignored = (), 0 + record_scheduled(scheduled) + ignored_commands_after_end += ignored + if ignored: + record_outside_tape( + self._record_reactive_commands_outside_tape( + commands=final_commands, + effective_bar=len(idx), + emitted_order_ids=emitted_order_ids, + ) + ) + + replay_required = kernel_mode == "replay_certified" or level in {"standard", "audit"} or execution_mode == "audit" + if _return_score: + return self._reactive_session_score_result( + session=session, + symbol_list=symbol_list, + leverages=leverages, + requirements=score_requirements, + trading_days=_trading_days, + metadata={ + "backend": "native_event", + "engine": "event_v2_reactive_score", + "report_level": "score", + "artifact_plan": asdict(plan), + "score_requirements": asdict(score_requirements), + "reactive_execution_mode": execution_mode, + "reactive_kernel_mode": kernel_mode, + "command_effective_phase": "next_bar", + "emitted_command_count": int(emitted_command_count), + "emitted_executable_command_count": int(emitted_executable_command_count), + "ignored_commands_after_end": int(ignored_commands_after_end), + "strategy_callback_count": int(callback_count), + "static_replay_available": False, + "reactive_static_replay_count": 0, + "reactive_session_liquidated": bool(session.liquidated), + "reactive_session_liquidation_bar": int(session.liquidation_bar), + "execution_counters": dict(getattr(session, "execution_counters", {})), + **self._backend_selection_metadata(), + }, + ) + replay_result = None + if replay_required: + replay_result = self.run_order_commands( + datetime_index=idx, + commands=tuple(emitted), + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + contract_size=contract_size, + leverage=leverage, + fee_rate=fee_rate, + symbols=symbol_list, + market_arrays=market_arrays, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + report_level=level, + audit_sink=audit_sink, + audit_sink_path=audit_sink_path, + _force_python_backend=True, + ) + if kernel_mode == "replay_certified": + final_result = replay_result + engine_name = "event_v2_reactive_incremental" + else: + if replay_result is not None: + self._assert_reactive_session_replay_parity(session, replay_result) + final_result = self._reactive_session_result( + session=session, + symbol_list=symbol_list, + market_arrays=market_arrays, + leverages=leverages, + report_level=level, + plan=plan, + replay_result=replay_result, + audit_sink=audit_sink, + audit_sink_path=audit_sink_path, + ) + engine_name = "event_v2_reactive_single_pass" + final_result.metadata.update( + { + "engine": engine_name, + **self._backend_selection_metadata(), + "reactive_execution_mode": execution_mode, + "reactive_kernel_mode": kernel_mode, + "command_effective_phase": "next_bar", + "emitted_command_tape": tuple(emitted_audit_tape) if plan.keep_command_tape else (), + "emitted_command_tape_retained": bool(plan.keep_command_tape), + "emitted_command_count": int(emitted_command_count), + "emitted_executable_command_count": int(emitted_executable_command_count), + "ignored_commands_after_end": int(ignored_commands_after_end), + "strategy_callback_count": int(callback_count), + "static_replay_available": bool(replay_result is not None), + "reactive_static_replay_count": int(replay_result is not None), + "reactive_context_builder": "incremental_session_v1", + "reactive_incremental_compile_replays": 0, + "reactive_session_liquidated": bool(session.liquidated), + "reactive_session_liquidation_bar": int(session.liquidation_bar), + } + ) + if backend_selection.resolved == "rust": + final_result.metadata["rust_r1_session_fills"] = tuple(session.fills) if plan.materialize_python_objects else () + final_result.metadata["rust_r1_session_events"] = tuple(session.events) if plan.keep_event_ledger else () + if execution_mode == "audit" and replay_result is not None: + replay_last_pos = { + symbol: float(replay_result.positions[f"Position_{symbol}"].iloc[-1]) + for symbol in symbol_list + } + session_last_pos = {symbol: float(last_context.positions[symbol]) for symbol in symbol_list} + final_result.metadata["reactive_audit"] = { + "final_equity_diff": float(abs(float(replay_result.equity.iloc[-1]) - float(last_context.equity))), + "final_position_diff": { + symbol: float(abs(replay_last_pos.get(symbol, 0.0) - session_last_pos.get(symbol, 0.0))) + for symbol in symbol_list + }, + } + return final_result + + def run_strategy_score( + self, + *args, + trading_days: int = 365, + score_requirements: Optional[NativeEventScoreRequirements] = None, + **kwargs, + ) -> Union[NativeEventScoreResult, NativeEventScalarScoreResult]: + """Execute a prepared reactive score without pandas/result materialization. + + This is an internal prepared-runner path. Public ``run_strategy`` keeps + returning ``BacktestResultV2`` for every report level, including + ``score``; callers that need an audit trace must use that public path. + """ + kwargs.update( + { + "reactive_kernel_mode": "single_pass", + "report_level": "score", + "audit_sink": "none", + "_score_requirements": score_requirements, + "_return_score": True, + "_trading_days": int(trading_days), + } + ) + result = self.run_strategy(*args, **kwargs) + if not isinstance(result, (NativeEventScoreResult, NativeEventScalarScoreResult)): # pragma: no cover + raise TypeError("native-event direct score did not return a native-event score result") + return result + + def run_compiled_tape_score( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + compiled_commands: CompiledOrderCommandArrays, + *, + market_arrays: PreparedMarketArrays, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + initial_capital: Optional[float] = None, + maintenance_ratio: Optional[float] = None, + slippage: Optional[float] = None, + use_funding: Optional[bool] = None, + trading_days: int = 365, + ) -> NativeEventScalarScoreResult: + """Run a prepared static command tape and retain scalar state only. + + This is the Python-side apples-to-apples score contract for the Rust + batched runner. It accepts already prepared market arrays and compiled + commands, schedules the existing lifecycle commands without pandas + reports or full ledgers, and returns the same scalar accounting fields + as :class:`RustBatchedScoreResult` via the result properties and + metadata. + + The method is intentionally internal-facing: quantity preflight and + capability validation must happen before compiling the tape. It does + not change the public endpoint default or the audit ``run_orders`` + contract. + """ + if market_arrays is None: + raise ValueError("run_compiled_tape_score requires prepared market_arrays") + idx = validate_datetime(datetime_index) + symbol_list = list(compiled_commands.symbols) + if not symbol_list: + raise ValueError("compiled command tape must contain at least one symbol") + if market_arrays.signature != self._market_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + if compiled_commands.index_signature != market_arrays.signature: + raise ValueError("compiled commands do not match prepared market arrays") + + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + configured_fee = self.config.fee_rate if fee_rate is None else fee_rate + fee_rates = self._per_symbol_array(configured_fee, symbol_list, default=0.0) + initial = float(self.config.account.initial_capital if initial_capital is None else initial_capital) + maint = float( + self.config.account.maintenance_ratio if maintenance_ratio is None else maintenance_ratio + ) + slip = float(self.config.execution.slippage_rate if slippage is None else slippage) + funding_enabled = bool(self.config.use_funding if use_funding is None else use_funding) + if initial <= 0.0 or maint < 0.0 or slip < 0.0 or np.any(contract_sizes <= 0.0) or np.any(leverages <= 0.0): + raise ValueError("invalid scalar score account or execution configuration") + + if self._backend_selection.resolved == "rust": + runner = RustFullRunner( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + contract_sizes=contract_sizes, + leverages=leverages, + fee_rates=fee_rates, + initial_capital=initial, + maintenance_ratio=maint, + slippage=slip, + use_funding=funding_enabled, + ) + # ``run_compiled_tape_score`` is the legacy/public score facade + # and promises dense accounting arrays for metric computation. + # Keep the Rust runner's scalar score ABI minimal, but use its + # typed audit projection here rather than manufacturing missing + # paths or changing the public result contract. + audit = runner.run_tape_audit(compiled_commands) + equity = np.ascontiguousarray(np.asarray(audit.equity, dtype=np.float64)) + positions = np.ascontiguousarray(np.asarray(audit.positions, dtype=np.float64)) + returns = np.zeros_like(equity) + if len(equity) > 1: + with np.errstate(divide="ignore", invalid="ignore"): + returns[1:] = equity[1:] / equity[:-1] - 1.0 + returns[~np.isfinite(returns)] = 0.0 + from ..metrics.performance import compute_performance_metrics + + metrics = compute_performance_metrics( + timestamps=idx, + equity=equity, + returns=returns, + positions=positions, + symbols=tuple(symbol_list), + initial_capital=initial, + liquidated=bool(audit.liquidated), + trading_days=int(trading_days), + ) + metadata = { + "backend": "native_event", + "engine": "event_v2_compiled_tape_score_facade_rust_full", + "report_level": "score", + "score_pandas_materialized": False, + "score_full_ledgers_materialized": False, + "compiled_tape_commands": int(compiled_commands.n_commands), + "compiled_tape_symbols": tuple(symbol_list), + "use_funding": funding_enabled, + "total_fee": float(audit.total_fee), + "total_funding": float(audit.total_funding), + "total_turnover": float(audit.total_turnover), + "lifecycle_counters": { + "fill_count": int(audit.fill_count), + "event_count": int(audit.event_count), + "rejected_count": int(audit.rejected_count), + "canceled_count": int(audit.canceled_count), + }, + "trading_days": int(trading_days), + "rust_contract": "native_event_v2_full_contract", + } + metrics.update({ + "total_fee": float(audit.total_fee), + "total_funding": float(audit.total_funding), + "total_turnover": float(audit.total_turnover), + "max_initial_margin": float(audit.max_initial_margin), + "max_maintenance_margin": float(audit.max_maintenance_margin), + }) + return NativeEventScalarScoreResult( + final_equity=float(audit.equity[-1]), + final_positions=np.asarray(audit.positions[-1], dtype=np.float64), + fill_count=int(audit.fill_count), + rejection_count=int(audit.rejected_count), + cancellation_count=int(audit.canceled_count), + liquidated=bool(audit.liquidated), + liquidation_bar=int(audit.liquidation_bar), + metrics=metrics, + metadata=metadata, + ) + + requirements = NativeEventScoreRequirements( + need_trade_stats=True, + need_context_fills=False, + need_context_events=False, + need_context_active_orders=False, + need_context_positions=False, + need_context_margin=False, + ) + opens_arr = np.ascontiguousarray(market_arrays.closes, dtype=np.float64) + volumes_arr = np.zeros_like(opens_arr, dtype=np.float64) + session = _NativeEventReactiveSession( + idx=idx, + symbols=symbol_list, + market_arrays=market_arrays, + opens_arr=opens_arr, + volumes_arr=volumes_arr, + constraints=build_quantity_constraints(symbol_list), + contract_sizes=contract_sizes, + leverages=leverages, + fee_rates=fee_rates, + initial_capital=initial, + maintenance_ratio=maint, + slippage=slip, + use_funding=funding_enabled, + retain_terminal_orders=False, + score_requirements=requirements, + ) + session.online_score.trading_days = int(trading_days) + + for bar in range(len(idx)): + start = int(compiled_commands.command_ptr[bar]) + stop = int(compiled_commands.command_ptr[bar + 1]) + if stop > start: + session.schedule( + bar, + tuple(compiled_commands.sorted_commands[row][1] for row in range(start, stop)), + ) + session.process_bar(len(idx) - 1) + result = self._reactive_session_score_result( + session=session, + symbol_list=symbol_list, + leverages=leverages, + requirements=requirements, + trading_days=int(trading_days), + metadata={ + "backend": "native_event", + "engine": "event_v2_compiled_tape_scalar_python", + "report_level": "score", + "score_pandas_materialized": False, + "score_full_ledgers_materialized": False, + "compiled_tape_commands": int(compiled_commands.n_commands), + "compiled_tape_symbols": tuple(symbol_list), + "use_funding": funding_enabled, + "total_fee": float(session.total_fee), + "total_funding": float(session.total_funding), + "total_turnover": float(session.total_turnover), + }, + ) + if not isinstance(result, NativeEventScalarScoreResult): # pragma: no cover + raise TypeError("compiled tape scalar path unexpectedly retained dense accounting") + return result + + def run_orders( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + orders: Sequence[OrderIntent], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + symbols: Optional[List[str]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + compiled_orders: Optional[CompiledOrderArrays] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + ) -> BacktestResultV2: + idx = validate_datetime(datetime_index) + symbol_list = symbols or list(closes.keys()) + + if market_arrays is None: + market_arrays = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + symbols=symbol_list, + ) + elif market_arrays.signature != self._market_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + effective_orders, quantity_preflight = self._apply_order_quantity_constraints( + idx=idx, + orders=orders, + closes=market_arrays.closes, + symbol_list=symbol_list, + contract_sizes=contract_sizes, + constraints=constraints, + ) + if quantity_preflight["changed_count"] or quantity_preflight["dropped_count"]: + compiled_orders = None + orders = tuple(effective_orders) + else: + effective_orders = tuple(orders) + + if compiled_orders is None: + compiled_orders = self.compile_orders(datetime_index=idx, orders=effective_orders, symbols=symbol_list) + elif ( + compiled_orders.index_signature != market_arrays.signature + or compiled_orders.symbols != tuple(symbol_list) + ): + raise ValueError("compiled orders do not match prepared market arrays") + n_orders = compiled_orders.n_orders + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + fee_rates = self._per_symbol_array( + self.config.fee_rate if fee_rate is None else fee_rate, + symbol_list, + default=0.0, + ) + + ( + equity_arr, + pos_arr, + fee_arr, + turnover_arr, + funding_arr, + init_margin_arr, + maint_margin_arr, + rejected_bar, + canceled_bar, + order_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + liq_flag, + liq_idx, + liq_reason, + ) = _engine_event_v1( + n_bars=len(idx), + n_syms=len(symbol_list), + n_orders=n_orders, + order_ptr=compiled_orders.order_ptr, + order_symbol=compiled_orders.order_symbol, + order_side=compiled_orders.order_side, + order_type=compiled_orders.order_type, + order_qty=compiled_orders.order_qty, + order_price=compiled_orders.order_price, + order_tif=compiled_orders.order_tif, + highs=market_arrays.highs, + lows=market_arrays.lows, + closes=market_arrays.closes, + funding_rates=market_arrays.funding, + is_funding_bar=market_arrays.is_funding_bar, + init_capital=self.config.account.initial_capital, + leverages=leverages, + maint_ratio=self.config.account.maintenance_ratio, + fee_rates=fee_rates, + contract_sizes=contract_sizes, + slippage=self.config.execution.slippage_rate, + use_funding=bool(self.config.use_funding), + ) + + fills = self._build_fills(compiled_orders.sorted_orders, idx, fill_bar, fill_qty, fill_price, fill_fee) + equity = pd.Series(equity_arr, index=idx, name="equity") + positions = pd.DataFrame( + {f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + close_df = pd.DataFrame( + {f"Close_{s}": market_arrays.closes[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + + diagnostics = pd.DataFrame( + { + "turnover": turnover_arr, + "rejected_orders": rejected_bar, + "canceled_orders": canceled_bar, + }, + index=idx, + ) + order_report = pd.DataFrame( + { + "original_index": compiled_orders.original_index, + "status": order_status, + "reject_code": reject_code, + "fill_bar": fill_bar, + "fill_qty": fill_qty, + "fill_price": fill_price, + "fill_fee": fill_fee, + } + ).sort_values("original_index", kind="stable") + + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=positions, + closes=close_df, + symbols=symbol_list, + initial_capital=self.config.account.initial_capital, + leverage=float(np.mean(leverages)), + liquidated=bool(liq_flag), + liquidation_bar=int(liq_idx), + orders=tuple(orders), + fills=tuple(fills), + fees=pd.Series(fee_arr, index=idx, name="fees"), + funding=pd.Series(funding_arr, index=idx, name="funding"), + margin=pd.DataFrame( + { + "initial_margin": init_margin_arr, + "maintenance_margin": maint_margin_arr, + }, + index=idx, + ), + diagnostics=diagnostics, + metadata={ + "backend": "native_event", + "engine": "event_v1", + "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list), + "slippage_bps": self.config.execution.slippage_bps, + "order_report": order_report, + "quantity_constraints": constraints.as_dict(), + "quantity_preflight": quantity_preflight, + "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), + "liquidation_reason": int(liq_reason), + }, + ) + + @staticmethod + def _apply_order_quantity_constraints( + *, + idx: pd.DatetimeIndex, + orders: Sequence[OrderIntent], + closes: np.ndarray, + symbol_list: List[str], + contract_sizes: np.ndarray, + constraints, + ) -> tuple[tuple[OrderIntent, ...], Dict]: + if not constraints.enabled: + return tuple(orders), {"changed_count": 0, "dropped_count": 0, "dropped_orders": []} + sym_to_col = {symbol: j for j, symbol in enumerate(symbol_list)} + changed = 0 + dropped = [] + out: list[OrderIntent] = [] + idx_ns = idx.view("int64") + for order_idx, order in enumerate(orders): + col = sym_to_col[order.symbol] + ts = pd.Timestamp(order.timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + bar = int(np.searchsorted(idx_ns, ts.value, side="left")) + if bar >= len(idx): + bar = len(idx) - 1 + price = float(order.price) if order.price is not None else float(closes[bar, col]) + signed = order.signed_qty + q = abs( + quantize_signed_quantity( + signed, + price, + float(contract_sizes[col]), + float(constraints.qty_step[col]), + float(constraints.min_qty[col]), + float(constraints.min_notional[col]), + ) + ) + if q <= 0.0: + dropped.append({"original_index": order_idx, "symbol": order.symbol, "requested_qty": float(order.qty)}) + continue + if abs(q - float(order.qty)) > 1e-12: + changed += 1 + out.append( + OrderIntent( + timestamp=order.timestamp, + symbol=order.symbol, + side=order.side, + order_type=order.order_type, + qty=q, + price=order.price, + trigger_price=order.trigger_price, + tif=order.tif, + reduce_only=order.reduce_only, + order_id=order.order_id, + tag=order.tag, + metadata={**order.metadata, "requested_qty": float(order.qty), "quantity_quantized": True}, + ) + ) + else: + out.append(order) + return tuple(out), {"changed_count": changed, "dropped_count": len(dropped), "dropped_orders": dropped} + + @staticmethod + def _apply_command_quantity_constraints( + *, + idx: pd.DatetimeIndex, + commands: Sequence[OrderCommand], + closes: np.ndarray, + symbol_list: List[str], + contract_sizes: np.ndarray, + constraints, + ) -> tuple[tuple[OrderCommand, ...], Dict]: + if not constraints.enabled: + return tuple(commands), {"changed_count": 0, "dropped_count": 0, "dropped_orders": []} + sym_to_col = {symbol: j for j, symbol in enumerate(symbol_list)} + changed = 0 + dropped = [] + out: list[OrderCommand] = [] + idx_ns = idx.view("int64") + for command_idx, command in enumerate(commands): + if command.action not in (OrderAction.PLACE, OrderAction.REPLACE) or command.symbol is None: + out.append(command) + continue + if command.symbol not in sym_to_col: + raise ValueError(f"command symbol {command.symbol!r} is not in symbols") + col = sym_to_col[command.symbol] + ts = pd.Timestamp(command.timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + bar = int(np.searchsorted(idx_ns, ts.value, side="left")) + if bar >= len(idx): + bar = len(idx) - 1 + price = float(command.price) if command.price is not None else float(closes[bar, col]) + signed = command.signed_qty + q = abs( + quantize_signed_quantity( + signed, + price, + float(contract_sizes[col]), + float(constraints.qty_step[col]), + float(constraints.min_qty[col]), + float(constraints.min_notional[col]), + ) + ) + if q <= 0.0: + dropped.append( + { + "original_index": command_idx, + "symbol": command.symbol, + "requested_qty": None if command.qty is None else float(command.qty), + } + ) + continue + if command.qty is not None and abs(q - float(command.qty)) > 1e-12: + changed += 1 + out.append( + OrderCommand( + timestamp=command.timestamp, + action=command.action, + symbol=command.symbol, + side=command.side, + order_type=command.order_type, + qty=q, + price=command.price, + trigger_price=command.trigger_price, + tif=command.tif, + reduce_only=command.reduce_only, + order_id=command.order_id, + target_order_id=command.target_order_id, + parent_order_id=command.parent_order_id, + group_id=command.group_id, + oco_group_id=command.oco_group_id, + activation_policy=command.activation_policy, + expires_at=command.expires_at, + tag=command.tag, + tag_prefix=command.tag_prefix, + metadata={ + **command.metadata, + "requested_qty": float(command.qty), + "quantity_quantized": True, + }, + ) + ) + else: + out.append(command) + return tuple(out), {"changed_count": changed, "dropped_count": len(dropped), "dropped_orders": dropped} + + @staticmethod + def _reactive_session_score_result( + *, + session, + symbol_list: List[str], + leverages: np.ndarray, + requirements: NativeEventScoreRequirements, + trading_days: int, + metadata: Dict[str, object], + ) -> NativeEventScoreResult: + """Build direct score arrays from session state without pandas objects.""" + required = { + "equity_path": session.equity_path, + "pos_path": session.pos_path, + "fee_path": session.fee_path, + "funding_path": session.funding_path, + "initial_margin_path": session.initial_margin_path, + "maintenance_margin_path": session.maintenance_margin_path, + } + counters = { + "fill_count": int(session.fill_count), + "event_count": int(session.event_count), + "rejected_count": int(session.rejected_count), + "canceled_count": int(session.canceled_count), + "filled_command_count": int(session.fill_count), + "pending_command_count": int(sum(1 for state in session.pending if session._is_pending(state))), + "expired_event_count": int(getattr(session, "expired_count", 0)), + } + score_metadata = { + **metadata, + "lifecycle_counters": counters, + "execution_counters": dict(getattr(session, "execution_counters", {})), + "score_direct_arrays": True, + "score_pandas_materialized": False, + "score_full_ledgers_materialized": False, + "score_requirements": asdict(requirements), + "score_primitive_order_state": bool(getattr(session, "compact_score_state", False)), + "trading_days": int(trading_days), + } + all_paths = all(value is not None for value in required.values()) + if all_paths: + equity = required["equity_path"] + returns = np.zeros_like(equity) + if len(equity) > 1: + with np.errstate(divide="ignore", invalid="ignore"): + returns[1:] = equity[1:] / equity[:-1] - 1.0 + returns[~np.isfinite(returns)] = 0.0 + accounting = NativeAccountingArrays( + timestamps=np.ascontiguousarray(session.idx.asi8, dtype=np.int64), + equity=equity, + returns=returns, + positions=required["pos_path"], + fees=required["fee_path"], + funding=required["funding_path"], + initial_margin=required["initial_margin_path"], + maintenance_margin=required["maintenance_margin_path"], + symbols=tuple(symbol_list), + initial_capital=float(session.initial_capital), + leverage=float(np.mean(leverages)), + liquidated=bool(session.liquidated), + liquidation_bar=int(session.liquidation_bar), + ) + from ..metrics.performance import compute_performance_metrics + + metrics = compute_performance_metrics( + timestamps=session.idx, + equity=accounting.equity, + returns=accounting.returns, + positions=accounting.positions, + symbols=accounting.symbols, + initial_capital=accounting.initial_capital, + liquidated=bool(session.liquidated), + trading_days=int(trading_days), + ) + return NativeEventScoreResult( + accounting=accounting, + final_positions=accounting.positions[-1].copy(), + fill_count=counters["fill_count"], + rejection_count=counters["rejected_count"], + cancellation_count=counters["canceled_count"], + liquidated=bool(session.liquidated), + liquidation_bar=int(session.liquidation_bar), + metrics=metrics, + metadata=score_metadata, + ) + + online = getattr(session, "online_score", None) + if online is None: + raise RuntimeError("scalar native-event score requires online metric state") + metrics = online.finish(session.idx) + metrics["liquidated"] = bool(session.liquidated) + metrics["total_fee"] = float(getattr(session, "total_fee", 0.0)) + metrics["total_funding"] = float(getattr(session, "total_funding", 0.0)) + metrics["total_turnover"] = float(getattr(session, "total_turnover", 0.0)) + metrics["max_initial_margin"] = float(online.max_initial_margin) + metrics["max_maintenance_margin"] = float(online.max_maintenance_margin) + score_metadata["score_scalar"] = True + score_metadata["score_retained_paths"] = { + name: bool(value is not None) for name, value in required.items() + } + score_metadata["total_fee"] = float(getattr(session, "total_fee", 0.0)) + score_metadata["total_funding"] = float(getattr(session, "total_funding", 0.0)) + score_metadata["total_turnover"] = float(getattr(session, "total_turnover", 0.0)) + return NativeEventScalarScoreResult( + final_equity=float(online.last_equity), + final_positions=np.asarray(session.current_pos, dtype=np.float64).copy(), + fill_count=counters["fill_count"], + rejection_count=counters["rejected_count"], + cancellation_count=counters["canceled_count"], + liquidated=bool(session.liquidated), + liquidation_bar=int(session.liquidation_bar), + metrics=metrics, + metadata=score_metadata, + ) + + def _reactive_session_result( + self, + *, + session: _NativeEventReactiveSession, + symbol_list: List[str], + market_arrays: PreparedMarketArrays, + leverages: np.ndarray, + report_level: str, + plan: NativeEventArtifactPlan, + replay_result: Optional[BacktestResultV2], + audit_sink: Optional[str], + audit_sink_path: Optional[str], + ) -> BacktestResultV2: + idx = session.idx + equity = pd.Series(session.equity_path.copy(), index=idx, name="equity") + returns = equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + positions = pd.DataFrame( + {f"Position_{symbol}": session.pos_path[:, j].copy() for j, symbol in enumerate(symbol_list)}, + index=idx, + ) + closes = pd.DataFrame( + {f"Close_{symbol}": market_arrays.closes[:, j].copy() for j, symbol in enumerate(symbol_list)}, + index=idx, + ) + margin = pd.DataFrame( + { + "initial_margin": session.initial_margin_path.copy(), + "maintenance_margin": session.maintenance_margin_path.copy(), + }, + index=idx, + ) + diagnostics = pd.DataFrame( + { + "turnover": session.turnover_path.copy(), + "rejected_orders": session.rejected_bar.copy(), + "canceled_orders": session.canceled_bar.copy(), + }, + index=idx, + ) + session_fills = self._fills_from_reactive_session(session) + fill_ledger = self._compact_fill_ledger_from_session(session, symbol_list) + lifecycle_counters = { + "fill_count": int(len(session_fills)), + "event_count": int(len(session.events)), + "rejected_count": int(np.sum(session.rejected_bar)), + "canceled_count": int(np.sum(session.canceled_bar)), + "filled_command_count": int(len(session_fills)), + "pending_command_count": int(sum(1 for state in session.pending if session._is_pending(state))), + "expired_event_count": int(sum(1 for event in session.events if event.event_name == "expire")), + } + command_report = pd.DataFrame() + order_events = pd.DataFrame() + active_orders = pd.DataFrame() + orders = () + fills = tuple(session_fills) if plan.materialize_python_objects else () + compact_command_ledger = None + compact_order_event_ledger = None + audit_artifacts = {} + quantity_preflight = {"changed_count": 0, "dropped_count": 0, "dropped_orders": []} + if replay_result is not None: + command_report = replay_result.metadata.get("command_report", pd.DataFrame()) + order_events = replay_result.metadata.get("order_events", pd.DataFrame()) + active_orders = replay_result.metadata.get("active_orders", pd.DataFrame()) + orders = replay_result.orders if plan.materialize_python_objects else () + fills = replay_result.fills if plan.materialize_python_objects else () + compact_command_ledger = replay_result.metadata.get("compact_command_ledger") + compact_order_event_ledger = replay_result.metadata.get("compact_order_event_ledger") + audit_artifacts = replay_result.metadata.get("audit_artifacts", {}) + quantity_preflight = replay_result.metadata.get("quantity_preflight", quantity_preflight) + + metadata = { + "backend": "native_event", + "engine": "event_v2_reactive_single_pass", + "report_level": report_level, + "artifact_plan": asdict(plan), + "audit_sink": self.config.audit_sink if audit_sink is None else _normalize_native_event_audit_sink(audit_sink), + "audit_sink_path": self.config.audit_sink_path if audit_sink_path is None else audit_sink_path, + "audit_artifacts": audit_artifacts, + "fee_rate_oneway": self._fee_rate_metadata(session.fee_rates, symbol_list), + "slippage_bps": self.config.execution.slippage_bps, + "order_report": command_report, + "command_report": command_report, + "order_events": order_events, + "active_orders": active_orders, + "compact_fill_ledger": fill_ledger if plan.keep_fill_ledger else None, + "compact_command_ledger": compact_command_ledger if plan.keep_command_terminal_state else None, + "compact_order_event_ledger": compact_order_event_ledger if plan.keep_event_ledger else None, + "quantity_constraints": session.constraints.as_dict(), + "quantity_preflight": quantity_preflight, + "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), + "liquidation_reason": int(session.liquidation_reason), + "lifecycle_counters": lifecycle_counters, + "execution_counters": dict(getattr(session, "execution_counters", {})), + "single_pass_accounting_source": "reactive_session_state", + "single_pass_replay_certified": bool(replay_result is not None), + } + return BacktestResultV2( + equity=equity, + returns=returns, + positions=positions, + closes=closes, + symbols=symbol_list, + initial_capital=self.config.account.initial_capital, + leverage=float(np.mean(leverages)), + liquidated=bool(session.liquidated), + liquidation_bar=int(session.liquidation_bar), + orders=orders, + fills=fills, + fees=pd.Series(session.fee_path.copy(), index=idx, name="fees"), + funding=pd.Series(session.funding_path.copy(), index=idx, name="funding"), + margin=margin, + diagnostics=diagnostics, + metadata=metadata, + ) + + @staticmethod + def _fills_from_reactive_session(session: _NativeEventReactiveSession) -> tuple[Fill, ...]: + fills: list[Fill] = [] + for fill in session.fills: + fills.append( + Fill( + timestamp=fill.timestamp, + symbol=fill.symbol, + side=fill.side, + qty=float(fill.qty), + price=float(fill.price), + fee=float(fill.fee), + order_id=fill.order_id, + metadata={ + **dict(fill.metadata), + "tag": fill.tag, + "campaign_id": fill.campaign_id, + "cycle_id": fill.cycle_id, + "level_id": fill.level_id, + "parent_order_id": fill.parent_order_id, + "oco_group_id": fill.oco_group_id, + }, + ) + ) + return tuple(fills) + + @staticmethod + def _compact_fill_ledger_from_session( + session: _NativeEventReactiveSession, + symbol_list: List[str], + ) -> CompactFillLedger: + id_map: Dict[str, int] = {} + symbol_to_col = {symbol: j for j, symbol in enumerate(symbol_list)} + bars = [] + command_index = [] + original_index = [] + order_id_code = [] + symbol_code = [] + side = [] + qty = [] + price = [] + fee = [] + for fill_index, fill in enumerate(session.fills): + code = -1 + if fill.order_id: + if fill.order_id not in id_map: + id_map[fill.order_id] = len(id_map) + code = id_map[fill.order_id] + bars.append(int(session.idx.searchsorted(pd.Timestamp(fill.timestamp), side="left"))) + command_index.append(fill_index) + original_index.append(-1) + order_id_code.append(code) + symbol_code.append(symbol_to_col.get(fill.symbol, -1)) + side.append(fill.side.sign) + qty.append(float(fill.qty)) + price.append(float(fill.price)) + fee.append(float(fill.fee)) + return CompactFillLedger( + bar=np.asarray(bars, dtype=np.int64), + command_index=np.asarray(command_index, dtype=np.int64), + original_index=np.asarray(original_index, dtype=np.int64), + order_id_code=np.asarray(order_id_code, dtype=np.int64), + symbol_code=np.asarray(symbol_code, dtype=np.int64), + side=np.asarray(side, dtype=np.int64), + qty=np.asarray(qty, dtype=np.float64), + price=np.asarray(price, dtype=np.float64), + fee=np.asarray(fee, dtype=np.float64), + id_values=tuple(sorted(id_map, key=id_map.get)), + symbols=tuple(symbol_list), + ) + + @staticmethod + def _compact_fill_ledger_from_fills(fills: Sequence[Fill], symbol_list: List[str]) -> CompactFillLedger: + id_map: Dict[str, int] = {} + symbol_to_col = {symbol: j for j, symbol in enumerate(symbol_list)} + bars = [] + command_index = [] + original_index = [] + order_id_code = [] + symbol_code = [] + side = [] + qty = [] + price = [] + fee = [] + for n, fill in enumerate(fills): + code = -1 + if fill.order_id: + if fill.order_id not in id_map: + id_map[fill.order_id] = len(id_map) + code = id_map[fill.order_id] + bars.append(n) + command_index.append(n) + original_index.append(-1) + order_id_code.append(code) + symbol_code.append(symbol_to_col.get(fill.symbol, -1)) + side.append(fill.side.sign) + qty.append(float(fill.qty)) + price.append(float(fill.price)) + fee.append(float(fill.fee)) + return CompactFillLedger( + bar=np.asarray(bars, dtype=np.int64), + command_index=np.asarray(command_index, dtype=np.int64), + original_index=np.asarray(original_index, dtype=np.int64), + order_id_code=np.asarray(order_id_code, dtype=np.int64), + symbol_code=np.asarray(symbol_code, dtype=np.int64), + side=np.asarray(side, dtype=np.int64), + qty=np.asarray(qty, dtype=np.float64), + price=np.asarray(price, dtype=np.float64), + fee=np.asarray(fee, dtype=np.float64), + id_values=tuple(sorted(id_map, key=id_map.get)), + symbols=tuple(symbol_list), + ) + + @staticmethod + def _assert_reactive_session_replay_parity( + session: _NativeEventReactiveSession, + replay_result: BacktestResultV2, + *, + atol: float = 1e-9, + ) -> None: + checks = { + "equity": (session.equity_path, replay_result.equity.to_numpy(dtype=np.float64)), + "fees": (session.fee_path, replay_result.fees.to_numpy(dtype=np.float64)), + "funding": (session.funding_path, replay_result.funding.to_numpy(dtype=np.float64)), + "positions": ( + session.pos_path, + replay_result.positions[[f"Position_{symbol}" for symbol in replay_result.symbols]].to_numpy(dtype=np.float64), + ), + "initial_margin": (session.initial_margin_path, replay_result.margin["initial_margin"].to_numpy(dtype=np.float64)), + "maintenance_margin": ( + session.maintenance_margin_path, + replay_result.margin["maintenance_margin"].to_numpy(dtype=np.float64), + ), + } + for name, (left, right) in checks.items(): + if not np.allclose(left, right, rtol=0.0, atol=atol, equal_nan=True): + diff = float(np.nanmax(np.abs(left - right))) + raise AssertionError(f"reactive single-pass replay parity failed for {name}: max_diff={diff}") + if bool(session.liquidated) != bool(replay_result.liquidated): + raise AssertionError("reactive single-pass replay parity failed for liquidated flag") + if int(session.liquidation_bar) != int(replay_result.liquidation_bar): + raise AssertionError("reactive single-pass replay parity failed for liquidation_bar") + + def _reactive_replay( + self, + *, + idx: pd.DatetimeIndex, + commands: Sequence[OrderCommand], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]], + lows: Optional[Dict[str, pd.Series]], + funding_rate, + contract_size, + leverage, + fee_rate, + symbols: List[str], + market_arrays: Optional[PreparedMarketArrays], + instruments, + qty_step, + lot_size, + slot_size, + min_qty, + min_notional, + ) -> BacktestResultV2: + return self.run_order_commands( + datetime_index=idx, + commands=tuple(commands), + closes={symbol: closes[symbol].reindex(idx).ffill().bfill() for symbol in symbols}, + highs=None if highs is None else {symbol: highs[symbol].reindex(idx).ffill().bfill() for symbol in symbols}, + lows=None if lows is None else {symbol: lows[symbol].reindex(idx).ffill().bfill() for symbol in symbols}, + funding_rate=funding_rate, + contract_size=contract_size, + leverage=leverage, + fee_rate=fee_rate, + symbols=symbols, + market_arrays=market_arrays, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + + def _reactive_context_from_result( + self, + *, + bar_index: int, + idx: pd.DatetimeIndex, + symbols: List[str], + result: BacktestResultV2, + opens_arr: np.ndarray, + highs_arr: np.ndarray, + lows_arr: np.ndarray, + closes_arr: np.ndarray, + volumes_arr: np.ndarray, + constraints, + contract_sizes: np.ndarray, + ) -> NativeStrategyContext: + local_bar = min(int(bar_index), len(result.equity) - 1) + ts = idx[int(bar_index)] + margin_row = result.margin.iloc[local_bar] if not result.margin.empty else None + init_margin = 0.0 if margin_row is None else float(margin_row.get("initial_margin", 0.0)) + maint_margin = 0.0 if margin_row is None else float(margin_row.get("maintenance_margin", 0.0)) + equity = float(result.equity.iloc[local_bar]) + position_row = result.positions.iloc[local_bar] + positions = { + symbol: float(position_row.get(f"Position_{symbol}", 0.0)) + for symbol in symbols + } + fills_this_bar = tuple( + self._fill_to_native_event(fill) + for fill in result.fills + if pd.Timestamp(fill.timestamp).value == ts.value + ) + events_this_bar = self._native_order_events_for_bar(result.metadata.get("order_events"), int(bar_index)) + active_orders = self._native_active_snapshots(result.metadata.get("active_orders")) + size_helper = self._reactive_size_helper( + symbols=symbols, + constraints=constraints, + contract_sizes=contract_sizes, + ) + return NativeStrategyContext( + bar_index=int(bar_index), + timestamp=ts, + open=np.ascontiguousarray(opens_arr[int(bar_index)].copy()), + high=np.ascontiguousarray(highs_arr[int(bar_index)].copy()), + low=np.ascontiguousarray(lows_arr[int(bar_index)].copy()), + close=np.ascontiguousarray(closes_arr[int(bar_index)].copy()), + volume=np.ascontiguousarray(volumes_arr[int(bar_index)].copy()), + equity=equity, + available_equity=equity - init_margin, + initial_margin=init_margin, + maintenance_margin=maint_margin, + positions=positions, + fills_this_bar=fills_this_bar, + order_events_this_bar=events_this_bar, + active_orders=active_orders, + liquidated=bool(result.liquidated), + symbols=tuple(symbols), + size_order=size_helper, + ) + + @staticmethod + def _expand_scoped_cancel_all_commands( + commands: Sequence[OrderCommand], + context: NativeStrategyContext, + ) -> tuple[OrderCommand, ...]: + """ + Make string-scoped cancel-all replayable by the Numba command kernel. + + Kernel v2 can scope CANCEL_ALL by numeric fields such as symbol, side, + order type, parent id, group id, and OCO id. Tag/prefix/campaign scopes + are expanded here into explicit target CANCEL commands using the active + snapshot visible to the strategy at the close of the current bar. + """ + if commands is None: + return () + out: list[OrderCommand] = [] + for command in tuple(commands): + if not isinstance(command, OrderCommand): + raise TypeError("reactive strategy callbacks must return OrderCommand objects") + if command.action is not OrderAction.CANCEL_ALL or not NativeEventBackend._has_string_cancel_scope(command): + out.append(command) + continue + for snapshot in context.active_orders: + if snapshot.order_id is None: + continue + if not NativeEventBackend._cancel_all_snapshot_matches(command, snapshot): + continue + out.append( + OrderCommand( + timestamp=command.timestamp, + action=OrderAction.CANCEL, + target_order_id=snapshot.order_id, + tag=command.tag, + metadata={ + **dict(command.metadata), + "expanded_from_cancel_all": True, + "cancel_scope_tag_prefix": command.tag_prefix, + "cancel_scope_tag": command.tag, + }, + ) + ) + return tuple(out) + + @staticmethod + def _has_string_cancel_scope(command: OrderCommand) -> bool: + if command.tag is not None or command.tag_prefix is not None: + return True + return any(key in command.metadata for key in ("campaign_id", "cycle_id", "level_id")) + + @staticmethod + def _cancel_all_snapshot_matches(command: OrderCommand, snapshot: NativeActiveOrderSnapshot) -> bool: + if command.symbol is not None and command.symbol != snapshot.symbol: + return False + if command.side is not None and command.side.value != snapshot.side: + return False + if command.order_type is not None and command.order_type.value != snapshot.order_type: + return False + if command.parent_order_id is not None and command.parent_order_id != snapshot.parent_order_id: + return False + if command.group_id is not None and command.group_id != snapshot.group_id: + return False + if command.oco_group_id is not None and command.oco_group_id != snapshot.oco_group_id: + return False + if command.tag is not None and command.tag != snapshot.tag: + return False + if command.tag_prefix is not None and not (snapshot.tag or "").startswith(command.tag_prefix): + return False + for key, attr in (("campaign_id", "campaign_id"), ("cycle_id", "cycle_id"), ("level_id", "level_id")): + if key in command.metadata and command.metadata.get(key) != getattr(snapshot, attr): + return False + return True + + @staticmethod + def _retime_reactive_commands( + *, + commands: Sequence[OrderCommand], + effective_bar: int, + idx: pd.DatetimeIndex, + emitted_order_ids: set[str], + ) -> tuple[tuple[OrderCommand, ...], int]: + if commands is None: + return (), 0 + if effective_bar >= len(idx): + return (), len(tuple(commands)) + out: list[OrderCommand] = [] + ignored = 0 + effective_ts = idx[int(effective_bar)] + for seq, command in enumerate(tuple(commands)): + if not isinstance(command, OrderCommand): + raise TypeError("reactive strategy callbacks must return OrderCommand objects") + order_id = command.order_id + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if order_id is None: + order_id = command.tag or f"reactive-{effective_bar}-{seq}" + if order_id in emitted_order_ids: + raise ValueError(f"duplicate reactive order_id={order_id!r}") + emitted_order_ids.add(order_id) + out.append(replace(command, timestamp=effective_ts, order_id=order_id)) + return tuple(out), ignored + + @staticmethod + def _record_reactive_commands_outside_tape( + *, + commands: Sequence[OrderCommand], + effective_bar: int, + emitted_order_ids: set[str], + ) -> tuple[OrderCommand, ...]: + """Retain non-executable callback output without replaying a fake fill. + + A final-close command has valid strategy intent but no next market bar. + It belongs in the audit tape, marked as outside executable data, while + the static replay consumes only the executable tape. + """ + out: list[OrderCommand] = [] + for seq, command in enumerate(tuple(commands)): + if not isinstance(command, OrderCommand): + raise TypeError("reactive strategy callbacks must return OrderCommand objects") + order_id = command.order_id + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if order_id is None: + order_id = command.tag or f"reactive-{effective_bar}-{seq}" + if order_id in emitted_order_ids: + raise ValueError(f"duplicate reactive order_id={order_id!r}") + emitted_order_ids.add(order_id) + out.append( + replace( + command, + order_id=order_id, + metadata={ + **dict(command.metadata), + "reactive_effective_bar": int(effective_bar), + "outside_executable_tape": True, + }, + ) + ) + return tuple(out) + + @staticmethod + def _call_strategy_callback(strategy, callback: str, context: NativeStrategyContext) -> tuple[OrderCommand, ...]: + fn = getattr(strategy, callback, None) + if fn is None: + return () + try: + commands = fn(context) + except Exception as exc: + raise NativeEventStrategyError(callback, context.bar_index, context.timestamp, exc) from exc + if commands is None: + return () + return tuple(commands) + + @staticmethod + def _fill_to_native_event(fill: Fill) -> NativeFillEvent: + metadata = dict(fill.metadata or {}) + return NativeFillEvent( + timestamp=pd.Timestamp(fill.timestamp), + symbol=fill.symbol, + side=fill.side, + qty=float(fill.qty), + price=float(fill.price), + fee=float(fill.fee), + order_id=fill.order_id, + tag=metadata.get("tag"), + campaign_id=metadata.get("campaign_id"), + cycle_id=metadata.get("cycle_id"), + level_id=metadata.get("level_id"), + parent_order_id=metadata.get("parent_order_id"), + oco_group_id=metadata.get("oco_group_id"), + metadata=metadata, + ) + + @staticmethod + def _native_order_events_for_bar(events, bar: int) -> tuple[NativeOrderEvent, ...]: + if events is None or len(events) == 0: + return () + frame = events[events["bar"] == int(bar)] + out = [] + for row in frame.to_dict("records"): + out.append( + NativeOrderEvent( + timestamp=pd.Timestamp(row["timestamp"]), + bar=int(row["bar"]), + event_name=str(row["event_name"]), + status=int(row["status"]), + order_id=row.get("order_id"), + target_order_id=row.get("target_order_id"), + parent_order_id=row.get("parent_order_id"), + oco_group_id=row.get("oco_group_id"), + tag=row.get("tag"), + campaign_id=row.get("campaign_id"), + cycle_id=row.get("cycle_id"), + level_id=row.get("level_id"), + original_index=int(row.get("original_index", -1)), + related_original_index=int(row.get("related_original_index", -1)), + ) + ) + return tuple(out) + + @staticmethod + def _native_active_snapshots(active_orders) -> tuple[NativeActiveOrderSnapshot, ...]: + if active_orders is None or len(active_orders) == 0: + return () + out = [] + for row in active_orders.to_dict("records"): + out.append( + NativeActiveOrderSnapshot( + order_id=row.get("order_id"), + symbol=row.get("symbol"), + side=row.get("side"), + order_type=row.get("order_type"), + status=int(row.get("status", 0)), + remaining_qty=float(row.get("working_qty", 0.0)), + price=float(row.get("working_price", 0.0)), + trigger_price=float(row.get("working_trigger_price", 0.0)), + reduce_only=bool(row.get("reduce_only", False)), + parent_order_id=row.get("parent_order_id"), + group_id=row.get("group_id"), + oco_group_id=row.get("oco_group_id"), + tag=row.get("tag"), + campaign_id=row.get("campaign_id"), + cycle_id=row.get("cycle_id"), + level_id=row.get("level_id"), + ) + ) + return tuple(out) + + @staticmethod + def _reactive_size_helper(symbols: List[str], constraints, contract_sizes: np.ndarray): + symbol_to_col = {symbol: j for j, symbol in enumerate(symbols)} + + def size_order(symbol: str, notional: float, price: float, side: OrderSide = OrderSide.BUY) -> float: + if symbol not in symbol_to_col: + raise ValueError(f"unknown symbol={symbol!r}") + if price <= 0.0: + raise ValueError("price must be > 0") + col = symbol_to_col[symbol] + signed_qty = (float(notional) / (float(price) * float(contract_sizes[col]))) * side.sign + return abs( + quantize_signed_quantity( + signed_qty, + float(price), + float(contract_sizes[col]), + float(constraints.qty_step[col]), + float(constraints.min_qty[col]), + float(constraints.min_notional[col]), + ) + ) + + return size_order + + @staticmethod + def _build_compact_fill_ledger( + *, + compiled_commands: CompiledOrderCommandArrays, + fill_bar: np.ndarray, + fill_qty: np.ndarray, + fill_price: np.ndarray, + fill_fee: np.ndarray, + ) -> CompactFillLedger: + mask = (fill_bar >= 0) & (fill_qty != 0.0) + command_index = np.nonzero(mask)[0].astype(np.int64) + return CompactFillLedger( + bar=np.ascontiguousarray(fill_bar[mask], dtype=np.int64), + command_index=np.ascontiguousarray(command_index, dtype=np.int64), + original_index=np.ascontiguousarray(compiled_commands.original_index[mask], dtype=np.int64), + order_id_code=np.ascontiguousarray(compiled_commands.command_order_id[mask], dtype=np.int64), + symbol_code=np.ascontiguousarray(compiled_commands.command_symbol[mask], dtype=np.int64), + side=np.ascontiguousarray(compiled_commands.command_side[mask], dtype=np.int64), + qty=np.ascontiguousarray(fill_qty[mask], dtype=np.float64), + price=np.ascontiguousarray(fill_price[mask], dtype=np.float64), + fee=np.ascontiguousarray(fill_fee[mask], dtype=np.float64), + id_values=tuple(compiled_commands.id_values), + symbols=tuple(compiled_commands.symbols), + ) + + @staticmethod + def _build_compact_command_ledger( + *, + compiled_commands: CompiledOrderCommandArrays, + command_status: np.ndarray, + reject_code: np.ndarray, + fill_bar: np.ndarray, + fill_qty: np.ndarray, + fill_price: np.ndarray, + fill_fee: np.ndarray, + active: np.ndarray, + waiting_parent: np.ndarray, + working_qty: np.ndarray, + working_price: np.ndarray, + working_trigger: np.ndarray, + ) -> CompactCommandLedger: + return CompactCommandLedger( + original_index=np.ascontiguousarray(compiled_commands.original_index, dtype=np.int64), + command_bar=np.ascontiguousarray(compiled_commands.command_bar, dtype=np.int64), + action=np.ascontiguousarray(compiled_commands.command_action, dtype=np.int64), + symbol_code=np.ascontiguousarray(compiled_commands.command_symbol, dtype=np.int64), + side=np.ascontiguousarray(compiled_commands.command_side, dtype=np.int64), + order_type=np.ascontiguousarray(compiled_commands.command_type, dtype=np.int64), + order_id_code=np.ascontiguousarray(compiled_commands.command_order_id, dtype=np.int64), + target_order_id_code=np.ascontiguousarray(compiled_commands.command_target_order_id, dtype=np.int64), + parent_order_id_code=np.ascontiguousarray(compiled_commands.command_parent_order_id, dtype=np.int64), + group_id_code=np.ascontiguousarray(compiled_commands.command_group_id, dtype=np.int64), + oco_group_id_code=np.ascontiguousarray(compiled_commands.command_oco_group_id, dtype=np.int64), + status=np.ascontiguousarray(command_status, dtype=np.int64), + reject_code=np.ascontiguousarray(reject_code, dtype=np.int64), + fill_bar=np.ascontiguousarray(fill_bar, dtype=np.int64), + fill_qty=np.ascontiguousarray(fill_qty, dtype=np.float64), + fill_price=np.ascontiguousarray(fill_price, dtype=np.float64), + fill_fee=np.ascontiguousarray(fill_fee, dtype=np.float64), + active=np.ascontiguousarray(active, dtype=np.int64), + waiting_parent=np.ascontiguousarray(waiting_parent, dtype=np.int64), + working_qty=np.ascontiguousarray(working_qty, dtype=np.float64), + working_price=np.ascontiguousarray(working_price, dtype=np.float64), + working_trigger=np.ascontiguousarray(working_trigger, dtype=np.float64), + id_values=tuple(compiled_commands.id_values), + symbols=tuple(compiled_commands.symbols), + ) + + @staticmethod + def _build_compact_order_event_ledger( + *, + event_count: int, + event_bar: np.ndarray, + event_command: np.ndarray, + event_type: np.ndarray, + event_status: np.ndarray, + event_related_command: np.ndarray, + ) -> CompactOrderEventLedger: + n = max(int(event_count), 0) + return CompactOrderEventLedger( + bar=np.ascontiguousarray(event_bar[:n], dtype=np.int64), + command_index=np.ascontiguousarray(event_command[:n], dtype=np.int64), + event_type=np.ascontiguousarray(event_type[:n], dtype=np.int64), + status=np.ascontiguousarray(event_status[:n], dtype=np.int64), + related_command_index=np.ascontiguousarray(event_related_command[:n], dtype=np.int64), + ) + + @staticmethod + def _write_native_event_audit_sink( + *, + sink: str, + sink_path: Optional[str], + command_report: pd.DataFrame, + order_events: pd.DataFrame, + fill_ledger: CompactFillLedger, + command_ledger: CompactCommandLedger, + event_ledger: CompactOrderEventLedger, + report_level: str, + ) -> Dict: + if sink in {"none", "memory"} or report_level != "audit": + return {} + if not sink_path: + raise ValueError("native_event audit_sink='jsonl' or 'parquet' requires audit_sink_path") + root = Path(sink_path) + root.mkdir(parents=True, exist_ok=True) + if sink == "jsonl": + command_path = root / "command_report.jsonl" + event_path = root / "order_events.jsonl" + fill_path = root / "fill_ledger.jsonl" + command_report.to_json(command_path, orient="records", lines=True, date_format="iso") + order_events.to_json(event_path, orient="records", lines=True, date_format="iso") + pd.DataFrame( + { + "bar": fill_ledger.bar, + "command_index": fill_ledger.command_index, + "original_index": fill_ledger.original_index, + "order_id_code": fill_ledger.order_id_code, + "symbol_code": fill_ledger.symbol_code, + "side": fill_ledger.side, + "qty": fill_ledger.qty, + "price": fill_ledger.price, + "fee": fill_ledger.fee, + } + ).to_json(fill_path, orient="records", lines=True, date_format="iso") + return { + "format": "jsonl", + "command_report": str(command_path), + "order_events": str(event_path), + "fill_ledger": str(fill_path), + "event_count": int(event_ledger.event_count), + "fill_count": int(fill_ledger.fill_count), + } + command_path = root / "command_report.parquet" + event_path = root / "order_events.parquet" + fill_path = root / "fill_ledger.parquet" + command_report.to_parquet(command_path, index=False) + order_events.to_parquet(event_path, index=False) + pd.DataFrame( + { + "bar": fill_ledger.bar, + "command_index": fill_ledger.command_index, + "original_index": fill_ledger.original_index, + "order_id_code": fill_ledger.order_id_code, + "symbol_code": fill_ledger.symbol_code, + "side": fill_ledger.side, + "qty": fill_ledger.qty, + "price": fill_ledger.price, + "fee": fill_ledger.fee, + } + ).to_parquet(fill_path, index=False) + return { + "format": "parquet", + "command_report": str(command_path), + "order_events": str(event_path), + "fill_ledger": str(fill_path), + "event_count": int(event_ledger.event_count), + "fill_count": int(fill_ledger.fill_count), + } + + @staticmethod + def _build_command_report( + compiled_commands: CompiledOrderCommandArrays, + command_status: np.ndarray, + reject_code: np.ndarray, + fill_bar: np.ndarray, + fill_qty: np.ndarray, + fill_price: np.ndarray, + fill_fee: np.ndarray, + active: np.ndarray, + waiting_parent: np.ndarray, + working_qty: np.ndarray, + working_price: np.ndarray, + working_trigger: np.ndarray, + ) -> pd.DataFrame: + rows = [] + for sorted_idx, (original_idx, command) in enumerate(compiled_commands.sorted_commands): + rows.append( + { + "original_index": int(original_idx), + "sorted_index": int(sorted_idx), + "timestamp": command.timestamp, + "action": command.action.value, + "symbol": command.symbol, + "side": None if command.side is None else command.side.value, + "order_type": None if command.order_type is None else command.order_type.value, + "order_id": command.order_id, + "target_order_id": command.target_order_id, + "parent_order_id": command.parent_order_id, + "group_id": command.group_id, + "oco_group_id": command.oco_group_id, + "campaign_id": command.metadata.get("campaign_id"), + "cycle_id": command.metadata.get("cycle_id"), + "level_id": command.metadata.get("level_id"), + "activation_policy": command.activation_policy.value, + "status": int(command_status[sorted_idx]), + "reject_code": int(reject_code[sorted_idx]), + "fill_bar": int(fill_bar[sorted_idx]), + "fill_qty": float(fill_qty[sorted_idx]), + "fill_price": float(fill_price[sorted_idx]), + "fill_fee": float(fill_fee[sorted_idx]), + "active": bool(active[sorted_idx]), + "waiting_parent": bool(waiting_parent[sorted_idx]), + "working_qty": float(working_qty[sorted_idx]), + "working_price": float(working_price[sorted_idx]), + "working_trigger_price": float(working_trigger[sorted_idx]), + "reduce_only": bool(command.reduce_only), + "tag": command.tag, + "tag_prefix": command.tag_prefix, + } + ) + if not rows: + return pd.DataFrame() + return pd.DataFrame(rows).sort_values("original_index", kind="stable").reset_index(drop=True) + + @staticmethod + def _build_order_events( + *, + idx: pd.DatetimeIndex, + compiled_commands: CompiledOrderCommandArrays, + event_count: int, + event_bar: np.ndarray, + event_command: np.ndarray, + event_type: np.ndarray, + event_status: np.ndarray, + event_related_command: np.ndarray, + ) -> pd.DataFrame: + rows = [] + for n in range(event_count): + command_idx = int(event_command[n]) + related_idx = int(event_related_command[n]) + original_idx = -1 + related_original_idx = -1 + command = None + if 0 <= command_idx < len(compiled_commands.sorted_commands): + original_idx = int(compiled_commands.sorted_commands[command_idx][0]) + command = compiled_commands.sorted_commands[command_idx][1] + if 0 <= related_idx < len(compiled_commands.sorted_commands): + related_original_idx = int(compiled_commands.sorted_commands[related_idx][0]) + bar = int(event_bar[n]) + rows.append( + { + "timestamp": idx[bar] if 0 <= bar < len(idx) else pd.NaT, + "bar": bar, + "sorted_index": command_idx, + "original_index": original_idx, + "event_type": int(event_type[n]), + "event_name": _event_type_name(int(event_type[n])), + "status": int(event_status[n]), + "related_sorted_index": related_idx, + "related_original_index": related_original_idx, + "order_id": None if command is None else command.order_id, + "target_order_id": None if command is None else command.target_order_id, + "parent_order_id": None if command is None else command.parent_order_id, + "oco_group_id": None if command is None else command.oco_group_id, + "tag": None if command is None else command.tag, + "campaign_id": None if command is None else command.metadata.get("campaign_id"), + "cycle_id": None if command is None else command.metadata.get("cycle_id"), + "level_id": None if command is None else command.metadata.get("level_id"), + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _commands_to_order_intents(sorted_commands) -> tuple[OrderIntent, ...]: + orders: list[OrderIntent] = [] + for _, command in sorted_commands: + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if command.symbol is None or command.side is None or command.order_type is None or command.qty is None: + continue + orders.append( + OrderIntent( + timestamp=command.timestamp, + symbol=command.symbol, + side=command.side, + order_type=command.order_type, + qty=float(command.qty), + price=command.price, + trigger_price=command.trigger_price, + tif=command.tif, + reduce_only=command.reduce_only, + order_id=command.order_id, + tag=command.tag, + metadata=dict(command.metadata), + ) + ) + return tuple(orders) + + def run_basket( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + basket: BasketSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + rebalance_threshold: Optional[float] = None, + symbols: Optional[List[str]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + ) -> BacktestResultV2: + """ + Build frozen basket orders from a scalar signal and execute them. + + Basket legs are sized once on signal transitions and held constant until + the next transition. Phase 4 carries all-or-none policy in metadata; the + current matching kernel executes generated leg orders best-effort. + """ + plan = build_frozen_basket_orders( + datetime_index=datetime_index, + basket=basket, + signal=signal, + closes=closes, + hedge_ratios=hedge_ratios, + order_type=OrderType.MARKET, + tif=TimeInForce.IOC, + rebalance_threshold=rebalance_threshold, + ) + result = self.run_orders( + datetime_index=datetime_index, + orders=plan.orders, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + contract_size=contract_size, + leverage=leverage, + fee_rate=fee_rate, + symbols=symbols, + market_arrays=market_arrays, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + result.metadata["basket_plan"] = plan + result.metadata["basket_target_units"] = plan.target_units + result.metadata["basket_execution_policy"] = basket.execution_policy.value + return result + + def run_stat_arb_pair_arbitrage( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + spec: StatArbPairSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Optional[Union[float, Dict[str, float]]] = None, + leverage: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + ) -> BacktestResultV2: + """ + Execute a Phase D stat-arb pair through the frozen basket planner. + + Dynamic hedge-ratio series are sampled at entry and held frozen until + exit. If `spec.hedge_policy.rebalance_threshold` is set, only hedge + ratio drift beyond that threshold can trigger a package rebalance; price + movement alone does not create micro-rebalancing orders. + """ + if not isinstance(spec, StatArbPairSpec): + raise TypeError("run_stat_arb_pair_arbitrage requires a StatArbPairSpec") + basket = self._stat_arb_basket_from_spec(spec) + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in spec.legs] + close_dict = align_series(closes, symbols, idx) + contract_sizes = self._contract_size_for_spec(spec, contract_size) + fee_rates = self._fee_rate_for_spec(spec) + stat_funding = self._funding_for_spec(spec, funding_rate) + rebalance_threshold = spec.hedge_policy.rebalance_threshold + if not spec.hedge_policy.freeze_on_entry and rebalance_threshold is None: + rebalance_threshold = 0.0 + + plan = build_frozen_basket_orders( + datetime_index=idx, + basket=basket, + signal=signal, + closes=close_dict, + hedge_ratios=hedge_ratios, + order_type=OrderType.MARKET, + tif=TimeInForce.IOC, + rebalance_threshold=rebalance_threshold, + ) + arb_plan = self._apply_atomic_package_margin_policy( + idx=idx, + plan=ArbitragePlan( + spec=spec, + orders=plan.orders, + target_units=plan.target_units, + signals=plan.signals, + entry_ratios=plan.entry_ratios, + rejections=(), + metadata=plan.metadata, + ), + closes=close_dict, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + leverage=leverage, + ) + + result = self.run_orders( + datetime_index=idx, + orders=arb_plan.orders, + closes=close_dict, + highs=highs, + lows=lows, + funding_rate=stat_funding, + contract_size=contract_sizes, + leverage=leverage, + fee_rate=fee_rates, + symbols=symbols, + market_arrays=market_arrays, + ) + funding_dict = prepare_funding(stat_funding if self.config.use_funding else 0.0, symbols, idx) + roles = self._stat_arb_roles(spec) + leg_pnl_report = self._leg_pnl_report( + idx=idx, + symbols=symbols, + roles=roles, + result=result, + closes=close_dict, + funding=funding_dict, + contract_sizes=contract_sizes, + ) + package_report = self._package_pnl_report(idx, result, leg_pnl_report) + beta_drift_report = self._stat_arb_beta_drift_report( + idx=idx, + spec=spec, + plan=arb_plan, + rebalance_threshold=rebalance_threshold, + ) + diagnostics = result.diagnostics.copy() + diagnostics["package_pnl"] = package_report["package_pnl"] + diagnostics["package_pnl_residual"] = package_report["pnl_residual"] + result.diagnostics = diagnostics + result.metadata.update( + { + "backend": "native_event", + "engine": "event_v1_stat_arb_pair", + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "arbitrage_plan": arb_plan, + "package_target_units": arb_plan.target_units, + "package_rejection_report": arb_plan.rejection_report, + "basket_plan": plan, + "basket_target_units": arb_plan.target_units, + "beta_drift_report": beta_drift_report, + "spread_report": self._stat_arb_spread_report(idx, spec, close_dict, arb_plan), + "leg_pnl_report": leg_pnl_report, + "package_pnl_report": package_report, + "rebalance_threshold": rebalance_threshold, + "fee_rate_oneway": fee_rates, + "contract_size": contract_sizes, + } + ) + return result + + def run_basis_arbitrage( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + spec: BasisArbitrageSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Optional[Union[float, Dict[str, float]]] = None, + leverage: Optional[Union[float, Dict[str, float]]] = None, + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + ) -> BacktestResultV2: + """ + Execute a minimal native-event USDM linear basis arbitrage backtest. + + Phase C models a package trade: signal transitions generate all leg + orders at the same timestamp, units are frozen until the next signal + transition, and reports decompose package PnL into leg-level mark, + fill, fee, and funding components. + """ + if not isinstance(spec, BasisArbitrageSpec): + raise TypeError("run_basis_arbitrage requires a BasisArbitrageSpec") + + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in spec.legs] + close_dict = align_series(closes, symbols, idx) + contract_sizes = self._contract_size_for_spec(spec, contract_size) + fee_rates = self._fee_rate_for_spec(spec) + basis_funding = self._funding_for_spec(spec, funding_rate) + + plan = build_arbitrage_order_plan( + datetime_index=idx, + spec=spec, + signal=signal, + closes=close_dict, + hedge_ratios=hedge_ratios, + ) + plan = self._apply_atomic_package_margin_policy(idx, plan, close_dict, contract_sizes, fee_rates, leverage) + result = self.run_orders( + datetime_index=idx, + orders=plan.orders, + closes=close_dict, + highs=highs, + lows=lows, + funding_rate=basis_funding, + contract_size=contract_sizes, + leverage=leverage, + fee_rate=fee_rates, + symbols=symbols, + market_arrays=market_arrays, + ) + + funding_dict = prepare_funding(basis_funding if self.config.use_funding else 0.0, symbols, idx) + leg_pnl_report = self._basis_leg_pnl_report( + idx=idx, + spec=spec, + result=result, + closes=close_dict, + funding=funding_dict, + contract_sizes=contract_sizes, + ) + package_pnl = leg_pnl_report.groupby("timestamp", sort=False)["total_pnl"].sum().reindex(idx, fill_value=0.0) + package_report = pd.DataFrame( + { + "package_pnl": package_pnl, + "equity_delta": result.equity.diff().fillna(0.0), + }, + index=idx, + ) + package_report["pnl_residual"] = package_report["equity_delta"] - package_report["package_pnl"] + spread_report = self._basis_spread_report(idx, spec, close_dict, plan.target_units) + + diagnostics = result.diagnostics.copy() + diagnostics["package_pnl"] = package_report["package_pnl"] + diagnostics["package_pnl_residual"] = package_report["pnl_residual"] + result.diagnostics = diagnostics + result.metadata.update( + { + "backend": "native_event", + "engine": "event_v1_basis_arbitrage", + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "arbitrage_plan": plan, + "package_target_units": plan.target_units, + "package_rejection_report": plan.rejection_report, + "spread_report": spread_report, + "leg_pnl_report": leg_pnl_report, + "package_pnl_report": package_report, + "fee_rate_oneway": fee_rates, + "contract_size": contract_sizes, + } + ) + return result + + def run_package_arbitrage( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + spec: ArbitrageSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Optional[Union[float, Dict[str, float]]] = None, + leverage: Optional[Union[float, Dict[str, float]]] = None, + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + ) -> BacktestResultV2: + """ + Execute Phase G package-style advanced arbitrage specs. + + This route is intentionally limited to advanced arbitrage types whose + execution can be represented as frozen package target units. Types that + require sequencing, cross-venue account state, or options Greeks remain + explicit NotImplemented paths. + """ + unsupported = (CrossExchangeArbSpec, TriangularArbSpec, OptionsVolArbSpec) + if isinstance(spec, unsupported): + raise NotImplementedError( + f"{type(spec).__name__} is schema-validated but requires a specialized arbitrage engine; " + "do not route it through generic package execution. " + "Use QuantBTEndpoint.arbitrage_support_matrix() to inspect supported routes." + ) + supported = (CalendarSpreadSpec, FundingArbitrageSpec, SpotPerpCashCarrySpec, IndexBasketArbSpec) + if not isinstance(spec, supported): + raise TypeError("run_package_arbitrage requires a Phase G package-style arbitrage spec") + + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in spec.legs] + close_dict = align_series(closes, symbols, idx) + contract_sizes = self._contract_size_for_spec(spec, contract_size) + fee_rates = self._fee_rate_for_spec(spec) + package_funding = self._funding_for_spec(spec, funding_rate) + plan = build_arbitrage_order_plan( + datetime_index=idx, + spec=spec, + signal=signal, + closes=close_dict, + hedge_ratios=hedge_ratios, + ) + plan = self._apply_atomic_package_margin_policy(idx, plan, close_dict, contract_sizes, fee_rates, leverage) + result = self.run_orders( + datetime_index=idx, + orders=plan.orders, + closes=close_dict, + highs=highs, + lows=lows, + funding_rate=package_funding, + contract_size=contract_sizes, + leverage=leverage, + fee_rate=fee_rates, + symbols=symbols, + market_arrays=market_arrays, + ) + + funding_dict = prepare_funding(package_funding if self.config.use_funding else 0.0, symbols, idx) + leg_pnl_report = self._basis_leg_pnl_report( + idx=idx, + spec=spec, + result=result, + closes=close_dict, + funding=funding_dict, + contract_sizes=contract_sizes, + ) + package_pnl = leg_pnl_report.groupby("timestamp", sort=False)["total_pnl"].sum().reindex(idx, fill_value=0.0) + package_report = pd.DataFrame( + { + "package_pnl": package_pnl, + "equity_delta": result.equity.diff().fillna(0.0), + }, + index=idx, + ) + package_report["pnl_residual"] = package_report["equity_delta"] - package_report["package_pnl"] + diagnostics = result.diagnostics.copy() + diagnostics["package_pnl"] = package_report["package_pnl"] + diagnostics["package_pnl_residual"] = package_report["pnl_residual"] + result.diagnostics = diagnostics + result.metadata.update( + { + "backend": "native_event", + "engine": f"event_v1_{spec.arb_type.value}", + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "arbitrage_plan": plan, + "package_target_units": plan.target_units, + "package_rejection_report": plan.rejection_report, + "spread_report": self._basis_spread_report(idx, spec, close_dict, plan.target_units), + "leg_pnl_report": leg_pnl_report, + "package_pnl_report": package_report, + "carry_report": self._carry_report(idx, spec, result, close_dict, funding_dict, contract_sizes), + "fee_rate_oneway": fee_rates, + "contract_size": contract_sizes, + } + ) + return result + + @staticmethod + def _bar_index(idx: pd.DatetimeIndex, timestamp) -> int: + ts = pd.Timestamp(timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + pos = idx.searchsorted(ts, side="left") + if pos >= len(idx): + raise ValueError("order timestamp is after the available data") + return int(pos) + + def _apply_atomic_package_margin_policy( + self, + idx: pd.DatetimeIndex, + plan: ArbitragePlan, + closes: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + fee_rates: Dict[str, float], + leverage: Optional[Union[float, Dict[str, float]]], + ) -> ArbitragePlan: + spec = plan.spec + if spec.execution_policy.kind not in (PackageExecutionKind.ATOMIC_ALL_OR_NONE, PackageExecutionKind.BEST_EFFORT): + return plan + + symbols = [leg.symbol for leg in spec.legs] + current_units = {symbol: 0.0 for symbol in symbols} + equity = float(self.config.account.initial_capital) + target_rows = [] + orders = [] + rejections = list(plan.rejections) + leverages = self._leverage_mapping(leverage, symbols) + slippage = self.config.execution.slippage_rate + + for i, ts in enumerate(idx): + if i > 0: + prev_ts = idx[i - 1] + for symbol in symbols: + units = current_units[symbol] + if units != 0.0: + equity += units * ( + float(closes[symbol].loc[ts]) - float(closes[symbol].loc[prev_ts]) + ) * float(contract_sizes[symbol]) + + original_desired = {symbol: float(plan.target_units.loc[ts, symbol]) for symbol in symbols} + changed_symbols = [ + symbol for symbol in symbols + if abs(original_desired[symbol] - current_units[symbol]) > 1e-12 + ] + if changed_symbols: + if spec.execution_policy.kind is PackageExecutionKind.ATOMIC_ALL_OR_NONE: + allowed, details = self._atomic_package_has_margin( + ts=ts, + symbols=symbols, + current_units=current_units, + desired_units=original_desired, + closes=closes, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + leverages=leverages, + equity=equity, + slippage=slippage, + ) + if not allowed: + rejections.append( + PackageRejection( + timestamp=ts, + arb_id=spec.arb_id, + reason="insufficient_margin_atomic", + failed_legs=tuple(changed_symbols), + metadata={"details": details, "policy": spec.execution_policy.kind.value}, + ) + ) + else: + self._append_package_orders(orders, ts, spec, symbols, current_units, original_desired) + equity -= float(details.get("cost", 0.0)) + current_units = original_desired + else: + for symbol in symbols: + if abs(original_desired[symbol] - current_units[symbol]) <= 1e-12: + continue + candidate_units = dict(current_units) + candidate_units[symbol] = original_desired[symbol] + allowed, details = self._atomic_package_has_margin( + ts=ts, + symbols=symbols, + current_units=current_units, + desired_units=candidate_units, + closes=closes, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + leverages=leverages, + equity=equity, + slippage=slippage, + ) + if not allowed: + rejections.append( + PackageRejection( + timestamp=ts, + arb_id=spec.arb_id, + reason="insufficient_margin_best_effort", + failed_legs=(symbol,), + metadata={"details": details, "policy": spec.execution_policy.kind.value}, + ) + ) + continue + self._append_package_orders(orders, ts, spec, [symbol], current_units, candidate_units) + equity -= float(details.get("cost", 0.0)) + current_units = candidate_units + + target_rows.append({symbol: current_units[symbol] for symbol in symbols}) + + return ArbitragePlan( + spec=spec, + orders=tuple(orders), + target_units=pd.DataFrame(target_rows, index=idx), + signals=plan.signals, + entry_ratios=plan.entry_ratios, + rejections=tuple(rejections), + metadata={**plan.metadata, "execution_margin_policy": "package_preflight"}, + ) + + @staticmethod + def _append_package_orders( + orders: List[OrderIntent], + ts, + spec: ArbitrageSpec, + symbols: List[str], + current_units: Dict[str, float], + desired_units: Dict[str, float], + ) -> None: + for symbol in symbols: + delta = desired_units[symbol] - current_units[symbol] + if abs(delta) <= 1e-12: + continue + side = OrderSide.BUY if delta > 0.0 else OrderSide.SELL + orders.append( + OrderIntent( + timestamp=ts, + symbol=symbol, + side=side, + order_type=spec.execution_policy.order_type, + qty=abs(delta), + tif=spec.execution_policy.tif, + tag=spec.arb_id, + metadata={ + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "package_policy": spec.execution_policy.kind.value, + "hedge_policy": spec.hedge_policy.kind.value, + "sizing_policy": spec.sizing_policy.kind.value, + "target_units": desired_units[symbol], + "previous_units": current_units[symbol], + }, + ) + ) + + def _atomic_package_has_margin( + self, + ts, + symbols: List[str], + current_units: Dict[str, float], + desired_units: Dict[str, float], + closes: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + fee_rates: Dict[str, float], + leverages: Dict[str, float], + equity: float, + slippage: float, + ) -> tuple[bool, Dict[str, float]]: + cur_im = 0.0 + margin_delta_sum = 0.0 + cost_sum = 0.0 + for symbol in symbols: + close_price = float(closes[symbol].loc[ts]) + cs = float(contract_sizes[symbol]) + lev = float(leverages[symbol]) + current = float(current_units[symbol]) + target = float(desired_units[symbol]) + cur_im += abs(current) * close_price * cs / lev + delta = target - current + if abs(delta) <= 1e-12: + continue + exec_price = close_price * (1.0 + slippage if delta > 0.0 else 1.0 - slippage) + old_im = abs(current) * close_price * cs / lev + new_im = abs(target) * exec_price * cs / lev + margin_delta_sum += new_im - old_im + cost_sum += abs(delta) * exec_price * cs * float(fee_rates[symbol]) + cost_sum += abs(delta) * abs(exec_price - close_price) * cs + + available = max(0.0, float(equity) - cur_im) + required = cost_sum + max(0.0, margin_delta_sum) + return required <= available + 1e-12, { + "available": available, + "required": required, + "current_initial_margin": cur_im, + "margin_delta": margin_delta_sum, + "cost": cost_sum, + } + + def _leverage_mapping(self, leverage, symbols: List[str]) -> Dict[str, float]: + default = float(self.config.account.leverage) + if isinstance(leverage, dict): + return {symbol: float(leverage.get(symbol, default)) for symbol in symbols} + if leverage is None: + return {symbol: default for symbol in symbols} + return {symbol: float(leverage) for symbol in symbols} + + @staticmethod + def _side_code(side: OrderSide) -> int: + return 1 if side is OrderSide.BUY else -1 + + @staticmethod + def _order_type_code(order_type: OrderType) -> int: + if order_type is OrderType.MARKET: + return ORDER_TYPE_MARKET + if order_type is OrderType.LIMIT: + return ORDER_TYPE_LIMIT + raise NotImplementedError(f"unsupported order_type={order_type!r}") + + @staticmethod + def _tif_code(tif: TimeInForce) -> int: + if tif is TimeInForce.GTC: + return TIF_GTC + if tif is TimeInForce.IOC: + return TIF_IOC + if tif is TimeInForce.FOK: + return TIF_FOK + if tif is TimeInForce.GTD: + return TIF_GTD + raise NotImplementedError(f"unsupported tif={tif!r}") + + @staticmethod + def _per_symbol_array(value, symbols: List[str], default: float) -> np.ndarray: + if isinstance(value, dict): + return np.array([float(value.get(s, default)) for s in symbols], dtype=np.float64) + return np.full(len(symbols), float(value), dtype=np.float64) + + @staticmethod + def _market_signature(idx: pd.DatetimeIndex, symbols: List[str]): + from ..core.preprocessor import market_data_signature + + return market_data_signature(idx, symbols) + + @staticmethod + def _fee_rate_metadata(fee_rates: np.ndarray, symbols: List[str]): + if len(fee_rates) == 0: + return 0.0 + if np.allclose(fee_rates, fee_rates[0]): + return float(fee_rates[0]) + return {symbol: float(fee_rates[i]) for i, symbol in enumerate(symbols)} + + def _fee_rate_for_spec(self, spec: ArbitrageSpec) -> Dict[str, float]: + default_rates = self.config.fee_rate + out: Dict[str, float] = {} + for leg in spec.legs: + if leg.fee_rate is not None: + out[leg.symbol] = float(leg.fee_rate) + elif isinstance(default_rates, dict): + out[leg.symbol] = float(default_rates.get(leg.symbol, 0.0)) + else: + out[leg.symbol] = float(default_rates) + return out + + @staticmethod + def _contract_size_for_spec( + spec: ArbitrageSpec, + contract_size: Optional[Union[float, Dict[str, float]]], + ) -> Dict[str, float]: + out = {leg.symbol: float(leg.contract_size) for leg in spec.legs} + if contract_size is None: + return out + if isinstance(contract_size, dict): + out.update({symbol: float(value) for symbol, value in contract_size.items()}) + return out + return {leg.symbol: float(contract_size) for leg in spec.legs} + + @staticmethod + def _funding_for_spec(spec: ArbitrageSpec, funding_rate: Union[float, pd.Series, Dict]): + funding_symbols = {leg.symbol for leg in spec.legs if leg.funding_enabled} + if isinstance(funding_rate, dict): + return { + leg.symbol: funding_rate.get(leg.symbol, 0.0) if leg.symbol in funding_symbols else 0.0 + for leg in spec.legs + } + return {leg.symbol: funding_rate if leg.symbol in funding_symbols else 0.0 for leg in spec.legs} + + @staticmethod + def _stat_arb_basket_from_spec(spec: StatArbPairSpec) -> BasketSpec: + if spec.sizing_policy.kind is not SizingPolicyKind.TARGET_GROSS_NOTIONAL: + raise NotImplementedError("Phase D StatArbPairSpec requires target_gross_notional sizing") + return BasketSpec( + basket_id=spec.arb_id, + legs=tuple(BasketLegSpec(symbol=leg.symbol, ratio=float(leg.ratio)) for leg in spec.legs), + gross_notional=float(spec.sizing_policy.notional), + freeze_hedge=bool(spec.hedge_policy.freeze_on_entry), + hedged_margin_offset=float(spec.margin_model.hedged_margin_offset), + metadata={ + "arb_type": spec.arb_type.value, + "hedge_policy": spec.hedge_policy.kind.value, + "sizing_policy": spec.sizing_policy.kind.value, + }, + ) + + @staticmethod + def _stat_arb_roles(spec: StatArbPairSpec) -> Dict[str, str]: + symbols = [leg.symbol for leg in spec.legs] + roles = {leg.symbol: str(leg.role or "leg") for leg in spec.legs} + if len(symbols) >= 2 and len(set(roles.values())) == 1: + roles[symbols[0]] = "leg" + roles[symbols[1]] = "hedge" + return roles + + @staticmethod + def _stat_arb_beta_drift_report( + idx: pd.DatetimeIndex, + spec: StatArbPairSpec, + plan, + rebalance_threshold: Optional[float], + ) -> pd.DataFrame: + symbols = [leg.symbol for leg in spec.legs] + reference_symbol = symbols[0] + rows = [] + for ts in idx: + ref_units = float(plan.target_units.loc[ts, reference_symbol]) + ref_ratio = float(plan.entry_ratios.loc[ts, reference_symbol]) + active = abs(ref_units) > 1e-12 and abs(ref_ratio) > 1e-12 + for symbol in symbols: + units = float(plan.target_units.loc[ts, symbol]) + current_ratio = float(plan.entry_ratios.loc[ts, symbol]) + if active: + frozen_ratio_to_ref = units / ref_units + current_ratio_to_ref = current_ratio / ref_ratio + abs_drift = abs(current_ratio_to_ref - frozen_ratio_to_ref) + rel_drift = abs_drift / max(abs(frozen_ratio_to_ref), 1e-12) + else: + frozen_ratio_to_ref = 0.0 + current_ratio_to_ref = 0.0 + abs_drift = 0.0 + rel_drift = 0.0 + rows.append( + { + "timestamp": ts, + "symbol": symbol, + "reference_symbol": reference_symbol, + "target_units": units, + "frozen_ratio_to_ref": frozen_ratio_to_ref, + "current_ratio_to_ref": current_ratio_to_ref, + "abs_beta_drift": abs_drift, + "rel_beta_drift": rel_drift, + "rebalance_threshold": rebalance_threshold, + "breached": ( + rebalance_threshold is not None + and rel_drift > rebalance_threshold + and symbol != reference_symbol + ), + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _stat_arb_spread_report( + idx: pd.DatetimeIndex, + spec: StatArbPairSpec, + closes: Dict[str, pd.Series], + plan, + ) -> pd.DataFrame: + symbols = [leg.symbol for leg in spec.legs] + leg_symbol = symbols[0] + hedge_symbol = symbols[1] if len(symbols) > 1 else symbols[0] + leg_close = closes[leg_symbol].astype(float) + hedge_close = closes[hedge_symbol].astype(float) + ref_ratio = plan.entry_ratios[leg_symbol].replace(0.0, np.nan).astype(float) + hedge_ratio = (plan.entry_ratios[hedge_symbol].astype(float) / ref_ratio).fillna(0.0) + spread = leg_close + hedge_ratio * hedge_close + return pd.DataFrame( + { + "leg_symbol": leg_symbol, + "hedge_symbol": hedge_symbol, + "leg_close": leg_close, + "hedge_close": hedge_close, + "hedge_ratio_to_leg": hedge_ratio, + "spread": spread, + "abs_spread": spread.abs(), + }, + index=idx, + ) + + def _leg_pnl_report( + self, + idx: pd.DatetimeIndex, + symbols: List[str], + roles: Dict[str, str], + result: BacktestResultV2, + closes: Dict[str, pd.Series], + funding: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + ) -> pd.DataFrame: + fill_rows = {} + for fill in result.fills: + ts = pd.Timestamp(fill.timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + key = (ts, fill.symbol) + fee, fill_pnl = fill_rows.get(key, (0.0, 0.0)) + close_price = float(closes[fill.symbol].loc[ts]) + cs = float(contract_sizes[fill.symbol]) + fill_pnl += fill.signed_qty * (close_price - float(fill.price)) * cs + fee += float(fill.fee) + fill_rows[key] = (fee, fill_pnl) + + funding_mask = make_funding_mask(idx) + cumulative = {symbol: 0.0 for symbol in symbols} + rows = [] + for i, ts in enumerate(idx): + for symbol in symbols: + cs = float(contract_sizes[symbol]) + close_price = float(closes[symbol].iloc[i]) + prev_units = 0.0 if i == 0 else float(result.positions[f"Position_{symbol}"].iloc[i - 1]) + units = float(result.positions[f"Position_{symbol}"].iloc[i]) + price_pnl = 0.0 + if i > 0: + price_pnl = prev_units * (close_price - float(closes[symbol].iloc[i - 1])) * cs + funding_cost = 0.0 + if self.config.use_funding and funding_mask[i]: + funding_cost = prev_units * close_price * cs * float(funding[symbol].iloc[i]) + fee, fill_pnl = fill_rows.get((ts, symbol), (0.0, 0.0)) + total_pnl = price_pnl + fill_pnl - fee - funding_cost + cumulative[symbol] += total_pnl + rows.append( + { + "timestamp": ts, + "symbol": symbol, + "role": roles.get(symbol, "leg"), + "units": units, + "close": close_price, + "notional": abs(units) * close_price * cs, + "price_pnl": price_pnl, + "fill_pnl": fill_pnl, + "fee": fee, + "funding_pnl": -funding_cost, + "total_pnl": total_pnl, + "cumulative_pnl": cumulative[symbol], + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _package_pnl_report(idx: pd.DatetimeIndex, result: BacktestResultV2, leg_pnl_report: pd.DataFrame) -> pd.DataFrame: + grouped = leg_pnl_report.groupby("timestamp", sort=False) + package_pnl = grouped["total_pnl"].sum().reindex(idx, fill_value=0.0) + price_pnl = grouped["price_pnl"].sum().reindex(idx, fill_value=0.0) + fill_pnl = grouped["fill_pnl"].sum().reindex(idx, fill_value=0.0) + fees = grouped["fee"].sum().reindex(idx, fill_value=0.0) + funding_pnl = grouped["funding_pnl"].sum().reindex(idx, fill_value=0.0) + role_pnl = leg_pnl_report.pivot_table( + index="timestamp", + columns="role", + values="total_pnl", + aggfunc="sum", + fill_value=0.0, + ).reindex(idx, fill_value=0.0) + leg_pnl = role_pnl["leg"] if "leg" in role_pnl else pd.Series(0.0, index=idx) + hedge_pnl = role_pnl["hedge"] if "hedge" in role_pnl else pd.Series(0.0, index=idx) + report = pd.DataFrame( + { + "price_pnl": price_pnl, + "fill_pnl": fill_pnl, + "fees": fees, + "funding_pnl": funding_pnl, + "leg_pnl": leg_pnl, + "hedge_pnl": hedge_pnl, + "spread_pnl": leg_pnl + hedge_pnl, + "package_pnl": package_pnl, + "equity_delta": result.equity.diff().fillna(0.0), + }, + index=idx, + ) + report["pnl_residual"] = report["equity_delta"] - report["package_pnl"] + return report + + def _basis_leg_pnl_report( + self, + idx: pd.DatetimeIndex, + spec: BasisArbitrageSpec, + result: BacktestResultV2, + closes: Dict[str, pd.Series], + funding: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + ) -> pd.DataFrame: + fill_rows = {} + for fill in result.fills: + ts = pd.Timestamp(fill.timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + key = (ts, fill.symbol) + fee, fill_pnl = fill_rows.get(key, (0.0, 0.0)) + close_price = float(closes[fill.symbol].loc[ts]) + cs = float(contract_sizes[fill.symbol]) + fill_pnl += fill.signed_qty * (close_price - float(fill.price)) * cs + fee += float(fill.fee) + fill_rows[key] = (fee, fill_pnl) + + funding_mask = make_funding_mask(idx) + cumulative = {leg.symbol: 0.0 for leg in spec.legs} + rows = [] + for i, ts in enumerate(idx): + for leg in spec.legs: + symbol = leg.symbol + cs = float(contract_sizes[symbol]) + close_price = float(closes[symbol].iloc[i]) + prev_pos = 0.0 if i == 0 else float(result.positions[f"Position_{symbol}"].iloc[i - 1]) + units = float(result.positions[f"Position_{symbol}"].iloc[i]) + price_pnl = 0.0 + if i > 0: + price_pnl = prev_pos * (close_price - float(closes[symbol].iloc[i - 1])) * cs + funding_cost = 0.0 + if self.config.use_funding and funding_mask[i]: + funding_cost = prev_pos * close_price * cs * float(funding[symbol].iloc[i]) + fee, fill_pnl = fill_rows.get((ts, symbol), (0.0, 0.0)) + total_pnl = price_pnl + fill_pnl - fee - funding_cost + cumulative[symbol] += total_pnl + rows.append( + { + "timestamp": ts, + "symbol": symbol, + "role": leg.role, + "units": units, + "close": close_price, + "notional": abs(units) * close_price * cs, + "price_pnl": price_pnl, + "fill_pnl": fill_pnl, + "fee": fee, + "funding_pnl": -funding_cost, + "total_pnl": total_pnl, + "cumulative_pnl": cumulative[symbol], + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _basis_spread_report( + idx: pd.DatetimeIndex, + spec: ArbitrageSpec, + closes: Dict[str, pd.Series], + target_units: pd.DataFrame, + ) -> pd.DataFrame: + symbols = [leg.symbol for leg in spec.legs] + base_symbol = spec.spread_formula.base_symbol + quote_symbol = spec.spread_formula.quote_symbol + if base_symbol is None: + base_symbol = next((leg.symbol for leg in spec.legs if leg.ratio < 0.0), symbols[0]) + if quote_symbol is None: + quote_symbol = next((leg.symbol for leg in spec.legs if leg.ratio > 0.0), symbols[-1]) + + base_close = closes[base_symbol].astype(float) + quote_close = closes[quote_symbol].astype(float) + spread = quote_close - base_close + ratio_spread = quote_close / base_close.replace(0.0, np.nan) - 1.0 + expiry = next((leg.expiry for leg in spec.legs if leg.symbol == quote_symbol and leg.expiry is not None), None) + if expiry is None: + expiry = next((leg.expiry for leg in spec.legs if leg.expiry is not None), None) + if expiry is None: + annualized = pd.Series(np.nan, index=idx, dtype=float) + else: + days_to_expiry = pd.Series( + [(expiry - ts).total_seconds() / 86_400.0 for ts in idx], + index=idx, + dtype=float, + ) + annualized = ratio_spread * (365.0 / days_to_expiry.where(days_to_expiry > 0.0)) + + report = pd.DataFrame( + { + "base_symbol": base_symbol, + "quote_symbol": quote_symbol, + "base_close": base_close, + "quote_close": quote_close, + "spread": spread, + "ratio_spread": ratio_spread, + "annualized_basis": annualized, + }, + index=idx, + ) + for symbol in symbols: + report[f"target_units_{symbol}"] = target_units[symbol] + return report + + @staticmethod + def _carry_report( + idx: pd.DatetimeIndex, + spec: ArbitrageSpec, + result: BacktestResultV2, + closes: Dict[str, pd.Series], + funding: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + ) -> pd.DataFrame: + rows = [] + funding_mask = make_funding_mask(idx) + for i, ts in enumerate(idx): + for leg in spec.legs: + symbol = leg.symbol + prev_units = 0.0 if i == 0 else float(result.positions[f"Position_{symbol}"].iloc[i - 1]) + close_price = float(closes[symbol].iloc[i]) + notional = abs(prev_units) * close_price * float(contract_sizes[symbol]) + funding_cost = 0.0 + if funding_mask[i] and leg.funding_enabled: + funding_cost = prev_units * close_price * float(contract_sizes[symbol]) * float(funding[symbol].iloc[i]) + rows.append( + { + "timestamp": ts, + "symbol": symbol, + "role": leg.role, + "funding_enabled": bool(leg.funding_enabled), + "borrow_rate": float(spec.carry_model.borrow_rate), + "cash_yield": float(spec.carry_model.cash_yield), + "notional": notional, + "funding_cost": funding_cost, + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _build_fills(sorted_orders, idx, fill_bar, fill_qty, fill_price, fill_fee) -> List[Fill]: + fills: List[Fill] = [] + filled_indices = np.flatnonzero(fill_bar >= 0) + for sorted_idx in filled_indices: + order = sorted_orders[int(sorted_idx)][1] + bar = int(fill_bar[sorted_idx]) + metadata = dict(getattr(order, "metadata", {}) or {}) + if getattr(order, "tag", None) is not None: + metadata.setdefault("tag", order.tag) + if getattr(order, "parent_order_id", None) is not None: + metadata.setdefault("parent_order_id", order.parent_order_id) + if getattr(order, "oco_group_id", None) is not None: + metadata.setdefault("oco_group_id", order.oco_group_id) + fills.append( + Fill( + timestamp=idx[bar], + symbol=order.symbol, + side=order.side, + qty=float(fill_qty[sorted_idx]), + price=float(fill_price[sorted_idx]), + fee=float(fill_fee[sorted_idx]), + liquidity=( + LiquiditySide.TAKER + if order.order_type is OrderType.MARKET + else LiquiditySide.MAKER + ), + order_id=order.order_id, + metadata={**metadata, "source": "native_event"}, + ) + ) + return fills diff --git a/src/quantbt/backends/native_option.py b/src/quantbt/backends/native_option.py new file mode 100644 index 0000000..9713557 --- /dev/null +++ b/src/quantbt/backends/native_option.py @@ -0,0 +1,796 @@ +""" +Native option backend facade. + +This backend wires the Phase 1-6 option components into the common QuantBT +result contract. It does not attempt to be a venue-exact options exchange; the +venue-specific gaps stay explicit in reports and metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import hashlib +from typing import Dict, Iterable, Mapping, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.results import OptionBacktestResult +from ..core.schema import AccountConfig, ExecutionConfig +from ..options.cache import OptionPreparedRunCache +from ..options.execution import OptionExecutionConfig, execute_option_package +from ..options.fees import OptionFeeResult, OptionFeeSchedule, calculate_option_fee +from ..options.hedging import OptionHedgeConfig, run_delta_hedge_path +from ..options.ledger import OptionLedger +from ..options.lifecycle import OptionSettlementRepresentation, settle_option_expiry +from ..options.margin import OptionMarginConfig, OptionMarginRequirement, calculate_option_margin +from ..options.packages import OptionPackageIntent +from ..options.schema import OptionInstrumentRegistry, OptionInstrumentSpec +from ..options.tape import PreparedOptionTape, prepare_option_tape + + +@dataclass(frozen=True) +class NativeOptionConfig: + account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0)) + execution: ExecutionConfig = field(default_factory=ExecutionConfig) + option_execution: OptionExecutionConfig = field(default_factory=OptionExecutionConfig) + margin: OptionMarginConfig = field(default_factory=OptionMarginConfig) + fee_schedule: Optional[OptionFeeSchedule] = None + reporting_currency: str = "USD" + initial_balances: Optional[Dict[str, float]] = None + conversion_rates: Dict[str, float] = field(default_factory=dict) + settle_expired: bool = False + max_spread_bps: Optional[float] = None + max_source_latency_ns: Optional[int] = None + random_seed: Optional[int] = 42 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "reporting_currency", str(self.reporting_currency).upper()) + if self.account.initial_capital <= 0.0: + raise ValueError("account.initial_capital must be > 0") + + +@dataclass(frozen=True) +class OptionSettlementEvent: + symbol: str + timestamp_ns: int + settlement_price: float + representation: Optional[OptionSettlementRepresentation] = None + + +class NativeOptionBackend: + """Array-first native option backend returning `OptionBacktestResult`.""" + + def __init__(self, config: Optional[NativeOptionConfig] = None): + self.config = config or NativeOptionConfig() + + def run( + self, + *, + chain: pd.DataFrame, + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], + packages: Sequence[OptionPackageIntent] = (), + prepared_tape: Optional[PreparedOptionTape] = None, + prepared_cache: Optional[OptionPreparedRunCache] = None, + underlying: Optional[pd.DataFrame | pd.Series] = None, + hedge_policy: Optional[OptionHedgeConfig] = None, + net_option_delta: Optional[pd.Series] = None, + settlement_events: Optional[Sequence[OptionSettlementEvent | Mapping]] = None, + conversion_rates: Optional[Dict[str, float]] = None, + reporting_currency: Optional[str] = None, + ) -> OptionBacktestResult: + registry = _normalize_registry(instruments) + if prepared_cache is not None: + prepared_cache.validate(registry) + tape = prepared_cache.tape + else: + tape = prepared_tape or prepare_option_tape( + chain, + registry, + max_spread_bps=self.config.max_spread_bps, + max_source_latency_ns=self.config.max_source_latency_ns, + ) + tape.validate_compatible(registry_signature=registry.signature) + rates = {**self.config.conversion_rates, **(conversion_rates or {})} + report_ccy = str(reporting_currency or self.config.reporting_currency).upper() + if report_ccy not in rates: + rates[report_ccy] = 1.0 + + ledger = OptionLedger.from_cash(self.config.initial_balances or {report_ccy: self.config.account.initial_capital}) + instrument_map = registry.by_symbol + packages_sorted = tuple(sorted(packages or (), key=lambda package: int(package.timestamp_ns))) + order_reports = [] + package_reports = [] + applied_fills = [] + snapshots = [] + + snapshots.append(_snapshot_state(tape, 0, ledger, instrument_map, rates, report_ccy, "initial")) + for package in packages_sorted: + pkg_result = execute_option_package( + package, + tape, + config=self.config.option_execution, + positions={symbol: position.qty for symbol, position in ledger.positions.items()}, + compiled_orders=prepared_cache.compile_package(package) if prepared_cache is not None else None, + ) + order_reports.append(pkg_result.order_report) + package_reports.append(pkg_result.package_report) + for fill in pkg_result.fills: + instrument = instrument_map[fill.symbol] + fee = _option_fee(fill, instrument, tape, self.config.fee_schedule) + ledger.apply_fill(fill, instrument, fee=fee, timestamp_ns=int(fill.timestamp)) + applied_fills.append((fill, fee)) + snap_idx = tape.snapshot_index_at_or_before(int(package.timestamp_ns)) + snapshots.append(_snapshot_state(tape, snap_idx, ledger, instrument_map, rates, report_ccy, package.package_id)) + + settlements = [] + for event in _normalize_settlement_events(settlement_events): + instrument = instrument_map[event.symbol] + settlement = settle_option_expiry( + ledger, + instrument, + timestamp_ns=int(event.timestamp_ns), + settlement_price=float(event.settlement_price), + representation=event.representation, + ) + settlements.append(settlement) + snap_idx = min(tape.snapshot_count - 1, max(0, np.searchsorted(tape.timestamp_ns, int(event.timestamp_ns), side="right") - 1)) + snapshots.append(_snapshot_state(tape, int(snap_idx), ledger, instrument_map, rates, report_ccy, f"settlement:{event.symbol}")) + + if self.config.settle_expired: + last_ts = int(tape.timestamp_ns[-1]) + marks = _snapshot_marks(tape, tape.snapshot_count - 1) + underlyings = _snapshot_underlyings(tape, tape.snapshot_count - 1) + for symbol, position in list(ledger.positions.items()): + instrument = instrument_map[symbol] + if position.is_flat or int(instrument.expiry_ns) > last_ts: + continue + settlement = settle_option_expiry( + ledger, + instrument, + timestamp_ns=last_ts, + settlement_price=underlyings.get(instrument.underlying_id, marks.get(symbol, 0.0)), + ) + settlements.append(settlement) + snapshots.append(_snapshot_state(tape, tape.snapshot_count - 1, ledger, instrument_map, rates, report_ccy, "auto_settlement")) + + final_snapshot_idx = tape.snapshot_count - 1 + final_marks = _snapshot_marks(tape, final_snapshot_idx) + final_underlyings = _snapshot_underlyings(tape, final_snapshot_idx) + margin = calculate_option_margin( + ledger, + instrument_map, + final_marks, + final_underlyings, + config=self.config.margin, + reporting_currency=report_ccy, + conversion_rates=rates, + ) + snapshots.append(_snapshot_state(tape, final_snapshot_idx, ledger, instrument_map, rates, report_ccy, "final")) + + result = _build_result( + tape=tape, + registry=registry, + ledger=ledger, + account=self.config.account, + report_ccy=report_ccy, + conversion_rates=rates, + snapshots=snapshots, + fills_with_fees=applied_fills, + order_report=_concat(order_reports), + package_report=_concat(package_reports), + settlements=settlements, + margin=margin, + metadata={ + "backend": "native_option", + "engine": "native_option", + "phase": "phase7_backend_endpoint_result", + "package_count": len(packages_sorted), + "fill_count": len(applied_fills), + "settlement_count": len(settlements), + "venue_exact_margin": bool(margin.venue_exact), + "reporting_currency": report_ccy, + "prepared_cache_used": prepared_cache is not None, + "package_cache_size": 0 if prepared_cache is None else prepared_cache.package_cache_size, + "fee_schedule_id": "execution_fee_rate" + if self.config.fee_schedule is None + else self.config.fee_schedule.schedule_id, + "limit_fidelity": self.config.option_execution.limit_fidelity.value, + "depth_fidelity": self.config.option_execution.depth_fidelity.value, + "random_seed": self.config.random_seed, + **self.config.metadata, + }, + ) + if hedge_policy is not None: + result = _attach_delta_hedge_contract( + result, + tape=tape, + registry=registry, + underlying=underlying, + hedge_policy=hedge_policy, + net_option_delta=net_option_delta, + account=self.config.account, + report_ccy=report_ccy, + ) + return result + + +def _normalize_registry( + instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec], +) -> OptionInstrumentRegistry: + if isinstance(instruments, OptionInstrumentRegistry): + return instruments + if isinstance(instruments, Mapping): + return OptionInstrumentRegistry.from_iterable(instruments.values()) + return OptionInstrumentRegistry.from_iterable(tuple(instruments)) + + +def _normalize_settlement_events(events: Optional[Sequence[OptionSettlementEvent | Mapping]]) -> tuple[OptionSettlementEvent, ...]: + if not events: + return () + out = [] + for event in events: + if isinstance(event, OptionSettlementEvent): + out.append(event) + else: + out.append( + OptionSettlementEvent( + symbol=str(event["symbol"]), + timestamp_ns=int(event["timestamp_ns"]), + settlement_price=float(event["settlement_price"]), + representation=event.get("representation"), + ) + ) + return tuple(out) + + +def _option_fee(fill, instrument: OptionInstrumentSpec, tape: PreparedOptionTape, schedule: Optional[OptionFeeSchedule]) -> Optional[OptionFeeResult]: + schedule = schedule or fill.metadata.get("option_fee_schedule") + if schedule is None: + return None + if not isinstance(schedule, OptionFeeSchedule): + return None + row_index = int(fill.metadata.get("option_row_index", -1)) + if row_index < 0: + return None + reference = float(tape.index_price[row_index] if np.isfinite(tape.index_price[row_index]) else tape.forward_price[row_index]) + return calculate_option_fee(fill, instrument, schedule, reference_price=reference) + + +def _snapshot_state( + tape: PreparedOptionTape, + snapshot_idx: int, + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + conversion_rates: Dict[str, float], + report_ccy: str, + label: str, +) -> Dict: + marks = _snapshot_marks(tape, snapshot_idx) + equity = ledger.equity(conversion_rates=conversion_rates, marks=marks, instruments=instruments, reporting_currency=report_ccy) + return { + "timestamp_ns": int(tape.timestamp_ns[snapshot_idx]), + "label": label, + "equity": float(equity), + "cash": dict(ledger.cash), + "positions": {symbol: position.qty for symbol, position in ledger.positions.items()}, + "marks": marks, + } + + +def _snapshot_marks(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]: + rows = tape.snapshot_slice(snapshot_idx) + return {tape.instrument_id[idx]: float(tape.mark_price[idx]) for idx in range(rows.start, rows.stop)} + + +def _snapshot_underlyings(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]: + rows = tape.snapshot_slice(snapshot_idx) + out = {} + registry = tape.registry.by_symbol + for idx in range(rows.start, rows.stop): + symbol = tape.instrument_id[idx] + instrument = registry[symbol] + price = float(tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx]) + out[instrument.underlying_id] = price + out[symbol] = price + return out + + +def _build_result( + *, + tape: PreparedOptionTape, + registry: OptionInstrumentRegistry, + ledger: OptionLedger, + account: AccountConfig, + report_ccy: str, + conversion_rates: Dict[str, float], + snapshots: Sequence[Dict], + fills_with_fees: Sequence[tuple], + order_report: pd.DataFrame, + package_report: pd.DataFrame, + settlements: Sequence, + margin: OptionMarginRequirement, + metadata: Dict, +) -> OptionBacktestResult: + index = pd.DatetimeIndex(pd.to_datetime([snap["timestamp_ns"] for snap in snapshots], utc=True)).tz_convert(None) + equity = pd.Series([snap["equity"] for snap in snapshots], index=index, name="equity") + if len(equity.index) != len(set(equity.index)): + offsets = pd.to_timedelta(np.arange(len(equity)), unit="ns") + equity.index = pd.DatetimeIndex(equity.index + offsets) + returns = equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + + symbols = list(registry.symbols) + positions = pd.DataFrame( + [{f"Position_{symbol}": snap["positions"].get(symbol, 0.0) for symbol in symbols} for snap in snapshots], + index=equity.index, + columns=[f"Position_{symbol}" for symbol in symbols], + ) + closes = pd.DataFrame( + [{f"Close_{symbol}": snap["marks"].get(symbol, np.nan) for symbol in symbols} for snap in snapshots], + index=equity.index, + columns=[f"Close_{symbol}" for symbol in symbols], + ).ffill() + cash_report = _cash_report(snapshots, equity.index) + marks_report = _marks_report(tape) + greeks_report = _greeks_report(tape) + fills_report = _fills_report(fills_with_fees) + settlements_report = _settlements_report(settlements) + attribution_report = _attribution_report(ledger, account, equity.iloc[-1], report_ccy, conversion_rates) + run_manifest = { + "backend": "native_option", + "result_contract": "OptionBacktestResult", + "symbols": symbols, + "snapshot_count": int(tape.snapshot_count), + "row_count": int(tape.row_count), + "initial_capital": float(account.initial_capital), + "final_equity": float(equity.iloc[-1]), + "reporting_currency": report_ccy, + "data_hash": _chain_data_hash(marks_report), + "registry_signature_hash": _stable_hash(repr(registry.signature.signature)), + "convention_versions": sorted( + {instrument.convention_version for instrument in registry.instruments if instrument.convention_version} + ), + "fee_schedule": metadata.get("fee_schedule_id", "execution_fee_rate"), + "margin_model": str(getattr(margin.model, "value", margin.model)), + "pricing_model": "observed_chain_bid_ask_mark", + "deterministic_replay": True, + "random_seed": metadata.get("random_seed"), + "fidelity_manifest": { + "tape": "prepared_csr_option_chain", + "execution": "top_of_book_bbo", + "limit_fidelity": metadata.get("limit_fidelity"), + "depth_fidelity": metadata.get("depth_fidelity"), + "margin": str(getattr(margin.model, "value", margin.model)), + "venue_exact_margin": bool(margin.venue_exact), + "prepared_cache_used": bool(metadata.get("prepared_cache_used", False)), + }, + "option_reports": [ + "fills_report", + "packages_report", + "cash_report", + "marks_report", + "greeks_report", + "settlements_report", + "margin_report", + "attribution_report", + ], + } + result_metadata = { + **metadata, + "order_report": order_report, + "fills_report": fills_report, + "packages_report": package_report, + "cash_report": cash_report, + "marks_report": marks_report, + "greeks_report": greeks_report, + "settlements_report": settlements_report, + "margin_report": margin.detail_report, + "attribution_report": attribution_report, + "run_manifest": run_manifest, + "ledger_event_report": ledger.event_report(), + "equity_identity": ledger.equity_identity_report( + conversion_rates=conversion_rates, + marks=_snapshot_marks(tape, tape.snapshot_count - 1), + instruments=registry.by_symbol, + reporting_currency=report_ccy, + ), + } + fees = pd.Series(0.0, index=equity.index, name="fees") + if len(fees) > 0: + fees.iloc[-1] = float(sum((fee.fee if fee is not None else fill.fee) for fill, fee in fills_with_fees)) + return OptionBacktestResult( + equity=equity, + returns=returns, + positions=positions, + closes=closes, + symbols=symbols, + initial_capital=float(account.initial_capital), + leverage=float(account.leverage), + liquidated=False, + fills=tuple(fill for fill, _ in fills_with_fees), + fees=fees, + margin=margin.detail_report, + diagnostics=package_report, + metadata=result_metadata, + fills_report=fills_report, + packages_report=package_report, + cash_report=cash_report, + marks_report=marks_report, + greeks_report=greeks_report, + settlements_report=settlements_report, + margin_report=margin.detail_report, + attribution_report=attribution_report, + run_manifest=run_manifest, + ) + + +def _concat(frames: Iterable[pd.DataFrame]) -> pd.DataFrame: + items = [frame for frame in frames if frame is not None and not frame.empty] + return pd.concat(items, ignore_index=True) if items else pd.DataFrame() + + +def _chain_data_hash(frame: pd.DataFrame) -> str: + if frame.empty: + return "0" + hashed = pd.util.hash_pandas_object(frame.sort_index(axis=1), index=False).to_numpy(dtype="uint64") + return str(int(hashed.sum(dtype="uint64"))) + + +def _stable_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + +def _attach_delta_hedge_contract( + result: OptionBacktestResult, + *, + tape: PreparedOptionTape, + registry: OptionInstrumentRegistry, + underlying: Optional[pd.DataFrame | pd.Series], + hedge_policy: OptionHedgeConfig, + net_option_delta: Optional[pd.Series], + account: AccountConfig, + report_ccy: str, +) -> OptionBacktestResult: + path_timestamps = np.concatenate((np.array([int(tape.timestamp_ns[0]) - 1], dtype=np.int64), tape.timestamp_ns.astype(np.int64))) + index = _datetime_index_from_ns(path_timestamps) + option_equity, positions, closes, fees = _linear_quote_option_path( + result, + tape, + registry, + account, + report_ccy, + index, + path_timestamps, + ) + deltas = _normalize_net_delta(net_option_delta, result.greeks_report, positions, registry, index) + prices, underlying_source = _normalize_underlying_prices(underlying, tape, index) + + hedge = run_delta_hedge_path( + timestamps_ns=list(path_timestamps), + underlying_prices=prices.to_numpy(dtype=np.float64), + net_option_deltas=deltas.to_numpy(dtype=np.float64), + config=hedge_policy, + ) + hedge_report = hedge.hedge_report.copy() + hedge_report.index = index + cumulative_hedge = pd.Series( + hedge_report["cumulative_hedge_pnl"].to_numpy(dtype=np.float64), + index=index, + name="hedge_pnl", + ) + combined = (option_equity + cumulative_hedge).rename("equity") + combined_returns = combined.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + + result.option_equity = option_equity + result.hedge_report = hedge_report + result.combined_equity = combined + result.combined_returns = combined_returns + result.equity = combined + result.returns = combined_returns + result.positions = positions + result.closes = closes + result.fees = fees + result.metadata["option_equity"] = option_equity + result.metadata["hedge_report"] = hedge_report + result.metadata["combined_equity"] = combined + result.metadata["combined_returns"] = combined_returns + result.metadata["delta_hedge_contract"] = { + "enabled": True, + "underlying_source": underlying_source, + "policy": hedge_policy.policy.value, + "target_delta": float(hedge_policy.target_delta), + "final_hedge_qty": float(hedge.final_hedge_qty), + "hedge_pnl": float(hedge.hedge_pnl), + "hedge_rebalances": int(hedge_report["should_rebalance"].sum()) if not hedge_report.empty else 0, + "option_path_method": result.metadata.get("option_path_method", "linear_quote_replay"), + } + result.run_manifest["delta_hedge"] = result.metadata["delta_hedge_contract"] + result.run_manifest["final_equity"] = float(combined.iloc[-1]) + result.metadata["run_manifest"] = result.run_manifest + return result + + +def _linear_quote_option_path( + result: OptionBacktestResult, + tape: PreparedOptionTape, + registry: OptionInstrumentRegistry, + account: AccountConfig, + report_ccy: str, + index: pd.DatetimeIndex, + path_timestamps: np.ndarray, +) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame, pd.Series]: + symbols = list(registry.symbols) + linear_quote_exact = all( + instrument.premium_currency.upper() == report_ccy and instrument.settlement_currency.upper() == report_ccy + for instrument in registry.instruments + ) + if not linear_quote_exact: + option_equity = result.equity.reindex(index).ffill().bfill().rename("option_equity") + positions = result.positions.reindex(index).ffill().fillna(0.0) + closes = result.closes.reindex(index).ffill().bfill() + fees = result.fees.reindex(index).fillna(0.0) + result.metadata["option_path_method"] = "event_equity_reindexed_non_quote_currency" + return option_equity, positions, closes, fees + + cash = float(account.initial_capital) + pos = {symbol: 0.0 for symbol in symbols} + fills = result.fills_report.sort_values("timestamp") if not result.fills_report.empty else pd.DataFrame() + fill_idx = 0 + equity_rows = [] + position_rows = [] + close_rows = [] + fee_values = [] + mark_by_ts_symbol = _mark_lookup(tape) + + for ts, dt in zip(path_timestamps, index): + snap_idx = max(0, int(np.searchsorted(tape.timestamp_ns, int(ts), side="right") - 1)) + fee_at_ts = 0.0 + while not fills.empty and fill_idx < len(fills) and int(fills.iloc[fill_idx]["timestamp"]) <= int(ts): + row = fills.iloc[fill_idx] + qty = float(row["qty"]) + price = float(row["price"]) + fee = float(row.get("applied_fee", row.get("execution_fee", 0.0))) + symbol = str(row["symbol"]) + side = str(row["side"]).lower() + if side == "buy": + cash -= qty * price + fee + pos[symbol] = pos.get(symbol, 0.0) + qty + else: + cash += qty * price - fee + pos[symbol] = pos.get(symbol, 0.0) - qty + fee_at_ts += fee + fill_idx += 1 + mark_ts = int(tape.timestamp_ns[snap_idx]) + marks = {symbol: mark_by_ts_symbol.get((mark_ts, symbol), np.nan) for symbol in symbols} + marked_value = sum(pos.get(symbol, 0.0) * marks[symbol] for symbol in symbols if np.isfinite(marks[symbol])) + equity_rows.append(cash + marked_value) + position_rows.append({f"Position_{symbol}": pos.get(symbol, 0.0) for symbol in symbols}) + close_rows.append({f"Close_{symbol}": marks[symbol] for symbol in symbols}) + fee_values.append(fee_at_ts) + + option_equity = pd.Series(equity_rows, index=index, name="option_equity") + positions = pd.DataFrame(position_rows, index=index).fillna(0.0) + closes = pd.DataFrame(close_rows, index=index).ffill().bfill() + fees = pd.Series(fee_values, index=index, name="fees") + result.metadata["option_path_method"] = "linear_quote_replay" + return option_equity, positions, closes, fees + + +def _normalize_net_delta( + net_option_delta: Optional[pd.Series], + greeks_report: pd.DataFrame, + positions: pd.DataFrame, + registry: OptionInstrumentRegistry, + index: pd.DatetimeIndex, +) -> pd.Series: + if net_option_delta is not None: + series = _coerce_series_index(net_option_delta, "net_option_delta") + return series.reindex(index).ffill().bfill().fillna(0.0).rename("net_option_delta") + if greeks_report.empty: + return pd.Series(0.0, index=index, name="net_option_delta") + greeks = greeks_report.copy() + greeks["datetime"] = pd.to_datetime(greeks["timestamp_ns"], utc=True).dt.tz_convert(None) + delta = greeks.pivot_table(index="datetime", columns="instrument_id", values="delta", aggfunc="last").reindex(index).ffill() + total = pd.Series(0.0, index=index, name="net_option_delta") + instruments = registry.by_symbol + for symbol in registry.symbols: + pos_col = f"Position_{symbol}" + if pos_col not in positions or symbol not in delta: + continue + multiplier = float(instruments[symbol].multiplier) + contribution = pd.Series( + positions[pos_col].to_numpy(dtype=np.float64) * delta[symbol].fillna(0.0).to_numpy(dtype=np.float64) * multiplier, + index=index, + ) + total = total.add(contribution, fill_value=0.0) + return total.fillna(0.0).rename("net_option_delta") + + +def _normalize_underlying_prices( + underlying: Optional[pd.DataFrame | pd.Series], + tape: PreparedOptionTape, + index: pd.DatetimeIndex, +) -> tuple[pd.Series, str]: + if underlying is None: + tape_index = _datetime_index_from_ns(tape.timestamp_ns.astype(np.int64)) + base = pd.Series( + [_snapshot_underlying_price(tape, i) for i in range(tape.snapshot_count)], + index=tape_index, + name="underlying_price", + ) + return _align_price_series(base, index), "option_chain_index_price" + if isinstance(underlying, pd.Series): + series = _coerce_series_index(underlying, "underlying_price") + return _align_price_series(series, index), "underlying_series" + if not isinstance(underlying, pd.DataFrame): + raise TypeError("underlying must be a pandas Series or DataFrame") + frame = underlying.copy() + if "timestamp_ns" in frame.columns: + idx = pd.to_datetime(frame["timestamp_ns"].astype("int64"), utc=True).dt.tz_convert(None) + elif "time" in frame.columns: + idx = pd.to_datetime(frame["time"], utc=True, errors="coerce").dt.tz_convert(None) + elif isinstance(frame.index, pd.DatetimeIndex): + idx = pd.DatetimeIndex(pd.to_datetime(frame.index, utc=True)).tz_convert(None) + else: + raise ValueError("underlying DataFrame requires timestamp_ns, time, or DatetimeIndex") + column = "close" if "close" in frame.columns else ("price" if "price" in frame.columns else None) + if column is None: + raise ValueError("underlying DataFrame requires close or price column") + series = pd.Series(pd.to_numeric(frame[column], errors="raise").to_numpy(dtype=np.float64), index=idx, name="underlying_price") + return _align_price_series(series, index), f"underlying_dataframe:{column}" + + +def _align_price_series(series: pd.Series, index: pd.DatetimeIndex) -> pd.Series: + out = series.sort_index() + out = out[~out.index.duplicated(keep="last")] + out = out.reindex(index).ffill().bfill() + if out.isna().any() or bool((out <= 0.0).any()): + raise ValueError("underlying prices must align to option tape and be finite > 0") + return out.rename("underlying_price") + + +def _coerce_series_index(series: pd.Series, name: str) -> pd.Series: + out = series.copy() + if not isinstance(out.index, pd.DatetimeIndex): + out.index = pd.to_datetime(out.index, utc=True) + else: + out.index = pd.DatetimeIndex(pd.to_datetime(out.index, utc=True)) + out.index = out.index.tz_convert(None) + out = pd.to_numeric(out, errors="raise").astype("float64") + out.name = name + return out + + +def _datetime_index_from_ns(timestamps_ns: np.ndarray) -> pd.DatetimeIndex: + return pd.DatetimeIndex(pd.to_datetime(timestamps_ns, utc=True)).tz_convert(None) + + +def _mark_lookup(tape: PreparedOptionTape) -> Dict[tuple[int, str], float]: + out: Dict[tuple[int, str], float] = {} + for snap_idx, ts in enumerate(tape.timestamp_ns): + slc = tape.snapshot_slice(snap_idx) + for idx in range(slc.start, slc.stop): + out[(int(ts), tape.instrument_id[idx])] = float(tape.mark_price[idx]) + return out + + +def _snapshot_underlying_price(tape: PreparedOptionTape, snapshot_idx: int) -> float: + rows = tape.snapshot_slice(snapshot_idx) + for idx in range(rows.start, rows.stop): + price = tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx] + if np.isfinite(price) and price > 0.0: + return float(price) + raise ValueError("option tape snapshot has no finite underlying/index price") + + +def _cash_report(snapshots: Sequence[Dict], index: pd.DatetimeIndex) -> pd.DataFrame: + currencies = sorted({currency for snap in snapshots for currency in snap["cash"]}) + return pd.DataFrame( + [{currency: snap["cash"].get(currency, 0.0) for currency in currencies} for snap in snapshots], + index=index, + columns=currencies, + ) + + +def _marks_report(tape: PreparedOptionTape) -> pd.DataFrame: + rows = [] + for snap_idx, ts in enumerate(tape.timestamp_ns): + slc = tape.snapshot_slice(snap_idx) + for idx in range(slc.start, slc.stop): + rows.append( + { + "timestamp_ns": int(ts), + "instrument_id": tape.instrument_id[idx], + "bid_price": float(tape.bid_price[idx]), + "ask_price": float(tape.ask_price[idx]), + "mark_price": float(tape.mark_price[idx]), + "index_price": float(tape.index_price[idx]), + "forward_price": float(tape.forward_price[idx]), + "bid_size": float(tape.bid_size[idx]), + "ask_size": float(tape.ask_size[idx]), + } + ) + return pd.DataFrame(rows) + + +def _greeks_report(tape: PreparedOptionTape) -> pd.DataFrame: + return pd.DataFrame( + { + "timestamp_ns": np.repeat(tape.timestamp_ns, np.diff(tape.row_ptr)), + "instrument_id": tape.instrument_id, + "mark_iv": tape.mark_iv, + "bid_iv": tape.bid_iv, + "ask_iv": tape.ask_iv, + "delta": tape.delta, + "gamma": tape.gamma, + "vega": tape.vega, + "theta": tape.theta, + } + ) + + +def _fills_report(fills_with_fees: Sequence[tuple]) -> pd.DataFrame: + rows = [] + for fill, fee in fills_with_fees: + rows.append( + { + "timestamp": fill.timestamp, + "symbol": fill.symbol, + "side": fill.side.value, + "qty": float(fill.qty), + "price": float(fill.price), + "notional": float(fill.notional), + "execution_fee": float(fill.fee), + "applied_fee": float(fee.fee if fee is not None else fill.fee), + "fee_currency": fee.currency if fee is not None else "", + "liquidity": fill.liquidity.value, + "order_id": fill.order_id, + "package_id": fill.metadata.get("package_id"), + } + ) + return pd.DataFrame(rows) + + +def _settlements_report(settlements: Sequence) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "timestamp_ns": item.timestamp_ns, + "symbol": item.symbol, + "settlement_price": item.settlement_price, + "payoff_per_unit": item.payoff_per_unit, + "cashflow": item.cashflow, + "settlement_currency": item.settlement_currency, + "representation": item.representation.value, + "itm": item.itm, + "position_closed": item.position_closed, + } + for item in settlements + ] + ) + + +def _attribution_report( + ledger: OptionLedger, + account: AccountConfig, + final_equity: float, + report_ccy: str, + conversion_rates: Dict[str, float], +) -> pd.DataFrame: + rows = [] + for currency, amount in ledger.cash.items(): + rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan)) + rows.append({"bucket": "cash", "currency": currency, "amount": float(amount), "reporting_value": float(amount) * rate}) + for currency, fee in ledger.fees.items(): + rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan)) + rows.append({"bucket": "fees", "currency": currency, "amount": -float(fee), "reporting_value": -float(fee) * rate}) + rows.append( + { + "bucket": "total", + "currency": report_ccy, + "amount": float(final_equity - account.initial_capital), + "reporting_value": float(final_equity - account.initial_capital), + } + ) + return pd.DataFrame(rows) diff --git a/src/quantbt/backends/native_portfolio.py b/src/quantbt/backends/native_portfolio.py new file mode 100644 index 0000000..8f100a1 --- /dev/null +++ b/src/quantbt/backends/native_portfolio.py @@ -0,0 +1,928 @@ +""" +quantbt.backends.native_portfolio +--------------------------------- +Native portfolio backend. + +Phase 11B keeps the proven `_engine_portfolio` accounting kernel as the +compatibility oracle path, but moves portfolio preparation, mode transforms, and +report construction behind an explicit backend. This lets the native portfolio +route evolve independently from `MultiSymbolPortfolio` without changing legacy +endpoint defaults. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Sequence, Union + +import numpy as np +import pandas as pd + +from ..core.engine import _engine_portfolio, _engine_portfolio_equity_sizing +from ..core.constraints import build_quantity_constraints, quantize_target_units_matrix +from ..core.portfolio import ( + NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES, + PortfolioDomainSpec, + normalize_portfolio_mode, + normalize_portfolio_sizing_mode, + validate_portfolio_result_contract, +) +from ..core.preprocessor import ( + PreparedMarketArrays, + align_series, + build_market_arrays, + build_signal_matrix, + market_data_signature, + prepare_funding, + validate_datetime, +) +from ..core.results import BacktestResultV2 +from ..core.schema import AccountConfig +from ..core.schema import ExecutionConfig, InstrumentSpec +from ..sizing.fast import scale_signal_notional_matrix + + +@dataclass(frozen=True) +class NativePortfolioConfig: + account: AccountConfig + execution: ExecutionConfig = field(default_factory=ExecutionConfig) + fee_rate: float = 0.0 + use_funding: bool = True + report_level: str = "full" + + def __post_init__(self) -> None: + if float(self.fee_rate) < 0.0: + raise ValueError("fee_rate must be >= 0") + object.__setattr__(self, "report_level", _normalize_report_level(self.report_level)) + + +class NativePortfolioBackend: + """ + Explicit native portfolio backend for multi-symbol position matrices. + + `fee_rate` is interpreted as a canonical one-way rate inside this backend. + Legacy round-trip `fee` compatibility is handled only at facade boundaries. + """ + + def __init__(self, config: NativePortfolioConfig): + self.config = config + + def run_signals( + self, + positions: Optional[Dict[str, pd.Series]], + closes: Dict[str, pd.Series], + datetime_index: Union[pd.DatetimeIndex, pd.Series], + *, + mode: str = "longshort", + alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0, + contract_size: Union[float, Dict[str, float], None] = 1.0, + hedge_type: str = "signal_notional", + funding_rate: Union[float, Dict[str, float], pd.Series, None] = 0.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + maintenance_ratio: Optional[float] = None, + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + symbols: Optional[Sequence[str]] = None, + use_pyramiding: bool = True, + asset_type: str = "crypto", + betas: Optional[Union[float, Dict[str, float]]] = None, + risk_lookback: int = 60, + market_arrays: Optional[PreparedMarketArrays] = None, + raw_signal_matrix: Optional[np.ndarray] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + report_level: Optional[str] = None, + ) -> BacktestResultV2: + idx = validate_datetime(datetime_index) + if positions is None and raw_signal_matrix is None: + raise ValueError("positions or raw_signal_matrix is required") + position_keys = set(positions.keys()) if positions is not None else set() + symbol_list = list(symbols) if symbols is not None else list(positions.keys() if positions is not None else closes.keys()) + if positions is not None and set(symbol_list) != position_keys: + raise ValueError("symbols and positions must contain the same keys") + if market_arrays is None and set(symbol_list) != set(closes.keys()): + raise ValueError("symbols and closes must contain the same keys") + + portfolio_mode = normalize_portfolio_mode(mode) + sizing_mode = normalize_portfolio_sizing_mode(hedge_type) + if sizing_mode not in NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES: + raise NotImplementedError( + f"native_portfolio does not yet support equity-dependent sizing mode {hedge_type!r}" + ) + + if market_arrays is None: + market = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + symbols=symbol_list, + ) + elif market_arrays.signature != market_data_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + else: + market = market_arrays + + if raw_signal_matrix is None: + pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0) + raw_signals = build_signal_matrix(symbol_list, idx, pos_dict) + else: + raw_signals = np.ascontiguousarray(raw_signal_matrix, dtype=np.float64) + if raw_signals.shape != market.closes.shape: + raise ValueError("raw_signal_matrix shape does not match prepared market arrays") + + cs_arr = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + lev_arr = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + alloc_arr = self._per_symbol_array(alloc_per_trade, symbol_list, default=100_000.0) + maint_ratio = self.config.account.maintenance_ratio if maintenance_ratio is None else float(maintenance_ratio) + + beta_arr = self._per_symbol_array(betas, symbol_list, default=1.0) + tradable_mask = self._tradable_matrix( + closes=closes, + idx=idx, + symbols=symbol_list, + market=market, + max_stale_bars=int(self.config.account.metadata.get("portfolio_max_stale_bars", 0)), + ) + risk_vol = self._risk_volatility_matrix(market.closes, lookback=int(risk_lookback)) + inv_vol = np.divide(1.0, risk_vol, out=np.zeros_like(risk_vol), where=risk_vol > 0.0) + equity_aware = sizing_mode in {"%_equity", "target_weight", "gross_exposure", "net_exposure"} + slippage_rate = float(self.config.execution.slippage_rate) + + if equity_aware: + ( + equity_arr, + target_units, + pos_arr, + sym_pnl_arr, + fee_arr, + slippage_arr, + turnover_arr, + liq_flag, + liq_idx, + ) = _engine_portfolio_equity_sizing( + n_bars=len(idx), + n_syms=len(symbol_list), + highs=market.highs, + lows=market.lows, + closes=market.closes, + raw_signals=raw_signals, + funding_rates=market.funding, + is_funding_bar=market.is_funding_bar, + init_capital=self.config.account.initial_capital, + leverages=lev_arr, + maint_ratio=maint_ratio, + fee_rate=float(self.config.fee_rate), + slippage_rate=slippage_rate, + contract_sizes=cs_arr, + use_funding=bool(self.config.use_funding), + allocs=alloc_arr, + sizing_mode_id=self._sizing_mode_id(sizing_mode), + portfolio_mode_id=self._portfolio_mode_id(portfolio_mode), + use_pyramiding=bool(use_pyramiding), + exposure_scalar=float(np.mean(alloc_arr)) if len(alloc_arr) else 1.0, + beta=beta_arr, + inv_vol=inv_vol, + qty_steps=constraints.qty_step, + min_qtys=constraints.min_qty, + min_notionals=constraints.min_notional, + tradable=tradable_mask, + ) + else: + target_units = self._scale_target_units( + sizing_mode=sizing_mode, + raw_signals=raw_signals, + closes=market.closes, + alloc_arr=alloc_arr, + contract_sizes=cs_arr, + use_pyramiding=use_pyramiding, + ) + target_units = self._apply_mode( + mode=portfolio_mode, + target_units=target_units, + closes=market.closes, + contract_sizes=cs_arr, + betas=beta_arr, + risk_vol=risk_vol, + ) + target_units = quantize_target_units_matrix(target_units, market.closes, cs_arr, constraints) + + ( + equity_arr, + pos_arr, + sym_pnl_arr, + fee_arr, + slippage_arr, + turnover_arr, + liq_flag, + liq_idx, + ) = _engine_portfolio( + n_bars=len(idx), + n_syms=len(symbol_list), + highs=market.highs, + lows=market.lows, + closes=market.closes, + target_pos=target_units, + funding_rates=market.funding, + is_funding_bar=market.is_funding_bar, + init_capital=self.config.account.initial_capital, + leverages=lev_arr, + maint_ratio=maint_ratio, + fee_rate=float(self.config.fee_rate), + slippage_rate=slippage_rate, + contract_sizes=cs_arr, + use_funding=bool(self.config.use_funding), + tradable=tradable_mask, + ) + + result = self._build_result( + idx=idx, + symbol_list=symbol_list, + closes_m=market.closes, + target_m=target_units, + pos_arr=pos_arr, + sym_pnl_arr=sym_pnl_arr, + funding_m=market.funding, + is_funding_bar=market.is_funding_bar, + equity_arr=equity_arr, + fee_arr=fee_arr, + slippage_arr=slippage_arr, + turnover_arr=turnover_arr, + contract_sizes=cs_arr, + leverages=lev_arr, + betas=beta_arr, + risk_vol=risk_vol, + mode=portfolio_mode, + hedge_type=sizing_mode, + asset_type=asset_type, + maintenance_ratio=maint_ratio, + liquidated=bool(liq_flag), + liquidation_bar=int(liq_idx), + quantity_constraints=constraints.as_dict(), + tradable_mask=tradable_mask, + report_level=self.config.report_level if report_level is None else report_level, + ) + spec = PortfolioDomainSpec(mode=portfolio_mode, sizing_mode=sizing_mode) + if result.metadata.get("report_level") == "minimal": + result.metadata["portfolio_contract_report"] = { + "status": "skipped", + "passed": None, + "reason": "report_level='minimal' omits heavy audit reports; rerun with report_level='full' for contract validation", + "spec": {"mode": portfolio_mode, "sizing_mode": sizing_mode}, + } + else: + result.metadata["portfolio_contract_report"] = validate_portfolio_result_contract(result, spec, tolerance=1e-8) + return result + + def prepare_market_arrays( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, Dict[str, float], pd.Series, None] = 0.0, + symbols: Optional[Sequence[str]] = None, + ) -> PreparedMarketArrays: + """ + Normalize portfolio market data once for WFO/service loops. + + The returned object is immutable ndarray-backed market state with a + datetime/symbol signature. `run_signals` rejects stale reuse against a + different index or symbol order, avoiding identity-cache bugs. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) if symbols is not None else list(closes.keys()) + close_dict = align_series(closes, symbol_list, idx) + high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) + low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) + funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) + return build_market_arrays(symbol_list, idx, close_dict, high_dict, low_dict, funding_dict) + + @staticmethod + def prepare_signal_matrix( + positions: Dict[str, pd.Series], + datetime_index: Union[pd.DatetimeIndex, pd.Series], + symbols: Sequence[str], + ) -> np.ndarray: + """ + Normalize a portfolio signal matrix once when replaying prepared data. + """ + idx = validate_datetime(datetime_index) + symbol_list = list(symbols) + if set(symbol_list) != set(positions.keys()): + raise ValueError("symbols and positions must contain the same keys") + pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0) + return build_signal_matrix(symbol_list, idx, pos_dict) + + @staticmethod + def _scale_target_units( + *, + sizing_mode: str, + raw_signals: np.ndarray, + closes: np.ndarray, + alloc_arr: np.ndarray, + contract_sizes: np.ndarray, + use_pyramiding: bool, + ) -> np.ndarray: + if sizing_mode in ("signal_notional", "signal"): + return scale_signal_notional_matrix(raw_signals, closes, alloc_arr, use_pyramiding=use_pyramiding) + + sig = raw_signals if use_pyramiding else np.sign(raw_signals) + denom = closes * contract_sizes.reshape(1, -1) + + if sizing_mode == "notional": + notionals = sig * alloc_arr.reshape(1, -1) + return np.ascontiguousarray( + np.divide(notionals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0), + dtype=np.float64, + ) + + if sizing_mode == "unit": + first_denom = denom[0:1, :] + scale = np.divide( + alloc_arr.reshape(1, -1), + first_denom, + out=np.zeros((1, raw_signals.shape[1]), dtype=np.float64), + where=first_denom != 0.0, + ) + return np.ascontiguousarray(sig * scale, dtype=np.float64) + + if sizing_mode == "target_units": + return np.ascontiguousarray(raw_signals, dtype=np.float64) + + if sizing_mode == "target_notional": + return np.ascontiguousarray( + np.divide(raw_signals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0), + dtype=np.float64, + ) + + if sizing_mode == "fixed_notional": + notionals = sig * alloc_arr.reshape(1, -1) + return np.ascontiguousarray( + np.divide(notionals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0), + dtype=np.float64, + ) + + raise NotImplementedError(f"native_portfolio sizing mode {sizing_mode!r} is not vectorized") + + @staticmethod + def _apply_mode( + *, + mode: str, + target_units: np.ndarray, + closes: np.ndarray, + contract_sizes: np.ndarray, + betas: np.ndarray, + risk_vol: np.ndarray, + ) -> np.ndarray: + out = np.array(target_units, dtype=np.float64, copy=True, order="C") + notional = out * closes * contract_sizes.reshape(1, -1) + + if mode == "market_neutral": + long_sum = np.where(notional > 0.0, notional, 0.0).sum(axis=1) + short_sum = np.where(notional < 0.0, -notional, 0.0).sum(axis=1) + target = (long_sum + short_sum) / 2.0 + valid = (long_sum > 0.0) & (short_sum > 0.0) + long_scale = np.divide(target, long_sum, out=np.zeros_like(target), where=valid) + short_scale = np.divide(target, short_sum, out=np.zeros_like(target), where=valid) + out = np.where( + notional > 0.0, + out * long_scale.reshape(-1, 1), + np.where(notional < 0.0, out * short_scale.reshape(-1, 1), 0.0), + ) + elif mode == "directional": + dominant = np.abs(notional).argmax(axis=1) + mask = np.zeros_like(out, dtype=bool) + mask[np.arange(out.shape[0]), dominant] = True + out = np.where(mask, out, 0.0) + out = np.where(np.abs(notional).sum(axis=1).reshape(-1, 1) > 0.0, out, 0.0) + elif mode == "equal_weight": + active = (notional != 0.0).sum(axis=1) + gross = np.abs(notional).sum(axis=1) + target_abs = np.divide(gross, active, out=np.zeros_like(gross), where=active != 0) + denom = closes * contract_sizes.reshape(1, -1) + out = np.sign(notional) * np.divide( + target_abs.reshape(-1, 1), + denom, + out=np.zeros_like(out), + where=denom != 0.0, + ) + elif mode == "risk_parity": + gross = np.abs(notional).sum(axis=1) + inv_vol = np.divide(1.0, risk_vol, out=np.zeros_like(risk_vol), where=risk_vol > 0.0) + active_inv = np.where(notional != 0.0, inv_vol, 0.0) + denom_inv = active_inv.sum(axis=1) + target_abs = np.divide(gross.reshape(-1, 1) * active_inv, denom_inv.reshape(-1, 1), out=np.zeros_like(out), where=denom_inv.reshape(-1, 1) != 0.0) + denom = closes * contract_sizes.reshape(1, -1) + out = np.sign(notional) * np.divide(target_abs, denom, out=np.zeros_like(out), where=denom != 0.0) + elif mode == "beta_neutral": + beta_notional = notional * betas.reshape(1, -1) + long_beta = np.where(beta_notional > 0.0, beta_notional, 0.0).sum(axis=1) + short_beta = np.where(beta_notional < 0.0, -beta_notional, 0.0).sum(axis=1) + target = (long_beta + short_beta) / 2.0 + long_scale = np.divide(target, long_beta, out=np.zeros_like(target), where=long_beta != 0.0) + short_scale = np.divide(target, short_beta, out=np.zeros_like(target), where=short_beta != 0.0) + out = np.where( + beta_notional > 0.0, + out * long_scale.reshape(-1, 1), + np.where(beta_notional < 0.0, out * short_scale.reshape(-1, 1), 0.0), + ) + + return np.ascontiguousarray(out, dtype=np.float64) + + def _build_result( + self, + *, + idx: pd.DatetimeIndex, + symbol_list: List[str], + closes_m: np.ndarray, + target_m: np.ndarray, + pos_arr: np.ndarray, + sym_pnl_arr: np.ndarray, + funding_m: np.ndarray, + is_funding_bar: np.ndarray, + equity_arr: np.ndarray, + fee_arr: np.ndarray, + slippage_arr: np.ndarray, + turnover_arr: np.ndarray, + contract_sizes: np.ndarray, + leverages: np.ndarray, + betas: np.ndarray, + risk_vol: np.ndarray, + mode: str, + hedge_type: str, + asset_type: str, + maintenance_ratio: float, + liquidated: bool, + liquidation_bar: int, + quantity_constraints: Dict[str, Dict[str, float]], + tradable_mask: np.ndarray, + report_level: str, + ) -> BacktestResultV2: + level = _normalize_report_level(report_level) + equity = pd.Series(equity_arr, index=idx, name="equity") + close_report = pd.DataFrame(closes_m, index=idx, columns=symbol_list, copy=False) + target_units_report = pd.DataFrame(target_m, index=idx, columns=symbol_list, copy=False) + accepted_units_report = pd.DataFrame(pos_arr, index=idx, columns=symbol_list, copy=False) + cs = pd.Series({s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)}) + lev = pd.Series({s: float(leverages[j]) for j, s in enumerate(symbol_list)}) + beta_s = pd.Series({s: float(betas[j]) for j, s in enumerate(symbol_list)}) + cs_row = contract_sizes.reshape(1, -1) + target_notional_arr = target_m * closes_m * cs_row + accepted_notional_arr = pos_arr * closes_m * cs_row + target_notional = pd.DataFrame(target_notional_arr, index=idx, columns=symbol_list, copy=False) + accepted_notional = pd.DataFrame(accepted_notional_arr, index=idx, columns=symbol_list, copy=False) + + positions = pd.DataFrame(pos_arr, index=idx, columns=[f"Position_{s}" for s in symbol_list], copy=False) + closes = pd.DataFrame(closes_m, index=idx, columns=[f"Close_{s}" for s in symbol_list], copy=False) + fees = pd.Series(fee_arr, index=idx, name="fees") + slippage = pd.Series(slippage_arr, index=idx, name="slippage") + turnover = pd.Series(turnover_arr, index=idx, name="turnover") + prev_units = np.vstack([np.zeros((1, len(symbol_list)), dtype=np.float64), pos_arr[:-1]]) + funding_cost_arr = prev_units * closes_m * cs_row * funding_m + funding_cost_arr = np.where(is_funding_bar.reshape(-1, 1).astype(bool), funding_cost_arr, 0.0).sum(axis=1) + abs_accepted = np.abs(accepted_notional_arr) + margin = pd.DataFrame( + { + "initial_margin": (abs_accepted / leverages.reshape(1, -1)).sum(axis=1), + "maintenance_margin": abs_accepted.sum(axis=1) * float(maintenance_ratio), + }, + index=idx, + ) + diagnostics = pd.DataFrame( + { + "turnover": turnover_arr, + "slippage": slippage_arr, + "rejected_rebalances": np.abs(target_m - pos_arr).sum(axis=1) > 1e-10, + }, + index=idx, + ) + returns_arr = np.zeros_like(equity_arr, dtype=np.float64) + if len(equity_arr) > 1: + returns_arr[1:] = np.divide( + equity_arr[1:] - equity_arr[:-1], + equity_arr[:-1], + out=np.zeros(len(equity_arr) - 1, dtype=np.float64), + where=equity_arr[:-1] != 0.0, + ) + + metadata = { + "backend": "native_portfolio", + "mode": mode, + "asset_type": asset_type, + "hedge_type": hedge_type, + "engine": "native_portfolio_v1", + "report_level": level, + "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), + "funding_rate_unit": "per_event", + "target_units_report": target_units_report, + "accepted_units_report": accepted_units_report, + "beta": {s: float(betas[j]) for j, s in enumerate(symbol_list)}, + "fee_series": fees, + "turnover_series": turnover, + "slippage_series": slippage, + "fee_total": float(np.sum(fee_arr)), + "slippage_total": float(np.sum(slippage_arr)), + "turnover_total": float(np.sum(turnover_arr)), + "fee_rate_oneway": float(self.config.fee_rate), + "canonical_one_way_fee_rate": float(self.config.fee_rate), + "slippage_bps": float(self.config.execution.slippage_bps), + "contract_size": {s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)}, + "quantity_constraints": quantity_constraints, + } + omitted = [] + if level in {"full", "standard"}: + funding_rates = pd.DataFrame(funding_m, index=idx, columns=symbol_list, copy=False) + exposure_report = self._build_exposure_report( + accepted_notional_arr=accepted_notional_arr, + target_notional_arr=target_notional_arr, + equity_arr=equity_arr, + idx=idx, + leverages=leverages, + maintenance_ratio=maintenance_ratio, + betas=betas, + ) + symbol_pnl_report = self._build_symbol_pnl_report( + idx=idx, + symbols=symbol_list, + accepted_units_arr=pos_arr, + closes_arr=closes_m, + funding_rates_arr=funding_m, + is_funding_bar=is_funding_bar, + contract_sizes=contract_sizes, + fee_arr=fee_arr, + slippage_arr=slippage_arr, + ) + metadata.update( + { + "target_notional_report": target_notional, + "accepted_notional_report": accepted_notional, + "exposure_report": exposure_report, + "funding_rates_report": funding_rates, + "symbol_pnl_report": symbol_pnl_report, + } + ) + if level == "full": + risk_vol_report = pd.DataFrame(risk_vol, index=idx, columns=symbol_list, copy=False) + risk_contribution_report = pd.DataFrame(np.abs(accepted_notional_arr) * risk_vol, index=idx, columns=symbol_list, copy=False) + exposure_report.attrs["risk_contribution_report"] = risk_contribution_report + rebalance_report = self._build_rebalance_report( + idx=idx, + symbols=symbol_list, + target_units_arr=target_m, + accepted_units_arr=pos_arr, + closes_arr=closes_m, + contract_sizes=contract_sizes, + tradable_mask=tradable_mask, + quantity_constraints=quantity_constraints, + ) + reconciliation_report = self._build_reconciliation_report( + initial_capital=float(self.config.account.initial_capital), + equity_arr=equity_arr, + fee_arr=fee_arr, + slippage_arr=slippage_arr, + turnover_arr=turnover_arr, + positions=positions, + target_units_report=target_units_report, + accepted_units_report=accepted_units_report, + symbol_pnl_report=symbol_pnl_report, + ) + metadata.update( + { + "risk_volatility_report": risk_vol_report, + "risk_contribution_report": risk_contribution_report, + "kernel_symbol_pnl": pd.DataFrame(sym_pnl_arr, index=idx, columns=symbol_list, copy=False), + "rebalance_report": rebalance_report, + "portfolio_reconciliation_report": reconciliation_report, + } + ) + else: + omitted.extend(["risk_volatility_report", "risk_contribution_report", "kernel_symbol_pnl", "rebalance_report", "portfolio_reconciliation_report"]) + else: + omitted.extend( + [ + "target_notional_report", + "accepted_notional_report", + "exposure_report", + "funding_rates_report", + "risk_volatility_report", + "risk_contribution_report", + "symbol_pnl_report", + "kernel_symbol_pnl", + "rebalance_report", + "portfolio_reconciliation_report", + ] + ) + metadata["reports_omitted"] = tuple(omitted) + + return BacktestResultV2( + equity=equity, + returns=pd.Series(returns_arr, index=idx, name="returns"), + positions=positions, + closes=closes, + symbols=symbol_list, + initial_capital=self.config.account.initial_capital, + leverage=float(np.mean(leverages)), + liquidated=liquidated, + liquidation_bar=liquidation_bar, + fees=fees, + funding=pd.Series(funding_cost_arr, index=idx, name="funding"), + margin=margin, + diagnostics=diagnostics, + metadata=metadata, + ) + + @staticmethod + def _build_symbol_pnl_report( + *, + idx: pd.DatetimeIndex, + symbols: List[str], + accepted_units_arr: np.ndarray, + closes_arr: np.ndarray, + funding_rates_arr: np.ndarray, + is_funding_bar: np.ndarray, + contract_sizes: np.ndarray, + fee_arr: np.ndarray, + slippage_arr: np.ndarray, + ) -> pd.DataFrame: + n_bars, n_syms = accepted_units_arr.shape + if n_bars == 0 or n_syms == 0: + return pd.DataFrame() + prev_units = np.vstack([np.zeros((1, n_syms), dtype=np.float64), accepted_units_arr[:-1]]) + prev_close = np.vstack([closes_arr[0:1], closes_arr[:-1]]) + cs = contract_sizes.reshape(1, -1) + mark_pnl = prev_units * (closes_arr - prev_close) * cs + funding_cost = prev_units * closes_arr * cs * funding_rates_arr + funding_cost = np.where(is_funding_bar.reshape(-1, 1).astype(bool), funding_cost, 0.0) + trade_delta = np.abs(accepted_units_arr - prev_units) + trade_notional = trade_delta * closes_arr * cs + total_trade_notional = trade_notional.sum(axis=1, keepdims=True) + share = np.divide( + trade_notional, + total_trade_notional, + out=np.zeros_like(trade_notional), + where=total_trade_notional != 0.0, + ) + fee = fee_arr.reshape(-1, 1) * share + slippage = slippage_arr.reshape(-1, 1) * share + total_pnl = mark_pnl - funding_cost - fee - slippage + + return pd.DataFrame( + { + "timestamp": np.tile(np.asarray(idx, dtype=object), n_syms), + "symbol": np.repeat(np.asarray(symbols, dtype=object), n_bars), + "position_units": accepted_units_arr.T.reshape(-1), + "close": closes_arr.T.reshape(-1), + "mark_pnl": mark_pnl.T.reshape(-1), + "funding_cost": funding_cost.T.reshape(-1), + "funding_pnl": (-funding_cost).T.reshape(-1), + "fee": fee.T.reshape(-1), + "fee_pnl": (-fee).T.reshape(-1), + "slippage_cost": slippage.T.reshape(-1), + "slippage_pnl": (-slippage).T.reshape(-1), + "total_pnl": total_pnl.T.reshape(-1), + } + ) + + @staticmethod + def _build_exposure_report( + *, + accepted_notional_arr: np.ndarray, + target_notional_arr: np.ndarray, + equity_arr: np.ndarray, + idx: pd.DatetimeIndex, + leverages: np.ndarray, + maintenance_ratio: float, + betas: np.ndarray, + ) -> pd.DataFrame: + abs_accepted = np.abs(accepted_notional_arr) + gross = abs_accepted.sum(axis=1) + net = accepted_notional_arr.sum(axis=1) + initial_margin = (abs_accepted / leverages.reshape(1, -1)).sum(axis=1) + maintenance_margin = gross * float(maintenance_ratio) + beta_exposure = (accepted_notional_arr * betas.reshape(1, -1)).sum(axis=1) + target_gross = np.abs(target_notional_arr).sum(axis=1) + target_beta_exposure = (target_notional_arr * betas.reshape(1, -1)).sum(axis=1) + mean_leverage = float(np.mean(leverages)) + gross_leverage = np.divide(gross, equity_arr, out=np.zeros_like(gross), where=equity_arr != 0.0) + net_exposure_pct = np.divide(net, equity_arr, out=np.zeros_like(net), where=equity_arr != 0.0) + return pd.DataFrame( + { + "long_notional": np.where(accepted_notional_arr > 0.0, accepted_notional_arr, 0.0).sum(axis=1), + "short_notional": np.where(accepted_notional_arr < 0.0, -accepted_notional_arr, 0.0).sum(axis=1), + "gross_notional": gross, + "net_notional": net, + "beta_exposure_notional": beta_exposure, + "target_gross_notional": target_gross, + "target_beta_exposure_notional": target_beta_exposure, + "initial_margin": initial_margin, + "maintenance_margin": maintenance_margin, + "equity": equity_arr, + "available_equity_after_im": equity_arr - initial_margin, + "buying_power": equity_arr * mean_leverage, + "gross_leverage": gross_leverage, + "net_exposure_pct": net_exposure_pct, + }, + index=idx, + ) + + @staticmethod + def _build_rebalance_report( + *, + idx: pd.DatetimeIndex, + symbols: List[str], + target_units_arr: np.ndarray, + accepted_units_arr: np.ndarray, + closes_arr: np.ndarray, + contract_sizes: np.ndarray, + tradable_mask: np.ndarray, + quantity_constraints: Dict[str, Dict[str, float]], + ) -> pd.DataFrame: + diff = target_units_arr - accepted_units_arr + row_idx, col_idx = np.nonzero(np.abs(diff) > 1e-10) + if len(row_idx) == 0: + return pd.DataFrame( + columns=["timestamp", "symbol", "target_units", "accepted_units", "unit_diff", "notional_diff", "reason"] + ) + unit_diff = diff[row_idx, col_idx] + notional_diff = unit_diff * closes_arr[row_idx, col_idx] * contract_sizes[col_idx] + symbol_arr = np.asarray(symbols, dtype=object) + reasons = [] + for r, c in zip(row_idx, col_idx): + symbol = symbols[int(c)] + target = float(target_units_arr[r, c]) + close = float(closes_arr[r, c]) + cs = float(contract_sizes[c]) + constraints = quantity_constraints.get(symbol, {}) + min_qty = float(constraints.get("min_qty", 0.0) or 0.0) + min_notional = float(constraints.get("min_notional", 0.0) or 0.0) + abs_target = abs(target) + notional = abs_target * close * cs if np.isfinite(close) else np.nan + if not np.isfinite(target): + reasons.append("INVALID_TARGET") + elif not np.isfinite(close) or close <= 0.0: + reasons.append("NON_TRADABLE") + elif not bool(tradable_mask[r, c]): + reasons.append("STALE_PRICE") + elif min_qty > 0.0 and 0.0 < abs_target < min_qty: + reasons.append("MIN_QTY") + elif min_notional > 0.0 and np.isfinite(notional) and 0.0 < notional < min_notional: + reasons.append("MIN_NOTIONAL") + else: + reasons.append("POST_COST_MARGIN") + return pd.DataFrame( + { + "timestamp": idx.take(row_idx), + "symbol": symbol_arr[col_idx], + "target_units": target_units_arr[row_idx, col_idx], + "accepted_units": accepted_units_arr[row_idx, col_idx], + "unit_diff": unit_diff, + "notional_diff": notional_diff, + "reason": reasons, + } + ) + + @staticmethod + def _build_reconciliation_report( + *, + initial_capital: float, + equity_arr: np.ndarray, + fee_arr: np.ndarray, + slippage_arr: np.ndarray, + turnover_arr: np.ndarray, + positions: pd.DataFrame, + target_units_report: pd.DataFrame, + accepted_units_report: pd.DataFrame, + symbol_pnl_report: pd.DataFrame, + ) -> Dict[str, float]: + if symbol_pnl_report is None or symbol_pnl_report.empty: + symbol_fee = 0.0 + symbol_slippage = 0.0 + symbol_pnl = 0.0 + else: + symbol_fee = float(symbol_pnl_report["fee"].sum()) + symbol_slippage = float(symbol_pnl_report["slippage_cost"].sum()) + symbol_pnl = float(symbol_pnl_report["total_pnl"].sum()) + positions_values = positions.to_numpy(dtype=np.float64, copy=False) + accepted_values = accepted_units_report.to_numpy(dtype=np.float64, copy=False) + return { + "fee_total": float(np.sum(fee_arr)), + "symbol_fee_total": symbol_fee, + "fee_diff": float(np.sum(fee_arr) - symbol_fee), + "slippage_total": float(np.sum(slippage_arr)), + "symbol_slippage_total": symbol_slippage, + "slippage_diff": float(np.sum(slippage_arr) - symbol_slippage), + "turnover_total": float(np.sum(turnover_arr)), + "symbol_total_pnl": symbol_pnl, + "equity_pnl": float(equity_arr[-1] - initial_capital) if len(equity_arr) else 0.0, + "equity_symbol_pnl_diff": float((equity_arr[-1] - initial_capital) - symbol_pnl) if len(equity_arr) else 0.0, + "max_result_position_diff": float(np.nanmax(np.abs(positions_values - accepted_values))) if positions_values.size else 0.0, + "max_target_accepted_diff": float(np.nanmax(np.abs(target_units_report.to_numpy(dtype=np.float64, copy=False) - accepted_values))) if accepted_values.size else 0.0, + } + + @staticmethod + def _per_symbol_array(value, symbols: List[str], default: float) -> np.ndarray: + if value is None: + return np.full(len(symbols), float(default), dtype=np.float64) + if isinstance(value, dict): + return np.array([float(value.get(symbol, default)) for symbol in symbols], dtype=np.float64) + return np.full(len(symbols), float(value), dtype=np.float64) + + @staticmethod + def _risk_volatility_matrix(closes: np.ndarray, lookback: int) -> np.ndarray: + frame = pd.DataFrame(closes).where(lambda x: x > 0.0) + returns = np.log(frame).diff() + window = max(2, int(lookback)) + vol = returns.rolling(window, min_periods=window).std() + arr = vol.to_numpy(dtype=np.float64) + arr[~np.isfinite(arr)] = 0.0 + arr[arr <= 0.0] = 0.0 + return np.ascontiguousarray(arr, dtype=np.float64) + + @staticmethod + def _tradable_matrix( + *, + closes: Dict[str, pd.Series], + idx: pd.DatetimeIndex, + symbols: Sequence[str], + market: PreparedMarketArrays, + max_stale_bars: int = 0, + ) -> np.ndarray: + out = np.isfinite(market.closes) & (market.closes > 0.0) + if closes is None: + return np.ascontiguousarray(out, dtype=np.bool_) + max_stale = max(0, int(max_stale_bars)) + for j, symbol in enumerate(symbols): + raw = closes[symbol] + if not isinstance(raw, pd.Series): + raw = pd.Series(raw, index=idx) + raw_idx = raw.index + if isinstance(raw_idx, pd.DatetimeIndex): + if raw_idx.tz is None: + raw = raw.copy() + raw.index = raw.index.tz_localize("UTC") + else: + raw = raw.copy() + raw.index = raw.index.tz_convert("UTC") + observed = raw[~raw.index.duplicated(keep="first")].reindex(idx) + values = observed.to_numpy(dtype=np.float64) + stale = max_stale + 1 + for i in range(len(idx)): + if np.isfinite(values[i]) and values[i] > 0.0: + stale = 0 + else: + stale += 1 + out[i, j] = bool(out[i, j] and stale <= max_stale) + return np.ascontiguousarray(out, dtype=np.bool_) + + @staticmethod + def _sizing_mode_id(sizing_mode: str) -> int: + mapping = {"%_equity": 0, "target_weight": 1, "gross_exposure": 2, "net_exposure": 3} + return mapping[sizing_mode] + + @staticmethod + def _portfolio_mode_id(mode: str) -> int: + mapping = { + "longshort": 0, + "market_neutral": 1, + "directional": 2, + "equal_weight": 3, + "risk_parity": 4, + "beta_neutral": 5, + } + return mapping[mode] + + +def _normalize_report_level(report_level: str) -> str: + level = str(report_level or "full").lower().strip() + aliases = { + "audit": "full", + "complete": "full", + "default": "full", + "lite": "standard", + "light": "minimal", + "optimizer": "minimal", + "scoring": "minimal", + } + level = aliases.get(level, level) + if level not in {"full", "standard", "minimal"}: + raise ValueError("report_level must be one of 'full', 'standard', or 'minimal'") + return level diff --git a/src/quantbt/backends/native_vectorized.py b/src/quantbt/backends/native_vectorized.py new file mode 100644 index 0000000..e4399c7 --- /dev/null +++ b/src/quantbt/backends/native_vectorized.py @@ -0,0 +1,1299 @@ +""" +quantbt.backends.native_vectorized +---------------------------------- +V2 backend facade over Numba vectorized kernels. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Union +import warnings + +import numpy as np +import pandas as pd + +from ..core.preprocessor import ( + PreparedMarketArrays, + align_series, + build_arrays, + build_market_arrays, + build_signal_matrix, + market_data_signature, + prepare_funding, + validate_datetime, +) +from ..core.constraints import build_quantity_constraints, quantize_target_units_matrix +from ..core.results import BacktestResultV2 +from ..core.schema import ( + AccountConfig, + BasketLegSpec, + BasketSpec, + ExecutionConfig, + FillPricePolicy, + InstrumentSpec, + SameBarPolicy, +) +from ..core.vectorized import _engine_units_v2 +from ..core.arbitrage import ( + ArbitrageSpec, + ArbitragePlan, + BasisArbitrageSpec, + CalendarSpreadSpec, + CrossExchangeArbSpec, + FundingArbitrageSpec, + IndexBasketArbSpec, + OptionsVolArbSpec, + PackageExecutionKind, + PackageRejection, + SizingPolicyKind, + SpotPerpCashCarrySpec, + StatArbPairSpec, + TriangularArbSpec, + build_arbitrage_order_plan, +) +from ..core.basket import build_frozen_basket_orders +from ..core.orders import OrderIntent +from ..core.preprocessor import make_funding_mask +from ..core.schema import OrderSide +from ..sizing.fast import scale_signal_notional_matrix +from ..sizing.modes import compute_target_units + + +@dataclass(frozen=True) +class NativeVectorizedConfig: + account: AccountConfig + execution: ExecutionConfig = field(default_factory=ExecutionConfig) + fee_rate: Union[float, Dict[str, float]] = 0.0 + use_funding: bool = True + + def __post_init__(self) -> None: + if isinstance(self.fee_rate, dict): + if any(float(rate) < 0.0 for rate in self.fee_rate.values()): + raise ValueError("fee_rate must be >= 0") + elif float(self.fee_rate) < 0.0: + raise ValueError("fee_rate must be >= 0") + unsupported = [] + if self.execution.fill_price_policy is not FillPricePolicy.CLOSE: + unsupported.append(f"fill_price_policy={self.execution.fill_price_policy.value!r}") + if self.execution.same_bar_policy is not SameBarPolicy.CONSERVATIVE: + unsupported.append(f"same_bar_policy={self.execution.same_bar_policy.value!r}") + if self.execution.allow_partial_fill: + unsupported.append("allow_partial_fill=True") + if self.execution.min_order_notional > 0.0: + unsupported.append("min_order_notional") + if not self.execution.reject_on_insufficient_margin: + unsupported.append("reject_on_insufficient_margin=False") + if unsupported: + raise NotImplementedError( + "native_vectorized is the close_target_v2 contract and does not support " + + ", ".join(unsupported) + ) + + +class NativeVectorizedBackend: + """ + Fast vectorized backend returning BacktestResultV2 diagnostics. + + This initial Phase 2 backend consumes pre-scaled target units. Sizing modes + remain in the existing public wrappers and will be migrated onto this backend + incrementally. + """ + + def __init__(self, config: NativeVectorizedConfig): + self.config = config + + @staticmethod + def _close_target_metadata( + *, + symbol_list: List[str], + idx: pd.DatetimeIndex, + high_low_source: str, + first_bar_policy: str, + ) -> Dict: + signature = market_data_signature(idx, symbol_list) + return { + "backend": "native_vectorized", + "backend_alias": "native_vectorized", + "engine": "close_target_v2", + "engine_id": "close_target_v2", + "kernel_version": "units_v2", + "execution_contract": { + "engine_id": "close_target_v2", + "signal_phase": "bar_close", + "fill_phase": "same_close", + "intrabar_exit_model": "none", + "market_fill_policy": "close", + "timeline": "mark close[t-1]->close[t], rebalance target at close[t]", + "accounting_certified": True, + "execution_generated_by_engine": True, + }, + "signal_phase": "bar_close", + "fill_phase": "same_close", + "intrabar_exit_model": "none", + "first_bar_target_policy": first_bar_policy, + "high_low_source": high_low_source, + "data_signature": signature, + } + + @staticmethod + def _high_low_source(highs, lows) -> str: + if highs is None and lows is None: + return "close_fallback_uncertified_intrabar_risk" + if highs is None: + return "high_close_fallback_uncertified_intrabar_risk" + if lows is None: + return "low_close_fallback_uncertified_intrabar_risk" + return "provided" + + @staticmethod + def _warn_high_low_fallback(high_low_source: str) -> None: + if high_low_source != "provided": + warnings.warn( + "native_vectorized close_target_v2 received missing high/low data and will use close fallback; " + "intrabar liquidation/risk is uncertified for this run. Pass explicit highs/lows for certified risk.", + RuntimeWarning, + stacklevel=3, + ) + + def prepare_market_arrays( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + symbols: Optional[List[str]] = None, + ) -> PreparedMarketArrays: + """ + Normalize single-symbol or multi-symbol market data once for repeated + signal-notional scoring loops. + + The prepared object is a copied ndarray snapshot plus an explicit + datetime/symbol signature. `run_signals` rejects it when reused against + a different index or symbol layout. + """ + idx = validate_datetime(datetime_index) + symbol_list = symbols or list(closes.keys()) + close_dict = align_series(closes, symbol_list, idx) + high_low_source = self._high_low_source(highs, lows) + self._warn_high_low_fallback(high_low_source) + high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) + low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) + funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) + market = build_market_arrays( + symbols=symbol_list, + idx=idx, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + funding_dict=funding_dict, + ) + return market + + def run_target_units( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + target_units: Dict[str, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + symbols: Optional[List[str]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + ) -> BacktestResultV2: + idx = validate_datetime(datetime_index) + symbol_list = symbols or list(target_units.keys()) + if set(symbol_list) != set(target_units.keys()) or set(symbol_list) != set(closes.keys()): + raise ValueError("symbols, target_units, and closes must contain the same keys") + + close_dict = align_series(closes, symbol_list, idx) + high_low_source = self._high_low_source(highs, lows) + self._warn_high_low_fallback(high_low_source) + high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) + low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) + target_dict = align_series(target_units, symbol_list, idx, fill_val=0.0) + funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) + + closes_m, highs_m, lows_m, target_m, funding_m, is_funding = build_arrays( + symbols=symbol_list, + idx=idx, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + signals_dict=target_dict, + funding_dict=funding_dict, + ) + + return self._run_target_arrays( + idx=idx, + symbol_list=symbol_list, + closes_m=closes_m, + highs_m=highs_m, + lows_m=lows_m, + target_m=target_m, + funding_m=funding_m, + is_funding=is_funding, + contract_size=contract_size, + leverage=leverage, + fee_rate=fee_rate, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + high_low_source=high_low_source, + ) + + def _run_target_arrays( + self, + idx: pd.DatetimeIndex, + symbol_list: List[str], + closes_m: np.ndarray, + highs_m: np.ndarray, + lows_m: np.ndarray, + target_m: np.ndarray, + funding_m: np.ndarray, + is_funding: np.ndarray, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + raw_signal_matrix: Optional[np.ndarray] = None, + high_low_source: str = "provided", + ) -> BacktestResultV2: + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + target_m = quantize_target_units_matrix(target_m, closes_m, contract_sizes, constraints) + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + fee_rates = self._per_symbol_array( + self.config.fee_rate if fee_rate is None else fee_rate, + symbol_list, + default=0.0, + ) + + ( + equity_arr, + pos_arr, + fee_arr, + turnover_arr, + funding_arr, + init_margin_arr, + maint_margin_arr, + rejected_arr, + reject_code_arr, + liq_flag, + liq_idx, + liq_reason, + ) = _engine_units_v2( + n_bars=len(idx), + n_syms=len(symbol_list), + highs=highs_m, + lows=lows_m, + closes=closes_m, + target_units=target_m, + funding_rates=funding_m, + is_funding_bar=is_funding, + init_capital=self.config.account.initial_capital, + leverages=leverages, + maint_ratio=self.config.account.maintenance_ratio, + fee_rates=fee_rates, + contract_sizes=contract_sizes, + slippage=self.config.execution.slippage_rate, + use_funding=bool(self.config.use_funding), + ) + + equity = pd.Series(equity_arr, index=idx, name="equity") + returns = equity.pct_change().fillna(0.0) + positions = pd.DataFrame( + {f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + close_df = pd.DataFrame( + {f"Close_{s}": closes_m[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + fees = pd.Series(fee_arr, index=idx, name="fees") + funding = pd.Series(funding_arr, index=idx, name="funding") + margin = pd.DataFrame( + { + "initial_margin": init_margin_arr, + "maintenance_margin": maint_margin_arr, + }, + index=idx, + ) + diagnostics = pd.DataFrame( + { + "turnover": turnover_arr, + "rejected_orders": rejected_arr, + "reject_code": reject_code_arr, + }, + index=idx, + ) + + metadata = self._close_target_metadata( + symbol_list=symbol_list, + idx=idx, + high_low_source=high_low_source, + first_bar_policy="target_units[0]_not_executed; first executable rebalance occurs at bar index 1", + ) + metadata.update( + { + "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list), + "slippage_bps": self.config.execution.slippage_bps, + "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), + "liquidation_reason": int(liq_reason), + "quantity_constraints": constraints.as_dict(), + } + ) + + return BacktestResultV2( + equity=equity, + returns=returns, + positions=positions, + closes=close_df, + symbols=symbol_list, + initial_capital=self.config.account.initial_capital, + leverage=float(np.mean(leverages)), + liquidated=bool(liq_flag), + liquidation_bar=int(liq_idx), + fees=fees, + funding=funding, + margin=margin, + diagnostics=diagnostics, + metadata=metadata, + ) + + def run_signals( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + positions: Dict[str, pd.Series], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0, + hedge_type: str = "signal_notional", + use_pyramiding: bool = True, + symbols: Optional[List[str]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + raw_signal_matrix: Optional[np.ndarray] = None, + ) -> BacktestResultV2: + """ + Scale raw position signals into target units, then run the V2 kernel. + + Phase 2 supports target-unit sizing modes here. `%_equity` and + `dca_ladder` remain on the legacy kernels until their V2 diagnostics + kernels are added. + """ + ht = hedge_type.lower().strip() + if ht in ("%_equity", "pct_equity", "dca_ladder", "dca"): + raise NotImplementedError(f"NativeVectorizedBackend.run_signals does not yet support hedge_type={hedge_type!r}") + + idx = validate_datetime(datetime_index) + symbol_list = symbols or list(positions.keys()) + pos_dict = None if raw_signal_matrix is not None else align_series(positions, symbol_list, idx, fill_val=0.0) + close_dict = None if market_arrays is not None else align_series(closes, symbol_list, idx) + high_low_source = "prepared_market_arrays" if market_arrays is not None else self._high_low_source(highs, lows) + if market_arrays is None: + self._warn_high_low_fallback(high_low_source) + alloc = self._per_symbol_mapping(alloc_per_trade, symbol_list, default=100_000.0) + + if ht in ("signal_notional", "signal"): + if market_arrays is None: + high_dict = align_series(highs, symbol_list, idx, fallback=close_dict) + low_dict = align_series(lows, symbol_list, idx, fallback=close_dict) + funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx) + closes_m, highs_m, lows_m, signals_m, funding_m, is_funding = build_arrays( + symbols=symbol_list, + idx=idx, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + signals_dict=pos_dict, + funding_dict=funding_dict, + ) + else: + if market_arrays.signature != market_data_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + closes_m = market_arrays.closes + highs_m = market_arrays.highs + lows_m = market_arrays.lows + funding_m = market_arrays.funding + is_funding = market_arrays.is_funding_bar + if raw_signal_matrix is None: + signals_m = build_signal_matrix(symbol_list, idx, pos_dict) + else: + signals_m = np.ascontiguousarray(raw_signal_matrix, dtype=np.float64) + if signals_m.shape != closes_m.shape: + raise ValueError("raw_signal_matrix shape does not match prepared market arrays") + allocs = np.array([alloc[s] for s in symbol_list], dtype=np.float64) + target_m = scale_signal_notional_matrix( + signals=signals_m, + closes=closes_m, + allocs=allocs, + use_pyramiding=use_pyramiding, + ) + return self._run_target_arrays( + idx=idx, + symbol_list=symbol_list, + closes_m=closes_m, + highs_m=highs_m, + lows_m=lows_m, + target_m=target_m, + funding_m=funding_m, + is_funding=is_funding, + contract_size=contract_size, + leverage=leverage, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + high_low_source=high_low_source, + ) + + if close_dict is None: + close_dict = { + symbol: pd.Series(market_arrays.closes[:, j], index=idx, name=symbol) + for j, symbol in enumerate(symbol_list) + } + if pos_dict is None: + pos_dict = { + symbol: pd.Series(raw_signal_matrix[:, j], index=idx, name=symbol) + for j, symbol in enumerate(symbol_list) + } + target_units = { + s: compute_target_units( + hedge_type=hedge_type, + signal=pos_dict[s], + close=close_dict[s], + alloc=alloc[s], + use_pyramiding=use_pyramiding, + ) + for s in symbol_list + } + + return self.run_target_units( + datetime_index=idx, + target_units=target_units, + closes=close_dict, + highs=highs, + lows=lows, + funding_rate=funding_rate, + contract_size=contract_size, + leverage=leverage, + symbols=symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + + def run_basis_arbitrage( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + spec: BasisArbitrageSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Optional[Union[float, Dict[str, float]]] = None, + leverage: Optional[Union[float, Dict[str, float]]] = None, + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + ) -> BacktestResultV2: + if not isinstance(spec, BasisArbitrageSpec): + raise TypeError("run_basis_arbitrage requires a BasisArbitrageSpec") + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in spec.legs] + close_dict = align_series(closes, symbols, idx) + plan = build_arbitrage_order_plan( + datetime_index=idx, + spec=spec, + signal=signal, + closes=close_dict, + hedge_ratios=hedge_ratios, + ) + contract_sizes = self._contract_size_for_spec(spec, contract_size) + fee_rates = self._fee_rate_for_spec(spec) + plan = self._apply_atomic_package_margin_policy(idx, plan, close_dict, contract_sizes, fee_rates, leverage) + basis_funding = self._funding_for_spec(spec, funding_rate) + target_units = {symbol: plan.target_units[symbol] for symbol in symbols} + + result = self.run_target_units( + datetime_index=idx, + target_units=target_units, + closes=close_dict, + highs=highs, + lows=lows, + funding_rate=basis_funding, + contract_size=contract_sizes, + leverage=leverage, + fee_rate=fee_rates, + symbols=symbols, + ) + funding_dict = prepare_funding(basis_funding if self.config.use_funding else 0.0, symbols, idx) + leg_pnl_report = self._leg_pnl_report( + idx=idx, + symbols=symbols, + roles={leg.symbol: leg.role for leg in spec.legs}, + result=result, + closes=close_dict, + funding=funding_dict, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + ) + package_report = self._package_pnl_report(idx, result, leg_pnl_report) + result.metadata.update( + { + "backend": "native_vectorized", + "engine": "units_v2_basis_arbitrage", + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "arbitrage_plan": plan, + "package_target_units": plan.target_units, + "package_rejection_report": plan.rejection_report, + "spread_report": self._basis_spread_report(idx, spec, close_dict, plan.target_units), + "leg_pnl_report": leg_pnl_report, + "package_pnl_report": package_report, + "fee_rate_oneway": fee_rates, + "contract_size": contract_sizes, + } + ) + return result + + def run_stat_arb_pair_arbitrage( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + spec: StatArbPairSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Optional[Union[float, Dict[str, float]]] = None, + leverage: Optional[Union[float, Dict[str, float]]] = None, + ) -> BacktestResultV2: + if not isinstance(spec, StatArbPairSpec): + raise TypeError("run_stat_arb_pair_arbitrage requires a StatArbPairSpec") + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in spec.legs] + close_dict = align_series(closes, symbols, idx) + basket = self._stat_arb_basket_from_spec(spec) + rebalance_threshold = spec.hedge_policy.rebalance_threshold + if not spec.hedge_policy.freeze_on_entry and rebalance_threshold is None: + rebalance_threshold = 0.0 + plan = build_frozen_basket_orders( + datetime_index=idx, + basket=basket, + signal=signal, + closes=close_dict, + hedge_ratios=hedge_ratios, + rebalance_threshold=rebalance_threshold, + ) + contract_sizes = self._contract_size_for_spec(spec, contract_size) + fee_rates = self._fee_rate_for_spec(spec) + stat_funding = self._funding_for_spec(spec, funding_rate) + arb_plan = self._apply_atomic_package_margin_policy( + idx=idx, + plan=ArbitragePlan( + spec=spec, + orders=plan.orders, + target_units=plan.target_units, + signals=plan.signals, + entry_ratios=plan.entry_ratios, + rejections=(), + metadata=plan.metadata, + ), + closes=close_dict, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + leverage=leverage, + ) + target_units = {symbol: arb_plan.target_units[symbol] for symbol in symbols} + + result = self.run_target_units( + datetime_index=idx, + target_units=target_units, + closes=close_dict, + highs=highs, + lows=lows, + funding_rate=stat_funding, + contract_size=contract_sizes, + leverage=leverage, + fee_rate=fee_rates, + symbols=symbols, + ) + funding_dict = prepare_funding(stat_funding if self.config.use_funding else 0.0, symbols, idx) + leg_pnl_report = self._leg_pnl_report( + idx=idx, + symbols=symbols, + roles=self._stat_arb_roles(spec), + result=result, + closes=close_dict, + funding=funding_dict, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + ) + package_report = self._package_pnl_report(idx, result, leg_pnl_report) + result.metadata.update( + { + "backend": "native_vectorized", + "engine": "units_v2_stat_arb_pair", + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "arbitrage_plan": arb_plan, + "package_target_units": arb_plan.target_units, + "package_rejection_report": arb_plan.rejection_report, + "basket_plan": plan, + "basket_target_units": arb_plan.target_units, + "beta_drift_report": self._stat_arb_beta_drift_report(idx, spec, arb_plan, rebalance_threshold), + "spread_report": self._stat_arb_spread_report(idx, spec, close_dict, arb_plan), + "leg_pnl_report": leg_pnl_report, + "package_pnl_report": package_report, + "rebalance_threshold": rebalance_threshold, + "fee_rate_oneway": fee_rates, + "contract_size": contract_sizes, + } + ) + return result + + def run_package_arbitrage( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + spec: ArbitrageSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Optional[Union[float, Dict[str, float]]] = None, + leverage: Optional[Union[float, Dict[str, float]]] = None, + ) -> BacktestResultV2: + unsupported = (CrossExchangeArbSpec, TriangularArbSpec, OptionsVolArbSpec) + if isinstance(spec, unsupported): + raise NotImplementedError( + f"{type(spec).__name__} is schema-validated but requires a specialized arbitrage engine; " + "do not route it through generic package execution. " + "Use QuantBTEndpoint.arbitrage_support_matrix() to inspect supported routes." + ) + supported = (CalendarSpreadSpec, FundingArbitrageSpec, SpotPerpCashCarrySpec, IndexBasketArbSpec) + if not isinstance(spec, supported): + raise TypeError("run_package_arbitrage requires a Phase G package-style arbitrage spec") + + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in spec.legs] + close_dict = align_series(closes, symbols, idx) + plan = build_arbitrage_order_plan( + datetime_index=idx, + spec=spec, + signal=signal, + closes=close_dict, + hedge_ratios=hedge_ratios, + ) + contract_sizes = self._contract_size_for_spec(spec, contract_size) + fee_rates = self._fee_rate_for_spec(spec) + plan = self._apply_atomic_package_margin_policy(idx, plan, close_dict, contract_sizes, fee_rates, leverage) + package_funding = self._funding_for_spec(spec, funding_rate) + target_units = {symbol: plan.target_units[symbol] for symbol in symbols} + + result = self.run_target_units( + datetime_index=idx, + target_units=target_units, + closes=close_dict, + highs=highs, + lows=lows, + funding_rate=package_funding, + contract_size=contract_sizes, + leverage=leverage, + fee_rate=fee_rates, + symbols=symbols, + ) + funding_dict = prepare_funding(package_funding if self.config.use_funding else 0.0, symbols, idx) + leg_pnl_report = self._leg_pnl_report( + idx=idx, + symbols=symbols, + roles={leg.symbol: leg.role for leg in spec.legs}, + result=result, + closes=close_dict, + funding=funding_dict, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + ) + package_report = self._package_pnl_report(idx, result, leg_pnl_report) + result.metadata.update( + { + "backend": "native_vectorized", + "engine": f"units_v2_{spec.arb_type.value}", + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "arbitrage_plan": plan, + "package_target_units": plan.target_units, + "package_rejection_report": plan.rejection_report, + "spread_report": self._basis_spread_report(idx, spec, close_dict, plan.target_units), + "leg_pnl_report": leg_pnl_report, + "package_pnl_report": package_report, + "carry_report": self._carry_report(idx, spec, result, close_dict, funding_dict, contract_sizes), + "fee_rate_oneway": fee_rates, + "contract_size": contract_sizes, + } + ) + return result + + @staticmethod + def _per_symbol_array(value, symbols: List[str], default: float) -> np.ndarray: + if isinstance(value, dict): + return np.array([float(value.get(s, default)) for s in symbols], dtype=np.float64) + return np.full(len(symbols), float(value), dtype=np.float64) + + @staticmethod + def _per_symbol_mapping(value, symbols: List[str], default: float) -> Dict[str, float]: + if isinstance(value, dict): + return {s: float(value.get(s, default)) for s in symbols} + return {s: float(value) for s in symbols} + + def _apply_atomic_package_margin_policy( + self, + idx: pd.DatetimeIndex, + plan: ArbitragePlan, + closes: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + fee_rates: Dict[str, float], + leverage: Optional[Union[float, Dict[str, float]]], + ) -> ArbitragePlan: + spec = plan.spec + if spec.execution_policy.kind not in (PackageExecutionKind.ATOMIC_ALL_OR_NONE, PackageExecutionKind.BEST_EFFORT): + return plan + + symbols = [leg.symbol for leg in spec.legs] + current_units = {symbol: 0.0 for symbol in symbols} + equity = float(self.config.account.initial_capital) + target_rows = [] + orders = [] + rejections = list(plan.rejections) + leverages = self._leverage_mapping(leverage, symbols) + slippage = self.config.execution.slippage_rate + + for i, ts in enumerate(idx): + if i > 0: + prev_ts = idx[i - 1] + for symbol in symbols: + units = current_units[symbol] + if units != 0.0: + equity += units * ( + float(closes[symbol].loc[ts]) - float(closes[symbol].loc[prev_ts]) + ) * float(contract_sizes[symbol]) + + original_desired = {symbol: float(plan.target_units.loc[ts, symbol]) for symbol in symbols} + changed_symbols = [ + symbol for symbol in symbols + if abs(original_desired[symbol] - current_units[symbol]) > 1e-12 + ] + if changed_symbols: + if spec.execution_policy.kind is PackageExecutionKind.ATOMIC_ALL_OR_NONE: + allowed, details = self._atomic_package_has_margin( + ts=ts, + symbols=symbols, + current_units=current_units, + desired_units=original_desired, + closes=closes, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + leverages=leverages, + equity=equity, + slippage=slippage, + ) + if not allowed: + rejections.append( + PackageRejection( + timestamp=ts, + arb_id=spec.arb_id, + reason="insufficient_margin_atomic", + failed_legs=tuple(changed_symbols), + metadata={"details": details, "policy": spec.execution_policy.kind.value}, + ) + ) + else: + self._append_package_orders(orders, ts, spec, symbols, current_units, original_desired) + equity -= float(details.get("cost", 0.0)) + current_units = original_desired + else: + for symbol in symbols: + if abs(original_desired[symbol] - current_units[symbol]) <= 1e-12: + continue + candidate_units = dict(current_units) + candidate_units[symbol] = original_desired[symbol] + allowed, details = self._atomic_package_has_margin( + ts=ts, + symbols=symbols, + current_units=current_units, + desired_units=candidate_units, + closes=closes, + contract_sizes=contract_sizes, + fee_rates=fee_rates, + leverages=leverages, + equity=equity, + slippage=slippage, + ) + if not allowed: + rejections.append( + PackageRejection( + timestamp=ts, + arb_id=spec.arb_id, + reason="insufficient_margin_best_effort", + failed_legs=(symbol,), + metadata={"details": details, "policy": spec.execution_policy.kind.value}, + ) + ) + continue + self._append_package_orders(orders, ts, spec, [symbol], current_units, candidate_units) + equity -= float(details.get("cost", 0.0)) + current_units = candidate_units + + target_rows.append({symbol: current_units[symbol] for symbol in symbols}) + + return ArbitragePlan( + spec=spec, + orders=tuple(orders), + target_units=pd.DataFrame(target_rows, index=idx), + signals=plan.signals, + entry_ratios=plan.entry_ratios, + rejections=tuple(rejections), + metadata={**plan.metadata, "execution_margin_policy": "package_preflight"}, + ) + + @staticmethod + def _append_package_orders( + orders: List[OrderIntent], + ts, + spec: ArbitrageSpec, + symbols: List[str], + current_units: Dict[str, float], + desired_units: Dict[str, float], + ) -> None: + for symbol in symbols: + delta = desired_units[symbol] - current_units[symbol] + if abs(delta) <= 1e-12: + continue + side = OrderSide.BUY if delta > 0.0 else OrderSide.SELL + orders.append( + OrderIntent( + timestamp=ts, + symbol=symbol, + side=side, + order_type=spec.execution_policy.order_type, + qty=abs(delta), + tif=spec.execution_policy.tif, + tag=spec.arb_id, + metadata={ + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "package_policy": spec.execution_policy.kind.value, + "hedge_policy": spec.hedge_policy.kind.value, + "sizing_policy": spec.sizing_policy.kind.value, + "target_units": desired_units[symbol], + "previous_units": current_units[symbol], + }, + ) + ) + + def _atomic_package_has_margin( + self, + ts, + symbols: List[str], + current_units: Dict[str, float], + desired_units: Dict[str, float], + closes: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + fee_rates: Dict[str, float], + leverages: Dict[str, float], + equity: float, + slippage: float, + ) -> tuple[bool, Dict[str, float]]: + cur_im = 0.0 + margin_delta_sum = 0.0 + cost_sum = 0.0 + for symbol in symbols: + close_price = float(closes[symbol].loc[ts]) + cs = float(contract_sizes[symbol]) + lev = float(leverages[symbol]) + current = float(current_units[symbol]) + target = float(desired_units[symbol]) + cur_im += abs(current) * close_price * cs / lev + delta = target - current + if abs(delta) <= 1e-12: + continue + exec_price = close_price * (1.0 + slippage if delta > 0.0 else 1.0 - slippage) + old_im = abs(current) * close_price * cs / lev + new_im = abs(target) * exec_price * cs / lev + margin_delta_sum += new_im - old_im + cost_sum += abs(delta) * exec_price * cs * float(fee_rates[symbol]) + cost_sum += abs(delta) * abs(exec_price - close_price) * cs + + available = max(0.0, float(equity) - cur_im) + required = cost_sum + max(0.0, margin_delta_sum) + return required <= available + 1e-12, { + "available": available, + "required": required, + "current_initial_margin": cur_im, + "margin_delta": margin_delta_sum, + "cost": cost_sum, + } + + def _leverage_mapping(self, leverage, symbols: List[str]) -> Dict[str, float]: + default = float(self.config.account.leverage) + if isinstance(leverage, dict): + return {symbol: float(leverage.get(symbol, default)) for symbol in symbols} + if leverage is None: + return {symbol: default for symbol in symbols} + return {symbol: float(leverage) for symbol in symbols} + + @staticmethod + def _fee_rate_metadata(fee_rates: np.ndarray, symbols: List[str]): + if len(fee_rates) == 0: + return 0.0 + if np.allclose(fee_rates, fee_rates[0]): + return float(fee_rates[0]) + return {symbol: float(fee_rates[i]) for i, symbol in enumerate(symbols)} + + def _fee_rate_for_spec(self, spec: ArbitrageSpec) -> Dict[str, float]: + default_rates = self.config.fee_rate + out: Dict[str, float] = {} + for leg in spec.legs: + if leg.fee_rate is not None: + out[leg.symbol] = float(leg.fee_rate) + elif isinstance(default_rates, dict): + out[leg.symbol] = float(default_rates.get(leg.symbol, 0.0)) + else: + out[leg.symbol] = float(default_rates) + return out + + @staticmethod + def _contract_size_for_spec( + spec: ArbitrageSpec, + contract_size: Optional[Union[float, Dict[str, float]]], + ) -> Dict[str, float]: + out = {leg.symbol: float(leg.contract_size) for leg in spec.legs} + if contract_size is None: + return out + if isinstance(contract_size, dict): + out.update({symbol: float(value) for symbol, value in contract_size.items()}) + return out + return {leg.symbol: float(contract_size) for leg in spec.legs} + + @staticmethod + def _funding_for_spec(spec: ArbitrageSpec, funding_rate: Union[float, pd.Series, Dict]): + funding_symbols = {leg.symbol for leg in spec.legs if leg.funding_enabled} + if isinstance(funding_rate, dict): + return { + leg.symbol: funding_rate.get(leg.symbol, 0.0) if leg.symbol in funding_symbols else 0.0 + for leg in spec.legs + } + return {leg.symbol: funding_rate if leg.symbol in funding_symbols else 0.0 for leg in spec.legs} + + @staticmethod + def _stat_arb_roles(spec: StatArbPairSpec) -> Dict[str, str]: + symbols = [leg.symbol for leg in spec.legs] + roles = {leg.symbol: str(leg.role or "leg") for leg in spec.legs} + if len(symbols) >= 2 and len(set(roles.values())) == 1: + roles[symbols[0]] = "leg" + roles[symbols[1]] = "hedge" + return roles + + @staticmethod + def _stat_arb_spread_report( + idx: pd.DatetimeIndex, + spec: StatArbPairSpec, + closes: Dict[str, pd.Series], + plan, + ) -> pd.DataFrame: + symbols = [leg.symbol for leg in spec.legs] + leg_symbol = symbols[0] + hedge_symbol = symbols[1] if len(symbols) > 1 else symbols[0] + leg_close = closes[leg_symbol].astype(float) + hedge_close = closes[hedge_symbol].astype(float) + ref_ratio = plan.entry_ratios[leg_symbol].replace(0.0, np.nan).astype(float) + hedge_ratio = (plan.entry_ratios[hedge_symbol].astype(float) / ref_ratio).fillna(0.0) + spread = leg_close + hedge_ratio * hedge_close + return pd.DataFrame( + { + "leg_symbol": leg_symbol, + "hedge_symbol": hedge_symbol, + "leg_close": leg_close, + "hedge_close": hedge_close, + "hedge_ratio_to_leg": hedge_ratio, + "spread": spread, + "abs_spread": spread.abs(), + }, + index=idx, + ) + + @staticmethod + def _stat_arb_basket_from_spec(spec: StatArbPairSpec) -> BasketSpec: + if spec.sizing_policy.kind is not SizingPolicyKind.TARGET_GROSS_NOTIONAL: + raise NotImplementedError("Phase E StatArbPairSpec requires target_gross_notional sizing") + return BasketSpec( + basket_id=spec.arb_id, + legs=tuple(BasketLegSpec(symbol=leg.symbol, ratio=float(leg.ratio)) for leg in spec.legs), + gross_notional=float(spec.sizing_policy.notional), + freeze_hedge=bool(spec.hedge_policy.freeze_on_entry), + hedged_margin_offset=float(spec.margin_model.hedged_margin_offset), + metadata={ + "arb_type": spec.arb_type.value, + "hedge_policy": spec.hedge_policy.kind.value, + "sizing_policy": spec.sizing_policy.kind.value, + }, + ) + + def _leg_pnl_report( + self, + idx: pd.DatetimeIndex, + symbols: List[str], + roles: Dict[str, str], + result: BacktestResultV2, + closes: Dict[str, pd.Series], + funding: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + fee_rates: Dict[str, float], + ) -> pd.DataFrame: + funding_mask = make_funding_mask(idx) + cumulative = {symbol: 0.0 for symbol in symbols} + rows = [] + slippage = self.config.execution.slippage_rate + for i, ts in enumerate(idx): + for symbol in symbols: + cs = float(contract_sizes[symbol]) + close_price = float(closes[symbol].iloc[i]) + prev_units = 0.0 if i == 0 else float(result.positions[f"Position_{symbol}"].iloc[i - 1]) + units = float(result.positions[f"Position_{symbol}"].iloc[i]) + delta = units - prev_units + price_pnl = 0.0 + if i > 0: + price_pnl = prev_units * (close_price - float(closes[symbol].iloc[i - 1])) * cs + exec_price = close_price * (1.0 + slippage if delta > 0.0 else 1.0 - slippage) + fee = abs(delta) * exec_price * cs * float(fee_rates[symbol]) if abs(delta) > 1e-12 else 0.0 + slippage_cost = abs(delta) * abs(exec_price - close_price) * cs if abs(delta) > 1e-12 else 0.0 + funding_cost = 0.0 + if self.config.use_funding and funding_mask[i]: + funding_cost = prev_units * close_price * cs * float(funding[symbol].iloc[i]) + total_pnl = price_pnl - fee - slippage_cost - funding_cost + cumulative[symbol] += total_pnl + rows.append( + { + "timestamp": ts, + "symbol": symbol, + "role": roles.get(symbol, "leg"), + "units": units, + "close": close_price, + "notional": abs(units) * close_price * cs, + "price_pnl": price_pnl, + "fill_pnl": -slippage_cost, + "fee": fee, + "funding_pnl": -funding_cost, + "total_pnl": total_pnl, + "cumulative_pnl": cumulative[symbol], + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _package_pnl_report(idx: pd.DatetimeIndex, result: BacktestResultV2, leg_pnl_report: pd.DataFrame) -> pd.DataFrame: + grouped = leg_pnl_report.groupby("timestamp", sort=False) + package_pnl = grouped["total_pnl"].sum().reindex(idx, fill_value=0.0) + price_pnl = grouped["price_pnl"].sum().reindex(idx, fill_value=0.0) + fill_pnl = grouped["fill_pnl"].sum().reindex(idx, fill_value=0.0) + fees = grouped["fee"].sum().reindex(idx, fill_value=0.0) + funding_pnl = grouped["funding_pnl"].sum().reindex(idx, fill_value=0.0) + role_pnl = leg_pnl_report.pivot_table( + index="timestamp", + columns="role", + values="total_pnl", + aggfunc="sum", + fill_value=0.0, + ).reindex(idx, fill_value=0.0) + leg_pnl = role_pnl["leg"] if "leg" in role_pnl else pd.Series(0.0, index=idx) + hedge_pnl = role_pnl["hedge"] if "hedge" in role_pnl else pd.Series(0.0, index=idx) + report = pd.DataFrame( + { + "price_pnl": price_pnl, + "fill_pnl": fill_pnl, + "fees": fees, + "funding_pnl": funding_pnl, + "leg_pnl": leg_pnl, + "hedge_pnl": hedge_pnl, + "spread_pnl": leg_pnl + hedge_pnl, + "package_pnl": package_pnl, + "equity_delta": result.equity.diff().fillna(0.0), + }, + index=idx, + ) + report["pnl_residual"] = report["equity_delta"] - report["package_pnl"] + return report + + @staticmethod + def _basis_spread_report( + idx: pd.DatetimeIndex, + spec: ArbitrageSpec, + closes: Dict[str, pd.Series], + target_units: pd.DataFrame, + ) -> pd.DataFrame: + symbols = [leg.symbol for leg in spec.legs] + base_symbol = spec.spread_formula.base_symbol + quote_symbol = spec.spread_formula.quote_symbol + if base_symbol is None: + base_symbol = next((leg.symbol for leg in spec.legs if leg.ratio < 0.0), symbols[0]) + if quote_symbol is None: + quote_symbol = next((leg.symbol for leg in spec.legs if leg.ratio > 0.0), symbols[-1]) + base_close = closes[base_symbol].astype(float) + quote_close = closes[quote_symbol].astype(float) + spread = quote_close - base_close + ratio_spread = quote_close / base_close.replace(0.0, np.nan) - 1.0 + expiry = next((leg.expiry for leg in spec.legs if leg.symbol == quote_symbol and leg.expiry is not None), None) + if expiry is None: + expiry = next((leg.expiry for leg in spec.legs if leg.expiry is not None), None) + if expiry is None: + annualized = pd.Series(np.nan, index=idx, dtype=float) + else: + days_to_expiry = pd.Series( + [(expiry - ts).total_seconds() / 86_400.0 for ts in idx], + index=idx, + dtype=float, + ) + annualized = ratio_spread * (365.0 / days_to_expiry.where(days_to_expiry > 0.0)) + report = pd.DataFrame( + { + "base_symbol": base_symbol, + "quote_symbol": quote_symbol, + "base_close": base_close, + "quote_close": quote_close, + "spread": spread, + "ratio_spread": ratio_spread, + "annualized_basis": annualized, + }, + index=idx, + ) + for symbol in symbols: + report[f"target_units_{symbol}"] = target_units[symbol] + return report + + @staticmethod + def _carry_report( + idx: pd.DatetimeIndex, + spec: ArbitrageSpec, + result: BacktestResultV2, + closes: Dict[str, pd.Series], + funding: Dict[str, pd.Series], + contract_sizes: Dict[str, float], + ) -> pd.DataFrame: + rows = [] + funding_mask = make_funding_mask(idx) + for i, ts in enumerate(idx): + for leg in spec.legs: + symbol = leg.symbol + prev_units = 0.0 if i == 0 else float(result.positions[f"Position_{symbol}"].iloc[i - 1]) + close_price = float(closes[symbol].iloc[i]) + notional = abs(prev_units) * close_price * float(contract_sizes[symbol]) + funding_cost = 0.0 + if funding_mask[i] and leg.funding_enabled: + funding_cost = prev_units * close_price * float(contract_sizes[symbol]) * float(funding[symbol].iloc[i]) + rows.append( + { + "timestamp": ts, + "symbol": symbol, + "role": leg.role, + "funding_enabled": bool(leg.funding_enabled), + "borrow_rate": float(spec.carry_model.borrow_rate), + "cash_yield": float(spec.carry_model.cash_yield), + "notional": notional, + "funding_cost": funding_cost, + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _stat_arb_beta_drift_report( + idx: pd.DatetimeIndex, + spec: StatArbPairSpec, + plan, + rebalance_threshold: Optional[float], + ) -> pd.DataFrame: + symbols = [leg.symbol for leg in spec.legs] + reference_symbol = symbols[0] + rows = [] + for ts in idx: + ref_units = float(plan.target_units.loc[ts, reference_symbol]) + ref_ratio = float(plan.entry_ratios.loc[ts, reference_symbol]) + active = abs(ref_units) > 1e-12 and abs(ref_ratio) > 1e-12 + for symbol in symbols: + units = float(plan.target_units.loc[ts, symbol]) + current_ratio = float(plan.entry_ratios.loc[ts, symbol]) + if active: + frozen_ratio_to_ref = units / ref_units + current_ratio_to_ref = current_ratio / ref_ratio + abs_drift = abs(current_ratio_to_ref - frozen_ratio_to_ref) + rel_drift = abs_drift / max(abs(frozen_ratio_to_ref), 1e-12) + else: + frozen_ratio_to_ref = 0.0 + current_ratio_to_ref = 0.0 + abs_drift = 0.0 + rel_drift = 0.0 + rows.append( + { + "timestamp": ts, + "symbol": symbol, + "reference_symbol": reference_symbol, + "target_units": units, + "frozen_ratio_to_ref": frozen_ratio_to_ref, + "current_ratio_to_ref": current_ratio_to_ref, + "abs_beta_drift": abs_drift, + "rel_beta_drift": rel_drift, + "rebalance_threshold": rebalance_threshold, + "breached": ( + rebalance_threshold is not None + and rel_drift > rebalance_threshold + and symbol != reference_symbol + ), + } + ) + return pd.DataFrame(rows) diff --git a/src/quantbt/backtester.py b/src/quantbt/backtester.py new file mode 100644 index 0000000..324935a --- /dev/null +++ b/src/quantbt/backtester.py @@ -0,0 +1,485 @@ +""" +quantbt.backtester +------------------ +BacktestEngine — single and multi-symbol futures backtest. + +Key design decisions +~~~~~~~~~~~~~~~~~~~~ +* Signals enter as raw weights. Position scaling (units / notional / pct_equity) + is handled by quantbt.sizing.modes BEFORE passing to the numba kernel. +* BacktestEngine.__init__ does data alignment + scaling. +* BacktestEngine.run() executes the simulation and returns a BacktestResult. +* analyze() is the convenience entry point: prints a text report + quick_plot. +* tearsheet() is a separate opt-in call. + +hedge_type values +~~~~~~~~~~~~~~~~~ +'notional' constant notional per bar (recomputes units every bar) +'unit' fixed unit count from first-bar price +'signal_notional' anchor on signal change; stable between transitions ← recommended +'%_equity' dynamic sizing from live equity, no pre-scaling +'dca_ladder' signed structural level; High/Low limit fills at grid triggers + +Parameters (unchanged from original) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Datetime pd.Series | pd.DatetimeIndex +Position pd.Series | Dict[str, pd.Series] raw signal weight +Close pd.Series | Dict[str, pd.Series] +High / Low optional, used for intrabar liquidation +fee float round-trip fee (split internally to one-way) +use_pyramiding bool False → snap signal to {-1, 0, 1} +initial_capital float +leverage float +maintenance_ratio float Binance-style: notional × ratio +contract_size float | Dict[str, float] +use_funding_rate bool +funding_rate float | pd.Series | Dict[str, float | pd.Series] +alloc_per_trade float | Dict[str, float] notional per full signal unit +hedge_type str +slippage float e.g. 0.0001 = 1 bps +symbols List[str] | None +High / Low required for dca_ladder limit-fill detection +dca_base_notional base order notional; defaults to alloc_per_trade +dca_safety_notional safety order notional; defaults to alloc_per_trade +dca_step_pct AO1 distance from base entry, e.g. 0.01 = 1% +dca_step_scale multiplier for each next AO distance increment +dca_volume_scale multiplier for each next safety order notional +dca_take_profit_pct TP from weighted average entry; 0 disables internal TP +""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np +import pandas as pd + +from .core.engine import _engine_units, _engine_pct_equity, _engine_dca_ladder +from .core.types import BacktestResult +from .core.preprocessor import ( + validate_datetime, + align_series, + prepare_funding, + build_arrays, +) +from .core.constraints import build_quantity_constraints, quantize_target_units_matrix +from .core.schema import InstrumentSpec +from .sizing.modes import compute_target_units +from .metrics.performance import full_report + + +class BacktestEngine: + """ + Vectorised Binance-Futures backtest engine. + + Usage + ----- + >>> bt = BacktestEngine(Datetime=dt, Position=sig, Close=close, ...) + >>> result = bt.run() # BacktestResult + >>> bt.analyze() # text report + quick plot + >>> bt.tearsheet() # full dashboard (optional) + >>> bt.export_trade_log('log.csv') + """ + + def __init__( + self, + Datetime: Union[pd.Series, pd.DatetimeIndex], + Position: Union[pd.Series, Dict[str, pd.Series]], + Close: Union[pd.Series, Dict[str, pd.Series]], + fee: float = 0.0004, + use_pyramiding: bool = True, + initial_capital: float = 20_000.0, + leverage: float = 10.0, + maintenance_ratio: float = 0.005, + contract_size: Union[float, Dict[str, float]] = 1.0, + use_funding_rate: bool = True, + funding_rate: Union[float, pd.Series, Dict] = 0.0001, + alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0, + hedge_type: str = "signal_notional", + slippage: float = 0.0001, + symbols: Optional[List[str]] = None, + High: Optional[Union[pd.Series, Dict[str, pd.Series]]] = None, + Low: Optional[Union[pd.Series, Dict[str, pd.Series]]] = None, + dca_base_notional: Optional[Union[float, Dict[str, float]]] = None, + dca_safety_notional: Optional[Union[float, Dict[str, float]]] = None, + dca_step_pct: Union[float, Dict[str, float]] = 0.01, + dca_step_scale: Union[float, Dict[str, float]] = 1.0, + dca_volume_scale: Union[float, Dict[str, float]] = 1.0, + dca_max_safety_orders: int = 5, + dca_take_profit_pct: Union[float, Dict[str, float]] = 0.0, + dca_allow_same_bar_exit: bool = False, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + # kept for backward compat, not used internally + run_portfolio: bool = True, + use_binance_netting: bool = True, + margin_buffer: float = 0.01, + ): + # ── store config ────────────────────────────────────────────────── + self.fee_oneway = fee / 2.0 # one-way + self.use_pyramiding = use_pyramiding + self.initial_capital = initial_capital + self.leverage = leverage + self.maintenance_ratio = maintenance_ratio + self.use_funding_rate = use_funding_rate + self.alloc_per_trade = alloc_per_trade + self.hedge_type = hedge_type + self._hedge_type_norm = hedge_type.lower().strip() + self._is_dca_ladder = self._hedge_type_norm in ("dca_ladder", "dca") + self.slippage = slippage + self.dca_max_safety_orders = int(dca_max_safety_orders) + self.dca_allow_same_bar_exit = bool(dca_allow_same_bar_exit) + + if self.dca_max_safety_orders < 0: + raise ValueError("dca_max_safety_orders must be >= 0") + if self._is_dca_ladder and (High is None or Low is None): + raise ValueError("hedge_type='dca_ladder' requires High and Low for limit-fill detection") + if self.initial_capital <= 0.0: + raise ValueError("initial_capital must be > 0") + if self.leverage <= 0.0: + raise ValueError("leverage must be > 0") + if self.maintenance_ratio < 0.0: + raise ValueError("maintenance_ratio must be >= 0") + + # ── datetime index ──────────────────────────────────────────────── + self._idx = validate_datetime(Datetime) + + # ── symbols ─────────────────────────────────────────────────────── + if symbols is not None: + self.symbols = symbols + elif isinstance(Position, dict): + self.symbols = list(Position.keys()) + else: + self.symbols = ["DEFAULT"] + + self.n_syms = len(self.symbols) + self.n_bars = len(self._idx) + + # ── align price / signal data ────────────────────────────────────── + self._closes = align_series(Close, self.symbols, self._idx) + self._highs = align_series(High, self.symbols, self._idx, fallback=self._closes) + self._lows = align_series(Low, self.symbols, self._idx, fallback=self._closes) + self._positions = align_series(Position, self.symbols, self._idx, fill_val=0.0) + + # ── contract sizes ──────────────────────────────────────────────── + if isinstance(contract_size, dict): + self._contract_sizes = np.array( + [contract_size.get(s, 1.0) for s in self.symbols], dtype=np.float64 + ) + else: + self._contract_sizes = np.full(self.n_syms, float(contract_size), dtype=np.float64) + + if np.any(self._contract_sizes <= 0.0): + raise ValueError("contract_size must be > 0") + + self._quantity_constraints = build_quantity_constraints( + self.symbols, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + + # ── funding rates ───────────────────────────────────────────────── + fr_input = funding_rate if use_funding_rate else 0.0 + self._funding = prepare_funding(fr_input, self.symbols, self._idx) + + # ── alloc dict ──────────────────────────────────────────────────── + if isinstance(alloc_per_trade, dict): + self._alloc = {s: float(alloc_per_trade[s]) for s in self.symbols} + else: + self._alloc = {s: float(alloc_per_trade) for s in self.symbols} + + if any(v < 0.0 for v in self._alloc.values()): + raise ValueError("alloc_per_trade must be >= 0") + + def _per_symbol_array(value, default_map): + out = [] + for s in self.symbols: + default = default_map[s] if isinstance(default_map, dict) else default_map + if value is None: + v = default + elif isinstance(value, dict): + v = value.get(s, default) + else: + v = value + out.append(float(v)) + return np.array(out, dtype=np.float64) + + # DCA ladder parameters. Defaults keep base and safety orders aligned + # with alloc_per_trade, while the grid geometry is explicit and stable. + self._dca_base_notional = _per_symbol_array(dca_base_notional, self._alloc) + self._dca_safety_notional = _per_symbol_array(dca_safety_notional, self._alloc) + self._dca_step_pct = _per_symbol_array(dca_step_pct, 0.01) + self._dca_step_scale = _per_symbol_array(dca_step_scale, 1.0) + self._dca_volume_scale = _per_symbol_array(dca_volume_scale, 1.0) + self._dca_take_profit_pct = _per_symbol_array(dca_take_profit_pct, 0.0) + + if self._is_dca_ladder: + if self.dca_max_safety_orders > 0 and np.any(self._dca_step_pct <= 0.0): + raise ValueError("dca_step_pct must be > 0 when safety orders are enabled") + if np.any(self._dca_base_notional <= 0.0) or np.any(self._dca_safety_notional <= 0.0): + raise ValueError("DCA base/safety notionals must be > 0") + if np.any(self._dca_step_scale <= 0.0) or np.any(self._dca_volume_scale <= 0.0): + raise ValueError("DCA step/volume scales must be > 0") + + # ── scale signals → target units ────────────────────────────────── + self._target_units: Dict[str, pd.Series] = {} + for sym in self.symbols: + if self._is_dca_ladder: + self._target_units[sym] = self._positions[sym].fillna(0.0) + else: + self._target_units[sym] = compute_target_units( + hedge_type = self.hedge_type, + signal = self._positions[sym], + close = self._closes[sym], + alloc = self._alloc[sym], + use_pyramiding = self.use_pyramiding, + ) + + # run on construction so result is immediately available + self._result: Optional[BacktestResult] = None + self.run() + + # ── public interface ───────────────────────────────────────────────────── + + def run(self) -> BacktestResult: + """ + Execute the simulation and return a BacktestResult. + Also caches the result as self.result. + """ + closes, highs, lows, signals, funding, is_funding = build_arrays( + symbols = self.symbols, + idx = self._idx, + closes_dict = self._closes, + highs_dict = self._highs, + lows_dict = self._lows, + signals_dict = self._target_units, + funding_dict = self._funding, + ) + + cs = self._contract_sizes + qc = self._quantity_constraints + + if self._is_dca_ladder: + equity_arr, pos_arr, level_arr, liq_flag, liq_idx = _engine_dca_ladder( + n_bars = self.n_bars, + n_syms = self.n_syms, + highs = highs, + lows = lows, + closes = closes, + signals = signals, + funding_rates = funding, + is_funding_bar = is_funding, + init_capital = self.initial_capital, + leverage = self.leverage, + maint_ratio = self.maintenance_ratio, + fee_rate = self.fee_oneway, + contract_sizes = cs, + market_slippage = self.slippage, + base_notional = self._dca_base_notional, + safety_notional = self._dca_safety_notional, + step_pct = self._dca_step_pct, + step_scale = self._dca_step_scale, + volume_scale = self._dca_volume_scale, + max_safety_orders = self.dca_max_safety_orders, + take_profit_pct = self._dca_take_profit_pct, + allow_same_bar_exit = self.dca_allow_same_bar_exit, + qty_steps = qc.qty_step, + min_qtys = qc.min_qty, + min_notionals = qc.min_notional, + ) + result_positions = pos_arr + elif self._hedge_type_norm in ("%_equity", "pct_equity"): + alloc_pct = np.array( + [self._alloc[s] for s in self.symbols], dtype=np.float64 + ) + # normalise: if value > 1 assume percentage was passed (e.g. 10 → 0.10) + alloc_pct = np.where(alloc_pct > 1.0, alloc_pct / 100.0, alloc_pct) + + equity_arr, liq_flag, liq_idx = _engine_pct_equity( + n_bars = self.n_bars, + n_syms = self.n_syms, + highs = highs, + lows = lows, + closes = closes, + signals = signals, + funding_rates = funding, + is_funding_bar = is_funding, + init_capital = self.initial_capital, + leverage = self.leverage, + maint_ratio = self.maintenance_ratio, + fee_rate = self.fee_oneway, + contract_sizes = cs, + slippage = self.slippage, + alloc_pct = alloc_pct, + qty_steps = qc.qty_step, + min_qtys = qc.min_qty, + min_notionals = qc.min_notional, + ) + result_positions = signals + else: + signals = quantize_target_units_matrix(signals, closes, cs, qc) + equity_arr, liq_flag, liq_idx = _engine_units( + n_bars = self.n_bars, + n_syms = self.n_syms, + highs = highs, + lows = lows, + closes = closes, + signals = signals, + funding_rates = funding, + is_funding_bar = is_funding, + init_capital = self.initial_capital, + leverage = self.leverage, + maint_ratio = self.maintenance_ratio, + fee_rate = self.fee_oneway, + contract_sizes = cs, + slippage = self.slippage, + ) + result_positions = signals + + equity = pd.Series(equity_arr, index=self._idx, name="equity") + returns = equity.pct_change().fillna(0) + + # positions DataFrame + pos_df = pd.DataFrame( + {f"Position_{s}": result_positions[:, i] for i, s in enumerate(self.symbols)}, + index=self._idx, + ) + close_df = pd.DataFrame( + {f"Close_{s}": closes[:, i] for i, s in enumerate(self.symbols)}, + index=self._idx, + ) + + self._result = BacktestResult( + equity = equity, + returns = returns, + positions = pos_df, + closes = close_df, + symbols = self.symbols, + initial_capital = self.initial_capital, + leverage = self.leverage, + liquidated = bool(liq_flag), + liquidation_bar = int(liq_idx), + metadata = { + "hedge_type": self.hedge_type, + "initial_buying_power": self.initial_capital * self.leverage, + "fee_oneway": self.fee_oneway, + "slippage": self.slippage, + "maintenance_ratio": self.maintenance_ratio, + "quantity_constraints": self._quantity_constraints.as_dict(), + "dca_actual_level": ( + pd.DataFrame( + {f"Level_{s}": level_arr[:, i] for i, s in enumerate(self.symbols)}, + index=self._idx, + ) + if self._is_dca_ladder else None + ), + }, + ) + return self._result + + @property + def result(self) -> BacktestResult: + if self._result is None: + self.run() + return self._result + + # ── convenience methods ─────────────────────────────────────────────────── + + def analyze( + self, + trading_days: int = 365, + theme: str = "dark", + figsize: tuple = (14, 6), + ) -> None: + """ + Print a concise performance report, then show cumulative return + drawdown. + """ + from .viz.plots import quick_plot + + self.print_metrics(trading_days=trading_days) + quick_plot(self.result, theme=theme, figsize=figsize) + + def print_metrics(self, trading_days: int = 365) -> None: + """ + Print a structured text report to stdout. + No separators, no banner lines — clean columnar output. + """ + rpt = full_report(self.result, trading_days) + syms = ", ".join(self.symbols) + + lines = [ + ("Symbols", syms), + ("Hedge Type", self.hedge_type), + ("Initial Capital", f"${rpt['initial_capital']:>14,.0f}"), + ("Final Equity", f"${rpt['final_equity']:>14,.2f}"), + ("Total Return", f"{rpt['total_return_pct']:>+13.2f}%"), + ("CAGR", f"{rpt['cagr_pct']:>+13.2f}%"), + ("Sharpe Ratio", f"{rpt['sharpe']:>14.3f}"), + ("Sortino Ratio", f"{rpt['sortino']:>14.3f}"), + ("Calmar Ratio", f"{rpt['calmar']:>14.3f}"), + ("Omega Ratio", f"{rpt['omega']:>14.3f}"), + ("Max Drawdown", f"{rpt['max_drawdown_pct']:>13.2f}%"), + ("Avg Drawdown", f"{rpt['avg_drawdown_pct']:>13.2f}%"), + ("Max DD Duration", f"{rpt['max_dd_duration_days']:>11d} days"), + ("Profit Factor", f"{rpt['profit_factor']:>14.3f}"), + ("Long Hit Rate", f"{rpt['long_hitrate_pct']:>13.2f}%"), + ("Short Hit Rate", f"{rpt['short_hitrate_pct']:>13.2f}%"), + ("Avg Win", f"{rpt['avg_win_pct']:>+13.3f}%"), + ("Avg Loss", f"{rpt['avg_loss_pct']:>+13.3f}%"), + ("Expectancy", f"{rpt['expectancy_pct']:>+13.3f}%"), + ("Number of Trades", f"{rpt['num_trades']:>14,d}"), + ("Liquidated", f"{'Yes' if rpt['liquidated'] else 'No':>14}"), + ] + + col_width = max(len(k) for k, _ in lines) + print() + for key, val in lines: + print(f" {key:<{col_width}} {val}") + print() + + def tearsheet( + self, + theme: str = "light", + figsize: tuple = (18, 24), + trading_days: int = 365, + benchmark: Optional[pd.Series] = None, + ) -> None: + """Full dashboard. Optional; call explicitly when needed.""" + from .viz.plots import tearsheet as _tearsheet + + _tearsheet( + self.result, + theme = theme, + figsize = figsize, + trading_days = trading_days, + benchmark = benchmark, + ) + + def export_trade_log( + self, + filename: str = "trade_log.csv", + datetime_as_index: bool = True, + ) -> None: + r = self.result + log = pd.DataFrame({ + "returns": r.returns, + "cumulative_return": (r.equity / self.initial_capital - 1) * 100, + }, index=r.equity.index) + + for sym in self.symbols: + log[f"position_{sym}"] = r.positions[f"Position_{sym}"] + log[f"close_{sym}"] = r.closes[f"Close_{sym}"] + + if not datetime_as_index: + log = log.reset_index() + + log.to_csv(filename, index=datetime_as_index) + print(f"Trade log exported → {filename}") diff --git a/src/quantbt/benchmarks/README.md b/src/quantbt/benchmarks/README.md new file mode 100644 index 0000000..dfea896 --- /dev/null +++ b/src/quantbt/benchmarks/README.md @@ -0,0 +1,104 @@ +# QuantBT Benchmarks + +Phase 7 introduces a reproducible benchmark harness for the upgraded backtest +backends. + +```bash +python3 benchmarks/run_phase7.py --profile smoke +python3 benchmarks/run_phase7.py --profile standard --repeats 5 +python3 benchmarks/run_phase7.py --profile standard --repeats 5 --no-tracemalloc +python3 benchmarks/profile_phase7.py --profile standard --repeats 3 +``` + +Profiles: + +- `smoke`: quick local sanity check. +- `standard`: commit-to-commit comparison target. +- `large`: stress profile for optimization decisions. + +The runner writes both JSON and Markdown into `benchmarks/out/` by default. +Nautilus is optional and skipped unless `--include-nautilus` is passed. + +Backends currently measured: + +- `native_vectorized` +- `native_event` +- `native_event_prepared` +- `portfolio_legacy` +- `native_portfolio` +- optional `nautilus` + +The committed summary lives in `benchmarks/phase7_report.md`. Local JSON/MD +outputs under `benchmarks/out/` are git-ignored by design. + +When a backend misses a runtime threshold, run `profile_phase7.py` before +considering Cython/C++. The committed profiling summary lives in +`benchmarks/phase7_profile_report.md`. + +Phase 9 optimization follow-up: + +- `benchmarks/compare_phase9_parity.py` checks that optimized sizing/order + compilation does not change target units, equity, positions, order reports, + or fills. +- `benchmarks/phase9_optimization_report.md` records the first post-profiling + optimization pass and remaining bottlenecks. +- `native_event_prepared` measures the WFO/service pattern where market arrays + and compiled order arrays are prepared once and replayed through the same + event/accounting kernel. +- `--no-tracemalloc` is available when comparing runtime separately from memory + instrumentation overhead. Use the default traced mode when peak memory is the + metric under review. + +Phase 14/16 service-loop follow-up: + +```bash +python3 benchmarks/run_phase14_service_loop.py --rows 1440 --symbols 6 --trials 8 --repeats 2 +python3 benchmarks/run_phase16_performance_debt.py --rows 1440 --symbols 6 --replays 8 --repeats 2 +``` + +- `phase14_service_loop.*` decomposes WFO, native-event, arbitrage and report + workload costs. +- `phase16_performance_debt.*` compares normal endpoint replays with + `endpoint.prepare_service_context(...)` and records the current Cython/C++ + decision. + +Options Phase 10: + +```bash +python3 benchmarks/run_options_engine.py --snapshots 96 --contracts 48 --packages 96 --repeats 3 +python3 benchmarks/gamma_scalping_backtestsample.py --snapshots 90 --seed 42 +python3 benchmarks/gamma_scalping_backtestsample.py \ + --real-options-csv /root/bobby/pool_alpha/alphas_storage/option_based/options_full_history.csv.gz \ + --underlying-source spot \ + --hedge-timeframe 1h +``` + +- `options_phase10_baseline.*` records prepared-tape and compiled-package cache + parity for the native option backend. +- The benchmark reports snapshots, contracts, quotes, packages, fills, hedges, + memory, uncached runtime, cached runtime, and run-manifest hashes. +- `gamma_scalping_backtestsample.py` is a runnable long-straddle gamma-scalping + smoke sample. It keeps the original research helpers, then runs the public + `QuantBTEndpoint.options(...)` path through + `build_gamma_scalping_strategy_run(...)`, `strategy_run`, `underlying`, and + prepared-cache parity. +- The real-data mode converts legacy Binance options CSV history into QuantBT's + canonical option-chain schema, selects an ATM call/put pair with entry/exit + quotes, and loads BTCUSDT spot or USD-M perpetual candles from `_get_data` for + first-class delta-hedged combined-equity accounting. +- Cython/C++ should only be considered after a larger profile shows pure + kernels, not pandas/tape/report facade work, dominating runtime. + +Phase 31 intrabar execution: + +```bash +python3 benchmarks/run_phase31_intrabar.py --rows 25000 --repeats 3 +python3 benchmarks/run_phase31_intrabar.py --rows 512 --repeats 1 +``` + +- `phase31_intrabar_benchmark.*` compares the new fast + `intrabar_bracket_v1` kernel against the close-target pure kernel, the Python + intrabar oracle, fill replay, and the native-event explicit-order facade. +- Use the fast intrabar route for single-symbol next-open SL/TP/trailing + research. Use `report_level="audit"` for fill-ledger certification and + `report_level="minimal"` for WFO/optimizer loops. diff --git a/src/quantbt/benchmarks/__init__.py b/src/quantbt/benchmarks/__init__.py new file mode 100644 index 0000000..c402e40 --- /dev/null +++ b/src/quantbt/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Benchmark helpers for quantbt upgrade phases.""" diff --git a/src/quantbt/benchmarks/compare_phase9_parity.py b/src/quantbt/benchmarks/compare_phase9_parity.py new file mode 100644 index 0000000..c166261 --- /dev/null +++ b/src/quantbt/benchmarks/compare_phase9_parity.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Compare Phase 9 optimized paths against legacy-equivalent construction.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Dict, Optional + +import numpy as np +import pandas as pd + + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import AccountConfig, BacktestEngineV2, OrderIntent, OrderSide, OrderType, TimeInForce +from quantbt.backends import NativeEventBackend, NativeEventConfig +from quantbt.core.order_compiler import compile_order_intents +from quantbt.core.preprocessor import align_series, build_market_arrays, prepare_funding, validate_datetime +from quantbt.sizing.fast import scale_signal_notional_matrix +from quantbt.sizing.modes import compute_target_units + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Run Phase 9 parity checks.") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase9_parity.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase9_parity.md") + args = parser.parse_args(argv) + + report = run_parity() + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + args.md_out.write_text(markdown_report(report), encoding="utf-8") + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["passed"] else 1 + + +def run_parity() -> Dict: + idx, data, signals = _market() + symbols = ["A", "B"] + alloc = {"A": 10_000.0, "B": 5_000.0} + + target_diff = _target_unit_diff(data, signals, symbols, alloc) + vectorized_diff = _vectorized_result_diff(data, signals, alloc) + order_diff = _order_array_diff(idx) + event_diff = _event_result_diff(data, idx) + prepared_diff = _prepared_event_reuse_diff(data, idx) + + report = { + "target_unit_max_abs_diff": target_diff, + "vectorized_equity_max_abs_diff": vectorized_diff["equity"], + "vectorized_position_max_abs_diff": vectorized_diff["positions"], + "order_array_max_abs_diff": order_diff, + "event_equity_max_abs_diff": event_diff["equity"], + "event_order_report_max_abs_diff": event_diff["order_report"], + "event_fill_count_diff": event_diff["fill_count"], + "event_fill_price_max_abs_diff": event_diff["fill_price"], + "prepared_event_equity_max_abs_diff": prepared_diff["equity"], + "prepared_event_order_report_max_abs_diff": prepared_diff["order_report"], + "prepared_event_fill_count_diff": prepared_diff["fill_count"], + } + report["passed"] = all( + [ + report["target_unit_max_abs_diff"] <= 1e-12, + report["vectorized_equity_max_abs_diff"] <= 1e-10, + report["vectorized_position_max_abs_diff"] <= 1e-12, + report["order_array_max_abs_diff"] <= 0.0, + report["event_equity_max_abs_diff"] <= 1e-10, + report["event_order_report_max_abs_diff"] <= 1e-12, + report["event_fill_count_diff"] == 0, + report["event_fill_price_max_abs_diff"] <= 1e-12, + report["prepared_event_equity_max_abs_diff"] <= 1e-10, + report["prepared_event_order_report_max_abs_diff"] <= 1e-12, + report["prepared_event_fill_count_diff"] == 0, + ] + ) + return report + + +def _market(): + idx = pd.date_range("2024-01-01", periods=128, freq="15min", tz="UTC") + grid = np.arange(len(idx), dtype=float) + close_a = pd.Series(100.0 + np.sin(grid / 7.0) * 2.0 + grid * 0.01, index=idx) + close_b = pd.Series(50.0 + np.cos(grid / 11.0) * 1.5 + grid * 0.005, index=idx) + data = { + "A": pd.DataFrame({"open": close_a, "high": close_a + 0.8, "low": close_a - 0.8, "close": close_a, "volume": 1_000.0}), + "B": pd.DataFrame({"open": close_b, "high": close_b + 0.5, "low": close_b - 0.5, "close": close_b, "volume": 1_000.0}), + } + sig_a = np.where((grid.astype(int) // 9) % 4 == 0, 1.0, np.where((grid.astype(int) // 9) % 4 == 2, -0.5, 0.0)) + sig_b = np.where((grid.astype(int) // 13) % 3 == 0, -1.0, np.where((grid.astype(int) // 13) % 3 == 1, 0.5, 0.0)) + signals = {"A": pd.Series(sig_a, index=idx), "B": pd.Series(sig_b, index=idx)} + return idx, data, signals + + +def _orders(idx): + return [ + OrderIntent(idx[5], "A", OrderSide.BUY, OrderType.MARKET, qty=1.0, tif=TimeInForce.IOC), + OrderIntent(idx[12], "B", OrderSide.SELL, OrderType.LIMIT, qty=2.0, price=52.0, tif=TimeInForce.GTC), + OrderIntent(idx[12], "A", OrderSide.SELL, OrderType.MARKET, qty=0.5, tif=TimeInForce.IOC), + OrderIntent(idx[40], "B", OrderSide.BUY, OrderType.MARKET, qty=2.0, tif=TimeInForce.IOC), + OrderIntent(idx[90], "A", OrderSide.SELL, OrderType.LIMIT, qty=0.5, price=102.0, tif=TimeInForce.GTC), + ] + + +def _target_unit_diff(data, signals, symbols, alloc): + closes_m = np.column_stack([data[s]["close"].to_numpy(dtype=float) for s in symbols]) + signals_m = np.column_stack([signals[s].to_numpy(dtype=float) for s in symbols]) + allocs = np.array([alloc[s] for s in symbols], dtype=np.float64) + fast = scale_signal_notional_matrix(signals_m, closes_m, allocs, use_pyramiding=True) + legacy = np.column_stack( + [ + compute_target_units("signal_notional", signals[s], data[s]["close"], alloc[s], True).to_numpy() + for s in symbols + ] + ) + return float(np.max(np.abs(fast - legacy))) + + +def _vectorized_result_diff(data, signals, alloc): + account = AccountConfig(initial_capital=100_000.0, leverage=5.0) + fast = BacktestEngineV2( + data=data, + signals=signals, + backend="native_vectorized", + account=account, + alloc_per_trade=alloc, + hedge_type="signal_notional", + use_funding=False, + ).result + target_units = { + s: compute_target_units("signal_notional", signals[s], data[s]["close"], alloc[s], True) + for s in signals + } + legacy_route = BacktestEngineV2( + data=data, + target_units=target_units, + backend="native_vectorized", + account=account, + use_funding=False, + ).result + return { + "equity": float(np.max(np.abs(fast.equity.to_numpy() - legacy_route.equity.to_numpy()))), + "positions": float(np.max(np.abs(fast.positions.to_numpy() - legacy_route.positions.to_numpy()))), + } + + +def _order_array_diff(idx): + orders = _orders(idx) + symbol_to_col = {"A": 0, "B": 1} + compiled = compile_order_intents(idx, orders, symbol_to_col) + legacy = _legacy_order_arrays(idx, orders, symbol_to_col) + diffs = [ + np.max(np.abs(compiled.order_ptr - legacy[0])), + np.max(np.abs(compiled.order_symbol - legacy[1])), + np.max(np.abs(compiled.order_side - legacy[2])), + np.max(np.abs(compiled.order_type - legacy[3])), + np.max(np.abs(compiled.order_qty - legacy[4])), + np.max(np.abs(compiled.order_price - legacy[5])), + np.max(np.abs(compiled.order_tif - legacy[6])), + np.max(np.abs(compiled.original_index - legacy[7])), + ] + return float(max(diffs)) + + +def _event_result_diff(data, idx): + orders = _orders(idx) + result = BacktestEngineV2( + data=data, + orders=orders, + backend="native_event", + account=AccountConfig(initial_capital=100_000.0, leverage=5.0), + use_funding=False, + ).result + rerun = BacktestEngineV2( + data=data, + orders=orders, + backend="native_event", + account=AccountConfig(initial_capital=100_000.0, leverage=5.0), + use_funding=False, + ).result + report = result.metadata["order_report"].sort_values("original_index") + rerun_report = rerun.metadata["order_report"].sort_values("original_index") + fill_prices = np.array([fill.price for fill in result.fills], dtype=float) + rerun_fill_prices = np.array([fill.price for fill in rerun.fills], dtype=float) + fill_price_diff = 0.0 + if len(fill_prices) or len(rerun_fill_prices): + if len(fill_prices) != len(rerun_fill_prices): + fill_price_diff = math.inf + else: + fill_price_diff = float(np.max(np.abs(fill_prices - rerun_fill_prices))) + return { + "equity": float(np.max(np.abs(result.equity.to_numpy() - rerun.equity.to_numpy()))), + "order_report": float(np.max(np.abs(report.to_numpy(dtype=float) - rerun_report.to_numpy(dtype=float)))), + "fill_count": int(len(result.fills) - len(rerun.fills)), + "fill_price": fill_price_diff, + } + + +def _prepared_event_reuse_diff(data, idx): + symbols = ["A", "B"] + orders = _orders(idx) + closes = {symbol: data[symbol]["close"] for symbol in symbols} + highs = {symbol: data[symbol]["high"] for symbol in symbols} + lows = {symbol: data[symbol]["low"] for symbol in symbols} + backend = NativeEventBackend( + NativeEventConfig(account=AccountConfig(initial_capital=100_000.0, leverage=5.0), use_funding=False) + ) + normal = backend.run_orders(idx, orders, closes, highs=highs, lows=lows, symbols=symbols) + idx_n = validate_datetime(idx) + close_dict = align_series(closes, symbols, idx_n) + high_dict = align_series(highs, symbols, idx_n, fallback=close_dict) + low_dict = align_series(lows, symbols, idx_n, fallback=close_dict) + funding_dict = prepare_funding(0.0, symbols, idx_n) + market_arrays = build_market_arrays(symbols, idx_n, close_dict, high_dict, low_dict, funding_dict) + compiled = compile_order_intents(idx_n, orders, {"A": 0, "B": 1}) + reused = backend.run_orders( + idx, + orders, + closes, + highs=highs, + lows=lows, + symbols=symbols, + market_arrays=market_arrays, + compiled_orders=compiled, + ) + return { + "equity": float(np.max(np.abs(normal.equity.to_numpy() - reused.equity.to_numpy()))), + "order_report": float( + np.max( + np.abs( + normal.metadata["order_report"].to_numpy(dtype=float) + - reused.metadata["order_report"].to_numpy(dtype=float) + ) + ) + ), + "fill_count": int(len(normal.fills) - len(reused.fills)), + } + + +def _legacy_order_arrays(idx, orders, symbol_to_col): + def bar_index(timestamp): + ts = pd.Timestamp(timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + pos = idx.searchsorted(ts, side="left") + if pos >= len(idx): + raise ValueError("order timestamp is after the available data") + return int(pos) + + sorted_orders = sorted(enumerate(orders), key=lambda item: bar_index(item[1].timestamp)) + n = len(sorted_orders) + order_bar = np.zeros(n, dtype=np.int64) + order_symbol = np.zeros(n, dtype=np.int64) + order_side = np.zeros(n, dtype=np.int64) + order_type = np.zeros(n, dtype=np.int64) + order_qty = np.zeros(n, dtype=np.float64) + order_price = np.zeros(n, dtype=np.float64) + order_tif = np.zeros(n, dtype=np.int64) + original_index = np.zeros(n, dtype=np.int64) + for k, (orig_idx, order) in enumerate(sorted_orders): + order_bar[k] = bar_index(order.timestamp) + order_symbol[k] = symbol_to_col[order.symbol] + order_side[k] = 1 if order.side is OrderSide.BUY else -1 + order_type[k] = 0 if order.order_type is OrderType.MARKET else 1 + order_qty[k] = order.qty + order_price[k] = 0.0 if order.price is None else order.price + order_tif[k] = {TimeInForce.GTC: 0, TimeInForce.IOC: 1, TimeInForce.FOK: 2, TimeInForce.GTD: 3}[order.tif] + original_index[k] = orig_idx + order_ptr = np.zeros(len(idx) + 1, dtype=np.int64) + for bar in order_bar: + order_ptr[bar + 1] += 1 + for i in range(1, len(order_ptr)): + order_ptr[i] += order_ptr[i - 1] + return order_ptr, order_symbol, order_side, order_type, order_qty, order_price, order_tif, original_index + + +def markdown_report(report: Dict) -> str: + lines = [ + "# Phase 9 Optimization Parity Report", + "", + f"Passed: `{report['passed']}`", + "", + "| check | value |", + "| --- | ---: |", + ] + for key, value in report.items(): + if key == "passed": + continue + lines.append(f"| `{key}` | {value} |") + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/gamma_scalping_backtestsample.py b/src/quantbt/benchmarks/gamma_scalping_backtestsample.py new file mode 100644 index 0000000..c9e44c4 --- /dev/null +++ b/src/quantbt/benchmarks/gamma_scalping_backtestsample.py @@ -0,0 +1,802 @@ +import argparse +import json +import sys +from pathlib import Path + +import pandas as pd +import numpy as np +from datetime import datetime, timedelta + + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + ExerciseStyle, + GammaScalpingConfig, + OptionHedgeConfig, + OptionHedgePolicyType, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + OptionPackageIntent, + OptionPackageLeg, + OptionPreparedRunCache, + OrderSide, + PremiumConvention, + QuantBTEndpoint, + SettlementStyle, + build_gamma_scalping_strategy_run, +) + +def filter_atm_options(df: pd.DataFrame, iv_rank_threshold: float = 101.0, # Tạm set cao để bypass IV rank check + use_rv_condition: bool = False, # Thêm flag tắt RV > IV + rv_window: int = 20, iv_window: int = 252, min_oi: int = 50, # Giảm OI tạm + min_dte: int = 2, max_dte: int = 14) -> pd.DataFrame: # Mở rộng DTE + df = df.copy() + df['snapshot_time'] = pd.to_datetime(df['time']).dt.tz_localize(None).dt.normalize() + df['expiration_date'] = pd.to_datetime(df['expiration']).dt.tz_localize(None).dt.normalize() + df['spot_price'] = df['close'] + df['strike'] = df['strike'].astype(float).astype(int) + df['mid_price'] = (df['bid'] + df['ask']) / 2 + df['dte'] = (df['expiration_date'] - df['snapshot_time']).dt.days + + df_sorted = df.sort_values('snapshot_time') + df_sorted['log_return'] = np.log(df_sorted['spot_price'] / df_sorted['spot_price'].shift(1)) + df_sorted['rv'] = df_sorted['log_return'].ewm(span=rv_window).std() * np.sqrt(252) + + # IV rank chỉ tính nếu window đủ, nhưng bypass check + df_sorted['iv_rank'] = df_sorted.groupby('snapshot_time')['implied_volatility'].transform( + lambda x: x.rank(pct=True).iloc[-1] * 100 if len(x) > 0 else np.nan + ) # Simple rank per day, hoặc giữ rolling nhưng skip NaN + + result_rows = [] + for (time_snapshot, underlying), group_df in df_sorted.groupby(['snapshot_time', 'underlying']): + current_iv = group_df['implied_volatility'].mean() + current_rv = group_df['rv'].mean() if 'rv' in group_df else np.nan # Mean để tránh NaN + current_iv_rank = group_df['iv_rank'].mean() + + # Bypass condition tạm + if current_iv_rank >= iv_rank_threshold: + continue # Chỉ skip nếu rank cao, nhưng set threshold=101 để không skip + if use_rv_condition and (pd.isna(current_rv) or current_rv <= current_iv): + continue + + filtered_df = group_df[(group_df['dte'] >= min_dte) & (group_df['dte'] <= max_dte) & (group_df['open_interest'] >= min_oi)].copy() + if filtered_df.empty: + continue + + current_spot = filtered_df['spot_price'].iloc[0] + paired_strikes = filtered_df.groupby(['expiration_date', 'strike']).filter( + lambda x: set(x['type'].values) == {'call', 'put'} # Chính xác hơn: đúng 1 call + 1 put + ) + if paired_strikes.empty: + continue + + paired_strikes['atm_distance'] = abs(paired_strikes['strike'] - current_spot) + min_expiry_date = paired_strikes['dte'].min() # Ưu tiên DTE nhỏ nhất + best_expiry = paired_strikes[paired_strikes['dte'] == min_expiry_date]['expiration_date'].iloc[0] + best_strikes = paired_strikes[paired_strikes['expiration_date'] == best_expiry] + best_strike = best_strikes.loc[best_strikes['atm_distance'].idxmin(), 'strike'] + + final_pair = filtered_df[ + (filtered_df['expiration_date'] == best_expiry) & + (filtered_df['strike'] == best_strike) & + (filtered_df['type'].isin(['call', 'put'])) + ] + if len(final_pair) == 2: + result_rows.append(final_pair) + + if result_rows: + return pd.concat(result_rows, ignore_index=True) + else: + print("No straddle found after all filters - check data has paired call/put ATM short-dated") + return pd.DataFrame(columns=df.columns) + +def normalize_greeks(df_straddle: pd.DataFrame) -> pd.DataFrame: + """ + Chuẩn hóa dựa vendor: delta/gamma *100 (per $1), theta per day USD, vega *100 (per 1.0 IV). + """ + df = df_straddle.copy() + df['delta_norm'] = df['delta'] * 100 + df['gamma_norm'] = df['gamma'] * 100 + df['theta_norm'] = df['theta'] + df['vega_norm'] = df['vega'] * 100 + return df + +def aggregate_straddle_greeks(df: pd.DataFrame, position_type: str = 'long', notional: int = 100) -> pd.DataFrame: + """ + Aggregate Greeks, scale by notional và sign. + """ + sign = 1 if position_type == 'long' else -1 + df['time'] = pd.to_datetime(df['time']) + df = df.set_index('time').sort_index() + + straddle_df = df.groupby(level=0).agg({ + 'spot_price': 'first', + 'delta_norm': 'sum', + 'gamma_norm': 'sum', + 'theta_norm': 'sum', + 'vega_norm': 'sum', + 'implied_volatility': 'mean', + 'mid_price': 'sum', + 'dte': 'first' + }) + + for col in ['delta_norm', 'gamma_norm', 'theta_norm', 'vega_norm', 'mid_price']: + straddle_df[col] *= sign * notional + + straddle_df.rename(columns={'implied_volatility': 'iv_straddle'}, inplace=True) + return straddle_df + +def simulate_paths(S0: float, mu: float, sigma_rv: float, T: float, dt: float, n_paths: int = 1000) -> np.ndarray: + """GBM paths for backtest.""" + n_steps = int(T / dt) + paths = np.zeros((n_paths, n_steps + 1)) + paths[:, 0] = S0 + for t in range(1, n_steps + 1): + Z = np.random.standard_normal(n_paths) + paths[:, t] = paths[:, t-1] * np.exp((mu - 0.5 * sigma_rv**2) * dt + sigma_rv * np.sqrt(dt) * Z) + return paths + +def gamma_pnl_factor(gamma_norm: float, S: float, rv: float, iv: float, dt: float, notional: int = 100) -> float: + """Gamma P&L attribution.""" + return 0.5 * gamma_norm * S**2 * (rv**2 - iv**2) * dt * notional + +def hedge_and_pnl(df_straddle: pd.DataFrame, + position_type: str = 'long', + notional: int = 100, + hedge_threshold: float = 0.05, + min_dte: int = 2, + sim_paths: bool = False, + option_commission_per_straddle: float = 3.0, # USD per straddle round-trip (2 legs) + hedge_commission_per_unit_delta: float = 0.05 # USD per 1.0 delta rebalanced + ) -> pd.DataFrame: + """ + P&L realistic với commission: + - Option fee: khi open/rollover straddle mới + - Hedge fee: mỗi lần re-hedge delta + """ + df = normalize_greeks(df_straddle) + df = aggregate_straddle_greeks(df, position_type, notional) + + df['portfolio_delta'] = df['delta_norm'] + df['pnl'] = 0.0 + df['cum_pnl'] = 0.0 + df['cum_return'] = 0.0 + df['gamma_attrib'] = 0.0 + df['hedge_pnl'] = 0.0 + df['mtm_change'] = 0.0 + df['commission'] = 0.0 # NEW: track commission + df['commission_option'] = 0.0 # Phí từ option + df['commission_hedge'] = 0.0 # Phí từ hedge + + if sim_paths: + dt_base = 1/252 + T_total = len(df) * dt_base + paths = simulate_paths(df['spot_price'].iloc[0], mu=0.1, sigma_rv=0.3, T=T_total, dt=dt_base, n_paths=1) + df['spot_price'] = pd.Series(paths[0, :len(df)], index=df.index) + + if df.empty: + return df + + # Initial capital = giá trị straddle khi entry (mid_price đầu tiên) + initial_capital = abs(df.iloc[0]['mid_price']) # abs để tránh âm nếu short + if initial_capital == 0: + initial_capital = 1.0 # Tránh chia 0 + + current_position_value = df.iloc[0]['mid_price'] + prev_delta_for_hedge = df.iloc[0]['delta_norm'] # Để tính delta change khi hedge + + for i in range(1, len(df)): + row_prev, row = df.iloc[i-1], df.iloc[i] + + S_prev, S = row_prev['spot_price'], row['spot_price'] + ds = S - S_prev + dt_actual = (row.name - row_prev.name).days + + rv_actual = abs(ds / S_prev) * np.sqrt(252) if dt_actual > 0 and S_prev != 0 else 0.0 + + commission_today = 0.0 + commission_option_today = 0.0 + commission_hedge_today = 0.0 + + # === ROLLOVER: close old straddle, open new === + if row_prev['dte'] < min_dte: + close_pnl = row_prev['mid_price'] - current_position_value + df.at[row_prev.name, 'pnl'] += close_pnl + df.at[row_prev.name, 'mtm_change'] = close_pnl + + # Commission khi rollover: open new straddle (2 legs) + commission_option_today = option_commission_per_straddle * notional + commission_today += commission_option_today + + # Reset position + current_position_value = row['mid_price'] + prev_delta_for_hedge = row['delta_norm'] # Delta mới sau rollover + df.at[row.name, 'portfolio_delta'] = row['delta_norm'] + else: + current_position_value = row['mid_price'] + + # === DAILY MTM CHANGE === + mtm_change = row['mid_price'] - row_prev['mid_price'] + df.at[row.name, 'mtm_change'] = mtm_change + + # === DISCRETE HEDGE === + prev_delta = row_prev['portfolio_delta'] + hedge_pnl = 0.0 + if abs(prev_delta) > hedge_threshold: + hedge_pnl = -prev_delta * ds + delta_change = abs(row['delta_norm'] - prev_delta) # Amount rebalanced + commission_hedge_today = delta_change * hedge_commission_per_unit_delta + commission_today += commission_hedge_today + + df.at[row.name, 'portfolio_delta'] = row['delta_norm'] # Rebalanced to new delta + prev_delta_for_hedge = row['delta_norm'] + + df.at[row.name, 'hedge_pnl'] = hedge_pnl + + # === TOTAL PNL SAU COMMISSION === + gross_pnl = mtm_change + hedge_pnl + net_pnl = gross_pnl - commission_today + df.at[row.name, 'pnl'] = net_pnl + + + # CUM PNL & CUM RETURN + df.at[row.name, 'cum_pnl'] = df.at[row_prev.name, 'cum_pnl'] + net_pnl + df.at[row.name, 'cum_return'] = df.at[row.name, 'cum_pnl'] / initial_capital # % return + + # === COMMISSION BREAKDOWN === + df.at[row.name, 'commission'] = commission_today + df.at[row.name, 'commission_option'] = commission_option_today + df.at[row.name, 'commission_hedge'] = commission_hedge_today + + # === GAMMA ATTRIB (tạm giữ, bạn sẽ fix sau) === + df.at[row.name, 'gamma_attrib'] = gamma_pnl_factor( + row_prev['gamma_norm'], S_prev, rv_actual, row_prev['iv_straddle'], dt_actual, notional + ) + + df.iloc[0]['cum_return'] = 0.0 + df.iloc[0]['cum_pnl'] = 0.0 + + return df + + +def build_synthetic_gamma_scalping_case( + *, + snapshots: int = 90, + seed: int = 42, + initial_spot: float = 100_000.0, + strike: float = 100_000.0, +) -> tuple[pd.DataFrame, OptionInstrumentRegistry, list[OptionPackageIntent]]: + """ + Build a deterministic ATM long-straddle case for the native option engine. + + The sample intentionally keeps one listed call/put alive across the whole + tape. This isolates option-package execution, quote-side fills, MTM, + prepared-cache replay, and delta-hedge accounting without mixing in + selection/rollover noise. + """ + rng = np.random.default_rng(seed) + start = pd.Timestamp("2026-01-01 00:00:00", tz="UTC") + expiry = int((start + pd.Timedelta(days=max(30, snapshots + 10))).value) + call_id = "BTC-26MAR26-100000-C.TEST" + put_id = "BTC-26MAR26-100000-P.TEST" + registry = OptionInstrumentRegistry.from_iterable( + ( + _linear_option_spec(call_id, strike, OptionKind.CALL, expiry), + _linear_option_spec(put_id, strike, OptionKind.PUT, expiry), + ) + ) + + rows = [] + spot = float(initial_spot) + for i in range(snapshots): + ts = start + pd.Timedelta(days=i) + timestamp_ns = int(ts.value) + spot *= float(np.exp(0.0002 + rng.normal(0.0, 0.018))) + dte = max((expiry - timestamp_ns) / (24 * 60 * 60 * 1_000_000_000), 1.0) + time_value = max(800.0 * np.sqrt(dte / 365.0), 80.0) + skew = np.tanh((spot - strike) / (0.08 * strike)) + call_delta = float(np.clip(0.50 + 0.35 * skew, 0.05, 0.95)) + put_delta = call_delta - 1.0 + + call_mark = max(spot - strike, 0.0) + time_value + put_mark = max(strike - spot, 0.0) + time_value * 0.98 + for sequence_id, instrument_id, option_kind, mark, delta in ( + (0, call_id, "call", call_mark, call_delta), + (1, put_id, "put", put_mark, put_delta), + ): + spread = max(mark * 0.004, 2.0) + rows.append( + { + "timestamp_ns": timestamp_ns, + "instrument_id": instrument_id, + "venue": "TEST", + "underlying_id": "BTC-PERP.TEST", + "expiry_ns": expiry, + "strike": strike, + "option_kind": option_kind, + "bid_price": max(mark - 0.5 * spread, 0.01), + "bid_size": 100.0, + "ask_price": mark + 0.5 * spread, + "ask_size": 100.0, + "mark_price": mark, + "last_price": mark, + "index_price": spot, + "forward_price": spot, + "mark_iv": 0.55, + "bid_iv": 0.54, + "ask_iv": 0.56, + "delta": delta, + "gamma": 0.00008, + "vega": 90.0, + "theta": -8.0, + "open_interest": 500.0, + "volume": 100.0, + "quote_currency": "USD", + "settlement_currency": "USD", + "sequence_id": sequence_id, + "source_latency_ns": 1_000_000, + } + ) + + chain = pd.DataFrame(rows) + timestamps = sorted(chain["timestamp_ns"].unique()) + packages = [ + OptionPackageIntent( + timestamp_ns=int(timestamps[0]), + package_id="gamma-open-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.BUY, 1.0, role="long_call"), + OptionPackageLeg(put_id, OrderSide.BUY, 1.0, role="long_put"), + ), + quantity=1.0, + tag="gamma_scalping_entry", + metadata={"strategy": "gamma_scalping", "action": "open"}, + ), + OptionPackageIntent( + timestamp_ns=int(timestamps[-1]), + package_id="gamma-close-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.SELL, 1.0, role="close_call"), + OptionPackageLeg(put_id, OrderSide.SELL, 1.0, role="close_put"), + ), + quantity=1.0, + tag="gamma_scalping_exit", + metadata={"strategy": "gamma_scalping", "action": "close"}, + ), + ] + return chain, registry, packages + + +def run_quantbt_gamma_scalping_sample(*, snapshots: int = 90, seed: int = 42) -> dict: + """Run the synthetic gamma-scalping sample through the public options endpoint.""" + chain, registry, _ = build_synthetic_gamma_scalping_case(snapshots=snapshots, seed=seed) + strategy_run = build_gamma_scalping_strategy_run( + chain, + registry, + GammaScalpingConfig( + hedge_policy=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + ), + ) + cache = OptionPreparedRunCache.from_chain(chain, registry) + underlying = chain.groupby("timestamp_ns", sort=True)["index_price"].first() + underlying.index = pd.to_datetime(underlying.index, utc=True).tz_convert(None) + bt = QuantBTEndpoint.options( + initial_capital=100_000.0, + reporting_currency="USD", + initial_balances={"USD": 100_000.0}, + fee_rate=0.0002, + metadata={"sample": "gamma_scalping_backtestsample", "seed": seed}, + ) + uncached = bt.backtest(chain=chain, instruments=registry, strategy_run=strategy_run, underlying=underlying) + cached = bt.backtest(chain=chain, instruments=registry, strategy_run=strategy_run, underlying=underlying, prepared_cache=cache) + + final_equity_diff = float(abs(uncached.equity.iloc[-1] - cached.equity.iloc[-1])) + fills_equal = bool(uncached.fills_report.equals(cached.fills_report)) + if final_equity_diff > 1e-9 or not fills_equal: + raise RuntimeError("prepared-cache gamma sample parity failed") + + report = { + "status": "pass", + "sample": "gamma_scalping_backtestsample", + "snapshots": int(snapshots), + "chain_rows": int(len(chain)), + "packages": int(len(strategy_run.packages)), + "fills": int(len(cached.fills_report)), + "initial_equity": float(cached.equity.iloc[0]), + "final_equity": float(cached.equity.iloc[-1]), + "option_pnl": float(cached.option_equity.iloc[-1] - cached.option_equity.iloc[0]), + "hedge_pnl": float(cached.hedge_report["cumulative_hedge_pnl"].iloc[-1]), + "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), + "hedge_rebalances": int(cached.hedge_report["should_rebalance"].sum()), + "selected_contracts": cached.metadata["selected_contracts"].to_dict("records"), + "prepared_cache_used": bool(cached.metadata.get("prepared_cache_used")), + "package_cache_size": int(cached.metadata.get("package_cache_size", 0)), + "parity": { + "final_equity_abs_diff": final_equity_diff, + "fills_equal": fills_equal, + }, + "run_manifest": cached.run_manifest, + } + return report + + +def run_real_binance_gamma_scalping_sample( + *, + options_csv: Path, + underlying_source: str = "spot", + hedge_timeframe: str = "1h", +) -> dict: + """ + Run the gamma-scalping sample on a real Binance options snapshot CSV. + + The CSV is converted into QuantBT's canonical option-chain schema. BTCUSDT + spot/perp candles are loaded from `_get_data` for the hedge path; if that + loader is unavailable for the requested range, the snapshot `spot_BTCUSDT` + column is used as a transparent fallback. + """ + raw = pd.read_csv(options_csv, compression="gzip") + chain, registry = canonicalize_binance_options_history(raw) + strategy_run = build_gamma_scalping_strategy_run( + chain, + registry, + GammaScalpingConfig( + min_dte_days=10.0, + max_dte_days=21.0, + max_spread_bps=2_000.0, + hedge_policy=OptionHedgeConfig(policy=OptionHedgePolicyType.FIXED_THRESHOLD, threshold=0.05), + metadata={"source": "real_binance_csv"}, + ), + ) + cache = OptionPreparedRunCache.from_chain(chain, registry) + hedge_prices, hedge_price_source = load_underlying_prices_for_chain( + chain, + source=underlying_source, + timeframe=hedge_timeframe, + ) + + bt = QuantBTEndpoint.options( + initial_capital=100_000.0, + reporting_currency="USD", + initial_balances={"USD": 100_000.0}, + fee_rate=0.0002, + metadata={ + "sample": "real_binance_gamma_scalping", + "source_file": str(options_csv), + "underlying_source": underlying_source, + "hedge_timeframe": hedge_timeframe, + }, + ) + uncached = bt.backtest(chain=chain, instruments=registry, strategy_run=strategy_run, underlying=hedge_prices) + cached = bt.backtest( + chain=chain, + instruments=registry, + strategy_run=strategy_run, + underlying=hedge_prices, + prepared_cache=cache, + ) + final_equity_diff = float(abs(uncached.equity.iloc[-1] - cached.equity.iloc[-1])) + fills_equal = bool(uncached.fills_report.equals(cached.fills_report)) + if final_equity_diff > 1e-9 or not fills_equal: + raise RuntimeError("real Binance options prepared-cache parity failed") + + selected_contracts = cached.metadata["selected_contracts"].to_dict("records") + + report = { + "status": "pass", + "sample": "real_binance_gamma_scalping", + "source_file": str(options_csv), + "snapshots": int(chain["timestamp_ns"].nunique()), + "chain_rows": int(len(chain)), + "contracts": int(len(registry.instruments)), + "packages": int(len(strategy_run.packages)), + "fills": int(len(cached.fills_report)), + "selected": selected_contracts, + "initial_equity": float(cached.equity.iloc[0]), + "final_equity": float(cached.equity.iloc[-1]), + "option_pnl": float(cached.option_equity.iloc[-1] - cached.option_equity.iloc[0]), + "hedge_pnl": float(cached.hedge_report["cumulative_hedge_pnl"].iloc[-1]), + "combined_option_plus_hedge_pnl": float(cached.equity.iloc[-1] - cached.equity.iloc[0]), + "hedge_rebalances": int(cached.hedge_report["should_rebalance"].sum()), + "hedge_price_source": hedge_price_source, + "prepared_cache_used": bool(cached.metadata.get("prepared_cache_used")), + "package_cache_size": int(cached.metadata.get("package_cache_size", 0)), + "parity": { + "final_equity_abs_diff": final_equity_diff, + "fills_equal": fills_equal, + }, + "run_manifest": cached.run_manifest, + } + return report + + +def canonicalize_binance_options_history(raw: pd.DataFrame) -> tuple[pd.DataFrame, OptionInstrumentRegistry]: + """Convert the legacy Binance option snapshot CSV into QuantBT canonical schema.""" + required = { + "snapshot_time", + "symbol", + "spot_BTCUSDT", + "markPrice", + "bidPrice", + "askPrice", + "bidIV", + "askIV", + "markIV", + "delta", + "theta", + "gamma", + "vega", + "volume", + "strikePrice", + } + missing = sorted(required.difference(raw.columns)) + if missing: + raise ValueError(f"real options CSV missing required columns: {missing}") + + df = raw.copy() + df["snapshot_time"] = pd.to_datetime(df["snapshot_time"], utc=True, errors="coerce") + df = df.dropna(subset=["snapshot_time", "symbol"]) + parsed = df["symbol"].astype(str).str.extract(r"^(?P[A-Z]+)-(?P\d{6})-(?P\d+(?:\.\d+)?)-(?P[CP])$") + df = df.join(parsed) + df = df.dropna(subset=["underlying", "expiry", "strike", "kind"]) + df["timestamp_ns"] = df["snapshot_time"].astype("int64") + df["expiry_ns"] = df["expiry"].map(_binance_expiry_to_ns).astype("int64") + df["strike"] = pd.to_numeric(df["strike"], errors="coerce") + + numeric_pairs = { + "bidPrice": "bid_price", + "askPrice": "ask_price", + "markPrice": "mark_price", + "spot_BTCUSDT": "index_price", + "exercisePrice": "forward_price", + "bidIV": "bid_iv", + "askIV": "ask_iv", + "markIV": "mark_iv", + "delta": "delta", + "gamma": "gamma", + "vega": "vega", + "theta": "theta", + "volume": "volume", + } + for source, target in numeric_pairs.items(): + df[target] = pd.to_numeric(df[source], errors="coerce") + df["forward_price"] = df["forward_price"].fillna(df["index_price"]) + if "lastPrice" in df: + df["last_price"] = pd.to_numeric(df["lastPrice"], errors="coerce").fillna(df["mark_price"]) + else: + df["last_price"] = df["mark_price"] + df["bid_size"] = pd.to_numeric(df.get("lastQty", 1.0), errors="coerce").fillna(1.0).clip(lower=1.0) + df["ask_size"] = df["bid_size"] + df["open_interest"] = 1.0 + if "amount" in df: + df["open_interest"] = pd.to_numeric(df["amount"], errors="coerce").fillna(1.0).clip(lower=1.0) + + df = df[(df["bid_price"] > 0.0) & (df["ask_price"] > 0.0)] + df = df[df["ask_price"] >= df["bid_price"]] + df = df[df["expiry_ns"] > df["timestamp_ns"]] + df = df.dropna(subset=["strike", "index_price", "forward_price", "mark_price"]) + df = df.sort_values(["timestamp_ns", "symbol"]).reset_index(drop=True) + df["sequence_id"] = df.groupby("timestamp_ns").cumcount().astype("int64") + df["source_latency_ns"] = 1_000_000 + df["option_kind"] = np.where(df["kind"] == "C", "call", "put") + df["instrument_id"] = df["symbol"].astype(str) + ".BINANCE" + df["underlying_id"] = df["underlying"].astype(str) + "USDT.BINANCE" + df["venue"] = "BINANCE" + df["quote_currency"] = "USD" + df["settlement_currency"] = "USD" + + canonical = df[ + [ + "timestamp_ns", + "instrument_id", + "venue", + "underlying_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "last_price", + "index_price", + "forward_price", + "mark_iv", + "bid_iv", + "ask_iv", + "delta", + "gamma", + "vega", + "theta", + "open_interest", + "volume", + "quote_currency", + "settlement_currency", + "sequence_id", + "source_latency_ns", + ] + ].copy() + + specs = [] + static = canonical.drop_duplicates("instrument_id").sort_values("instrument_id") + for row in static.itertuples(index=False): + specs.append( + OptionInstrumentSpec( + symbol=row.instrument_id, + venue="binance", + underlying_id=row.underlying_id, + underlying_index_id="BTCUSDT-INDEX.BINANCE", + option_kind=OptionKind.CALL if row.option_kind == "call" else OptionKind.PUT, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=float(row.strike), + expiry_ns=int(row.expiry_ns), + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=0.001, + tick_size=0.01, + convention_version="binance_options_history_csv_v1", + ) + ) + return canonical, OptionInstrumentRegistry.from_iterable(specs) + + +def build_real_atm_straddle_packages(chain: pd.DataFrame) -> tuple[list[OptionPackageIntent], dict]: + """Select a real ATM call/put pair available at entry and exit.""" + timestamps = sorted(chain["timestamp_ns"].unique()) + entry_ts = int(timestamps[0]) + exit_ts = int(timestamps[-1]) + entry = chain[chain["timestamp_ns"] == entry_ts].copy() + exit_symbols = set(chain.loc[chain["timestamp_ns"] == exit_ts, "instrument_id"]) + entry = entry[entry["instrument_id"].isin(exit_symbols)] + pair_counts = entry.groupby(["expiry_ns", "strike"])["option_kind"].agg(lambda values: set(values)) + valid_pairs = [key for key, kinds in pair_counts.items() if kinds == {"call", "put"}] + if not valid_pairs: + raise ValueError("no entry ATM straddle pair survives until final snapshot") + spot = float(entry["index_price"].median()) + expiry_ns, strike = min(valid_pairs, key=lambda key: (abs(float(key[1]) - spot), int(key[0]))) + selected_rows = entry[(entry["expiry_ns"] == expiry_ns) & (entry["strike"] == strike)] + call_id = str(selected_rows.loc[selected_rows["option_kind"] == "call", "instrument_id"].iloc[0]) + put_id = str(selected_rows.loc[selected_rows["option_kind"] == "put", "instrument_id"].iloc[0]) + selected = { + "entry_timestamp_ns": entry_ts, + "exit_timestamp_ns": exit_ts, + "entry_time": str(pd.Timestamp(entry_ts, tz="UTC")), + "exit_time": str(pd.Timestamp(exit_ts, tz="UTC")), + "spot": spot, + "strike": float(strike), + "expiry": str(pd.Timestamp(int(expiry_ns), tz="UTC")), + "call_id": call_id, + "put_id": put_id, + } + packages = [ + OptionPackageIntent( + timestamp_ns=entry_ts, + package_id="real-gamma-open-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.BUY, 1.0, role="long_call"), + OptionPackageLeg(put_id, OrderSide.BUY, 1.0, role="long_put"), + ), + quantity=1.0, + tag="real_gamma_scalping_entry", + metadata={"strategy": "gamma_scalping", "action": "open", **selected}, + ), + OptionPackageIntent( + timestamp_ns=exit_ts, + package_id="real-gamma-close-long-straddle", + legs=( + OptionPackageLeg(call_id, OrderSide.SELL, 1.0, role="close_call"), + OptionPackageLeg(put_id, OrderSide.SELL, 1.0, role="close_put"), + ), + quantity=1.0, + tag="real_gamma_scalping_exit", + metadata={"strategy": "gamma_scalping", "action": "close", **selected}, + ), + ] + return packages, selected + + +def selected_straddle_delta_path(chain: pd.DataFrame, selected: dict) -> pd.Series: + active = chain[chain["instrument_id"].isin([selected["call_id"], selected["put_id"]])] + delta = active.groupby("timestamp_ns")["delta"].sum().sort_index() + delta.loc[int(selected["exit_timestamp_ns"])] = 0.0 + return delta.sort_index() + + +def load_underlying_prices_for_chain(chain: pd.DataFrame, *, source: str, timeframe: str) -> tuple[pd.Series, str]: + timestamps = sorted(chain["timestamp_ns"].unique()) + start = pd.Timestamp(int(timestamps[0]), tz="UTC").tz_localize(None) + end = pd.Timestamp(int(timestamps[-1]), tz="UTC").tz_localize(None) + dataset = "binance_spot_1m" if source == "spot" else "crypto_1m" + try: + get_data_path = Path("/root/bobby/pool_alpha/alphas_storage/_get_data") + if str(get_data_path) not in sys.path: + sys.path.insert(0, str(get_data_path)) + from data_loader import load_data # type: ignore + + ohlcv = load_data( + dataset, + symbols="BTCUSDT", + start_date=str(start), + end_date=str(end), + timeframe=timeframe, + check_val=False, + ) + if not ohlcv.empty: + out = ohlcv.copy() + out["timestamp_ns"] = pd.to_datetime(out["time"], utc=True).astype("int64") + series = out.set_index("timestamp_ns")["close"].sort_index() + return series, dataset + except Exception as exc: + fallback = chain.groupby("timestamp_ns")["index_price"].first().sort_index() + return fallback, f"option_chain_index_price_fallback:{exc}" + fallback = chain.groupby("timestamp_ns")["index_price"].first().sort_index() + return fallback, "option_chain_index_price_fallback:no_loader_rows" + + +def _binance_expiry_to_ns(value: str) -> int: + text = str(value) + year = 2000 + int(text[:2]) + month = int(text[2:4]) + day = int(text[4:6]) + return int(pd.Timestamp(year=year, month=month, day=day, hour=8, tz="UTC").value) + + +def _linear_option_spec(symbol: str, strike: float, kind: OptionKind, expiry_ns: int) -> OptionInstrumentSpec: + return OptionInstrumentSpec( + symbol=symbol, + venue="test", + underlying_id="BTC-PERP.TEST", + underlying_index_id="BTC-INDEX.TEST", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry_ns, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + tick_size=0.01, + convention_version="gamma_scalping_synthetic_linear_v1", + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a QuantBT options gamma-scalping smoke sample.") + parser.add_argument("--snapshots", type=int, default=90) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--real-options-csv", type=Path, default=None) + parser.add_argument("--underlying-source", choices=("spot", "perp"), default="spot") + parser.add_argument("--hedge-timeframe", default="1h") + parser.add_argument("--output-json", type=Path, default=None) + args = parser.parse_args() + + if args.real_options_csv is not None: + report = run_real_binance_gamma_scalping_sample( + options_csv=args.real_options_csv, + underlying_source=args.underlying_source, + hedge_timeframe=args.hedge_timeframe, + ) + else: + report = run_quantbt_gamma_scalping_sample(snapshots=args.snapshots, seed=args.seed) + payload = json.dumps(report, indent=2, default=str) + if args.output_json is not None: + args.output_json.write_text(payload + "\n", encoding="utf-8") + print(payload) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/phase7_thresholds.json b/src/quantbt/benchmarks/phase7_thresholds.json new file mode 100644 index 0000000..40adafd --- /dev/null +++ b/src/quantbt/benchmarks/phase7_thresholds.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "notes": [ + "Thresholds are guardrails, not hard promises across every machine.", + "Use the same machine and Python environment when comparing commits.", + "Cython/C++ escalation requires repeated threshold misses after profiling identifies a hot loop." + ], + "native_vectorized": { + "smoke_max_runtime_seconds": 0.25, + "standard_max_seconds_per_million_bar_symbols": 1.5, + "large_max_seconds_per_million_bar_symbols": 1.0 + }, + "native_event": { + "smoke_max_runtime_seconds": 0.35, + "standard_max_seconds_per_100k_orders": 1.25, + "large_max_seconds_per_100k_orders": 0.9 + }, + "native_event_prepared": { + "smoke_max_runtime_seconds": 0.2, + "standard_max_seconds_per_100k_orders": 1.5, + "large_max_seconds_per_100k_orders": 1.1, + "purpose": "higher-level WFO/service replay with validated prepared market arrays and compiled orders" + }, + "portfolio_legacy": { + "smoke_max_runtime_seconds": 0.5, + "standard_max_seconds_per_million_bar_symbols": 2.5, + "large_max_seconds_per_million_bar_symbols": 2.0, + "purpose": "multi-symbol portfolio matrix diagnostics and attribution" + }, + "native_portfolio": { + "smoke_max_runtime_seconds": 0.5, + "standard_max_seconds_per_million_bar_symbols": 2.5, + "large_max_seconds_per_million_bar_symbols": 2.0, + "purpose": "explicit Phase 11B native portfolio route with legacy parity" + }, + "nautilus": { + "smoke_max_runtime_seconds": 5.0, + "standard_max_seconds_per_100k_bars": 8.0, + "purpose": "validation oracle, not optimizer hot path" + }, + "memory": { + "native_vectorized_max_peak_mb_per_million_bar_symbols": 180.0, + "native_event_max_peak_mb_per_100k_orders": 80.0 + } +} diff --git a/src/quantbt/benchmarks/profile_phase7.py b/src/quantbt/benchmarks/profile_phase7.py new file mode 100644 index 0000000..2fef9ba --- /dev/null +++ b/src/quantbt/benchmarks/profile_phase7.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +""" +Phase 7 profiling follow-up. + +This script decomposes the two Phase 7 threshold misses into timing buckets so +optimization work can target the real layer: pandas normalization, ndarray +packing, order-array construction, pure Numba kernels, or result/report +construction. +""" + +from __future__ import annotations + +import argparse +import json +import math +import statistics +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt.benchmarks.run_phase7 import PROFILES, BenchmarkProfile, _make_market_frames, _make_orders, _make_signals + + +@dataclass(frozen=True) +class ProfileStage: + backend: str + profile: str + stage: str + seconds: float + percent_of_profile: float + repeats: int + notes: str = "" + + +@dataclass(frozen=True) +class BackendProfile: + backend: str + profile: str + bars: int + symbols: int + orders: int + total_seconds: float + stages: List[ProfileStage] + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Profile QuantBT Phase 7 backend layers.") + parser.add_argument("--profile", choices=sorted(PROFILES), default="smoke") + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase7_profile.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase7_profile.md") + args = parser.parse_args(argv) + + base = PROFILES[args.profile] + profile = BenchmarkProfile( + name=base.name, + bars=base.bars, + symbols=base.symbols, + order_count=base.order_count, + repeats=max(1, int(args.repeats)), + ) + records = [profile_native_vectorized(profile), profile_native_event(profile)] + write_outputs(records, args.json_out, args.md_out) + for record in records: + print(f"{record.backend}: total={record.total_seconds:.6f}s") + for stage in record.stages: + print(f" {stage.stage}: {stage.seconds:.6f}s ({stage.percent_of_profile:.1f}%)") + return 0 + + +def profile_native_vectorized(profile: BenchmarkProfile) -> BackendProfile: + import numpy as np + import pandas as pd + + from quantbt import AccountConfig, ExecutionConfig + from quantbt.core.preprocessor import align_series, build_arrays, prepare_funding, validate_datetime + from quantbt.core.results import BacktestResultV2 + from quantbt.core.vectorized import _engine_units_v2 + from quantbt.sizing.fast import scale_signal_notional_matrix + + idx, frames = _make_market_frames(profile.bars, profile.symbols) + signals = _make_signals(idx, profile.symbols) + symbols = list(frames.keys()) + account = AccountConfig(initial_capital=1_000_000.0, leverage=10.0) + execution = ExecutionConfig() + + def normalize(): + local_idx = validate_datetime(idx) + closes = {symbol: frames[symbol]["close"] for symbol in symbols} + highs = {symbol: frames[symbol]["high"] for symbol in symbols} + lows = {symbol: frames[symbol]["low"] for symbol in symbols} + close_dict = align_series(closes, symbols, local_idx) + high_dict = align_series(highs, symbols, local_idx, fallback=close_dict) + low_dict = align_series(lows, symbols, local_idx, fallback=close_dict) + signal_dict = align_series(signals, symbols, local_idx, fill_val=0.0) + funding_dict = prepare_funding(0.0, symbols, local_idx) + return local_idx, close_dict, high_dict, low_dict, signal_dict, funding_dict + + idx_n, close_dict, high_dict, low_dict, signal_dict, funding_dict = normalize() + + def pack_arrays(): + return build_arrays( + symbols=symbols, + idx=idx_n, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + signals_dict=signal_dict, + funding_dict=funding_dict, + ) + + closes_m, highs_m, lows_m, signals_m, funding_m, is_funding = pack_arrays() + allocs = np.full(len(symbols), 10_000.0, dtype=np.float64) + + def size_targets(): + return scale_signal_notional_matrix(signals_m, closes_m, allocs, use_pyramiding=True) + + target_m = size_targets() + leverages = np.full(len(symbols), account.leverage, dtype=np.float64) + fee_rates = np.zeros(len(symbols), dtype=np.float64) + contract_sizes = np.ones(len(symbols), dtype=np.float64) + + def kernel(): + return _engine_units_v2( + n_bars=len(idx_n), + n_syms=len(symbols), + highs=highs_m, + lows=lows_m, + closes=closes_m, + target_units=target_m, + funding_rates=funding_m, + is_funding_bar=is_funding, + init_capital=account.initial_capital, + leverages=leverages, + maint_ratio=account.maintenance_ratio, + fee_rates=fee_rates, + contract_sizes=contract_sizes, + slippage=execution.slippage_rate, + use_funding=False, + ) + + kernel_out = kernel() + + def build_result(): + ( + equity_arr, + pos_arr, + fee_arr, + turnover_arr, + funding_arr, + init_margin_arr, + maint_margin_arr, + rejected_arr, + reject_code_arr, + liq_flag, + liq_idx, + _liq_reason, + ) = kernel_out + equity = pd.Series(equity_arr, index=idx_n, name="equity") + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=pd.DataFrame({f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbols)}, index=idx_n), + closes=pd.DataFrame({f"Close_{s}": closes_m[:, j] for j, s in enumerate(symbols)}, index=idx_n), + symbols=symbols, + initial_capital=account.initial_capital, + leverage=account.leverage, + liquidated=bool(liq_flag), + liquidation_bar=int(liq_idx), + fees=pd.Series(fee_arr, index=idx_n, name="fees"), + funding=pd.Series(funding_arr, index=idx_n, name="funding"), + margin=pd.DataFrame({"initial_margin": init_margin_arr, "maintenance_margin": maint_margin_arr}, index=idx_n), + diagnostics=pd.DataFrame( + {"turnover": turnover_arr, "rejected_orders": rejected_arr, "reject_code": reject_code_arr}, + index=idx_n, + ), + metadata={"backend": "native_vectorized", "engine": "units_v2_profile"}, + ) + + stage_defs = [ + ("data_normalization", normalize, "validate_datetime + align OHLC/signals/funding"), + ("pandas_to_ndarray", pack_arrays, "build contiguous market/signal arrays"), + ("target_sizing", size_targets, "compute fast signal_notional target-unit matrix"), + ("pure_numba_kernel", kernel, "compiled _engine_units_v2 only"), + ("result_report_construction", build_result, "Series/DataFrame/BacktestResultV2 construction"), + ] + return _profile_backend("native_vectorized", profile, profile.order_count, stage_defs) + + +def profile_native_event(profile: BenchmarkProfile) -> BackendProfile: + import numpy as np + import pandas as pd + + from quantbt import AccountConfig, ExecutionConfig + from quantbt.core.event import _engine_event_v1 + from quantbt.core.order_compiler import compile_order_intents + from quantbt.core.preprocessor import align_series, build_market_arrays, prepare_funding, validate_datetime + from quantbt.core.results import BacktestResultV2 + + idx, frames = _make_market_frames(profile.bars, profile.symbols) + orders = _make_orders(idx, profile.order_count, profile.symbols) + symbols = list(frames.keys()) + account = AccountConfig(initial_capital=1_000_000.0, leverage=10.0) + execution = ExecutionConfig() + + def normalize(): + local_idx = validate_datetime(idx) + closes = {symbol: frames[symbol]["close"] for symbol in symbols} + highs = {symbol: frames[symbol]["high"] for symbol in symbols} + lows = {symbol: frames[symbol]["low"] for symbol in symbols} + close_dict = align_series(closes, symbols, local_idx) + high_dict = align_series(highs, symbols, local_idx, fallback=close_dict) + low_dict = align_series(lows, symbols, local_idx, fallback=close_dict) + funding_dict = prepare_funding(0.0, symbols, local_idx) + return local_idx, close_dict, high_dict, low_dict, funding_dict + + idx_n, close_dict, high_dict, low_dict, funding_dict = normalize() + + def pack_arrays(): + return build_market_arrays( + symbols=symbols, + idx=idx_n, + closes_dict=close_dict, + highs_dict=high_dict, + lows_dict=low_dict, + funding_dict=funding_dict, + ) + + market_arrays = pack_arrays() + symbol_to_col = {symbol: j for j, symbol in enumerate(symbols)} + + def build_order_arrays(): + return compile_order_intents(idx=idx_n, orders=orders, symbol_to_col=symbol_to_col) + + order_arrays = build_order_arrays() + leverages = np.full(len(symbols), account.leverage, dtype=np.float64) + fee_rates = np.zeros(len(symbols), dtype=np.float64) + contract_sizes = np.ones(len(symbols), dtype=np.float64) + + def kernel(): + return _engine_event_v1( + n_bars=len(idx_n), + n_syms=len(symbols), + n_orders=len(orders), + order_ptr=order_arrays.order_ptr, + order_symbol=order_arrays.order_symbol, + order_side=order_arrays.order_side, + order_type=order_arrays.order_type, + order_qty=order_arrays.order_qty, + order_price=order_arrays.order_price, + order_tif=order_arrays.order_tif, + highs=market_arrays.highs, + lows=market_arrays.lows, + closes=market_arrays.closes, + funding_rates=market_arrays.funding, + is_funding_bar=market_arrays.is_funding_bar, + init_capital=account.initial_capital, + leverages=leverages, + maint_ratio=account.maintenance_ratio, + fee_rates=fee_rates, + contract_sizes=contract_sizes, + slippage=execution.slippage_rate, + use_funding=False, + ) + + kernel_out = kernel() + + def build_result(): + ( + equity_arr, + pos_arr, + fee_arr, + turnover_arr, + funding_arr, + init_margin_arr, + maint_margin_arr, + rejected_bar, + canceled_bar, + order_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + liq_flag, + liq_idx, + _liq_reason, + ) = kernel_out + equity = pd.Series(equity_arr, index=idx_n, name="equity") + order_report = pd.DataFrame( + { + "original_index": order_arrays.original_index, + "status": order_status, + "reject_code": reject_code, + "fill_bar": fill_bar, + "fill_qty": fill_qty, + "fill_price": fill_price, + "fill_fee": fill_fee, + } + ).sort_values("original_index", kind="stable") + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=pd.DataFrame({f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbols)}, index=idx_n), + closes=pd.DataFrame({f"Close_{s}": market_arrays.closes[:, j] for j, s in enumerate(symbols)}, index=idx_n), + symbols=symbols, + initial_capital=account.initial_capital, + leverage=account.leverage, + liquidated=bool(liq_flag), + liquidation_bar=int(liq_idx), + orders=tuple(orders), + fees=pd.Series(fee_arr, index=idx_n, name="fees"), + funding=pd.Series(funding_arr, index=idx_n, name="funding"), + margin=pd.DataFrame({"initial_margin": init_margin_arr, "maintenance_margin": maint_margin_arr}, index=idx_n), + diagnostics=pd.DataFrame( + {"turnover": turnover_arr, "rejected_orders": rejected_bar, "canceled_orders": canceled_bar}, + index=idx_n, + ), + metadata={"backend": "native_event", "engine": "event_v1_profile", "order_report": order_report}, + ) + + stage_defs = [ + ("data_normalization", normalize, "validate_datetime + align OHLC/funding"), + ("pandas_to_ndarray", pack_arrays, "build contiguous market arrays"), + ("order_array_construction", build_order_arrays, "compile orders with vectorized timestamp mapping"), + ("pure_numba_kernel", kernel, "compiled _engine_event_v1 only"), + ("result_report_construction", build_result, "order report + Series/DataFrame/BacktestResultV2"), + ] + return _profile_backend("native_event", profile, len(orders), stage_defs) + + +def _profile_backend( + backend: str, + profile: BenchmarkProfile, + orders: int, + stage_defs: Sequence[Tuple[str, object, str]], +) -> BackendProfile: + raw: List[Tuple[str, float, str]] = [] + for name, fn, notes in stage_defs: + fn() + timings = [] + for _ in range(profile.repeats): + start = time.perf_counter() + fn() + timings.append(time.perf_counter() - start) + raw.append((name, statistics.mean(timings), notes)) + total = sum(seconds for _, seconds, _ in raw) + stages = [ + ProfileStage( + backend=backend, + profile=profile.name, + stage=name, + seconds=seconds, + percent_of_profile=(seconds / total * 100.0) if total > 0.0 else 0.0, + repeats=profile.repeats, + notes=notes, + ) + for name, seconds, notes in raw + ] + return BackendProfile( + backend=backend, + profile=profile.name, + bars=profile.bars, + symbols=profile.symbols, + orders=orders, + total_seconds=total, + stages=stages, + ) + + +def write_outputs(records: Sequence[BackendProfile], json_out: Path, md_out: Path) -> None: + json_out.parent.mkdir(parents=True, exist_ok=True) + md_out.parent.mkdir(parents=True, exist_ok=True) + payload = {"records": [asdict(record) for record in records]} + json_out.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + md_out.write_text(markdown_report(records), encoding="utf-8") + + +def markdown_report(records: Sequence[BackendProfile]) -> str: + lines = [ + "# Phase 7 Profiling Results", + "", + "| backend | stage | seconds | share | notes |", + "| --- | --- | ---: | ---: | --- |", + ] + for record in records: + for stage in record.stages: + lines.append( + f"| `{stage.backend}` | `{stage.stage}` | {_fmt(stage.seconds)} | {stage.percent_of_profile:.1f}% | {stage.notes} |" + ) + lines.extend( + [ + "", + "Interpretation rule: optimize the largest measured bucket first. Cython/C++ is only justified after pure Numba kernel profiling remains the bottleneck.", + ] + ) + return "\n".join(lines) + "\n" + + +def _fmt(value: Optional[float]) -> str: + if value is None or (isinstance(value, float) and math.isnan(value)): + return "-" + return f"{value:.6f}" + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_arbitrage_phase_e.py b/src/quantbt/benchmarks/run_arbitrage_phase_e.py new file mode 100644 index 0000000..80e4b15 --- /dev/null +++ b/src/quantbt/benchmarks/run_arbitrage_phase_e.py @@ -0,0 +1,160 @@ +""" +Phase E arbitrage benchmark smoke runner. + +Run from repository root: + + PYTHONPATH=/root/bobby/pool_alpha python3 quantbt/benchmarks/run_arbitrage_phase_e.py +""" + +from __future__ import annotations + +from dataclasses import dataclass +from time import perf_counter + +import pandas as pd + +from quantbt import ( + ArbExecutionPolicy, + ArbitrageLeg, + BasisArbitrageSpec, + ContractType, + HedgePolicy, + HedgePolicyKind, + NativeEventBackend, + NativeEventConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, + PackageExecutionKind, + SizingPolicy, + SizingPolicyKind, + StatArbPairSpec, +) +from quantbt.core.schema import AccountConfig + + +@dataclass(frozen=True) +class ArbBenchmarkProfile: + name: str + bars: int + + +PROFILES = { + "smoke": ArbBenchmarkProfile(name="smoke", bars=512), + "standard": ArbBenchmarkProfile(name="standard", bars=10_000), +} + + +def run(profile: ArbBenchmarkProfile = PROFILES["smoke"]) -> list[dict]: + idx = pd.date_range("2024-01-01", periods=profile.bars, freq="1h", tz="UTC") + records = [] + for name, runner in ( + ("basis_event", _run_basis_event), + ("basis_vectorized", _run_basis_vectorized), + ("stat_event", _run_stat_event), + ("stat_vectorized", _run_stat_vectorized), + ): + started = perf_counter() + result = runner(idx) + elapsed = perf_counter() - started + records.append( + { + "name": name, + "bars": profile.bars, + "seconds": elapsed, + "final_equity": float(result.equity.iloc[-1]), + "engine": result.metadata["engine"], + } + ) + return records + + +def _basis_spec(): + return BasisArbitrageSpec( + arb_id="BENCH_BASIS", + legs=( + ArbitrageLeg( + symbol="PERP", + ratio=-1.0, + role="perp", + contract_type=ContractType.LINEAR, + qty_step=0.001, + min_qty=0.001, + ), + ArbitrageLeg( + symbol="QUARTERLY", + ratio=1.0, + role="quarterly", + contract_type=ContractType.LINEAR, + qty_step=0.001, + min_qty=0.001, + ), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BASE_QTY_EQUAL), + sizing_policy=SizingPolicy( + kind=SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=10_000.0, + reference_symbol="PERP", + ), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + + +def _stat_spec(): + return StatArbPairSpec( + arb_id="BENCH_STAT", + legs=(ArbitrageLeg(symbol="BASE", ratio=1.0), ArbitrageLeg(symbol="HEDGE", ratio=-0.5)), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BETA_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=10_000.0), + ) + + +def _basis_data(idx): + steps = pd.Series(range(len(idx)), index=idx, dtype=float) + base = 100.0 + (steps % 31) * 0.1 + return {"PERP": base, "QUARTERLY": base + 2.0} + + +def _stat_data(idx): + steps = pd.Series(range(len(idx)), index=idx, dtype=float) + base = 50.0 + (steps % 17) * 0.2 + return {"BASE": base, "HEDGE": base * 2.0 + 1.0} + + +def _signal(idx): + signal = pd.Series(0.0, index=idx) + signal.iloc[1::200] = 1.0 + signal.iloc[100::200] = 0.0 + return signal.ffill() + + +def _event_backend(): + return NativeEventBackend( + NativeEventConfig(account=AccountConfig(initial_capital=100_000.0, leverage=10.0), use_funding=False) + ) + + +def _vectorized_backend(): + return NativeVectorizedBackend( + NativeVectorizedConfig(account=AccountConfig(initial_capital=100_000.0, leverage=10.0), use_funding=False) + ) + + +def _run_basis_event(idx): + return _event_backend().run_basis_arbitrage(idx, _basis_spec(), _signal(idx), _basis_data(idx)) + + +def _run_basis_vectorized(idx): + return _vectorized_backend().run_basis_arbitrage(idx, _basis_spec(), _signal(idx), _basis_data(idx)) + + +def _run_stat_event(idx): + return _event_backend().run_stat_arb_pair_arbitrage(idx, _stat_spec(), _signal(idx), _stat_data(idx)) + + +def _run_stat_vectorized(idx): + return _vectorized_backend().run_stat_arb_pair_arbitrage(idx, _stat_spec(), _signal(idx), _stat_data(idx)) + + +if __name__ == "__main__": + for record in run(PROFILES["standard"]): + print(record) diff --git a/src/quantbt/benchmarks/run_optimization_overhead.py b/src/quantbt/benchmarks/run_optimization_overhead.py new file mode 100644 index 0000000..6f6156e --- /dev/null +++ b/src/quantbt/benchmarks/run_optimization_overhead.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Phase 32C optimization overhead and prepared-evaluator benchmark.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +import time + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + GenericEndpointEvaluator, + IntrabarIntentTape, + ObjectiveResult, + OptimizationConfig, + OptunaOptimizer, + PreparedSignalEvaluator, + QuantBTEndpoint, + SamplerConfig, +) + + +def run_benchmark(rows: int = 360, trials: int = 24, loops: int = 24) -> dict: + df = _frame(rows) + optimizer_seconds = _optimizer_overhead(trials) + normal_seconds, prepared_seconds, signal_diff = _signal_replay_benchmark(df, loops) + first_intrabar, warm_intrabar, intrabar_diff = _intrabar_compile_benchmark(df) + status = "pass" if signal_diff <= 1e-9 and intrabar_diff <= 1e-9 else "fail" + return { + "status": status, + "rows": int(rows), + "trials": int(trials), + "loops": int(loops), + "optimizer_overhead_seconds": float(optimizer_seconds), + "optimizer_overhead_per_trial_seconds": float(optimizer_seconds / max(1, trials)), + "normal_signal_replay_seconds": float(normal_seconds), + "prepared_signal_replay_seconds": float(prepared_seconds), + "prepared_signal_speedup": float(normal_seconds / prepared_seconds) if prepared_seconds > 0 else 0.0, + "signal_final_equity_diff": float(signal_diff), + "intrabar_first_run_seconds": float(first_intrabar), + "intrabar_warm_run_seconds": float(warm_intrabar), + "intrabar_compile_to_warm_ratio": float(first_intrabar / warm_intrabar) if warm_intrabar > 0 else 0.0, + "intrabar_final_equity_diff": float(intrabar_diff), + } + + +def make_markdown(report: dict) -> str: + return "\n".join( + [ + "# Phase 32C Optimization Overhead Benchmark", + "", + f"Status: **{report['status']}**", + "", + "| Measurement | Value |", + "|---|---:|", + f"| Optimizer overhead | `{report['optimizer_overhead_seconds']:.6f}s` |", + f"| Optimizer overhead / trial | `{report['optimizer_overhead_per_trial_seconds']:.6f}s` |", + f"| Normal signal replays | `{report['normal_signal_replay_seconds']:.6f}s` |", + f"| Prepared signal replays | `{report['prepared_signal_replay_seconds']:.6f}s` |", + f"| Prepared signal speedup | `{report['prepared_signal_speedup']:.3f}x` |", + f"| Intrabar first run | `{report['intrabar_first_run_seconds']:.6f}s` |", + f"| Intrabar warm run | `{report['intrabar_warm_run_seconds']:.6f}s` |", + f"| Intrabar first/warm ratio | `{report['intrabar_compile_to_warm_ratio']:.3f}x` |", + "", + "Parity checks:", + "", + f"- Signal final equity diff: `{report['signal_final_equity_diff']}`", + f"- Intrabar final equity diff: `{report['intrabar_final_equity_diff']}`", + "", + "This benchmark measures facade/optimizer overhead, not strategy quality.", + ] + ) + "\n" + + +def _optimizer_overhead(trials: int) -> float: + evaluator = GenericEndpointEvaluator( + build_run_inputs=lambda params: {"value": float(params["x"])}, + run_func=lambda value: value, + objective_builder=lambda result, params: ObjectiveResult.scalar(float(result), metrics={"score": float(result)}), + ) + optimizer = OptunaOptimizer( + evaluator=evaluator, + config=OptimizationConfig( + study_name=f"phase32c_overhead_{time.time_ns()}", + n_trials=int(trials), + seed=42, + show_progress_bar=False, + duplicate_policy="allow", + ), + sampler_config=SamplerConfig(name="random"), + ) + start = time.perf_counter() + optimizer.optimize(param_ranges={"x": (0.0, 1.0)}) + return time.perf_counter() - start + + +def _signal_replay_benchmark(df: pd.DataFrame, loops: int): + endpoint = QuantBTEndpoint.signal_notional( + backend="native_vectorized", + initial_capital=20_000.0, + leverage=5.0, + alloc_per_trade=1_000.0, + fee_rate=0.0, + use_funding=False, + ) + signal = pd.Series(np.where(df["close"].diff().fillna(0.0) > 0.0, 1.0, 0.0), index=df.index) + normal = endpoint.backtest(data=df, signal=signal, symbols=["BTC"]) + prepared = endpoint.prepare_service_context(data=df, symbols=["BTC"]) + prepared_result = prepared.backtest(signal=signal) + diff = abs(float(normal.equity.iloc[-1]) - float(prepared_result.equity.iloc[-1])) + + start = time.perf_counter() + for _ in range(int(loops)): + endpoint.backtest(data=df, signal=signal, symbols=["BTC"]) + normal_seconds = time.perf_counter() - start + + evaluator = PreparedSignalEvaluator( + prepared_context=prepared, + strategy_func=lambda params: signal, + objective_builder=lambda result, params: ObjectiveResult.scalar(float(result.equity.iloc[-1])), + ) + start = time.perf_counter() + for _ in range(int(loops)): + evaluator.evaluate({}) + prepared_seconds = time.perf_counter() - start + return normal_seconds, prepared_seconds, diff + + +def _intrabar_compile_benchmark(df: pd.DataFrame): + endpoint = QuantBTEndpoint.intrabar_bracket( + initial_capital=20_000.0, + leverage=5.0, + fee_rate=0.0, + slippage_bps=0.0, + use_funding=False, + report_level="minimal", + ) + runner = endpoint.prepare_intrabar(data=df, symbols=["BTC"]) + entry = np.zeros(len(df)) + entry[0] = 1.0 + intent = IntrabarIntentTape.from_arrays(entry_side=entry, entry_size=np.abs(entry)) + + start = time.perf_counter() + first = runner.run(intent, report_level="minimal") + first_seconds = time.perf_counter() - start + start = time.perf_counter() + warm = runner.run(intent, report_level="minimal") + warm_seconds = time.perf_counter() - start + diff = abs(float(first.equity.iloc[-1]) - float(warm.equity.iloc[-1])) + return first_seconds, warm_seconds, diff + + +def _frame(rows: int) -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=int(rows), freq="1h", tz="UTC") + x = np.linspace(0.0, 16.0, len(idx)) + close = 100.0 + np.sin(x) * 2.0 + np.arange(len(idx)) * 0.01 + return pd.DataFrame( + { + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=360) + parser.add_argument("--trials", type=int, default=24) + parser.add_argument("--loops", type=int, default=24) + parser.add_argument("--json", type=Path, default=PACKAGE_DIR / "benchmarks" / "results" / "optimization_overhead.json") + parser.add_argument("--markdown", type=Path, default=PACKAGE_DIR / "benchmarks" / "results" / "optimization_overhead.md") + args = parser.parse_args() + report = run_benchmark(rows=args.rows, trials=args.trials, loops=args.loops) + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + args.markdown.write_text(make_markdown(report)) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_options_engine.py b/src/quantbt/benchmarks/run_options_engine.py new file mode 100644 index 0000000..6ccc4cd --- /dev/null +++ b/src/quantbt/benchmarks/run_options_engine.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Phase 10 options-engine benchmark and parity guard.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import tracemalloc +from pathlib import Path +from typing import Dict, Sequence + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + ExerciseStyle, + NativeOptionBackend, + NativeOptionConfig, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + OptionPackageIntent, + OptionPackageLeg, + OptionPreparedRunCache, + OrderSide, + PremiumConvention, + SettlementStyle, +) + + +def run_benchmark(*, snapshots: int, contracts: int, packages: int, repeats: int, seed: int) -> Dict: + rng = np.random.default_rng(seed) + registry = _registry(contracts) + chain = _chain(registry, snapshots=snapshots, rng=rng) + package_list = _packages(registry, chain, packages=packages) + config = NativeOptionConfig(initial_balances={"USD": 100_000.0}, reporting_currency="USD", random_seed=seed) + backend = NativeOptionBackend(config) + + uncached = backend.run(chain=chain, instruments=registry, packages=package_list) + cache = OptionPreparedRunCache.from_chain(chain, registry) + cached = backend.run(chain=chain, instruments=registry, packages=package_list, prepared_cache=cache) + parity = _parity(uncached, cached) + + uncached_seconds = _timeit(lambda: backend.run(chain=chain, instruments=registry, packages=package_list), repeats) + cached_seconds = _timeit(lambda: backend.run(chain=chain, instruments=registry, packages=package_list, prepared_cache=cache), repeats) + peak_mb = _peak_memory_mb(lambda: backend.run(chain=chain, instruments=registry, packages=package_list, prepared_cache=cache)) + return { + "phase": "options_phase10", + "status": "pass" if parity["passed"] else "fail", + "seed": int(seed), + "snapshots": int(snapshots), + "contracts": int(contracts), + "quotes": int(len(chain)), + "packages": int(len(package_list)), + "fills": int(len(cached.fills_report)), + "hedges": 0, + "memory_peak_mb": float(peak_mb), + "uncached_seconds": float(uncached_seconds), + "cached_seconds": float(cached_seconds), + "cache_speedup": float(uncached_seconds / cached_seconds) if cached_seconds > 0.0 else 0.0, + "package_cache_size": int(cache.package_cache_size), + "parity": parity, + "run_manifest": cached.run_manifest, + "cython_cpp_recommendation": ( + "not_recommended_yet: Phase 10 benchmark still targets pandas/tape/package facade and cache reuse; " + "collect pure-kernel profile evidence before Cython/C++." + ), + } + + +def make_markdown(report: Dict) -> str: + lines = [ + "# Options Engine Phase 10 Benchmark", + "", + f"Status: **{report['status']}**", + "", + "| metric | value |", + "| --- | ---: |", + f"| snapshots | `{report['snapshots']}` |", + f"| contracts | `{report['contracts']}` |", + f"| quotes | `{report['quotes']}` |", + f"| packages | `{report['packages']}` |", + f"| fills | `{report['fills']}` |", + f"| hedges | `{report['hedges']}` |", + f"| peak memory MB | `{report['memory_peak_mb']:.3f}` |", + f"| uncached seconds | `{report['uncached_seconds']:.6f}` |", + f"| cached seconds | `{report['cached_seconds']:.6f}` |", + f"| cache speedup | `{report['cache_speedup']:.3f}x` |", + f"| package cache size | `{report['package_cache_size']}` |", + "", + "## Parity Guard", + "", + f"- Passed: `{report['parity']['passed']}`", + f"- Final equity abs diff: `{report['parity']['final_equity_abs_diff']:.12f}`", + f"- Position max abs diff: `{report['parity']['position_max_abs_diff']:.12f}`", + f"- Fills equal: `{report['parity']['fills_equal']}`", + "", + "## Manifest", + "", + f"- Data hash: `{report['run_manifest'].get('data_hash')}`", + f"- Margin model: `{report['run_manifest'].get('margin_model')}`", + f"- Pricing model: `{report['run_manifest'].get('pricing_model')}`", + f"- Fidelity: `{report['run_manifest'].get('fidelity_manifest')}`", + "", + "## Cython / C++ Decision", + "", + report["cython_cpp_recommendation"], + "", + ] + return "\n".join(lines) + + +def _registry(contracts: int) -> OptionInstrumentRegistry: + expiry = int(pd.Timestamp("2026-03-01 08:00:00", tz="UTC").value) + specs = [] + for i in range(contracts): + strike = 80_000.0 + 1_000.0 * i + kind = OptionKind.CALL if i % 2 == 0 else OptionKind.PUT + specs.append( + OptionInstrumentSpec( + symbol=f"BTC-O{i:04d}.TEST", + venue="test", + underlying_id="BTC-PERP.TEST", + underlying_index_id="BTC-INDEX.TEST", + option_kind=kind, + exercise_style=ExerciseStyle.EUROPEAN, + premium_convention=PremiumConvention.LINEAR_QUOTE, + settlement_style=SettlementStyle.CASH, + strike=strike, + expiry_ns=expiry, + settlement_currency="USD", + premium_currency="USD", + quote_currency="USD", + multiplier=1.0, + contract_size=1.0, + qty_step=1.0, + tick_size=0.01, + convention_version="phase10_linear_benchmark_v1", + ) + ) + return OptionInstrumentRegistry.from_iterable(specs) + + +def _chain(registry: OptionInstrumentRegistry, *, snapshots: int, rng) -> pd.DataFrame: + start = pd.Timestamp("2026-01-01 00:00:00", tz="UTC") + rows = [] + for t in range(snapshots): + ts = int((start + pd.Timedelta(minutes=15 * t)).value) + index_price = 100_000.0 + 100.0 * np.sin(t / 10.0) + for code, spec in enumerate(registry.instruments): + intrinsic = max(index_price - spec.strike, 0.0) if spec.option_kind is OptionKind.CALL else max(spec.strike - index_price, 0.0) + time_value = 500.0 + 5.0 * code + float(rng.normal(0.0, 1.0)) + mark = max(intrinsic + time_value, 1.0) + rows.append( + { + "timestamp_ns": ts, + "instrument_id": spec.symbol, + "venue": "TEST", + "underlying_id": spec.underlying_id, + "expiry_ns": spec.expiry_ns, + "strike": spec.strike, + "option_kind": spec.option_kind.value, + "bid_price": mark * 0.995, + "bid_size": 50.0, + "ask_price": mark * 1.005, + "ask_size": 50.0, + "mark_price": mark, + "last_price": mark, + "index_price": index_price, + "forward_price": index_price, + "mark_iv": 0.6, + "bid_iv": 0.59, + "ask_iv": 0.61, + "delta": 0.5 if spec.option_kind is OptionKind.CALL else -0.5, + "gamma": 0.0001, + "vega": 100.0, + "theta": -10.0, + "open_interest": 1000.0, + "volume": 100.0, + "quote_currency": "USD", + "settlement_currency": "USD", + "sequence_id": code, + "source_latency_ns": 1_000_000, + } + ) + return pd.DataFrame(rows) + + +def _packages(registry: OptionInstrumentRegistry, chain: pd.DataFrame, *, packages: int) -> Sequence[OptionPackageIntent]: + timestamps = sorted(chain["timestamp_ns"].unique()) + symbols = list(registry.symbols) + out = [] + for i in range(packages): + ts = int(timestamps[i % len(timestamps)]) + symbol = symbols[i % len(symbols)] + side = OrderSide.BUY if i % 2 == 0 else OrderSide.SELL + out.append( + OptionPackageIntent( + timestamp_ns=ts, + package_id=f"bench-{i:05d}", + legs=(OptionPackageLeg(symbol, side, 1.0),), + quantity=1.0, + ) + ) + return tuple(out) + + +def _parity(a, b) -> Dict: + equity_diff = float(abs(a.equity.iloc[-1] - b.equity.iloc[-1])) + position_diff = float(np.max(np.abs(a.positions.to_numpy() - b.positions.to_numpy()))) + fills_equal = bool(a.fills_report.equals(b.fills_report)) + return { + "passed": bool(equity_diff <= 1e-9 and position_diff <= 1e-12 and fills_equal), + "final_equity_abs_diff": equity_diff, + "position_max_abs_diff": position_diff, + "fills_equal": fills_equal, + } + + +def _timeit(fn, repeats: int) -> float: + durations = [] + for _ in range(max(1, repeats)): + start = time.perf_counter() + fn() + durations.append(time.perf_counter() - start) + return float(min(durations)) + + +def _peak_memory_mb(fn) -> float: + tracemalloc.start() + try: + fn() + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + return peak / 1_000_000.0 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--snapshots", type=int, default=96) + parser.add_argument("--contracts", type=int, default=48) + parser.add_argument("--packages", type=int, default=96) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output-json", type=Path, default=PACKAGE_DIR / "benchmarks" / "options_phase10_baseline.json") + parser.add_argument("--output-md", type=Path, default=PACKAGE_DIR / "benchmarks" / "options_phase10_baseline.md") + args = parser.parse_args() + + report = run_benchmark( + snapshots=args.snapshots, + contracts=args.contracts, + packages=args.packages, + repeats=args.repeats, + seed=args.seed, + ) + args.output_json.write_text(json.dumps(report, indent=2, default=str) + "\n", encoding="utf-8") + args.output_md.write_text(make_markdown(report), encoding="utf-8") + print(json.dumps({"status": report["status"], "cache_speedup": report["cache_speedup"]}, indent=2)) + if report["status"] != "pass": + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/run_pct_equity_nautilus_smoke.py b/src/quantbt/benchmarks/run_pct_equity_nautilus_smoke.py new file mode 100644 index 0000000..df9bdc0 --- /dev/null +++ b/src/quantbt/benchmarks/run_pct_equity_nautilus_smoke.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Smoke compare native legacy `%_equity` and Nautilus `%_equity` validation.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict, Optional + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import QuantBTEndpoint # noqa: E402 +from quantbt.adapters.nautilus import NautilusBackendConfig # noqa: E402 + + +def run_smoke(rows: int = 300) -> Dict: + data = _synthetic_eth_data(rows=rows) + scenarios = [ + { + "name": "aligned_fee_no_funding_no_slippage", + "native_fee_round_trip": 0.0008, + "native_use_funding": False, + "native_slippage": 0.0, + "nautilus_fee_rate": 0.0004, + "nautilus_use_funding": False, + "nautilus_slippage": 0.0, + "note": "Native one-way fee approximates ETH taker fee; custom Nautilus fee_rate is not applied.", + }, + { + "name": "user_like_mismatch", + "native_fee_round_trip": 0.0005, + "native_use_funding": True, + "native_slippage": 0.0002, + "nautilus_fee_rate": 0.0005, + "nautilus_use_funding": False, + "nautilus_slippage": 0.0002, + "note": "Matches the observed notebook-style mismatch: fee convention, funding, and slippage differ.", + }, + ] + results = [] + for scenario in scenarios: + results.append(_run_scenario(data, scenario)) + return { + "status": "pass", + "rows": int(rows), + "symbol": "ETHUSDT-PERP.BINANCE", + "scenarios": results, + "conclusion": _conclusion(results), + } + + +def make_markdown(report: Dict) -> str: + lines = [ + "# `%_equity` Native vs Nautilus Smoke", + "", + f"Status: **{report['status']}**", + f"Rows: `{report['rows']}`", + f"Symbol: `{report['symbol']}`", + "", + ] + for item in report["scenarios"]: + lines.extend( + [ + f"## {item['name']}", + "", + f"- Native final equity: `{item['native']['final_equity']:.6f}`", + f"- Nautilus final equity: `{item['nautilus']['final_equity']:.6f}`", + f"- Final equity diff: `{item['final_equity_diff']:.6f}`", + f"- Native trades: `{item['native']['num_trades']}`", + f"- Nautilus trades: `{item['nautilus']['num_trades']}`", + f"- Signal transitions: `{item['diagnostic']['signal']['effective_transition_count']}`", + f"- Nautilus orders/fills: `{item['diagnostic']['orders']['orders_count']}` / `{item['diagnostic']['orders']['fills_count']}`", + f"- Checks: `{item['diagnostic']['checks']}`", + f"- Note: {item['note']}", + "", + ] + ) + lines.extend(["## Conclusion", "", report["conclusion"], ""]) + return "\n".join(lines) + + +def _run_scenario(data: pd.DataFrame, scenario: Dict) -> Dict: + native = QuantBTEndpoint.pct_equity( + initial_capital=20_000, + leverage=5, + maintenance_ratio=0.005, + contract_size=1.0, + use_funding=bool(scenario["native_use_funding"]), + funding_rate=0.0001, + alloc_per_trade=0.5, + fee=float(scenario["native_fee_round_trip"]), + slippage=float(scenario["native_slippage"]), + use_pyramiding=False, + ) + native_result = native.backtest(data=data, signal_col="pos_weight") + + nautilus = QuantBTEndpoint.nautilus_validation( + initial_capital=20_000, + leverage=5, + alloc_per_trade=0.5, + hedge_type="%_equity", + fee_rate=float(scenario["nautilus_fee_rate"]), + use_funding=bool(scenario["nautilus_use_funding"]), + use_pyramiding=False, + slippage=float(scenario["nautilus_slippage"]), + nautilus_config=NautilusBackendConfig( + timeframe="1h", + starting_balance=20_000, + trade_notional=0.5, + close_positions_on_stop=False, + bypass_logging=True, + log_level="ERROR", + ), + ) + nautilus_result = nautilus.simulate( + data=data, + signal_col="pos_weight", + symbols=["ETHUSDT-PERP.BINANCE"], + show_order_logs=False, + ) + diagnostic = nautilus.nautilus_pct_equity_diagnostic( + data=data, + signal_col="pos_weight", + native_fee_round_trip=float(scenario["native_fee_round_trip"]), + native_use_funding=bool(scenario["native_use_funding"]), + native_slippage=float(scenario["native_slippage"]), + ) + native_report = native_result.full_report() + nautilus_report = nautilus_result.full_report() + return { + "name": scenario["name"], + "note": scenario["note"], + "native": { + "final_equity": float(native_result.equity.iloc[-1]), + "total_return_pct": float(native_report["total_return_pct"]), + "num_trades": int(native_report["num_trades"]), + }, + "nautilus": { + "final_equity": float(nautilus_result.equity.iloc[-1]), + "total_return_pct": float(nautilus_report["total_return_pct"]), + "num_trades": int(nautilus_report["num_trades"]), + }, + "final_equity_diff": float(nautilus_result.equity.iloc[-1] - native_result.equity.iloc[-1]), + "diagnostic": _jsonable_diagnostic(diagnostic), + } + + +def _synthetic_eth_data(rows: int) -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=rows, freq="1h", tz="UTC") + grid = np.arange(rows) + close = pd.Series(2000 + 80 * np.sin(grid / 18) + 0.8 * grid + 20 * np.sin(grid / 5), index=idx) + signal = pd.Series(0.0, index=idx) + signal.iloc[10 : min(80, rows)] = 1.0 + signal.iloc[min(110, rows) : min(170, rows)] = -1.0 + signal.iloc[min(210, rows) : min(260, rows)] = 1.0 + return pd.DataFrame( + { + "open": close, + "high": close * 1.002, + "low": close * 0.998, + "close": close, + "volume": 10_000.0, + "pos_weight": signal, + }, + index=idx, + ) + + +def _conclusion(results) -> str: + aligned = next(item for item in results if item["name"] == "aligned_fee_no_funding_no_slippage") + mismatch = next(item for item in results if item["name"] == "user_like_mismatch") + return ( + "When fee/funding/slippage semantics are aligned as closely as the current adapters allow, " + f"the synthetic final-equity gap is only `{aligned['final_equity_diff']:.6f}` USD and order/fill counts match. " + "The user-like setup intentionally differs: legacy `fee` is round-trip, Nautilus `fee_rate` is metadata today, " + "native funding/slippage are applied while Nautilus signal validation does not apply custom funding/slippage. " + f"That scenario shows a larger synthetic gap of `{mismatch['final_equity_diff']:.6f}` USD. " + "Large real-alpha gaps should be audited with the diagnostic helper first; if transition counts match, the next " + "production task is implementing custom fee/slippage/funding in the Nautilus signal adapter." + ) + + +def _jsonable_diagnostic(diagnostic: Dict) -> Dict: + out = dict(diagnostic) + out["signal"] = dict(out["signal"]) + transition = out["signal"].pop("transition_report") + out["signal"]["transition_report_head"] = transition.head(20).to_dict(orient="records") + return out + + +def _json_default(value): + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating,)): + return float(value) + if isinstance(value, (np.bool_,)): + return bool(value) + if isinstance(value, pd.Timestamp): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=300) + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "pct_equity_nautilus_smoke.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "pct_equity_nautilus_smoke.md") + args = parser.parse_args(argv) + report = run_smoke(rows=args.rows) + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.md_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2, default=_json_default) + "\n", encoding="utf-8") + args.md_out.write_text(make_markdown(report), encoding="utf-8") + print(make_markdown(report)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase12_arbitrage_cert.py b/src/quantbt/benchmarks/run_phase12_arbitrage_cert.py new file mode 100644 index 0000000..c247435 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase12_arbitrage_cert.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +""" +Phase 12A arbitrage production-certification smoke runner. + +The runner uses deterministic realistic market data inspired by the local +Arbops Binance basis-arb alpha, but it does not import or commit that private +alpha. The copied alpha sandbox, if present, must live under +`.local_arbitrage_sandboxes/` and is intentionally git-ignored. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + AccountConfig, + ArbExecutionPolicy, + ArbitrageLeg, + BasisArbitrageSpec, + ContractType, + CrossExchangeArbSpec, + ExecutionConfig, + HedgePolicy, + HedgePolicyKind, + IndexBasketArbSpec, + NativeEventBackend, + NativeEventConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, + OptionsVolArbSpec, + PackageExecutionKind, + SignalModel, + SignalModelKind, + SizingPolicy, + SizingPolicyKind, + StatArbPairSpec, + TriangularArbSpec, + build_arbitrage_order_plan, + compare_native_arbitrage_results, + build_arbitrage_domain_audit, +) + + +def generate_basis_market(rows: int = 900, seed: int = 42): + rng = np.random.default_rng(seed) + idx = pd.date_range("2023-01-01", periods=rows, freq="1h", tz="UTC") + base_ret = rng.normal(0.0, 0.006, size=rows) + perp = 25_000.0 * np.exp(np.cumsum(base_ret)) + basis = 550.0 * np.exp(-np.linspace(0.0, 4.5, rows)) + 65.0 * np.sin(np.linspace(0.0, 18.0, rows)) + basis += rng.normal(0.0, 18.0, size=rows) + quarterly = np.maximum(perp + basis, 1.0) + spread = pd.Series(perp - quarterly, index=idx) + z = (spread - spread.rolling(48, min_periods=12).mean()) / spread.rolling(48, min_periods=12).std() + signal = pd.Series(np.where(z > 1.0, 1.0, np.where(z < -1.0, -1.0, 0.0)), index=idx).ffill().fillna(0.0) + # Force a terminal flat state so audit can verify package flattening. + signal.iloc[-3:] = 0.0 + + closes = { + "perpetual": pd.Series(perp, index=idx), + "quarterly": pd.Series(quarterly, index=idx), + } + highs = {symbol: series * 1.002 for symbol, series in closes.items()} + lows = {symbol: series * 0.998 for symbol, series in closes.items()} + funding = { + "perpetual": pd.Series(0.00004 + rng.normal(0.0, 0.00001, size=rows), index=idx), + "quarterly": pd.Series(0.0, index=idx), + } + return idx, signal, closes, highs, lows, funding + + +def basis_spec() -> BasisArbitrageSpec: + return BasisArbitrageSpec( + arb_id="PHASE12_BTC_PERP_QUARTERLY", + legs=( + ArbitrageLeg( + "perpetual", + 1.0, + role="perp", + contract_type=ContractType.LINEAR, + contract_size=1.0, + funding_enabled=True, + ), + ArbitrageLeg( + "quarterly", + -1.0, + role="quarterly", + contract_type=ContractType.LINEAR, + contract_size=1.0, + funding_enabled=False, + ), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy( + kind=SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=20_000.0, + reference_symbol="perpetual", + ), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + + +def stat_spec() -> StatArbPairSpec: + return StatArbPairSpec( + arb_id="PHASE12_STAT_PAIR", + legs=( + ArbitrageLeg("asset_a", 1.0, role="base", contract_type=ContractType.LINEAR), + ArbitrageLeg("asset_b", -1.0, role="hedge", contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=30_000.0), + signal_model=SignalModel(kind=SignalModelKind.ZSCORE), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + + +def run_certification(rows: int = 900, include_nautilus: bool = False) -> Dict: + idx, signal, closes, highs, lows, funding = generate_basis_market(rows=rows) + account = AccountConfig(initial_capital=100_000.0, leverage=8.0, maintenance_ratio=0.005) + execution = ExecutionConfig(slippage_bps=0.0) + event = NativeEventBackend(NativeEventConfig(account=account, execution=execution, fee_rate=0.0002, use_funding=True)) + vector = NativeVectorizedBackend(NativeVectorizedConfig(account=account, execution=execution, fee_rate=0.0002, use_funding=True)) + + spec = basis_spec() + event_result = event.run_basis_arbitrage(idx, spec, signal, closes, highs=highs, lows=lows, funding_rate=funding) + vector_result = vector.run_basis_arbitrage(idx, spec, signal, closes, highs=highs, lows=lows, funding_rate=funding) + basis_audit = build_arbitrage_domain_audit(event_result, raise_on_fail=False) + basis_parity = compare_native_arbitrage_results(event_result, vector_result, raise_on_fail=False) + + stat_idx, stat_signal, stat_closes, stat_highs, stat_lows, stat_funding = _stat_market(rows=rows) + stat_event = event.run_stat_arb_pair_arbitrage(stat_idx, stat_spec(), stat_signal, stat_closes, highs=stat_highs, lows=stat_lows, funding_rate=stat_funding) + stat_vector = vector.run_stat_arb_pair_arbitrage(stat_idx, stat_spec(), stat_signal, stat_closes, highs=stat_highs, lows=stat_lows, funding_rate=stat_funding) + stat_audit = build_arbitrage_domain_audit(stat_event, raise_on_fail=False) + stat_parity = compare_native_arbitrage_results(stat_event, stat_vector, raise_on_fail=False) + + basket_report = _index_basket_smoke(event, vector, rows) + schema_report = _schema_only_report(event, vector, idx, signal, closes) + nautilus_report = _optional_nautilus_report(idx, spec, signal, closes, include_nautilus) + + passed = bool( + basis_audit["passed"] + and basis_parity["passed"] + and _accounting_parity_passed(stat_parity) + and basket_report["passed"] + and schema_report["passed"] + and nautilus_report["status"] in {"pass", "skipped"} + ) + return { + "status": "pass" if passed else "fail", + "sandbox_path": str(PACKAGE_DIR / ".local_arbitrage_sandboxes" / "binance_basis_arb"), + "basis": _result_summary(event_result, vector_result, basis_audit, basis_parity), + "stat_pair": { + "event_final_equity": float(stat_event.equity.iloc[-1]), + "vectorized_final_equity": float(stat_vector.equity.iloc[-1]), + "accounting_parity_passed": _accounting_parity_passed(stat_parity), + "audit": stat_audit, + "parity": stat_parity, + "package_report_columns": list(stat_event.metadata["package_pnl_report"].columns), + "max_package_residual": float(stat_event.metadata["package_pnl_report"]["pnl_residual"].abs().max()), + }, + "index_basket": basket_report, + "schema_only": schema_report, + "nautilus": nautilus_report, + } + + +def make_markdown(report: Dict) -> str: + basis = report["basis"] + lines = [ + "# Phase 12A Arbitrage Production Certification", + "", + f"Status: **{report['status']}**", + f"Sandbox path: `{report['sandbox_path']}`", + "", + "## Basis Perp-Quarterly", + "", + f"- Event final equity: `{basis['event_final_equity']:.6f}`", + f"- Vectorized final equity: `{basis['vectorized_final_equity']:.6f}`", + f"- Max equity diff: `{basis['parity']['max_abs_equity_diff']}`", + f"- Audit status: `{basis['audit']['status']}`", + f"- Orders: `{basis['order_count']}`", + f"- Fills: `{basis['fill_count']}`", + f"- Fees: `{basis['fee_total']:.6f}`", + f"- Funding: `{basis['funding_total']:.6f}`", + "", + "## Other Certification Checks", + "", + f"- Stat pair accounting parity: `{report['stat_pair']['accounting_parity_passed']}`", + f"- Stat pair audit status: `{report['stat_pair']['audit']['status']}`", + f"- Stat pair package-residual report: `{report['stat_pair']['parity']['checks'].get('package_residuals_ok')}`", + f"- Stat pair max package residual: `{report['stat_pair']['max_package_residual']}`", + f"- Index basket package smoke: `{report['index_basket']['status']}`", + f"- Schema-only guardrails: `{report['schema_only']['status']}`", + f"- Nautilus package parity: `{report['nautilus']['status']}`", + ] + return "\n".join(lines) + "\n" + + +def _stat_market(rows: int): + idx = pd.date_range("2023-01-01", periods=rows, freq="1h", tz="UTC") + t = np.linspace(0.0, 12.0, rows) + a = 100.0 + np.cumsum(np.sin(t) * 0.05 + 0.1) + b = 50.0 + np.cumsum(np.sin(t + 0.4) * 0.025 + 0.05) + spread = pd.Series(a - 2.0 * b, index=idx) + z = (spread - spread.rolling(36, min_periods=12).mean()) / spread.rolling(36, min_periods=12).std() + signal = pd.Series(np.where(z > 1.0, -1.0, np.where(z < -1.0, 1.0, 0.0)), index=idx).fillna(0.0) + signal.iloc[-3:] = 0.0 + closes = {"asset_a": pd.Series(a, index=idx), "asset_b": pd.Series(b, index=idx)} + highs = {s: c * 1.001 for s, c in closes.items()} + lows = {s: c * 0.999 for s, c in closes.items()} + funding = {s: pd.Series(0.0, index=idx) for s in closes} + return idx, signal, closes, highs, lows, funding + + +def _index_basket_smoke(event: NativeEventBackend, vector: NativeVectorizedBackend, rows: int) -> Dict: + idx = pd.date_range("2023-01-01", periods=rows, freq="1h", tz="UTC") + closes = { + "ETF": pd.Series(100.0 + np.linspace(0.0, 3.0, rows), index=idx), + "A": pd.Series(30.0 + np.linspace(0.0, 1.0, rows), index=idx), + "B": pd.Series(70.0 + np.linspace(0.0, 2.0, rows), index=idx), + } + signal = pd.Series(0.0, index=idx) + signal.iloc[20: rows // 2] = 1.0 + signal.iloc[-3:] = 0.0 + spec = IndexBasketArbSpec( + arb_id="PHASE12_INDEX_BASKET", + legs=(ArbitrageLeg("ETF", -1.0), ArbitrageLeg("A", 1.0), ArbitrageLeg("B", 1.0)), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.NOTIONAL_NEUTRAL, freeze_on_entry=True), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=30_000.0), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + event_result = event.run_package_arbitrage(idx, spec, signal, closes) + vector_result = vector.run_package_arbitrage(idx, spec, signal, closes) + parity = compare_native_arbitrage_results(event_result, vector_result, raise_on_fail=False) + return {"status": "pass" if parity["passed"] else "fail", "passed": bool(parity["passed"]), "parity": parity} + + +def _schema_only_report(event: NativeEventBackend, vector: NativeVectorizedBackend, idx, signal, closes) -> Dict: + probes = {} + specs = { + "cross_exchange": CrossExchangeArbSpec( + arb_id="X", + legs=( + ArbitrageLeg("BINANCE_BTCUSDT", 1.0, venue="BINANCE"), + ArbitrageLeg("OKX_BTCUSDT", -1.0, venue="OKX"), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.NOTIONAL_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=10_000.0), + ), + "triangular": TriangularArbSpec( + arb_id="T", + legs=( + ArbitrageLeg("BTCUSDT", 1.0, base_currency="BTC", quote_currency="USDT"), + ArbitrageLeg("ETHBTC", 1.0, base_currency="ETH", quote_currency="BTC"), + ArbitrageLeg("ETHUSDT", -1.0, base_currency="ETH", quote_currency="USDT"), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.NOTIONAL_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=10_000.0), + ), + "options_vol": OptionsVolArbSpec( + arb_id="O", + legs=( + ArbitrageLeg("BTC_CALL", 1.0, contract_type=ContractType.OPTION), + ArbitrageLeg("BTC_PERP", -0.5, contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.VEGA_NEUTRAL), + sizing_policy=SizingPolicy(kind=SizingPolicyKind.TARGET_GROSS_NOTIONAL, notional=10_000.0), + ), + } + for name, spec in specs.items(): + backend_rejections = {} + for backend_name, backend in (("native_event", event), ("native_vectorized", vector)): + try: + backend.run_package_arbitrage(idx, spec, signal, closes) + backend_rejections[backend_name] = {"rejected": False, "error": None} + except NotImplementedError as exc: + backend_rejections[backend_name] = {"rejected": True, "error": type(exc).__name__, "message": str(exc)} + probes[name] = backend_rejections + passed = all( + all(route["rejected"] for route in backend_rejections.values()) + for backend_rejections in probes.values() + ) + return {"status": "pass" if passed else "fail", "passed": passed, "probes": probes} + + +def _optional_nautilus_report(idx, spec, signal, closes, include_nautilus: bool) -> Dict: + if not include_nautilus: + return {"status": "skipped", "reason": "run with --include-nautilus"} + try: + from quantbt import QuantBTEndpoint + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt_symbols = ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE") + nt_spec = BasisArbitrageSpec( + arb_id="PHASE12_NAUTILUS_PACKAGE_SMOKE", + legs=( + ArbitrageLeg(nt_symbols[0], 1.0, role="perp", contract_type=ContractType.LINEAR), + ArbitrageLeg(nt_symbols[1], -1.0, role="quarterly", contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(kind=HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy( + kind=SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=50_000.0, + reference_symbol=nt_symbols[0], + ), + execution_policy=ArbExecutionPolicy(kind=PackageExecutionKind.ATOMIC_ALL_OR_NONE), + metadata={"certification_note": "Nautilus supported-instrument package smoke; not a quarterly venue model."}, + ) + source_series = [closes["perpetual"], closes["quarterly"]] + data = { + symbol: pd.DataFrame( + { + "open": close, + "close": close, + "high": close * 1.001, + "low": close * 0.999, + "volume": 10_000.0, + }, + index=idx, + ) + for symbol, close in zip(nt_symbols, source_series) + } + endpoint = QuantBTEndpoint.arbitrage( + arb_type="basis", + spec=nt_spec, + backend="nautilus", + initial_capital=100_000.0, + leverage=8.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig(timeframe="1h", instrument_id=nt_symbols[0], bypass_risk=True), + ) + result = endpoint.simulate(data=data, signal=signal, symbols=list(nt_symbols)) + return {"status": "pass", "orders": int(result.metadata.get("orders_count", 0)), "fills": int(result.metadata.get("fills_count", 0))} + except Exception as exc: + return {"status": "skipped", "reason": f"{type(exc).__name__}: {exc}"} + + +def _result_summary(event_result, vector_result, audit, parity): + return { + "event_final_equity": float(event_result.equity.iloc[-1]), + "vectorized_final_equity": float(vector_result.equity.iloc[-1]), + "order_count": int(len(event_result.metadata.get("order_report", []))), + "fill_count": int(len(event_result.fills)), + "fee_total": float(event_result.fees.sum()), + "funding_total": float(event_result.funding.sum()), + "audit": audit, + "parity": parity, + } + + +def _accounting_parity_passed(parity: Dict) -> bool: + checks = parity.get("checks", {}) + return bool( + checks.get("equity_matches") + and checks.get("positions_match") + and checks.get("target_units_match") + and checks.get("package_residuals_ok") + ) + + +def _json_default(value): + if isinstance(value, (np.bool_,)): + return bool(value) + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating,)): + return float(value) + if isinstance(value, pd.Timestamp): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=900) + parser.add_argument("--include-nautilus", action="store_true") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_arbitrage_cert.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_arbitrage_cert.md") + args = parser.parse_args(argv) + report = run_certification(rows=args.rows, include_nautilus=args.include_nautilus) + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.md_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2, default=_json_default) + "\n", encoding="utf-8") + args.md_out.write_text(make_markdown(report), encoding="utf-8") + print(make_markdown(report)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py b/src/quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py new file mode 100644 index 0000000..08c81e1 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +""" +Phase 12B benchmark follow-up and Nautilus portfolio certification runner. + +The runner keeps production claims narrow and auditable: + +* benchmark stages separate full facade time from array preparation, pure + Numba portfolio kernel time, and report-construction residual time; +* Nautilus portfolio validation is optional because it depends on the external + NautilusTrader package and venue adapter state; +* all-or-none basket package semantics are certified through the deterministic + QuantBT depth preflight used before Nautilus package replay. +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +from pathlib import Path +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + AccountConfig, + NautilusExecutionDepthConfig, + OrderIntent, + OrderSide, + OrderType, + PortfolioBacktestEngine, + QuantBTEndpoint, + TimeInForce, + simulate_nautilus_order_package_depth, +) +from quantbt.backends import NativePortfolioBackend, NativePortfolioConfig # noqa: E402 +from quantbt.core.engine import _engine_portfolio # noqa: E402 +from quantbt.core.preprocessor import align_series, build_market_arrays, build_signal_matrix, prepare_funding, validate_datetime # noqa: E402 +from quantbt.sizing.fast import scale_signal_notional_matrix # noqa: E402 + + +def run_certification( + *, + rows: int = 2_000, + symbols: int = 6, + repeats: int = 3, + include_nautilus: bool = False, +) -> Dict: + benchmark = _benchmark_native_portfolio(rows=rows, symbols=symbols, repeats=repeats) + all_or_none = _all_or_none_basket_depth_smoke() + nautilus = _optional_real_nautilus_portfolio(include_nautilus=include_nautilus) + passed = ( + benchmark["status"] == "pass" + and all_or_none["status"] == "pass" + and nautilus["status"] in {"pass", "skipped", "diff"} + ) + return { + "status": "pass" if passed else "fail", + "benchmark_followup": benchmark, + "all_or_none_basket": all_or_none, + "nautilus_portfolio": nautilus, + "cython_cpp_recommendation": _cython_cpp_recommendation(benchmark), + } + + +def make_markdown(report: Dict) -> str: + bench = report["benchmark_followup"] + lines = [ + "# Phase 12B Benchmark And Nautilus Portfolio Certification", + "", + f"Status: **{report['status']}**", + "", + "## Benchmark Follow-Up", + "", + f"- Bars: `{bench['rows']}`", + f"- Symbols: `{bench['symbols']}`", + f"- Repeats: `{bench['repeats']}`", + f"- Full facade seconds: `{bench['stages']['full_facade_seconds']:.6f}`", + f"- Prepared reuse facade seconds: `{bench['stages']['prepared_reuse_facade_seconds']:.6f}`", + f"- Prepared reuse speedup: `{bench['stages']['prepared_reuse_speedup']:.3f}x`", + f"- Array preparation seconds: `{bench['stages']['array_preparation_seconds']:.6f}`", + f"- Pure Numba kernel seconds: `{bench['stages']['pure_numba_kernel_seconds']:.6f}`", + f"- Report construction residual seconds: `{bench['stages']['report_construction_estimate_seconds']:.6f}`", + f"- Pure kernel share: `{bench['stages']['pure_kernel_share_pct']:.2f}%`", + "", + "## Nautilus Portfolio", + "", + f"- Status: `{report['nautilus_portfolio']['status']}`", + f"- Validation status: `{report['nautilus_portfolio'].get('validation_status')}`", + f"- Equity tolerance profile: `{report['nautilus_portfolio'].get('equity_tolerance')}`", + f"- Position tolerance profile: `{report['nautilus_portfolio'].get('position_tolerance')}`", + f"- Final equity diff: `{report['nautilus_portfolio'].get('final_equity_diff')}`", + f"- Max position diff: `{report['nautilus_portfolio'].get('max_abs_position_diff')}`", + "", + "## All-Or-None Basket", + "", + f"- Status: `{report['all_or_none_basket']['status']}`", + f"- Input orders: `{report['all_or_none_basket']['input_orders']}`", + f"- Accepted orders: `{report['all_or_none_basket']['accepted_orders']}`", + f"- Rejected orders: `{report['all_or_none_basket']['rejected_orders']}`", + f"- Depth model: `{report['all_or_none_basket']['depth_model']}`", + "", + "## Cython/C++ Decision", + "", + report["cython_cpp_recommendation"], + ] + return "\n".join(lines) + "\n" + + +def _benchmark_native_portfolio(rows: int, symbols: int, repeats: int) -> Dict: + idx, positions, closes, highs, lows = _make_portfolio_fixture(rows, symbols) + account = AccountConfig(initial_capital=250_000.0, leverage=5.0, maintenance_ratio=0.005) + alloc = 10_000.0 + fee_rate = 0.0002 + fee_oneway = fee_rate / 2.0 + + def full_facade(): + return PortfolioBacktestEngine( + positions=positions, + closes=closes, + highs=highs, + lows=lows, + datetime_index=idx, + mode="longshort", + backend="native_portfolio", + account=account, + fee_rate=fee_rate, + alloc_per_trade=alloc, + hedge_type="signal_notional", + use_funding=False, + ).result + + backend = NativePortfolioBackend(NativePortfolioConfig(account=account, fee_rate=fee_oneway, use_funding=False)) + symbol_list = list(positions.keys()) + prepared_market = backend.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=0.0, + symbols=symbol_list, + ) + prepared_signals = backend.prepare_signal_matrix(positions, idx, symbol_list) + + def prepared_reuse(): + return backend.run_signals( + positions=None, + closes=closes, + highs=highs, + lows=lows, + datetime_index=idx, + mode="longshort", + alloc_per_trade=alloc, + contract_size=1.0, + hedge_type="signal_notional", + funding_rate=0.0, + leverage=account.leverage, + maintenance_ratio=account.maintenance_ratio, + symbols=symbol_list, + use_pyramiding=True, + market_arrays=prepared_market, + raw_signal_matrix=prepared_signals, + ) + + prepared = _prepare_portfolio_arrays(idx, positions, closes, highs, lows, account, alloc, fee_oneway) + _kernel_portfolio(prepared) + full_facade() + prepared_reuse() + + prep_seconds = _timeit(lambda: _prepare_portfolio_arrays(idx, positions, closes, highs, lows, account, alloc, fee_oneway), repeats) + kernel_seconds = _timeit(lambda: _kernel_portfolio(prepared), repeats) + full_seconds = _timeit(full_facade, repeats) + prepared_reuse_seconds = _timeit(prepared_reuse, repeats) + report_seconds = max(0.0, full_seconds - prep_seconds - kernel_seconds) + status = "pass" if full_seconds > 0.0 and kernel_seconds > 0.0 else "fail" + return { + "status": status, + "rows": int(rows), + "symbols": int(symbols), + "bar_symbols": int(rows * symbols), + "repeats": int(repeats), + "stages": { + "full_facade_seconds": float(full_seconds), + "prepared_reuse_facade_seconds": float(prepared_reuse_seconds), + "array_preparation_seconds": float(prep_seconds), + "pure_numba_kernel_seconds": float(kernel_seconds), + "report_construction_estimate_seconds": float(report_seconds), + "prepared_reuse_speedup": float(full_seconds / prepared_reuse_seconds) if prepared_reuse_seconds > 0.0 else 0.0, + "array_preparation_share_pct": float(prep_seconds / full_seconds * 100.0) if full_seconds > 0.0 else 0.0, + "pure_kernel_share_pct": float(kernel_seconds / full_seconds * 100.0) if full_seconds > 0.0 else 0.0, + "report_construction_share_pct": float(report_seconds / full_seconds * 100.0) if full_seconds > 0.0 else 0.0, + }, + "notes": ( + "Prepared-array cache targets WFO/service loops. Pure Numba kernel " + "remains separated from pandas normalization and report construction." + ), + } + + +def _optional_real_nautilus_portfolio(include_nautilus: bool) -> Dict: + if not include_nautilus: + return {"status": "skipped", "reason": "run with --include-nautilus"} + try: + from quantbt.adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine + + NautilusBacktestEngine.check_available() + idx, raw_positions, raw_closes, raw_highs, raw_lows = _make_portfolio_fixture(rows=96, symbols=2) + symbols = ["BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE"] + raw_symbols = list(raw_positions.keys()) + positions = {symbols[i]: raw_positions[raw_symbols[i]] for i in range(2)} + closes = {symbols[i]: raw_closes[raw_symbols[i]] for i in range(2)} + highs = {symbols[i]: raw_highs[raw_symbols[i]] for i in range(2)} + lows = {symbols[i]: raw_lows[raw_symbols[i]] for i in range(2)} + data = { + symbol: pd.DataFrame( + { + "open": closes[symbol], + "high": highs[symbol], + "low": lows[symbol], + "close": closes[symbol], + "volume": 1_000.0, + }, + index=idx, + ) + for symbol in symbols + } + endpoint = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="nautilus", + initial_capital=100_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + hedge_type="signal_notional", + alloc_per_trade={symbols[0]: 1_000_000.0, symbols[1]: 750_000.0}, + metadata={ + "portfolio_nautilus_equity_tolerance": 1.0, + "portfolio_nautilus_position_tolerance": 0.005, + }, + nautilus_config=NautilusBackendConfig(instrument_id=symbols[0], timeframe="1h", bypass_risk=True), + ) + result = endpoint.simulate(data=data, positions=pd.DataFrame(positions), symbols=symbols) + validation = result.metadata.get("portfolio_nautilus_validation_report", {}) + return { + "status": "pass" if validation.get("status") == "pass" else "diff", + "validation_status": validation.get("status"), + "checks": validation.get("checks", {}), + "equity_tolerance": validation.get("equity_tolerance"), + "position_tolerance": validation.get("position_tolerance"), + "expected_order_count": validation.get("expected_order_count"), + "nautilus_orders": validation.get("nautilus_orders"), + "nautilus_fills": validation.get("nautilus_fills"), + "final_equity_diff": validation.get("final_equity_diff"), + "max_abs_position_diff": validation.get("max_abs_position_diff"), + } + except Exception as exc: + return {"status": "skipped", "reason": f"{type(exc).__name__}: {exc}"} + + +def _all_or_none_basket_depth_smoke() -> Dict: + idx = pd.date_range("2024-01-01", periods=4, freq="1h", tz="UTC") + data = { + "BTC": _depth_frame(idx, close=100.0, high=101.0, low=95.0), + "ETH": _depth_frame(idx, close=50.0, high=51.0, low=49.0), + } + meta = {"package_id": "PHASE12-BASKET", "package_type": "basket_package"} + orders = ( + OrderIntent(idx[1], "BTC", OrderSide.BUY, OrderType.LIMIT, qty=1.0, price=96.0, tif=TimeInForce.GTC, metadata=meta), + OrderIntent(idx[1], "ETH", OrderSide.BUY, OrderType.LIMIT, qty=1.0, price=45.0, tif=TimeInForce.GTC, metadata=meta), + ) + result = simulate_nautilus_order_package_depth( + orders, + data, + NautilusExecutionDepthConfig(all_or_none_packages=True), + ) + package_status = result.package_report["status"].tolist() if not result.package_report.empty else [] + passed = len(result.orders) == 0 and result.metadata.get("rejected_orders") == 2 and package_status == ["rejected"] + return { + "status": "pass" if passed else "fail", + "input_orders": int(result.metadata.get("input_orders", len(orders))), + "accepted_orders": int(result.metadata.get("accepted_orders", len(result.orders))), + "rejected_orders": int(result.metadata.get("rejected_orders", 0)), + "package_status": package_status, + "depth_model": result.metadata.get("depth_model"), + } + + +def _make_portfolio_fixture(rows: int, symbols: int, symbol_prefix: str = "SYM"): + idx = pd.date_range("2022-01-01", periods=rows, freq="1h", tz="UTC") + grid = np.arange(rows) + base = 100.0 + np.cumsum(np.sin(grid / 19.0) * 0.08 + np.cos(grid / 37.0) * 0.02) + positions = {} + closes = {} + highs = {} + lows = {} + for j in range(symbols): + symbol = f"{symbol_prefix}{j:03d}" if symbol_prefix.endswith("SYM") else f"{symbol_prefix}{j}" + close = pd.Series(base * (1.0 + j * 0.015) + j * 3.0, index=idx) + raw = np.where(((grid // (18 + j % 4)) + j) % 4 == 0, 1.0, 0.0) + sign = 1.0 if j % 2 == 0 else -1.0 + positions[symbol] = pd.Series(raw * sign, index=idx) + closes[symbol] = close + highs[symbol] = close * 1.002 + lows[symbol] = close * 0.998 + return idx, positions, closes, highs, lows + + +def _prepare_portfolio_arrays(idx, positions, closes, highs, lows, account, alloc, fee_rate): + idx = validate_datetime(idx) + symbols = list(positions.keys()) + close_dict = align_series(closes, symbols, idx) + high_dict = align_series(highs, symbols, idx, fallback=close_dict) + low_dict = align_series(lows, symbols, idx, fallback=close_dict) + pos_dict = align_series(positions, symbols, idx, fill_val=0.0) + funding_dict = prepare_funding(0.0, symbols, idx) + market = build_market_arrays(symbols, idx, close_dict, high_dict, low_dict, funding_dict) + raw_signals = build_signal_matrix(symbols, idx, pos_dict) + alloc_arr = np.full(len(symbols), float(alloc), dtype=np.float64) + contract_sizes = np.ones(len(symbols), dtype=np.float64) + leverages = np.full(len(symbols), float(account.leverage), dtype=np.float64) + target_units = scale_signal_notional_matrix(raw_signals, market.closes, alloc_arr, use_pyramiding=True) + return { + "n_bars": len(idx), + "n_syms": len(symbols), + "highs": market.highs, + "lows": market.lows, + "closes": market.closes, + "target_units": target_units, + "funding": market.funding, + "is_funding_bar": market.is_funding_bar, + "initial_capital": float(account.initial_capital), + "leverages": leverages, + "maintenance_ratio": float(account.maintenance_ratio), + "fee_rate": float(fee_rate), + "slippage_rate": 0.0, + "contract_sizes": contract_sizes, + "tradable": np.ones_like(market.closes, dtype=np.bool_), + } + + +def _kernel_portfolio(prepared: Dict): + return _engine_portfolio( + n_bars=prepared["n_bars"], + n_syms=prepared["n_syms"], + highs=prepared["highs"], + lows=prepared["lows"], + closes=prepared["closes"], + target_pos=prepared["target_units"], + funding_rates=prepared["funding"], + is_funding_bar=prepared["is_funding_bar"], + init_capital=prepared["initial_capital"], + leverages=prepared["leverages"], + maint_ratio=prepared["maintenance_ratio"], + fee_rate=prepared["fee_rate"], + slippage_rate=prepared["slippage_rate"], + contract_sizes=prepared["contract_sizes"], + use_funding=False, + tradable=prepared["tradable"], + ) + + +def _depth_frame(idx, close: float, high: float, low: float) -> pd.DataFrame: + return pd.DataFrame( + { + "open": close, + "high": high, + "low": low, + "close": close, + "volume": 100.0, + }, + index=idx, + ) + + +def _timeit(fn, repeats: int) -> float: + samples: List[float] = [] + for _ in range(max(1, int(repeats))): + start = time.perf_counter() + fn() + samples.append(time.perf_counter() - start) + return float(statistics.mean(samples)) + + +def _cython_cpp_recommendation(benchmark: Dict) -> str: + share = benchmark.get("stages", {}).get("pure_kernel_share_pct", 100.0) + if share >= 35.0: + return "Pure kernel share is large enough to justify investigating Cython/C++ after correctness locks." + return "Cython/C++ is not justified yet; optimize cached array preparation and report construction first." + + +def _json_default(value): + if isinstance(value, (np.bool_,)): + return bool(value) + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating,)): + return float(value) + if isinstance(value, pd.Timestamp): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=2_000) + parser.add_argument("--symbols", type=int, default=6) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--include-nautilus", action="store_true") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_benchmark_nautilus_cert.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase12_benchmark_nautilus_cert.md") + args = parser.parse_args(argv) + report = run_certification(rows=args.rows, symbols=args.symbols, repeats=args.repeats, include_nautilus=args.include_nautilus) + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.md_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2, default=_json_default) + "\n", encoding="utf-8") + args.md_out.write_text(make_markdown(report), encoding="utf-8") + print(make_markdown(report)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase13_portfolio_report.py b/src/quantbt/benchmarks/run_phase13_portfolio_report.py new file mode 100644 index 0000000..a79f952 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase13_portfolio_report.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Phase 13B native portfolio report-construction benchmark. + +The runner reuses the Phase 12B decomposition and writes a focused artifact for +the report-construction optimization pass. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt.benchmarks.run_phase12_benchmark_nautilus_cert import run_certification # noqa: E402 + + +def run_report(rows: int = 2_000, symbols: int = 6, repeats: int = 3) -> Dict: + report = run_certification(rows=rows, symbols=symbols, repeats=repeats, include_nautilus=False) + bench = report["benchmark_followup"] + stages = bench["stages"] + return { + "status": "pass" if report["status"] == "pass" and bench["status"] == "pass" else "fail", + "rows": int(rows), + "symbols": int(symbols), + "repeats": int(repeats), + "full_facade_seconds": float(stages["full_facade_seconds"]), + "prepared_reuse_facade_seconds": float(stages["prepared_reuse_facade_seconds"]), + "array_preparation_seconds": float(stages["array_preparation_seconds"]), + "pure_numba_kernel_seconds": float(stages["pure_numba_kernel_seconds"]), + "report_construction_estimate_seconds": float(stages["report_construction_estimate_seconds"]), + "report_construction_share_pct": float(stages["report_construction_share_pct"]), + "pure_kernel_share_pct": float(stages["pure_kernel_share_pct"]), + "prepared_reuse_speedup": float(stages["prepared_reuse_speedup"]), + "cython_cpp_recommendation": report["cython_cpp_recommendation"], + "notes": ( + "Phase 13B keeps accounting unchanged and optimizes report construction " + "with ndarray-first calculations for funding, diagnostics, exposure, " + "and rebalance reports." + ), + } + + +def make_markdown(report: Dict) -> str: + return "\n".join( + [ + "# Phase 13B Native Portfolio Report Construction", + "", + f"Status: **{report['status']}**", + "", + f"- Rows: `{report['rows']}`", + f"- Symbols: `{report['symbols']}`", + f"- Repeats: `{report['repeats']}`", + f"- Full facade seconds: `{report['full_facade_seconds']:.6f}`", + f"- Prepared reuse seconds: `{report['prepared_reuse_facade_seconds']:.6f}`", + f"- Array preparation seconds: `{report['array_preparation_seconds']:.6f}`", + f"- Pure Numba kernel seconds: `{report['pure_numba_kernel_seconds']:.6f}`", + f"- Report construction residual seconds: `{report['report_construction_estimate_seconds']:.6f}`", + f"- Report construction share: `{report['report_construction_share_pct']:.2f}%`", + f"- Pure kernel share: `{report['pure_kernel_share_pct']:.2f}%`", + f"- Prepared reuse speedup: `{report['prepared_reuse_speedup']:.3f}x`", + "", + "## Notes", + "", + report["notes"], + "", + "## Cython/C++ Decision", + "", + report["cython_cpp_recommendation"], + ] + ) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=2_000) + parser.add_argument("--symbols", type=int, default=6) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--json", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase13_portfolio_report.json") + parser.add_argument("--markdown", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase13_portfolio_report.md") + args = parser.parse_args() + report = run_report(rows=args.rows, symbols=args.symbols, repeats=args.repeats) + args.json.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + args.markdown.write_text(make_markdown(report)) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase13_wfo_cache.py b/src/quantbt/benchmarks/run_phase13_wfo_cache.py new file mode 100644 index 0000000..4bf1a08 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase13_wfo_cache.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +Phase 13A WFO prepared market-cache benchmark. + +This runner verifies that portfolio WFO endpoint scoring can reuse prepared +market arrays across Optuna trials without changing selected params, objective, +or final backtest equity. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path +from typing import Dict + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import QuantBTEndpoint # noqa: E402 + + +def run_benchmark(rows: int = 720, trials: int = 16) -> Dict: + data = _make_data(rows) + _run_wfo(data, trials=max(2, min(int(trials), 4)), use_cache=True) + _run_wfo(data, trials=max(2, min(int(trials), 4)), use_cache=False) + cached_seconds, cached = _time_run(data, trials=trials, use_cache=True) + uncached_seconds, uncached = _time_run(data, trials=trials, use_cache=False) + cached_wf = cached.metadata["walk_forward"] + uncached_wf = uncached.metadata["walk_forward"] + cache_meta = cached_wf.get("prepared_scoring_cache", {}) + final_equity_diff = float(abs(cached.equity.iloc[-1] - uncached.equity.iloc[-1])) + objective_diff = float(abs(cached_wf["best_trial"]["objective"] - uncached_wf["best_trial"]["objective"])) + params_match = cached_wf["params"] == uncached_wf["params"] + speedup = float(uncached_seconds / cached_seconds) if cached_seconds > 0.0 else 0.0 + status = "pass" if final_equity_diff <= 1e-9 and objective_diff <= 1e-12 and params_match else "fail" + return { + "status": status, + "rows": int(rows), + "trials": int(trials), + "cached_seconds": float(cached_seconds), + "uncached_seconds": float(uncached_seconds), + "speedup": speedup, + "final_equity_diff": final_equity_diff, + "objective_diff": objective_diff, + "params_match": bool(params_match), + "selected_params": cached_wf["params"], + "cache_metadata": cache_meta, + } + + +def make_markdown(report: Dict) -> str: + cache = report["cache_metadata"] + lines = [ + "# Phase 13A WFO Prepared Market Cache", + "", + f"Status: **{report['status']}**", + "", + f"- Rows: `{report['rows']}`", + f"- Optuna trials: `{report['trials']}`", + f"- Cached seconds: `{report['cached_seconds']:.6f}`", + f"- Uncached seconds: `{report['uncached_seconds']:.6f}`", + f"- Speedup: `{report['speedup']:.3f}x`", + f"- Final equity diff: `{report['final_equity_diff']}`", + f"- Objective diff: `{report['objective_diff']}`", + f"- Params match: `{report['params_match']}`", + f"- Selected params: `{report['selected_params']}`", + "", + "## Cache Metadata", + "", + f"- Enabled: `{cache.get('enabled')}`", + f"- Prepared runs: `{cache.get('prepared_runs')}`", + f"- Fallback runs: `{cache.get('fallback_runs')}`", + f"- Market cache hits: `{cache.get('market_cache_hits')}`", + f"- Market cache misses: `{cache.get('market_cache_misses')}`", + f"- Market cache entries: `{cache.get('market_cache_entries')}`", + "", + "The benchmark is a deterministic parity/reuse guard, not a universal speed claim.", + "Full WFO runtime can still be dominated by Optuna and report construction.", + ] + return "\n".join(lines) + "\n" + + +def _time_run(data: Dict[str, pd.DataFrame], *, trials: int, use_cache: bool): + start = time.perf_counter() + result = _run_wfo(data, trials=trials, use_cache=use_cache) + return time.perf_counter() - start, result + + +def _run_wfo(data: Dict[str, pd.DataFrame], *, trials: int, use_cache: bool): + def strategy(data, params, train_index, test_index, fold): + scale = float(params["scale"]) + return pd.DataFrame({"BTC": scale, "ETH": -scale}, index=test_index) + + endpoint = QuantBTEndpoint.train_test_split( + strategy_class=strategy, + test_start="2022-01-01", + target_mode="portfolio", + portfolio_mode="longshort", + optimization_mode="mode_1_decay", + optimization_config={ + "scoring_backend": "endpoint", + "use_prepared_scoring_cache": bool(use_cache), + }, + optuna_trials=int(trials), + random_seed=123, + initial_capital=100_000.0, + leverage=5.0, + alloc_per_trade=1_000.0, + fee=0.0, + use_funding=False, + ) + return endpoint.backtest(data=data, param_ranges={"scale": (0.5, 1.5, 0.05)}) + + +def _make_data(rows: int) -> Dict[str, pd.DataFrame]: + idx = pd.date_range("2021-01-01", periods=int(rows), freq="1D", tz="UTC") + x = np.linspace(0.0, 16.0, len(idx)) + btc_close = 100.0 + np.sin(x) * 2.0 + np.arange(len(idx)) * 0.01 + eth_close = 50.0 + np.cos(x) * 1.5 + np.arange(len(idx)) * 0.005 + return { + "BTC": _frame(idx, btc_close), + "ETH": _frame(idx, eth_close), + } + + +def _frame(idx: pd.DatetimeIndex, close: np.ndarray) -> pd.DataFrame: + return pd.DataFrame( + { + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=720) + parser.add_argument("--trials", type=int, default=16) + parser.add_argument("--json", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase13_wfo_cache.json") + parser.add_argument("--markdown", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase13_wfo_cache.md") + args = parser.parse_args() + report = run_benchmark(rows=args.rows, trials=args.trials) + args.json.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + args.markdown.write_text(make_markdown(report)) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase14_service_loop.py b/src/quantbt/benchmarks/run_phase14_service_loop.py new file mode 100644 index 0000000..4456215 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase14_service_loop.py @@ -0,0 +1,694 @@ +#!/usr/bin/env python3 +""" +Phase 14C real WFO and service-loop benchmark. + +This runner measures the remaining higher-level performance debt without +changing engine semantics. It is intentionally a benchmark/certification +artifact, not an optimization pass. +""" + +from __future__ import annotations + +import argparse +import gc +import json +import statistics +import sys +import time +import tracemalloc +from dataclasses import asdict +from pathlib import Path +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + AccountConfig, + ArbExecutionPolicy, + ArbitrageLeg, + BasisArbitrageSpec, + ContractType, + ExecutionConfig, + HedgePolicy, + HedgePolicyKind, + NativeEventBackend, + NativeEventConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, + OrderIntent, + OrderSide, + OrderType, + PackageExecutionKind, + QuantBTEndpoint, + SizingPolicy, + SizingPolicyKind, + TimeInForce, + build_arbitrage_domain_audit, + compare_native_arbitrage_results, +) +from quantbt.benchmarks.profile_phase7 import profile_native_event, profile_native_vectorized # noqa: E402 +from quantbt.benchmarks.run_phase7 import BenchmarkProfile, _make_market_frames, _make_orders # noqa: E402 +from quantbt.benchmarks.run_phase12_benchmark_nautilus_cert import _benchmark_native_portfolio # noqa: E402 + + +def run_benchmark( + *, + rows: int = 720, + symbols: int = 4, + trials: int = 8, + folds: int = 1, + order_count: Optional[int] = None, + repeats: int = 2, +) -> Dict: + order_count = int(order_count if order_count is not None else max(20, rows // 3)) + stage_profile = BenchmarkProfile( + name="phase14b", + bars=int(rows), + symbols=max(2, int(symbols)), + order_count=order_count, + repeats=max(1, int(repeats)), + ) + + vectorized_profile = profile_native_vectorized(stage_profile) + event_profile = profile_native_event(stage_profile) + portfolio_profile = _benchmark_native_portfolio( + rows=int(rows), + symbols=max(2, int(symbols)), + repeats=max(1, int(repeats)), + ) + single_wfo = _single_symbol_wfo_benchmark(rows=rows, trials=trials, repeats=repeats) + portfolio_wfo = _portfolio_wfo_benchmark(rows=rows, trials=trials, repeats=repeats) + event_replay = _native_event_replay_benchmark(rows=rows, symbols=symbols, order_count=order_count, repeats=repeats) + arbitrage_sweep = _arbitrage_package_sweep(rows=rows, repeats=repeats) + report_cost = _report_level_benchmark(rows=rows, symbols=symbols, repeats=repeats) + + parity = { + "single_symbol_wfo": single_wfo["parity_passed"], + "portfolio_wfo": portfolio_wfo["parity_passed"], + "native_event_replay": event_replay["parity_passed"], + "arbitrage_package_sweep": arbitrage_sweep["parity_passed"], + "report_heavy_vs_light": report_cost["parity_passed"], + } + pure_kernel_share_pct = max( + _stage_share(vectorized_profile, "pure_numba_kernel"), + _stage_share(event_profile, "pure_numba_kernel"), + float(portfolio_profile["stages"]["pure_kernel_share_pct"]), + ) + status = "pass" if all(parity.values()) and portfolio_profile["status"] == "pass" else "fail" + return { + "status": status, + "rows": int(rows), + "symbols": max(2, int(symbols)), + "trials": int(trials), + "folds": int(folds), + "order_count": order_count, + "repeats": int(repeats), + "decomposition": { + "native_vectorized": asdict(vectorized_profile), + "native_event": asdict(event_profile), + "native_portfolio": portfolio_profile, + }, + "service_loops": { + "single_symbol_wfo": single_wfo, + "portfolio_wfo": portfolio_wfo, + "native_event_replay": event_replay, + "arbitrage_package_sweep": arbitrage_sweep, + "report_heavy_vs_light": report_cost, + }, + "parity": parity, + "cython_cpp_recommendation": _cython_cpp_recommendation(pure_kernel_share_pct), + "next_optimization_targets": _next_targets(vectorized_profile, event_profile, portfolio_profile), + } + + +def make_markdown(report: Dict) -> str: + loops = report["service_loops"] + lines = [ + "# Phase 14C Prepared Cache And Report-Level Benchmark", + "", + f"Status: **{report['status']}**", + "", + "## Profile", + "", + f"- Rows: `{report['rows']}`", + f"- Symbols: `{report['symbols']}`", + f"- Optuna trials: `{report['trials']}`", + f"- Order count: `{report['order_count']}`", + f"- Repeats: `{report['repeats']}`", + "", + "## Service Loop Timings", + "", + "| workload | cold/full seconds | prepared/light seconds | speedup | peak MB | parity | notes |", + "| --- | ---: | ---: | ---: | ---: | --- | --- |", + ] + for key, label in ( + ("single_symbol_wfo", "single-symbol WFO"), + ("portfolio_wfo", "portfolio WFO"), + ("native_event_replay", "native-event replay"), + ("arbitrage_package_sweep", "arbitrage sweep"), + ("report_heavy_vs_light", "portfolio report levels"), + ): + item = loops[key] + lines.append( + "| {label} | `{cold:.6f}` | `{prepared:.6f}` | `{speedup:.3f}x` | `{peak:.3f}` | `{parity}` | {notes} |".format( + label=label, + cold=float(item.get("full_seconds", item.get("cold_seconds", 0.0))), + prepared=float(item.get("prepared_seconds", item.get("light_seconds", 0.0))), + speedup=float(item.get("speedup", 0.0)), + peak=float(item.get("peak_memory_mb", 0.0)), + parity=bool(item.get("parity_passed", False)), + notes=item.get("notes", ""), + ) + ) + + lines.extend( + [ + "", + "## Stage Decomposition", + "", + "| backend | stage | seconds | share |", + "| --- | --- | ---: | ---: |", + ] + ) + for backend in ("native_vectorized", "native_event"): + record = report["decomposition"][backend] + for stage in record["stages"]: + lines.append( + f"| `{backend}` | `{stage['stage']}` | `{stage['seconds']:.6f}` | `{stage['percent_of_profile']:.2f}%` |" + ) + p = report["decomposition"]["native_portfolio"]["stages"] + for stage, label in ( + ("array_preparation_seconds", "array_preparation"), + ("pure_numba_kernel_seconds", "pure_numba_kernel"), + ("report_construction_estimate_seconds", "report_construction_estimate"), + ): + share_key = { + "array_preparation_seconds": "array_preparation_share_pct", + "pure_numba_kernel_seconds": "pure_kernel_share_pct", + "report_construction_estimate_seconds": "report_construction_share_pct", + }[stage] + lines.append(f"| `native_portfolio` | `{label}` | `{p[stage]:.6f}` | `{p[share_key]:.2f}%` |") + + lines.extend( + [ + "", + "## Parity Guards", + "", + ] + ) + for name, passed in report["parity"].items(): + lines.append(f"- `{name}`: `{passed}`") + + lines.extend( + [ + "", + "## Next Optimization Targets", + "", + ] + ) + for target in report["next_optimization_targets"]: + lines.append(f"- {target}") + + lines.extend( + [ + "", + "## Cython/C++ Decision", + "", + report["cython_cpp_recommendation"], + "", + "This report is a measurement artifact. It must not be used to justify changing accounting, fill policy, margin, or report semantics.", + ] + ) + return "\n".join(lines) + "\n" + + +def _single_symbol_wfo_benchmark(*, rows: int, trials: int, repeats: int) -> Dict: + _quiet_optuna() + data = _single_frame(rows) + + def run_once(use_cache: bool): + endpoint = QuantBTEndpoint.train_test_split( + strategy_class=_single_wfo_strategy, + test_start=data.index[max(20, len(data) // 2)], + target_mode="signal_notional", + backend="native_vectorized", + optimization_mode="mode_5_full_robust", + optimization_config={ + "scoring_backend": "endpoint", + "use_prepared_scoring_cache": bool(use_cache), + "candidate_selection_metric": "full_plateau_robust", + "top_is_fraction": 0.3, + "scoring_trading_days": 365, + "use_numba": True, + }, + optuna_trials=max(2, int(trials)), + random_seed=42, + initial_capital=20_000.0, + leverage=3.0, + alloc_per_trade=5_000.0, + fee_rate=0.0001, + use_funding=False, + use_pyramiding=False, + ) + return endpoint.backtest(data=data, param_ranges={"threshold": (0.2, 1.2, 0.1)}) + + cached = run_once(True) + uncached = run_once(False) + cached_seconds = _timeit(lambda: run_once(True), repeats) + uncached_seconds = _timeit(lambda: run_once(False), repeats) + peak_memory_mb = _peak_memory_mb(lambda: run_once(True)) + equity_diff = float(abs(cached.equity.iloc[-1] - uncached.equity.iloc[-1])) + objective_diff = float(abs(cached.metadata["walk_forward"]["best_trial"]["objective"] - uncached.metadata["walk_forward"]["best_trial"]["objective"])) + return { + "full_seconds": float(uncached_seconds), + "prepared_seconds": float(cached_seconds), + "speedup": float(uncached_seconds / cached_seconds) if cached_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-9 and objective_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "final_equity_diff": equity_diff, + "objective_diff": objective_diff, + "cache_metadata": cached.metadata["walk_forward"].get("prepared_scoring_cache", {}), + "best_params": cached.metadata["walk_forward"].get("params", {}), + "notes": "compares uncached vs prepared single-symbol native-vectorized WFO endpoint scoring", + } + + +def _portfolio_wfo_benchmark(*, rows: int, trials: int, repeats: int) -> Dict: + _quiet_optuna() + data = _portfolio_data(rows, 2) + + def run_once(use_cache: bool): + endpoint = QuantBTEndpoint.train_test_split( + strategy_class=_portfolio_wfo_strategy, + test_start=next(iter(data.values())).index[max(20, rows // 2)], + target_mode="portfolio", + portfolio_mode="longshort", + optimization_mode="mode_1_decay", + optimization_config={ + "scoring_backend": "endpoint", + "use_prepared_scoring_cache": bool(use_cache), + "top_is_fraction": 0.3, + }, + optuna_trials=max(2, int(trials)), + random_seed=7, + initial_capital=100_000.0, + leverage=4.0, + alloc_per_trade=1_000.0, + fee=0.0, + use_funding=False, + ) + return endpoint.backtest(data=data, param_ranges={"scale": (0.5, 1.5, 0.1)}) + + cached = run_once(True) + uncached = run_once(False) + cached_seconds = _timeit(lambda: run_once(True), repeats) + uncached_seconds = _timeit(lambda: run_once(False), repeats) + peak_memory_mb = _peak_memory_mb(lambda: run_once(True)) + equity_diff = float(abs(cached.equity.iloc[-1] - uncached.equity.iloc[-1])) + objective_diff = float(abs(cached.metadata["walk_forward"]["best_trial"]["objective"] - uncached.metadata["walk_forward"]["best_trial"]["objective"])) + return { + "full_seconds": float(uncached_seconds), + "prepared_seconds": float(cached_seconds), + "speedup": float(uncached_seconds / cached_seconds) if cached_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-9 and objective_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "final_equity_diff": equity_diff, + "objective_diff": objective_diff, + "cache_metadata": cached.metadata["walk_forward"].get("prepared_scoring_cache", {}), + "notes": "compares uncached vs prepared portfolio WFO endpoint scoring", + } + + +def _native_event_replay_benchmark(*, rows: int, symbols: int, order_count: int, repeats: int) -> Dict: + idx, frames = _make_market_frames(int(rows), max(2, int(symbols))) + symbols_list = list(frames.keys()) + orders = _make_orders(idx, int(order_count), len(symbols_list)) + closes = {symbol: frame["close"] for symbol, frame in frames.items()} + highs = {symbol: frame["high"] for symbol, frame in frames.items()} + lows = {symbol: frame["low"] for symbol, frame in frames.items()} + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=100_000.0, leverage=5.0), + execution=ExecutionConfig(slippage_bps=0.0), + fee_rate=0.0, + use_funding=False, + ) + ) + market = backend.prepare_market_arrays(idx, closes=closes, highs=highs, lows=lows, symbols=symbols_list) + compiled = backend.compile_orders(idx, orders=orders, symbols=symbols_list) + + def cold(): + return backend.run_orders(idx, orders, closes, highs=highs, lows=lows, symbols=symbols_list) + + def prepared(): + return backend.run_orders( + idx, + orders, + closes, + highs=highs, + lows=lows, + symbols=symbols_list, + market_arrays=market, + compiled_orders=compiled, + ) + + cold_result = cold() + prepared_result = prepared() + cold_seconds = _timeit(cold, repeats) + prepared_seconds = _timeit(prepared, repeats) + peak_memory_mb = _peak_memory_mb(prepared) + equity_diff = float(np.max(np.abs(cold_result.equity.to_numpy() - prepared_result.equity.to_numpy()))) + positions_diff = float(np.max(np.abs(cold_result.positions.to_numpy() - prepared_result.positions.to_numpy()))) + return { + "cold_seconds": float(cold_seconds), + "prepared_seconds": float(prepared_seconds), + "speedup": float(cold_seconds / prepared_seconds) if prepared_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-12 and positions_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "equity_diff": equity_diff, + "positions_diff": positions_diff, + "orders": int(len(orders)), + "notes": "prepared replay reuses market arrays and compiled order arrays", + } + + +def _arbitrage_package_sweep(*, rows: int, repeats: int) -> Dict: + idx = pd.date_range("2023-01-01", periods=int(rows), freq="1h", tz="UTC") + x = np.linspace(0.0, 8.0, len(idx)) + perp = pd.Series(100.0 + np.sin(x) * 2.0 + np.arange(len(idx)) * 0.01, index=idx) + quarterly = pd.Series(perp.to_numpy() + 1.5 + np.cos(x) * 0.5, index=idx) + closes = {"PERP": perp, "QUARTERLY": quarterly} + signal = pd.Series(0.0, index=idx) + signal.iloc[len(idx) // 4 : len(idx) // 2] = 1.0 + signal.iloc[-3:] = 0.0 + spec = BasisArbitrageSpec( + arb_id="PHASE14B_BASIS", + legs=( + ArbitrageLeg("PERP", 1.0, role="perp", contract_type=ContractType.LINEAR, funding_enabled=True), + ArbitrageLeg("QUARTERLY", -1.0, role="quarterly", contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy( + SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=10_000.0, + reference_symbol="PERP", + ), + execution_policy=ArbExecutionPolicy(PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + account = AccountConfig(initial_capital=100_000.0, leverage=5.0) + event = NativeEventBackend(NativeEventConfig(account=account, fee_rate=0.0001, use_funding=True)) + vector = NativeVectorizedBackend(NativeVectorizedConfig(account=account, fee_rate=0.0001, use_funding=True)) + funding = {"PERP": pd.Series(0.00005, index=idx), "QUARTERLY": 0.0} + + market = event.prepare_market_arrays(idx, closes=closes, highs=closes, lows=closes, funding_rate=funding, symbols=list(closes)) + + def run_event(): + return event.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding) + + def run_event_prepared(): + return event.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding, market_arrays=market) + + def run_vector(): + return vector.run_basis_arbitrage(idx, spec, signal, closes, funding_rate=funding) + + event_result = run_event() + prepared_result = run_event_prepared() + vector_result = run_vector() + audit = build_arbitrage_domain_audit(event_result) + parity = compare_native_arbitrage_results(event_result, vector_result) + event_seconds = _timeit(run_event, repeats) + prepared_seconds = _timeit(run_event_prepared, repeats) + peak_memory_mb = _peak_memory_mb(run_event_prepared) + prepared_equity_diff = float(np.max(np.abs(event_result.equity.to_numpy() - prepared_result.equity.to_numpy()))) + return { + "full_seconds": float(event_seconds), + "prepared_seconds": float(prepared_seconds), + "speedup": float(event_seconds / prepared_seconds) if prepared_seconds > 0.0 else 0.0, + "parity_passed": bool(audit["passed"] and parity["passed"] and prepared_equity_diff <= 1e-10), + "peak_memory_mb": peak_memory_mb, + "audit_status": audit["status"], + "parity_status": parity["status"], + "max_equity_diff": parity["max_abs_equity_diff"], + "prepared_equity_diff": prepared_equity_diff, + "max_package_residual": parity["max_abs_package_residual"], + "notes": "compares native-event arbitrage package cold vs prepared market-array replay; vectorized parity remains audited", + } + + +def _report_level_benchmark(*, rows: int, symbols: int, repeats: int) -> Dict: + def make_endpoint(report_level: str): + return QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="native_portfolio", + initial_capital=100_000.0, + leverage=4.0, + alloc_per_trade=1_000.0, + fee_rate=0.0, + use_funding=False, + report_level=report_level, + ) + + full_endpoint = make_endpoint("full") + minimal_endpoint = make_endpoint("minimal") + data = _portfolio_data(rows, max(2, symbols)) + positions = _portfolio_positions(next(iter(data.values())).index, max(2, symbols)) + + def run_full(): + return make_endpoint("full").backtest(data=data, positions=positions) + + def run_minimal(): + return make_endpoint("minimal").backtest(data=data, positions=positions) + + full_result = full_endpoint.backtest(data=data, positions=positions) + minimal_result = minimal_endpoint.backtest(data=data, positions=positions) + full_seconds = _timeit(run_full, repeats) + minimal_seconds = _timeit(run_minimal, repeats) + peak_memory_mb = _peak_memory_mb(run_full) + equity_diff = float(np.max(np.abs(full_result.equity.to_numpy() - minimal_result.equity.to_numpy()))) + position_diff = float(np.max(np.abs(full_result.positions.to_numpy() - minimal_result.positions.to_numpy()))) + return { + "full_seconds": float(full_seconds), + "light_seconds": float(minimal_seconds), + "speedup": float(full_seconds / minimal_seconds) if minimal_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-10 and position_diff <= 1e-12), + "peak_memory_mb": peak_memory_mb, + "equity_diff": equity_diff, + "position_diff": position_diff, + "full_reports": sorted(k for k in full_result.metadata if k.endswith("_report")), + "minimal_reports_omitted": tuple(minimal_result.metadata.get("reports_omitted", ())), + "notes": "compares native-portfolio report_level='full' vs 'minimal' construction with core accounting parity", + } + + +def _legacy_report_heavy_vs_light(*, rows: int, symbols: int, repeats: int) -> Dict: + endpoint = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="native_portfolio", + initial_capital=100_000.0, + leverage=4.0, + alloc_per_trade=1_000.0, + fee_rate=0.0, + use_funding=False, + ) + data = _portfolio_data(rows, max(2, symbols)) + positions = _portfolio_positions(next(iter(data.values())).index, max(2, symbols)) + result = endpoint.backtest(data=data, positions=positions) + + def light(): + return { + "final_equity": float(result.equity.iloc[-1]), + "fees": float(result.fees.sum()), + "funding": float(result.funding.sum()), + "rows": int(len(result.equity)), + } + + def heavy(): + return result.full_report(scope="full") + + light_summary = light() + heavy_report = heavy() + light_seconds = _timeit(light, repeats) + heavy_seconds = _timeit(heavy, repeats) + peak_memory_mb = _peak_memory_mb(heavy) + return { + "full_seconds": float(heavy_seconds), + "light_seconds": float(light_seconds), + "speedup": float(heavy_seconds / light_seconds) if light_seconds > 0.0 else 0.0, + "parity_passed": bool(abs(light_summary["final_equity"] - heavy_report["final_equity"]) <= 1e-9), + "peak_memory_mb": peak_memory_mb, + "final_equity": light_summary["final_equity"], + "notes": "legacy measurement of metrics/report export cost", + } + + +def _single_wfo_strategy(data, params, train_index, test_index, fold): + threshold = float(params["threshold"]) + frame = data.loc[: test_index[-1]] + ret = frame["close"].pct_change().fillna(0.0) + signal = np.where(ret > threshold / 10_000.0, 1.0, np.where(ret < -threshold / 10_000.0, -1.0, 0.0)) + return pd.Series(signal, index=frame.index).reindex(test_index).fillna(0.0) + + +def _portfolio_wfo_strategy(data, params, train_index, test_index, fold): + scale = float(params["scale"]) + return pd.DataFrame({"SYM000": scale, "SYM001": -scale}, index=test_index) + + +def _single_frame(rows: int) -> pd.DataFrame: + idx = pd.date_range("2021-01-01", periods=int(rows), freq="1h", tz="UTC") + x = np.linspace(0.0, 14.0, len(idx)) + close = 100.0 + np.cumsum(np.sin(x) * 0.05 + np.cos(x / 2.0) * 0.03) + return pd.DataFrame( + { + "open": close, + "high": close * 1.002, + "low": close * 0.998, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + + +def _portfolio_data(rows: int, symbols: int) -> Dict[str, pd.DataFrame]: + idx = pd.date_range("2021-01-01", periods=int(rows), freq="1h", tz="UTC") + out = {} + for j in range(int(symbols)): + x = np.linspace(0.0, 10.0 + j, len(idx)) + close = 100.0 + j * 3.0 + np.cumsum(np.sin(x) * 0.03 + 0.01) + out[f"SYM{j:03d}"] = pd.DataFrame( + { + "open": close, + "high": close * 1.002, + "low": close * 0.998, + "close": close, + "volume": 1_000.0 + j, + }, + index=idx, + ) + return out + + +def _portfolio_positions(idx: pd.DatetimeIndex, symbols: int) -> Dict[str, pd.Series]: + grid = np.arange(len(idx)) + out = {} + for j in range(int(symbols)): + active = np.where(((grid // (18 + j % 5)) + j) % 4 == 0, 1.0, 0.0) + out[f"SYM{j:03d}"] = pd.Series(active * (1.0 if j % 2 == 0 else -1.0), index=idx) + return out + + +def _stage_share(profile, stage_name: str) -> float: + for stage in profile.stages: + if stage.stage == stage_name: + return float(stage.percent_of_profile) + return 0.0 + + +def _quiet_optuna() -> None: + try: + import optuna + + optuna.logging.set_verbosity(optuna.logging.WARNING) + except Exception: + return + + +def _largest_stage(profile) -> str: + stage = max(profile.stages, key=lambda item: item.seconds) + return f"{profile.backend}: `{stage.stage}` ({stage.percent_of_profile:.1f}%)" + + +def _next_targets(vectorized_profile, event_profile, portfolio_profile: Dict) -> List[str]: + p = portfolio_profile["stages"] + return [ + _largest_stage(vectorized_profile), + _largest_stage(event_profile), + "native_portfolio: `report_construction_estimate` ({:.1f}%)".format( + float(p["report_construction_share_pct"]) + ), + "Next step should be real workload profiling before considering Cython/C++; Phase 14C moved the main cache/report controls into opt-in APIs.", + ] + + +def _cython_cpp_recommendation(pure_kernel_share_pct: float) -> str: + if pure_kernel_share_pct >= 35.0: + return ( + "Pure Numba kernel share is now large enough to investigate Cython/C++ " + "after adding parity locks around the target kernel." + ) + return ( + "Cython/C++ is not justified yet. The measured bottleneck remains in " + "facade/report/preparation layers. Phase 14C added opt-in cache " + "threading and report-level controls; larger real service-loop profiles " + "should come before any Cython/C++ decision." + ) + + +def _timeit(fn, repeats: int) -> float: + samples: List[float] = [] + for _ in range(max(1, int(repeats))): + start = time.perf_counter() + fn() + samples.append(time.perf_counter() - start) + return float(statistics.mean(samples)) + + +def _peak_memory_mb(fn) -> float: + gc.collect() + tracemalloc.start() + try: + fn() + _current, peak = tracemalloc.get_traced_memory() + return float(peak / (1024 * 1024)) + finally: + tracemalloc.stop() + + +def _json_default(value): + if isinstance(value, (np.bool_,)): + return bool(value) + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating,)): + return float(value) + if isinstance(value, pd.Timestamp): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=720) + parser.add_argument("--symbols", type=int, default=4) + parser.add_argument("--trials", type=int, default=8) + parser.add_argument("--folds", type=int, default=1) + parser.add_argument("--order-count", type=int, default=None) + parser.add_argument("--repeats", type=int, default=2) + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase14_service_loop.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase14_service_loop.md") + args = parser.parse_args(argv) + report = run_benchmark( + rows=args.rows, + symbols=args.symbols, + trials=args.trials, + folds=args.folds, + order_count=args.order_count, + repeats=args.repeats, + ) + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.md_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=_json_default) + "\n", encoding="utf-8") + args.md_out.write_text(make_markdown(report), encoding="utf-8") + print(make_markdown(report)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase15a_nautilus_certification.py b/src/quantbt/benchmarks/run_phase15a_nautilus_certification.py new file mode 100644 index 0000000..ec08981 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase15a_nautilus_certification.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +""" +Phase 15A Nautilus certification bundle runner. + +This runner is an evidence-layer tool. It does not change engine semantics. +Nautilus workflows are optional because they require the external +`nautilus_trader` dependency and supported test instruments. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + AccountConfig, + ArbExecutionPolicy, + ArbitrageLeg, + BasisArbitrageSpec, + BasketLegSpec, + BasketSpec, + ContractType, + HedgePolicy, + HedgePolicyKind, + NativeEventBackend, + NativeEventConfig, + NautilusToleranceProfile, + OrderIntent, + OrderSide, + OrderType, + PackageExecutionKind, + QuantBTEndpoint, + SizingPolicy, + SizingPolicyKind, + TimeInForce, + export_nautilus_report_bundle, + write_nautilus_certification_artifacts, +) + + +WORKFLOWS = ( + "pct_equity_signal", + "explicit_orders", + "basket_package", + "portfolio_package", + "basis_arbitrage_package", +) + + +def run_certification( + *, + rows: int = 96, + include_nautilus: bool = False, + output_dir: str | Path = PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_bundles", + make_quantstats: bool = False, +) -> Dict: + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + if not include_nautilus: + workflows = [ + { + "workflow": name, + "status": "skipped", + "reason": "run with --include-nautilus", + "bundle_dir": None, + } + for name in WORKFLOWS + ] + return _summary(workflows=workflows, output_dir=output_path, include_nautilus=False) + + availability = _nautilus_available() + if availability is not None: + workflows = [ + { + "workflow": name, + "status": "skipped", + "reason": availability, + "bundle_dir": None, + } + for name in WORKFLOWS + ] + return _summary(workflows=workflows, output_dir=output_path, include_nautilus=True) + + runners: Dict[str, Callable[[int, Path, bool], Dict]] = { + "pct_equity_signal": _run_pct_equity_signal, + "explicit_orders": _run_explicit_orders, + "basket_package": _run_basket_package, + "portfolio_package": _run_portfolio_package, + "basis_arbitrage_package": _run_basis_arbitrage_package, + } + workflows = [] + for name in WORKFLOWS: + try: + workflows.append(runners[name](int(rows), output_path, bool(make_quantstats))) + except ImportError as exc: + workflows.append({"workflow": name, "status": "skipped", "reason": str(exc), "bundle_dir": None}) + except NotImplementedError as exc: + workflows.append({"workflow": name, "status": "skipped", "reason": str(exc), "bundle_dir": None}) + except Exception as exc: # pragma: no cover - only hit with optional external backend drift + workflows.append({"workflow": name, "status": "failed", "reason": f"{type(exc).__name__}: {exc}", "bundle_dir": None}) + return _summary(workflows=workflows, output_dir=output_path, include_nautilus=True) + + +def make_markdown(report: Dict) -> str: + lines = [ + "# Phase 15A Nautilus Certification Bundles", + "", + f"Status: **{report['status']}**", + "", + f"- Include Nautilus: `{report['include_nautilus']}`", + f"- Output directory: `{report['output_dir']}`", + f"- Passed workflows: `{report['passed_workflows']}`", + f"- Skipped workflows: `{report['skipped_workflows']}`", + f"- Failed workflows: `{report['failed_workflows']}`", + "", + "## Workflow Matrix", + "", + "| workflow | status | bundle | tolerance status | reason |", + "| --- | --- | --- | --- | --- |", + ] + for item in report["workflows"]: + lines.append( + "| `{workflow}` | `{status}` | `{bundle}` | `{tol}` | {reason} |".format( + workflow=item["workflow"], + status=item["status"], + bundle=item.get("bundle_dir") or "", + tol=item.get("tolerance_status") or "", + reason=item.get("reason", ""), + ) + ) + lines.extend( + [ + "", + "## Required Bundle Files", + "", + "- `config.json`", + "- `run_manifest.json`", + "- `metrics_summary.json`", + "- `equity_curve.csv`, `returns.csv`, `account_report.csv`", + "- `orders_report.csv`, `fills_report.csv`, `positions_report.csv`", + "- `trade_log.csv`, `fill_log.txt`", + "- `native_vs_nautilus_parity.csv`", + "- `tolerance_profile.json`", + "- `known_differences.md`", + "", + "## Interpretation", + "", + "A skipped workflow is not a pass claim. It means the optional Nautilus dependency or instrument route was not available in this environment. A pass means the workflow produced a bundle and satisfied the declared tolerance profile.", + ] + ) + return "\n".join(lines) + "\n" + + +def _run_pct_equity_signal(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + data = _single_data(rows) + signal = _signal(data.index) + native = QuantBTEndpoint.pct_equity( + initial_capital=20_000.0, + leverage=3.0, + alloc_per_trade=0.4, + fee=0.0004, + slippage=0.0, + use_funding=False, + use_pyramiding=False, + ) + native_result = native.backtest(data=data, signal=signal) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.nautilus_validation( + initial_capital=20_000.0, + leverage=3.0, + alloc_per_trade=0.4, + hedge_type="%_equity", + fee_rate=0.0002, + use_funding=False, + use_pyramiding=False, + nautilus_config=NautilusBackendConfig( + instrument_id="BTCUSDT-PERP.BINANCE", + timeframe="1h", + trade_notional=0.4, + bypass_risk=True, + close_positions_on_stop=False, + ), + ) + nt_result = nt.simulate(data=data, signal=signal, symbols=["BTCUSDT-PERP.BINANCE"]) + return _export_workflow( + workflow="pct_equity_signal", + native_result=native_result, + nautilus_result=nt_result, + output_dir=output_dir, + make_quantstats=make_quantstats, + known_differences=[ + "Nautilus signal validation uses adapter-generated market orders from target signals.", + "Custom slippage and funding support depend on the current Nautilus adapter route.", + ], + tolerance=NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + ) + + +def _run_explicit_orders(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + data = _single_data(rows) + idx = data.index + orders = ( + OrderIntent(idx[5], "BTCUSDT-PERP.BINANCE", OrderSide.BUY, OrderType.MARKET, qty=0.05, tif=TimeInForce.IOC), + OrderIntent(idx[20], "BTCUSDT-PERP.BINANCE", OrderSide.SELL, OrderType.MARKET, qty=0.05, tif=TimeInForce.IOC), + ) + native = QuantBTEndpoint.orders( + backend="native_event", + initial_capital=20_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + ).simulate(data=data, orders=orders, symbols=["BTCUSDT-PERP.BINANCE"]) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.orders( + backend="nautilus", + initial_capital=20_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig( + instrument_id="BTCUSDT-PERP.BINANCE", + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, orders=orders, symbols=["BTCUSDT-PERP.BINANCE"]) + return _export_workflow("explicit_orders", native, nt, output_dir, make_quantstats) + + +def _run_basket_package(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + data = _multi_data(rows, ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE")) + idx = next(iter(data.values())).index + signal = pd.Series(0.0, index=idx) + signal.iloc[5:30] = 1.0 + basket = BasketSpec( + basket_id="PHASE15A_BASKET", + legs=(BasketLegSpec("BTCUSDT-PERP.BINANCE", 1.0), BasketLegSpec("ETHUSDT-PERP.BINANCE", -1.0)), + gross_notional=5_000.0, + freeze_hedge=True, + ) + native = QuantBTEndpoint.basket( + basket=basket, + backend="native_event", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + ).simulate(data=data, signal=signal, symbols=list(data)) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.basket( + basket=basket, + backend="nautilus", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig( + instrument_id="BTCUSDT-PERP.BINANCE", + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, signal=signal, symbols=list(data)) + return _export_workflow("basket_package", native, nt, output_dir, make_quantstats) + + +def _run_portfolio_package(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + symbols = ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE") + data = _multi_data(rows, symbols) + idx = next(iter(data.values())).index + positions = pd.DataFrame( + { + symbols[0]: np.where(np.arange(len(idx)) % 24 < 12, 1.0, 0.0), + symbols[1]: np.where(np.arange(len(idx)) % 24 < 12, -1.0, 0.0), + }, + index=idx, + ) + native = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="native_portfolio", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + hedge_type="signal_notional", + alloc_per_trade={symbols[0]: 2_500.0, symbols[1]: 2_500.0}, + use_funding=False, + ).backtest(data=data, positions=positions, symbols=list(symbols)) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="nautilus", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + hedge_type="signal_notional", + alloc_per_trade={symbols[0]: 2_500.0, symbols[1]: 2_500.0}, + use_funding=False, + metadata={"portfolio_nautilus_equity_tolerance": 5.0, "portfolio_nautilus_position_tolerance": 0.01}, + nautilus_config=NautilusBackendConfig( + instrument_id=symbols[0], + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, positions=positions, symbols=list(symbols)) + return _export_workflow( + "portfolio_package", + native, + nt, + output_dir, + make_quantstats, + known_differences=["Portfolio route submits native transformed target-unit deltas to Nautilus package replay."], + tolerance=NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + ) + + +def _run_basis_arbitrage_package(rows: int, output_dir: Path, make_quantstats: bool) -> Dict: + symbols = ("BTCUSDT-PERP.BINANCE", "ETHUSDT-PERP.BINANCE") + data = _multi_data(rows, symbols) + closes = {symbol: frame["close"] for symbol, frame in data.items()} + idx = next(iter(data.values())).index + signal = pd.Series(0.0, index=idx) + signal.iloc[8:40] = 1.0 + spec = BasisArbitrageSpec( + arb_id="PHASE15A_BASIS", + legs=( + ArbitrageLeg(symbols[0], 1.0, role="perp", contract_type=ContractType.LINEAR, funding_enabled=True), + ArbitrageLeg(symbols[1], -1.0, role="quarterly", contract_type=ContractType.LINEAR), + ), + hedge_policy=HedgePolicy(HedgePolicyKind.BASE_QTY_EQUAL, freeze_on_entry=True), + sizing_policy=SizingPolicy( + SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, + notional=5_000.0, + reference_symbol=symbols[0], + ), + execution_policy=ArbExecutionPolicy(PackageExecutionKind.ATOMIC_ALL_OR_NONE), + ) + native = NativeEventBackend( + NativeEventConfig(account=AccountConfig(initial_capital=50_000.0, leverage=3.0), fee_rate=0.0002, use_funding=False) + ).run_basis_arbitrage(idx, spec, signal, closes, funding_rate=0.0) + + from quantbt.adapters.nautilus import NautilusBackendConfig + + nt = QuantBTEndpoint.arbitrage( + "basis", + spec=spec, + backend="nautilus", + initial_capital=50_000.0, + leverage=3.0, + fee_rate=0.0002, + use_funding=False, + nautilus_config=NautilusBackendConfig( + instrument_id=symbols[0], + timeframe="1h", + bypass_risk=True, + ), + ).simulate(data=data, signal=signal, symbols=list(symbols)) + return _export_workflow( + "basis_arbitrage_package", + native, + nt, + output_dir, + make_quantstats, + known_differences=[ + "This smoke uses supported perpetual test instruments as a package proxy, not a real delivery-futures venue model.", + ], + tolerance=NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + ) + + +def _export_workflow( + workflow: str, + native_result, + nautilus_result, + output_dir: Path, + make_quantstats: bool, + known_differences: Optional[List[str]] = None, + tolerance: NautilusToleranceProfile | None = None, +) -> Dict: + bundle_dir = export_nautilus_report_bundle( + result=nautilus_result, + output_dir=output_dir / workflow, + strategy_id=workflow, + config={"certification_workflow": workflow}, + make_quantstats=make_quantstats, + fill_log_limit=200, + ) + artifacts = write_nautilus_certification_artifacts( + native_result=native_result, + nautilus_result=nautilus_result, + report_dir=bundle_dir, + workflow=workflow, + tolerance=tolerance or NautilusToleranceProfile(equity_tolerance=5.0, position_tolerance=0.01, quantity_tolerance=0.01), + known_differences=known_differences, + ) + profile = artifacts["tolerance_profile"] + return { + "workflow": workflow, + "status": "pass" if profile["passed"] else "diff", + "bundle_dir": str(bundle_dir), + "tolerance_status": profile["status"], + "checks": profile["checks"], + "artifact_files": artifacts["artifact_files"], + } + + +def _summary(workflows: List[Dict], output_dir: Path, include_nautilus: bool) -> Dict: + failed = [item for item in workflows if item["status"] == "failed"] + passed = [item for item in workflows if item["status"] == "pass"] + skipped = [item for item in workflows if item["status"] == "skipped"] + diff = [item for item in workflows if item["status"] == "diff"] + status = "fail" if failed else "pass" + return { + "status": status, + "include_nautilus": bool(include_nautilus), + "output_dir": str(output_dir), + "workflows": workflows, + "passed_workflows": len(passed), + "skipped_workflows": len(skipped), + "diff_workflows": len(diff), + "failed_workflows": len(failed), + } + + +def _nautilus_available() -> Optional[str]: + try: + from quantbt.adapters.nautilus import NautilusBacktestEngine + + NautilusBacktestEngine.check_available() + return None + except Exception as exc: + return f"{type(exc).__name__}: {exc}" + + +def _single_data(rows: int) -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=int(rows), freq="1h", tz="UTC") + x = np.linspace(0.0, 10.0, len(idx)) + close = 100.0 + np.cumsum(np.sin(x) * 0.08 + np.cos(x / 2.0) * 0.03) + return pd.DataFrame( + {"open": close, "high": close * 1.002, "low": close * 0.998, "close": close, "volume": 1_000.0}, + index=idx, + ) + + +def _multi_data(rows: int, symbols: Tuple[str, ...]) -> Dict[str, pd.DataFrame]: + base = _single_data(rows) + out = {} + for i, symbol in enumerate(symbols): + frame = base.copy() + scale = 1.0 + i * 0.15 + frame[["open", "high", "low", "close"]] = frame[["open", "high", "low", "close"]] * scale + frame["volume"] = frame["volume"] * (1.0 + i) + out[symbol] = frame + return out + + +def _signal(idx: pd.DatetimeIndex) -> pd.Series: + signal = pd.Series(0.0, index=idx) + signal.iloc[5 : max(6, len(idx) // 3)] = 1.0 + signal.iloc[max(8, len(idx) // 2) : max(9, len(idx) * 2 // 3)] = -1.0 + return signal + + +def _json_default(value): + if isinstance(value, (np.bool_,)): + return bool(value) + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating,)): + return float(value) + if isinstance(value, pd.Timestamp): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=96) + parser.add_argument("--include-nautilus", action="store_true") + parser.add_argument("--make-quantstats", action="store_true") + parser.add_argument("--output-dir", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_bundles") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_certification.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase15a_nautilus_certification.md") + args = parser.parse_args(argv) + report = run_certification( + rows=args.rows, + include_nautilus=args.include_nautilus, + output_dir=args.output_dir, + make_quantstats=args.make_quantstats, + ) + args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=_json_default), encoding="utf-8") + args.md_out.write_text(make_markdown(report), encoding="utf-8") + print(make_markdown(report)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase15b_synthetic_depth.py b/src/quantbt/benchmarks/run_phase15b_synthetic_depth.py new file mode 100644 index 0000000..c8b2f4b --- /dev/null +++ b/src/quantbt/benchmarks/run_phase15b_synthetic_depth.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Phase 15B synthetic depth evidence runner. + +This script creates deterministic OHLCV and synthetic-book depth cases. It is +not a venue L2 replay benchmark; it is an audit artifact for package-depth +invariants before optional Nautilus validation. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict, List + +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + NautilusExecutionDepthConfig, + OrderIntent, + OrderSide, + OrderType, + l2_replay_available, + simulate_nautilus_order_package_depth, +) + + +def run_phase15b_synthetic_depth() -> Dict: + data = {"BTCUSDT-PERP.BINANCE": _frame()} + idx = data["BTCUSDT-PERP.BINANCE"].index + cases = [ + ( + "synthetic_market_vwap", + [ + OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=2.0, + ) + ], + NautilusExecutionDepthConfig( + depth_model="synthetic_book", + allow_partial_fills=True, + synthetic_spread_bps=10.0, + synthetic_level_spacing_bps=10.0, + synthetic_levels=3, + synthetic_base_depth_qty=1.0, + ), + ), + ( + "synthetic_partial_queue", + [ + OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=3.0, + ) + ], + NautilusExecutionDepthConfig( + depth_model="synthetic_book", + allow_partial_fills=True, + synthetic_levels=2, + synthetic_base_depth_qty=1.0, + queue_ahead_qty=0.5, + ), + ), + ( + "ohlcv_all_or_none_baseline", + [ + OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT-PERP.BINANCE", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + metadata={"package_id": "P1", "package_type": "basket_package"}, + ) + ], + NautilusExecutionDepthConfig(all_or_none_packages=True), + ), + ] + results: List[Dict] = [] + for name, orders, cfg in cases: + preflight = simulate_nautilus_order_package_depth(orders, data, cfg) + row = preflight.order_report.iloc[0].to_dict() + results.append( + { + "case": name, + "depth_model": cfg.depth_model, + "status": str(row.get("status")), + "filled_qty": float(row.get("filled_qty", 0.0)), + "fill_price": float(row.get("fill_price", 0.0)), + "levels_consumed": int(row.get("levels_consumed", 0)), + "accepted_orders": int(preflight.metadata["accepted_orders"]), + "rejected_orders": int(preflight.metadata["rejected_orders"]), + } + ) + return { + "phase": "15B", + "status": "pass" if all(item["status"] in {"filled", "partial"} for item in results) else "review", + "l2_replay_available": bool(l2_replay_available()), + "cases": results, + "claim_scope": "Level-2 synthetic stress only; not venue L2 replay.", + } + + +def make_markdown(report: Dict) -> str: + lines = [ + "# Phase 15B Synthetic Depth Evidence", + "", + f"Status: **{report['status']}**", + "", + f"- L2 replay provider available: `{report['l2_replay_available']}`", + f"- Claim scope: {report['claim_scope']}", + "", + "| case | depth model | status | filled qty | fill price | levels | accepted | rejected |", + "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |", + ] + for item in report["cases"]: + lines.append( + "| `{case}` | `{depth_model}` | `{status}` | {filled_qty:.8f} | {fill_price:.8f} | {levels_consumed} | {accepted_orders} | {rejected_orders} |".format( + **item + ) + ) + lines.extend( + [ + "", + "## Interpretation", + "", + "Synthetic depth proves deterministic queue, participation, spread and level-consumption behavior. It does not certify real exchange queue priority. Real L2 certification remains gated by venue snapshots, incremental updates and trade prints.", + ] + ) + return "\n".join(lines) + "\n" + + +def _frame() -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=4, freq="1h", tz="UTC") + return pd.DataFrame( + { + "open": [100.0, 100.0, 100.0, 100.0], + "high": [101.0, 101.0, 101.0, 101.0], + "low": [99.0, 99.0, 99.0, 99.0], + "close": [100.0, 100.0, 100.0, 100.0], + "volume": [100.0, 100.0, 100.0, 100.0], + }, + index=idx, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--output-json", default=str(PACKAGE_DIR / "benchmarks" / "phase15b_synthetic_depth.json")) + parser.add_argument("--output-md", default=str(PACKAGE_DIR / "benchmarks" / "phase15b_synthetic_depth.md")) + args = parser.parse_args() + report = run_phase15b_synthetic_depth() + json_path = Path(args.output_json) + md_path = Path(args.output_md) + json_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + md_path.write_text(make_markdown(report), encoding="utf-8") + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/run_phase16_performance_debt.py b/src/quantbt/benchmarks/run_phase16_performance_debt.py new file mode 100644 index 0000000..3ada569 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase16_performance_debt.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +""" +Phase 16 performance-debt closure benchmark. + +This runner measures the remaining facade/service-loop overhead after Phase +13/14 and verifies that prepared service contexts do not change accounting. +It is intentionally focused on pandas normalization/report construction, not on +changing domain kernels. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import tracemalloc +from pathlib import Path +from typing import Callable, Dict, List + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import QuantBTEndpoint # noqa: E402 +from quantbt.benchmarks.run_phase14_service_loop import run_benchmark as run_phase14_benchmark # noqa: E402 + + +def run_phase16_benchmark( + *, + rows: int = 1_440, + symbols: int = 6, + replays: int = 8, + repeats: int = 2, + include_large_wfo: bool = True, +) -> Dict: + single = _single_service_context_benchmark(rows=rows, replays=replays, repeats=repeats) + portfolio = _portfolio_service_context_benchmark(rows=rows, symbols=symbols, replays=replays, repeats=repeats) + report = _portfolio_report_benchmark(rows=rows, symbols=symbols, repeats=repeats) + large_wfo = ( + run_phase14_benchmark( + rows=max(rows, 1_440), + symbols=max(symbols, 6), + trials=max(8, replays), + order_count=max(240, rows // 4), + repeats=max(1, repeats), + ) + if include_large_wfo + else {"status": "skipped", "reason": "include_large_wfo=False"} + ) + parity = { + "single_service_context": bool(single["parity_passed"]), + "portfolio_service_context": bool(portfolio["parity_passed"]), + "portfolio_report_levels": bool(report["parity_passed"]), + "large_wfo_service_loop": bool(large_wfo.get("status") == "pass") if include_large_wfo else True, + } + status = "pass" if all(parity.values()) else "fail" + return { + "phase": "16", + "status": status, + "rows": int(rows), + "symbols": int(symbols), + "replays": int(replays), + "repeats": int(repeats), + "service_context": { + "single_signal_notional": single, + "native_portfolio": portfolio, + }, + "report_construction": report, + "large_wfo_service_loop": _compact_phase14(large_wfo), + "parity": parity, + "cython_cpp_recommendation": _cython_cpp_recommendation(large_wfo), + "closed_debt": [ + "facade-level repeated pandas market normalization can now be avoided with endpoint.prepare_service_context(...)", + "report construction has an explicit full/minimal benchmark and parity guard", + "larger WFO/service-loop benchmark is archived before any Cython/C++ decision", + ], + "remaining_notes": [ + "normal endpoint.backtest(...) remains backward-compatible and still normalizes defensively per call", + "prepared service context is opt-in and currently covers native_vectorized signal_notional plus native_portfolio", + "Cython/C++ should wait until pure kernels, not pandas/report facades, dominate measured runtime", + ], + } + + +def make_markdown(report: Dict) -> str: + single = report["service_context"]["single_signal_notional"] + portfolio = report["service_context"]["native_portfolio"] + rpt = report["report_construction"] + lines = [ + "# Phase 16 Performance Debt Closure", + "", + f"Status: **{report['status']}**", + "", + "## Prepared Service Context", + "", + "| workload | normal seconds | prepared seconds | speedup | peak MB | parity |", + "| --- | ---: | ---: | ---: | ---: | --- |", + _row("single signal_notional", single), + _row("native portfolio", portfolio), + "", + "## Report Construction", + "", + "| workload | full seconds | minimal seconds | speedup | parity |", + "| --- | ---: | ---: | ---: | --- |", + "| native portfolio reports | `{full_seconds:.6f}` | `{minimal_seconds:.6f}` | `{speedup:.3f}x` | `{parity}` |".format( + full_seconds=float(rpt["full_seconds"]), + minimal_seconds=float(rpt["minimal_seconds"]), + speedup=float(rpt["speedup"]), + parity=bool(rpt["parity_passed"]), + ), + "", + "## Large WFO / Service Loop", + "", + f"- Status: `{report['large_wfo_service_loop'].get('status')}`", + f"- Rows: `{report['large_wfo_service_loop'].get('rows')}`", + f"- Symbols: `{report['large_wfo_service_loop'].get('symbols')}`", + f"- Cython/C++ recommendation: {report['cython_cpp_recommendation']}", + "", + "## Closed Debt", + "", + ] + for item in report["closed_debt"]: + lines.append(f"- {item}") + lines.extend(["", "## Remaining Notes", ""]) + for item in report["remaining_notes"]: + lines.append(f"- {item}") + lines.append("") + return "\n".join(lines) + + +def _row(label: str, item: Dict) -> str: + return "| {label} | `{normal:.6f}` | `{prepared:.6f}` | `{speedup:.3f}x` | `{peak:.3f}` | `{parity}` |".format( + label=label, + normal=float(item["normal_seconds"]), + prepared=float(item["prepared_seconds"]), + speedup=float(item["speedup"]), + peak=float(item["peak_memory_mb"]), + parity=bool(item["parity_passed"]), + ) + + +def _single_service_context_benchmark(*, rows: int, replays: int, repeats: int) -> Dict: + data = _single_frame(rows) + signals = _single_signals(data.index, replays) + + normal_endpoint = _single_endpoint() + prepared_endpoint = _single_endpoint() + context = prepared_endpoint.prepare_service_context(data=data, symbols=["BTC"]) + + normal_results = _run_single_replays(normal_endpoint, data, signals) + prepared_results = _run_single_context_replays(context, signals) + normal_seconds = _timeit(lambda: _run_single_replays(normal_endpoint, data, signals), repeats) + prepared_seconds = _timeit(lambda: _run_single_context_replays(context, signals), repeats) + peak = _peak_memory_mb(lambda: _run_single_context_replays(context, signals)) + equity_diff = max( + float(abs(normal.equity.iloc[-1] - prepared.equity.iloc[-1])) + for normal, prepared in zip(normal_results, prepared_results) + ) + position_diff = max( + float(np.max(np.abs(normal.positions.to_numpy() - prepared.positions.to_numpy()))) + for normal, prepared in zip(normal_results, prepared_results) + ) + return { + "normal_seconds": float(normal_seconds), + "prepared_seconds": float(prepared_seconds), + "speedup": float(normal_seconds / prepared_seconds) if prepared_seconds > 0.0 else 0.0, + "peak_memory_mb": float(peak), + "parity_passed": bool(equity_diff <= 1e-9 and position_diff <= 1e-12), + "final_equity_max_abs_diff": equity_diff, + "position_max_abs_diff": position_diff, + "context_metadata": context.metadata, + } + + +def _portfolio_service_context_benchmark(*, rows: int, symbols: int, replays: int, repeats: int) -> Dict: + data, positions_list, symbol_list = _portfolio_inputs(rows, symbols, replays) + normal_endpoint = _portfolio_endpoint(symbol_list, report_level="minimal") + prepared_endpoint = _portfolio_endpoint(symbol_list, report_level="minimal") + context = prepared_endpoint.prepare_service_context(data=data, symbols=symbol_list) + + normal_results = _run_portfolio_replays(normal_endpoint, data, positions_list, symbol_list) + prepared_results = _run_portfolio_context_replays(context, positions_list) + normal_seconds = _timeit(lambda: _run_portfolio_replays(normal_endpoint, data, positions_list, symbol_list), repeats) + prepared_seconds = _timeit(lambda: _run_portfolio_context_replays(context, positions_list), repeats) + peak = _peak_memory_mb(lambda: _run_portfolio_context_replays(context, positions_list)) + equity_diff = max( + float(abs(normal.equity.iloc[-1] - prepared.equity.iloc[-1])) + for normal, prepared in zip(normal_results, prepared_results) + ) + margin_diff = max( + float(np.max(np.abs(normal.margin.to_numpy() - prepared.margin.to_numpy()))) + for normal, prepared in zip(normal_results, prepared_results) + ) + return { + "normal_seconds": float(normal_seconds), + "prepared_seconds": float(prepared_seconds), + "speedup": float(normal_seconds / prepared_seconds) if prepared_seconds > 0.0 else 0.0, + "peak_memory_mb": float(peak), + "parity_passed": bool(equity_diff <= 1e-8 and margin_diff <= 1e-8), + "final_equity_max_abs_diff": equity_diff, + "margin_max_abs_diff": margin_diff, + "context_metadata": context.metadata, + } + + +def _portfolio_report_benchmark(*, rows: int, symbols: int, repeats: int) -> Dict: + data, positions_list, symbol_list = _portfolio_inputs(rows, symbols, 1) + full_endpoint = _portfolio_endpoint(symbol_list, report_level="full") + minimal_endpoint = _portfolio_endpoint(symbol_list, report_level="minimal") + full = full_endpoint.backtest(data=data, positions=positions_list[0], symbols=symbol_list) + minimal = minimal_endpoint.backtest(data=data, positions=positions_list[0], symbols=symbol_list) + full_seconds = _timeit(lambda: full_endpoint.backtest(data=data, positions=positions_list[0], symbols=symbol_list), repeats) + minimal_seconds = _timeit(lambda: minimal_endpoint.backtest(data=data, positions=positions_list[0], symbols=symbol_list), repeats) + equity_diff = float(np.max(np.abs(full.equity.to_numpy() - minimal.equity.to_numpy()))) + positions_diff = float(np.max(np.abs(full.positions.to_numpy() - minimal.positions.to_numpy()))) + return { + "full_seconds": float(full_seconds), + "minimal_seconds": float(minimal_seconds), + "speedup": float(full_seconds / minimal_seconds) if minimal_seconds > 0.0 else 0.0, + "parity_passed": bool(equity_diff <= 1e-8 and positions_diff <= 1e-12), + "equity_max_abs_diff": equity_diff, + "positions_max_abs_diff": positions_diff, + } + + +def _single_endpoint() -> QuantBTEndpoint: + return QuantBTEndpoint.signal_notional( + initial_capital=20_000.0, + leverage=4.0, + alloc_per_trade=5_000.0, + fee_rate=0.0002, + use_funding=False, + slippage=0.0001, + use_pyramiding=True, + ) + + +def _portfolio_endpoint(symbols: List[str], *, report_level: str) -> QuantBTEndpoint: + return QuantBTEndpoint.portfolio( + portfolio_mode="market_neutral", + backend="native_portfolio", + hedge_type="signal_notional", + initial_capital=100_000.0, + leverage=4.0, + alloc_per_trade={symbol: 5_000.0 for symbol in symbols}, + fee=0.0004, + use_funding=False, + report_level=report_level, + ) + + +def _single_frame(rows: int) -> pd.DataFrame: + idx = pd.date_range("2021-01-01", periods=int(rows), freq="1h", tz="UTC") + close = 100.0 + np.cumsum(np.sin(np.linspace(0.0, 32.0, len(idx))) * 0.03 + 0.002) + return pd.DataFrame( + { + "open": close, + "high": close * 1.002, + "low": close * 0.998, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + + +def _single_signals(idx: pd.DatetimeIndex, replays: int) -> List[pd.Series]: + out = [] + base = np.linspace(0.0, 20.0, len(idx)) + for replay in range(int(replays)): + out.append(pd.Series(np.sign(np.sin(base + replay * 0.3)), index=idx)) + return out + + +def _portfolio_inputs(rows: int, symbols: int, replays: int): + idx = pd.date_range("2021-01-01", periods=int(rows), freq="1h", tz="UTC") + symbol_list = [f"S{i:02d}" for i in range(int(symbols))] + data = {} + for j, symbol in enumerate(symbol_list): + close = 100.0 + j * 5.0 + np.cumsum(np.sin(np.linspace(0.0, 18.0, len(idx)) + j) * 0.02 + 0.001) + data[symbol] = pd.DataFrame( + { + "open": close, + "high": close * 1.002, + "low": close * 0.998, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + positions = [] + base = np.linspace(0.0, 16.0, len(idx)) + for replay in range(int(replays)): + matrix = { + symbol: np.sign(np.sin(base + replay * 0.2 + j * 0.5)) + for j, symbol in enumerate(symbol_list) + } + positions.append(pd.DataFrame(matrix, index=idx)) + return data, positions, symbol_list + + +def _run_single_replays(endpoint: QuantBTEndpoint, data: pd.DataFrame, signals: List[pd.Series]): + return [endpoint.backtest(data=data, signal=signal, symbols=["BTC"]) for signal in signals] + + +def _run_single_context_replays(context, signals: List[pd.Series]): + return [context.backtest(signal=signal) for signal in signals] + + +def _run_portfolio_replays(endpoint: QuantBTEndpoint, data, positions_list, symbols): + return [endpoint.backtest(data=data, positions=positions, symbols=symbols) for positions in positions_list] + + +def _run_portfolio_context_replays(context, positions_list): + return [context.backtest(positions=positions) for positions in positions_list] + + +def _timeit(func: Callable[[], object], repeats: int) -> float: + values = [] + for _ in range(max(1, int(repeats))): + start = time.perf_counter() + func() + values.append(time.perf_counter() - start) + return float(min(values)) + + +def _peak_memory_mb(func: Callable[[], object]) -> float: + tracemalloc.start() + func() + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + return float(peak / (1024 * 1024)) + + +def _compact_phase14(report: Dict) -> Dict: + if report.get("status") == "skipped": + return report + return { + "status": report.get("status"), + "rows": report.get("rows"), + "symbols": report.get("symbols"), + "trials": report.get("trials"), + "order_count": report.get("order_count"), + "parity": report.get("parity"), + "cython_cpp_recommendation": report.get("cython_cpp_recommendation"), + "next_optimization_targets": report.get("next_optimization_targets"), + } + + +def _cython_cpp_recommendation(large_wfo: Dict) -> str: + if large_wfo.get("status") != "pass": + return "defer; benchmark did not pass all parity/status gates" + text = str(large_wfo.get("cython_cpp_recommendation", "")).lower() + if "not justified" in text or "not yet" in text: + return "not justified yet; facade/report overhead remains the larger measured bucket" + return large_wfo.get("cython_cpp_recommendation", "defer until pure kernel bottleneck is proven") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=1_440) + parser.add_argument("--symbols", type=int, default=6) + parser.add_argument("--replays", type=int, default=8) + parser.add_argument("--repeats", type=int, default=2) + parser.add_argument("--skip-large-wfo", action="store_true") + parser.add_argument("--output-json", default=str(PACKAGE_DIR / "benchmarks" / "phase16_performance_debt.json")) + parser.add_argument("--output-md", default=str(PACKAGE_DIR / "benchmarks" / "phase16_performance_debt.md")) + args = parser.parse_args() + report = run_phase16_benchmark( + rows=args.rows, + symbols=args.symbols, + replays=args.replays, + repeats=args.repeats, + include_large_wfo=not args.skip_large_wfo, + ) + json_path = Path(args.output_json) + md_path = Path(args.output_md) + json_path.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8") + md_path.write_text(make_markdown(report), encoding="utf-8") + print(json.dumps(report, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/run_phase30e_reactive_runner.py b/src/quantbt/benchmarks/run_phase30e_reactive_runner.py new file mode 100644 index 0000000..436f923 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase30e_reactive_runner.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantbt import OrderCommand, OrderSide, OrderType, QuantBTEndpoint, TimeInForce +from quantbt.core.orders import OrderAction + + +def _bars(n: int) -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC") + x = np.arange(n, dtype=np.float64) + close = 100.0 + 0.002 * x + 2.0 * np.sin(x / 27.0) + 0.7 * np.sin(x / 7.0) + return pd.DataFrame( + { + "open": close, + "high": close + 1.25, + "low": close - 1.25, + "close": close, + "volume": 10_000.0 + 100.0 * np.cos(x / 11.0), + }, + index=idx, + ) + + +class ReactiveGridStrategy: + def __init__(self, *, levels: int, reseed_every: int) -> None: + self.levels = int(levels) + self.reseed_every = int(reseed_every) + self.cycle = 0 + + def on_bar_close(self, context): + commands = [] + if context.bar_index % self.reseed_every == 0: + self.cycle += 1 + commands.append( + OrderCommand( + timestamp=context.timestamp, + action=OrderAction.CANCEL_ALL, + symbol=context.symbols[0], + tag_prefix="GRID-", + ) + ) + center = float(context.close[0]) + for level in range(1, self.levels + 1): + commands.append( + OrderCommand( + timestamp=context.timestamp, + symbol=context.symbols[0], + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=0.01, + price=center - 0.05 * level, + tif=TimeInForce.GTC, + order_id=f"grid-{self.cycle}-{level}", + tag=f"GRID-C{self.cycle}-L{level}", + metadata={"campaign_id": "GRID", "cycle_id": str(self.cycle), "level_id": str(level)}, + ) + ) + if context.positions[context.symbols[0]] > 0.0 and context.bar_index % (self.reseed_every + 7) == 0: + commands.append( + OrderCommand( + timestamp=context.timestamp, + symbol=context.symbols[0], + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=abs(float(context.positions[context.symbols[0]])), + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"flatten-{context.bar_index}", + ) + ) + return commands + + +def run(*, bars: int, levels: int, reseed_every: int, out_dir: Path) -> dict: + data = _bars(bars) + strategy = ReactiveGridStrategy(levels=levels, reseed_every=reseed_every) + endpoint = QuantBTEndpoint.native_event_strategy(initial_capital=100_000, leverage=5, use_funding=False) + + t0 = time.perf_counter() + reactive = endpoint.simulate(data=data, strategy=strategy, symbols=["BTC"]) + reactive_seconds = time.perf_counter() - t0 + + t1 = time.perf_counter() + replay = QuantBTEndpoint.native_event_lifecycle(initial_capital=100_000, leverage=5, use_funding=False).simulate( + data=data, + order_commands=reactive.metadata["emitted_command_tape"], + symbols=["BTC"], + ) + replay_seconds = time.perf_counter() - t1 + + equity_diff = float(np.max(np.abs(reactive.equity.to_numpy() - replay.equity.to_numpy()))) + pos_diff = float( + np.max( + np.abs( + reactive.positions["Position_BTC"].to_numpy() + - replay.positions["Position_BTC"].to_numpy() + ) + ) + ) + report = { + "phase": "30E", + "bars": int(bars), + "levels": int(levels), + "reseed_every": int(reseed_every), + "emitted_commands": int(reactive.metadata["emitted_command_count"]), + "fills": int(len(reactive.fills)), + "reactive_seconds": reactive_seconds, + "static_replay_seconds": replay_seconds, + "total_seconds": reactive_seconds + replay_seconds, + "equity_max_abs_diff": equity_diff, + "position_max_abs_diff": pos_diff, + "context_builder": reactive.metadata["reactive_context_builder"], + "incremental_compile_replays": reactive.metadata["reactive_incremental_compile_replays"], + "final_equity": float(reactive.equity.iloc[-1]), + } + out_dir.mkdir(parents=True, exist_ok=True) + json_path = out_dir / "phase30e_reactive_runner.json" + md_path = out_dir / "phase30e_reactive_runner.md" + json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + md_path.write_text( + "\n".join( + [ + "# Phase 30E Reactive Runner Benchmark", + "", + f"- Bars: {bars:,}", + f"- Grid levels: {levels}", + f"- Emitted commands: {report['emitted_commands']:,}", + f"- Fills: {report['fills']:,}", + f"- Reactive runner seconds: {reactive_seconds:.6f}", + f"- Static replay seconds: {replay_seconds:.6f}", + f"- Max equity diff: {equity_diff:.12f}", + f"- Max position diff: {pos_diff:.12f}", + f"- Context builder: {report['context_builder']}", + "", + "Final accounting is still produced by one static native-event v2 replay.", + ] + ), + encoding="utf-8", + ) + return report + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--bars", type=int, default=25_000) + parser.add_argument("--levels", type=int, default=20) + parser.add_argument("--reseed-every", type=int, default=50) + parser.add_argument("--out-dir", type=Path, default=Path("benchmarks/out")) + args = parser.parse_args() + print(json.dumps(run(bars=args.bars, levels=args.levels, reseed_every=args.reseed_every, out_dir=args.out_dir), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/run_phase31_intrabar.py b/src/quantbt/benchmarks/run_phase31_intrabar.py new file mode 100644 index 0000000..633277d --- /dev/null +++ b/src/quantbt/benchmarks/run_phase31_intrabar.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Phase 31D intrabar execution benchmark and certification summary. +""" + +from __future__ import annotations + +import argparse +import gc +import json +import statistics +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Dict, List + +import numpy as np +import pandas as pd + + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + AccountConfig, + BacktestEngineV2, + ExecutionContract, + FillReplayTape, + IntrabarIntentTape, + IntrabarSessionTape, + OrderIntent, + OrderSide, + OrderType, + SessionExecutionPolicy, + prepare_market_tape, + run_fill_replay_kernel, + run_intrabar_kernel, + run_intrabar_reference, + run_intrabar_session_kernel, +) +from quantbt.core.vectorized import _engine_units_v2 # noqa: E402 + + +@dataclass +class Phase31BenchmarkRecord: + route: str + rows: int + symbols: int + fills_or_orders: int + warmup_seconds: float + runtime_seconds: float + runtime_min_seconds: float + runtime_max_seconds: float + bars_per_second: float + ratio_vs_close_target: float | None = None + ratio_vs_intrabar_minimal: float | None = None + speedup_vs_reference: float | None = None + parity: str = "n/a" + notes: str = "" + + +def run_benchmark(*, rows: int = 25_000, repeats: int = 3, seed: int = 31) -> Dict: + df, intent = _make_intrabar_fixture(rows=rows, seed=seed) + tape = prepare_market_tape(data=df, symbols=["BTC"], use_funding=False) + account = AccountConfig(initial_capital=100_000.0, leverage=10.0) + contract = ExecutionContract.intrabar_bracket(close_on_last_bar=True) + + records: list[Phase31BenchmarkRecord] = [] + close_stats = _measure(lambda: _run_close_target_kernel(tape, intent, account), repeats=repeats) + records.append(_record("close_target_v2_pure_kernel", rows, 1, 0, close_stats, baseline=close_stats["best"], parity="baseline")) + + minimal_stats = _measure( + lambda: run_intrabar_kernel(tape=tape, intent=intent, account=account, contract=contract, report_level="minimal"), + repeats=repeats, + ) + minimal_result = run_intrabar_kernel(tape=tape, intent=intent, account=account, contract=contract, report_level="minimal") + records.append( + _record( + "intrabar_bracket_v1_minimal", + rows, + 1, + minimal_result.fill_count, + minimal_stats, + baseline=close_stats["best"], + parity="oracle_checked_in_tests", + ) + ) + + audit_stats = _measure( + lambda: run_intrabar_kernel(tape=tape, intent=intent, account=account, contract=contract, report_level="audit"), + repeats=repeats, + ) + audit_result = run_intrabar_kernel(tape=tape, intent=intent, account=account, contract=contract, report_level="audit") + records.append( + _record( + "intrabar_bracket_v1_audit", + rows, + 1, + audit_result.fill_count, + audit_stats, + baseline=close_stats["best"], + intrabar_minimal=minimal_stats["best"], + parity="pass" if np.allclose(audit_result.equity, minimal_result.equity, atol=1e-9, rtol=0.0) else "fail", + notes="two_pass_sparse_fills", + ) + ) + + session_tape = IntrabarSessionTape( + session_id=np.arange(rows, dtype=np.int64) // 24, + entry_allowed_at_open=np.ones(rows, dtype=np.bool_), + force_flat_at_open=(np.arange(rows, dtype=np.int64) % 24) == 23, + ) + session_policy = SessionExecutionPolicy(max_long_entries_per_session=2) + session_minimal_stats = _measure( + lambda: run_intrabar_session_kernel( + tape=tape, + intent=intent, + account=account, + contract=contract, + session_policy=session_policy, + session_tape=session_tape, + report_level="minimal", + ), + repeats=repeats, + ) + session_minimal = run_intrabar_session_kernel( + tape=tape, + intent=intent, + account=account, + contract=contract, + session_policy=session_policy, + session_tape=session_tape, + report_level="minimal", + ) + records.append( + _record( + "intrabar_session_bracket_v1_minimal", + rows, + 1, + session_minimal.fill_count, + session_minimal_stats, + baseline=close_stats["best"], + intrabar_minimal=minimal_stats["best"], + parity="reference_checked_in_tests", + notes="session_state_kernel", + ) + ) + + session_audit_stats = _measure( + lambda: run_intrabar_session_kernel( + tape=tape, + intent=intent, + account=account, + contract=contract, + session_policy=session_policy, + session_tape=session_tape, + report_level="audit", + ), + repeats=repeats, + ) + session_audit = run_intrabar_session_kernel( + tape=tape, + intent=intent, + account=account, + contract=contract, + session_policy=session_policy, + session_tape=session_tape, + report_level="audit", + ) + records.append( + _record( + "intrabar_session_bracket_v1_audit", + rows, + 1, + session_audit.fill_count, + session_audit_stats, + baseline=close_stats["best"], + intrabar_minimal=minimal_stats["best"], + parity="pass" if np.allclose(session_audit.equity, session_minimal.equity, atol=1e-9, rtol=0.0) else "fail", + notes="session_two_pass_sparse_fills", + ) + ) + + reference_stats = _measure( + lambda: run_intrabar_reference(tape=tape, intent=intent, account=account, contract=contract), + repeats=max(1, min(2, repeats)), + ) + records.append( + _record( + "intrabar_reference_python", + rows, + 1, + audit_result.fill_count, + reference_stats, + baseline=close_stats["best"], + intrabar_minimal=minimal_stats["best"], + parity="truth_model", + ) + ) + + fill_tape = FillReplayTape.from_frame(audit_result.fills_report) + fill_replay_stats = _measure( + lambda: run_fill_replay_kernel(tape=tape, fill_tape=fill_tape, account=account), + repeats=repeats, + ) + records.append( + _record( + "fill_replay_v1_kernel", + rows, + 1, + len(fill_tape.bar_index), + fill_replay_stats, + baseline=close_stats["best"], + intrabar_minimal=minimal_stats["best"], + parity="accounting_only", + ) + ) + + native_event_stats = _measure(lambda: _run_native_event_orders(df, audit_result.fills_report), repeats=max(1, min(2, repeats))) + records.append( + _record( + "native_event_explicit_orders_facade", + rows, + 1, + int(len(audit_result.fills_report)), + native_event_stats, + baseline=close_stats["best"], + intrabar_minimal=minimal_stats["best"], + parity="speed_reference_not_semantic_claim", + notes="full_facade_order_replay", + ) + ) + + reference = next(r for r in records if r.route == "intrabar_reference_python") + for record in records: + if record.route.startswith("intrabar_bracket_v1") or record.route.startswith("intrabar_session_bracket_v1"): + record.speedup_vs_reference = reference.runtime_seconds / record.runtime_seconds + + return { + "rows": rows, + "repeats": repeats, + "seed": seed, + "records": [asdict(record) for record in records], + "summary": _summary(records), + } + + +def make_markdown(report: Dict) -> str: + lines = [ + "# Phase 31 Intrabar Benchmark", + "", + f"- Rows: `{report['rows']}`", + f"- Repeats: `{report['repeats']}`", + f"- Seed: `{report['seed']}`", + "", + "| Route | Runtime | Bars/s | Ratio vs close-target | Ratio vs intrabar minimal | Speedup vs Python oracle | Fills/orders | Parity | Notes |", + "|---|---:|---:|---:|---:|---:|---:|---|---|", + ] + for record in report["records"]: + lines.append( + "| `{route}` | {runtime:.6f}s | {bps:,.0f} | {rclose} | {rmin} | {speedup} | {fills} | {parity} | {notes} |".format( + route=record["route"], + runtime=record["runtime_seconds"], + bps=record["bars_per_second"], + rclose=_fmt_ratio(record["ratio_vs_close_target"]), + rmin=_fmt_ratio(record["ratio_vs_intrabar_minimal"]), + speedup=_fmt_ratio(record["speedup_vs_reference"]), + fills=record["fills_or_orders"], + parity=record["parity"], + notes=record["notes"] or "", + ) + ) + lines.extend( + [ + "", + "## Summary", + "", + f"- Fast intrabar minimal vs Python oracle: `{_fmt_ratio(report['summary']['intrabar_minimal_speedup_vs_reference'])}` faster.", + f"- Fast intrabar audit vs minimal: `{_fmt_ratio(report['summary']['intrabar_audit_ratio_vs_minimal'])}` runtime ratio.", + f"- Fast intrabar minimal vs close-target pure kernel: `{_fmt_ratio(report['summary']['intrabar_minimal_ratio_vs_close_target'])}` runtime ratio.", + "", + "Interpretation: close-target remains the fastest narrow contract. The new intrabar kernel is the fast path for alpha logic that needs next-open entry, intrabar SL/TP/trailing, and audit fills without falling back to Python event loops.", + ] + ) + return "\n".join(lines) + "\n" + + +def _make_intrabar_fixture(*, rows: int, seed: int): + rng = np.random.default_rng(seed) + idx = pd.date_range("2020-01-01", periods=rows, freq="1h", tz="UTC") + ret = rng.normal(0.0, 0.0015, size=rows) + close = 100.0 * np.exp(np.cumsum(ret)) + open_ = np.r_[close[0], close[:-1] * (1.0 + rng.normal(0.0, 0.0002, size=rows - 1))] + high = np.maximum(open_, close) * (1.0 + rng.uniform(0.0005, 0.006, size=rows)) + low = np.minimum(open_, close) * (1.0 - rng.uniform(0.0005, 0.006, size=rows)) + df = pd.DataFrame({"open": open_, "high": high, "low": low, "close": close, "volume": 100.0}, index=idx) + entry_side = np.zeros(rows, dtype=np.int8) + entry_size = np.zeros(rows, dtype=np.float64) + entry_side[5::50] = 1 + entry_size[5::50] = 1.0 + entry_side[30::50] = -1 + entry_size[30::50] = 1.0 + stop = np.full(rows, 0.012, dtype=np.float64) + tp = np.full(rows, 0.018, dtype=np.float64) + trailing = np.full(rows, 0.010, dtype=np.float64) + technical_exit = np.zeros(rows, dtype=np.bool_) + technical_exit[45::50] = True + intent = IntrabarIntentTape.from_arrays( + entry_side=entry_side, + entry_size=entry_size, + stop_value=stop, + take_profit_value=tp, + trailing_value=trailing, + technical_exit=technical_exit, + ) + return df, intent + + +def _run_close_target_kernel(tape, intent, account): + target = np.zeros((tape.n_bars, 1), dtype=np.float64) + current = 0.0 + for i in range(tape.n_bars): + if intent.entry_side[i] != 0 and intent.entry_size[i] > 0.0: + current = float(intent.entry_side[i]) * float(intent.entry_size[i]) + target[i, 0] = current + return _engine_units_v2( + tape.n_bars, + 1, + tape.highs, + tape.lows, + tape.closes, + target, + tape.funding_rates, + tape.funding_event_mask, + account.initial_capital, + np.array([account.leverage], dtype=np.float64), + account.maintenance_ratio, + np.array([0.0], dtype=np.float64), + np.array([1.0], dtype=np.float64), + 0.0, + False, + )[0][-1] + + +def _run_native_event_orders(df: pd.DataFrame, fills: pd.DataFrame): + orders = [] + idx = df.index + for row in fills.itertuples(index=False): + bar = int(row.bar_index) + side = OrderSide.BUY if int(row.side) > 0 else OrderSide.SELL + orders.append(OrderIntent(idx[bar], "BTC", side, OrderType.MARKET, qty=float(row.qty))) + engine = BacktestEngineV2( + data=df, + symbols=["BTC"], + backend="native_event", + orders=orders, + account=AccountConfig(initial_capital=100_000.0, leverage=10.0), + use_funding=False, + fee_rate=0.0, + ) + return engine.result.equity.iloc[-1] + + +def _measure(workload, *, repeats: int) -> Dict[str, float]: + gc.collect() + start = time.perf_counter() + workload() + warmup = time.perf_counter() - start + runtimes = [] + for _ in range(max(1, repeats)): + gc.collect() + start = time.perf_counter() + workload() + runtimes.append(time.perf_counter() - start) + return { + "best": float(min(runtimes)), + "worst": float(max(runtimes)), + "median": float(statistics.median(runtimes)), + "warmup": float(warmup), + } + + +def _record(route, rows, symbols, fills, stats, *, baseline, intrabar_minimal=None, parity="n/a", notes=""): + runtime = stats["best"] + return Phase31BenchmarkRecord( + route=route, + rows=rows, + symbols=symbols, + fills_or_orders=int(fills), + warmup_seconds=float(stats["warmup"]), + runtime_seconds=float(runtime), + runtime_min_seconds=float(stats["best"]), + runtime_max_seconds=float(stats["worst"]), + bars_per_second=float(rows / runtime) if runtime > 0 else float("inf"), + ratio_vs_close_target=float(runtime / baseline) if baseline and runtime else None, + ratio_vs_intrabar_minimal=float(runtime / intrabar_minimal) if intrabar_minimal and runtime else None, + parity=parity, + notes=notes, + ) + + +def _summary(records: List[Phase31BenchmarkRecord]) -> Dict: + lookup = {record.route: record for record in records} + minimal = lookup["intrabar_bracket_v1_minimal"] + audit = lookup["intrabar_bracket_v1_audit"] + reference = lookup["intrabar_reference_python"] + close_target = lookup["close_target_v2_pure_kernel"] + return { + "intrabar_minimal_speedup_vs_reference": reference.runtime_seconds / minimal.runtime_seconds, + "intrabar_audit_ratio_vs_minimal": audit.runtime_seconds / minimal.runtime_seconds, + "intrabar_minimal_ratio_vs_close_target": minimal.runtime_seconds / close_target.runtime_seconds, + } + + +def _fmt_ratio(value) -> str: + if value is None: + return "-" + return f"{float(value):.2f}x" + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="Run Phase 31 intrabar benchmark.") + parser.add_argument("--rows", type=int, default=25_000) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--seed", type=int, default=31) + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase31_intrabar_benchmark.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "phase31_intrabar_benchmark.md") + args = parser.parse_args(argv) + + report = run_benchmark(rows=args.rows, repeats=args.repeats, seed=args.seed) + args.json_out.write_text(json.dumps(report, indent=2), encoding="utf-8") + args.md_out.write_text(make_markdown(report), encoding="utf-8") + print(make_markdown(report)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_phase34a_native_event_memory.py b/src/quantbt/benchmarks/run_phase34a_native_event_memory.py new file mode 100644 index 0000000..8e3b188 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase34a_native_event_memory.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +import json +import resource +import subprocess +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantbt import AccountConfig, ExecutionConfig, NativeEventBackend, NativeEventConfig +from quantbt.core.orders import OrderAction, OrderCommand +from quantbt.core.schema import OrderSide, OrderType, TimeInForce + + +def _rss_mb() -> float: + value = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + if sys.platform == "darwin": + return value / (1024.0 * 1024.0) + return value / 1024.0 + + +def _market(rows: int): + idx = pd.date_range("2020-01-01", periods=rows, freq="15min", tz="UTC") + x = np.arange(rows, dtype=np.float64) + close = pd.Series(100.0 + np.sin(x / 17.0) * 2.0 + x * 0.0001, index=idx) + high = close + 1.2 + low = close - 1.2 + return idx, {"BTC": close}, {"BTC": high}, {"BTC": low} + + +def _commands(idx: pd.DatetimeIndex, levels: int, cycle: int): + commands = [] + order_id = 0 + for bar in range(1, len(idx), cycle): + commands.append(OrderCommand(timestamp=idx[bar], action=OrderAction.CANCEL_ALL, symbol="BTC")) + anchor = 100.0 + np.sin(bar / 17.0) * 2.0 + bar * 0.0001 + for level in range(1, levels + 1): + commands.append( + OrderCommand( + timestamp=idx[bar], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=0.01, + price=float(anchor - 0.08 * level), + tif=TimeInForce.GTC, + order_id=f"entry-{order_id}", + tag=f"GRID-C{bar}-L{level}", + metadata={"campaign_id": f"C{bar}", "level_id": str(level)}, + ) + ) + order_id += 1 + commands.append( + OrderCommand( + timestamp=idx[bar], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=0.01, + price=float(anchor + 0.08 * level), + tif=TimeInForce.GTC, + reduce_only=True, + order_id=f"exit-{order_id}", + tag=f"GRID-C{bar}-X{level}", + metadata={"campaign_id": f"C{bar}", "level_id": str(level), "leg_role": "take_profit"}, + ) + ) + order_id += 1 + return tuple(commands) + + +def _run_child(args) -> dict: + idx, close, high, low = _market(args.rows) + commands = _commands(idx, levels=args.levels, cycle=args.cycle) + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=100_000.0, leverage=10.0), + execution=ExecutionConfig(slippage_bps=0.0), + fee_rate=0.0, + use_funding=False, + report_level=args.report_level, + audit_sink=args.audit_sink, + audit_sink_path=args.audit_sink_path, + ) + ) + start = time.perf_counter() + result = backend.run_order_commands(idx, commands, close, high, low, symbols=["BTC"]) + elapsed = time.perf_counter() - start + payload = { + "report_level": result.metadata["report_level"], + "audit_sink": result.metadata["audit_sink"], + "rows": int(args.rows), + "levels": int(args.levels), + "commands": int(len(commands)), + "fills": int(result.metadata["lifecycle_counters"]["fill_count"]), + "events": int(result.metadata["lifecycle_counters"]["event_count"]), + "seconds": float(elapsed), + "peak_rss_mb": float(_rss_mb()), + "command_report_rows": int(len(result.metadata["command_report"])), + "order_event_rows": int(len(result.metadata["order_events"])), + "fills_materialized": int(len(result.fills)), + "orders_materialized": int(len(result.orders)), + "final_equity": float(result.equity.iloc[-1]), + } + print(json.dumps(payload, sort_keys=True)) + return payload + + +def _run_parent(args) -> list[dict]: + rows = [] + for level in ("minimal", "standard", "audit"): + cmd = [ + sys.executable, + __file__, + "--child", + "--rows", + str(args.rows), + "--levels", + str(args.levels), + "--cycle", + str(args.cycle), + "--report-level", + level, + ] + completed = subprocess.run(cmd, check=True, capture_output=True, text=True) + rows.append(json.loads(completed.stdout.strip().splitlines()[-1])) + if args.json_out: + Path(args.json_out).write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n") + if args.md_out: + lines = [ + "# Phase 34A Native Event Artifact Memory Benchmark", + "", + "| report_level | seconds | peak RSS MB | commands | fills | events | command rows | event rows | fills obj | orders obj |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for row in rows: + lines.append( + "| {report_level} | {seconds:.6f} | {peak_rss_mb:.3f} | {commands} | {fills} | {events} | " + "{command_report_rows} | {order_event_rows} | {fills_materialized} | {orders_materialized} |".format(**row) + ) + lines.extend( + [ + "", + "Notes:", + "", + "- Each row runs in a fresh subprocess.", + "- Peak RSS includes Python import, pandas, and Numba/cache overhead; on small workloads it is not expected to be monotonic by artifact level.", + "- The artifact contract is verified by command/event row counts and materialized Python object counts; larger command-heavy runs are needed for stable RSS deltas.", + ] + ) + Path(args.md_out).write_text("\n".join(lines) + "\n") + print(json.dumps(rows, indent=2, sort_keys=True)) + return rows + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--child", action="store_true") + parser.add_argument("--rows", type=int, default=5_000) + parser.add_argument("--levels", type=int, default=15) + parser.add_argument("--cycle", type=int, default=50) + parser.add_argument("--report-level", default="audit") + parser.add_argument("--audit-sink", default="memory") + parser.add_argument("--audit-sink-path", default=None) + parser.add_argument("--json-out", default="benchmarks/phase34a_native_event_memory.json") + parser.add_argument("--md-out", default="benchmarks/phase34a_native_event_memory.md") + args = parser.parse_args() + if args.child: + _run_child(args) + else: + _run_parent(args) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/run_phase34b_native_event_prepared_score.py b/src/quantbt/benchmarks/run_phase34b_native_event_prepared_score.py new file mode 100644 index 0000000..bb6e84f --- /dev/null +++ b/src/quantbt/benchmarks/run_phase34b_native_event_prepared_score.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import argparse +import json +import resource +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantbt import QuantBTEndpoint +from quantbt.core.orders import OrderCommand +from quantbt.core.schema import OrderSide, OrderType, TimeInForce + + +def _rss_mb() -> float: + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _bars(rows: int) -> pd.DataFrame: + idx = pd.date_range("2020-01-01", periods=rows, freq="1h", tz="UTC") + x = np.arange(rows, dtype=np.float64) + close = pd.Series(100.0 + np.sin(x / 11.0) * 2.0 + x * 0.001, index=idx) + return pd.DataFrame( + { + "open": close, + "high": close + 2.0, + "low": close - 2.0, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + + +class TimedStrategy: + def __init__(self, entry_mod: int, hold: int, qty: float): + self.entry_mod = int(entry_mod) + self.hold = int(hold) + self.qty = float(qty) + self.open_bar = -1 + + def on_bar_close(self, context): + symbol = context.symbols[0] + if context.positions[symbol] == 0.0 and context.bar_index % self.entry_mod == 0: + self.open_bar = int(context.bar_index) + return [ + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=self.qty, + tif=TimeInForce.IOC, + order_id=f"entry-{context.bar_index}", + ) + ] + if context.positions[symbol] > 0.0 and self.open_bar >= 0 and context.bar_index - self.open_bar >= self.hold: + self.open_bar = -1 + return [ + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=abs(context.positions[symbol]), + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"exit-{context.bar_index}", + ) + ] + return [] + + +def _params(trials: int): + return [ + { + "entry_mod": 5 + (i % 7), + "hold": 2 + (i % 5), + "qty": 0.1 + (i % 4) * 0.05, + } + for i in range(trials) + ] + + +def _metrics_subset(report: dict) -> dict: + return { + "sharpe": report["sharpe"], + "max_drawdown_pct": report["max_drawdown_pct"], + "profit_factor": report["profit_factor"], + "num_trades": report["num_trades"], + "final_equity": report["final_equity"], + "liquidated": report["liquidated"], + } + + +def run(rows: int, trials: int) -> dict: + df = _bars(rows) + params = _params(trials) + public_endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=50_000, + leverage=10, + use_funding=False, + fee_rate=0.0002, + report_level="audit", + ) + start = time.perf_counter() + public_reports = [] + for param in params: + result = public_endpoint.simulate(data=df, strategy=TimedStrategy(**param), symbols=["BTC"]) + public_reports.append(_metrics_subset(result.full_report(scope="full"))) + public_seconds = time.perf_counter() - start + + prepared_endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=50_000, + leverage=10, + use_funding=False, + fee_rate=0.0002, + report_level="audit", + ) + prepared = prepared_endpoint.prepare_native_event_strategy(data=df, symbols=["BTC"]) + start = time.perf_counter() + score_reports = [] + for param in params: + score = prepared.score(TimedStrategy(**param)) + score_reports.append(_metrics_subset(score.metrics)) + prepared_seconds = time.perf_counter() - start + + parity = public_reports == score_reports + return { + "rows": int(rows), + "trials": int(trials), + "public_audit_seconds": float(public_seconds), + "prepared_score_seconds": float(prepared_seconds), + "speedup": float(public_seconds / prepared_seconds) if prepared_seconds > 0.0 else np.inf, + "peak_rss_mb": float(_rss_mb()), + "metric_parity": bool(parity), + "prepared_scores": int(prepared.metadata["scores"]), + "public_last_report_level": public_endpoint.result.metadata["report_level"], + "prepared_endpoint_result_retained": prepared_endpoint.result is not None, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=1_000) + parser.add_argument("--trials", type=int, default=20) + parser.add_argument("--json-out", default="benchmarks/phase34b_native_event_prepared_score.json") + parser.add_argument("--md-out", default="benchmarks/phase34b_native_event_prepared_score.md") + args = parser.parse_args() + payload = run(rows=args.rows, trials=args.trials) + Path(args.json_out).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + lines = [ + "# Phase 34B Native Event Prepared Score Benchmark", + "", + f"- Rows: `{payload['rows']}`", + f"- Trials: `{payload['trials']}`", + f"- Public audit seconds: `{payload['public_audit_seconds']:.6f}`", + f"- Prepared score seconds: `{payload['prepared_score_seconds']:.6f}`", + f"- Speedup: `{payload['speedup']:.3f}x`", + f"- Peak RSS MB: `{payload['peak_rss_mb']:.3f}`", + f"- Metric parity: `{payload['metric_parity']}`", + f"- Prepared endpoint result retained: `{payload['prepared_endpoint_result_retained']}`", + "", + "Prepared score reuses market arrays and returns `NativeEventScoreResult` rather than storing full public artifacts on the endpoint.", + ] + Path(args.md_out).write_text("\n".join(lines) + "\n") + print(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/run_phase34c_native_event_single_pass.py b/src/quantbt/benchmarks/run_phase34c_native_event_single_pass.py new file mode 100644 index 0000000..ed7ea4e --- /dev/null +++ b/src/quantbt/benchmarks/run_phase34c_native_event_single_pass.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import argparse +import json +import resource +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantbt import OrderCommand, QuantBTEndpoint +from quantbt.core.schema import OrderSide, OrderType, TimeInForce + + +def _rss_mb() -> float: + return float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) / 1024.0 + + +def _bars(rows: int) -> pd.DataFrame: + idx = pd.date_range("2020-01-01", periods=rows, freq="1h", tz="UTC") + x = np.arange(rows, dtype=np.float64) + close = pd.Series(100.0 + np.sin(x / 9.0) * 3.0 + np.cos(x / 23.0) * 1.5, index=idx) + return pd.DataFrame( + { + "open": close.shift(1).fillna(close.iloc[0]), + "high": close + 2.5, + "low": close - 2.5, + "close": close, + "volume": 1_000.0 + (x % 50.0), + }, + index=idx, + ) + + +class CyclicStrategy: + def __init__(self, entry_mod: int, hold: int, qty: float): + self.entry_mod = int(entry_mod) + self.hold = int(hold) + self.qty = float(qty) + self.open_bar = -1 + + def on_bar_close(self, context): + symbol = context.symbols[0] + if context.positions[symbol] == 0.0 and context.bar_index % self.entry_mod == 0: + self.open_bar = int(context.bar_index) + return [ + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=self.qty, + tif=TimeInForce.IOC, + order_id=f"entry-{context.bar_index}", + ) + ] + if context.positions[symbol] > 0.0 and self.open_bar >= 0 and context.bar_index - self.open_bar >= self.hold: + self.open_bar = -1 + return [ + OrderCommand( + timestamp=context.timestamp, + symbol=symbol, + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=abs(context.positions[symbol]), + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"exit-{context.bar_index}", + ) + ] + return [] + + +def _params(trials: int): + return [ + { + "entry_mod": 4 + (i % 9), + "hold": 2 + (i % 6), + "qty": 0.1 + (i % 5) * 0.025, + } + for i in range(trials) + ] + + +def _accounting_tuple(result) -> tuple: + return ( + tuple(np.round(result.equity.to_numpy(dtype=np.float64), 12)), + tuple(np.round(result.returns.to_numpy(dtype=np.float64), 12)), + tuple(np.round(result.positions.to_numpy(dtype=np.float64).ravel(), 12)), + tuple(np.round(result.fees.to_numpy(dtype=np.float64), 12)), + tuple(np.round(result.funding.to_numpy(dtype=np.float64), 12)), + tuple(np.round(result.margin.to_numpy(dtype=np.float64).ravel(), 12)), + bool(result.liquidated), + int(result.liquidation_bar), + ) + + +def run(rows: int, trials: int) -> dict: + df = _bars(rows) + params = _params(trials) + kwargs = dict( + initial_capital=50_000, + leverage=10, + use_funding=False, + fee_rate=0.0002, + report_level="minimal", + ) + + replay_endpoint = QuantBTEndpoint.native_event_strategy(**kwargs, reactive_kernel_mode="replay_certified") + start = time.perf_counter() + replay_fingerprints = [] + replay_static_replays = 0 + for param in params: + result = replay_endpoint.simulate(data=df, strategy=CyclicStrategy(**param), symbols=["BTC"]) + replay_fingerprints.append(_accounting_tuple(result)) + replay_static_replays += int(result.metadata.get("reactive_static_replay_count", 0)) + replay_seconds = time.perf_counter() - start + + single_endpoint = QuantBTEndpoint.native_event_strategy(**kwargs, reactive_kernel_mode="single_pass") + start = time.perf_counter() + single_fingerprints = [] + single_static_replays = 0 + for param in params: + result = single_endpoint.simulate(data=df, strategy=CyclicStrategy(**param), symbols=["BTC"]) + single_fingerprints.append(_accounting_tuple(result)) + single_static_replays += int(result.metadata.get("reactive_static_replay_count", 0)) + single_seconds = time.perf_counter() - start + + return { + "rows": int(rows), + "trials": int(trials), + "replay_certified_seconds": float(replay_seconds), + "single_pass_seconds": float(single_seconds), + "speedup": float(replay_seconds / single_seconds) if single_seconds > 0.0 else np.inf, + "replay_certified_static_replays": int(replay_static_replays), + "single_pass_static_replays": int(single_static_replays), + "accounting_parity": bool(replay_fingerprints == single_fingerprints), + "peak_rss_mb": float(_rss_mb()), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=1_000) + parser.add_argument("--trials", type=int, default=20) + parser.add_argument("--json-out", default="benchmarks/phase34c_native_event_single_pass.json") + parser.add_argument("--md-out", default="benchmarks/phase34c_native_event_single_pass.md") + args = parser.parse_args() + payload = run(rows=args.rows, trials=args.trials) + json_path = Path(args.json_out) + md_path = Path(args.md_out) + json_path.parent.mkdir(parents=True, exist_ok=True) + md_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + lines = [ + "# Phase 34C Native Event Single-Pass Benchmark", + "", + f"- Rows: `{payload['rows']}`", + f"- Trials: `{payload['trials']}`", + f"- Replay-certified seconds: `{payload['replay_certified_seconds']:.6f}`", + f"- Single-pass seconds: `{payload['single_pass_seconds']:.6f}`", + f"- Speedup: `{payload['speedup']:.3f}x`", + f"- Replay-certified static replays: `{payload['replay_certified_static_replays']}`", + f"- Single-pass static replays: `{payload['single_pass_static_replays']}`", + f"- Accounting parity: `{payload['accounting_parity']}`", + f"- Peak RSS MB: `{payload['peak_rss_mb']:.3f}`", + "", + "This benchmark isolates the Phase 34C mode switch: `single_pass` materializes accounting from the reactive session for minimal/score runs and skips the final static replay.", + ] + md_path.write_text("\n".join(lines) + "\n") + print(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/src/quantbt/benchmarks/run_phase7.py b/src/quantbt/benchmarks/run_phase7.py new file mode 100644 index 0000000..aa25e35 --- /dev/null +++ b/src/quantbt/benchmarks/run_phase7.py @@ -0,0 +1,654 @@ +#!/usr/bin/env python3 +""" +Phase 7 benchmark runner. + +This is a lightweight stdlib CLI around the public V2 engines. It intentionally +does not require pytest or a benchmark plugin so it can run inside notebooks, +SSH shells, and CI jobs with the same command. +""" + +from __future__ import annotations + +import argparse +import gc +import json +import math +import resource +import statistics +import sys +import time +import tracemalloc +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + + +@dataclass(frozen=True) +class BenchmarkProfile: + name: str + bars: int + symbols: int + order_count: int + repeats: int + + +@dataclass +class BenchmarkRecord: + backend: str + profile: str + status: str + bars: int + symbols: int + bar_symbols: int + order_count: int + event_count: int + signal_transitions: int + warmup_seconds: Optional[float] + runtime_seconds: Optional[float] + runtime_min_seconds: Optional[float] + runtime_max_seconds: Optional[float] + peak_memory_mb: Optional[float] + rss_delta_mb: Optional[float] + throughput_bar_symbols_per_second: Optional[float] = None + throughput_orders_per_second: Optional[float] = None + threshold_metric: Optional[str] = None + threshold_value: Optional[float] = None + threshold_limit: Optional[float] = None + threshold_passed: Optional[bool] = None + error: Optional[str] = None + + +PROFILES = { + "smoke": BenchmarkProfile(name="smoke", bars=1_000, symbols=4, order_count=500, repeats=2), + "standard": BenchmarkProfile(name="standard", bars=25_000, symbols=20, order_count=25_000, repeats=5), + "large": BenchmarkProfile(name="large", bars=100_000, symbols=50, order_count=100_000, repeats=3), +} + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Run quantbt Phase 7 benchmarks.") + parser.add_argument("--profile", choices=sorted(PROFILES), default="smoke") + parser.add_argument("--repeats", type=int, default=None) + parser.add_argument("--include-nautilus", action="store_true") + parser.add_argument("--no-tracemalloc", action="store_true", help="Measure runtime without Python allocation tracing.") + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase7_results.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase7_results.md") + args = parser.parse_args(argv) + + profile = PROFILES[args.profile] + if args.repeats is not None: + profile = BenchmarkProfile( + name=profile.name, + bars=profile.bars, + symbols=profile.symbols, + order_count=profile.order_count, + repeats=max(1, args.repeats), + ) + + records = run_all(profile=profile, include_nautilus=args.include_nautilus, trace_memory=not args.no_tracemalloc) + write_outputs(records=records, profile=profile, json_out=args.json_out, md_out=args.md_out) + for record in records: + print(_record_line(record)) + return 0 if all(r.status in {"passed", "skipped"} for r in records) else 1 + + +def run_all(profile: BenchmarkProfile, include_nautilus: bool = False, trace_memory: bool = True) -> List[BenchmarkRecord]: + records = [ + run_native_vectorized(profile, trace_memory=trace_memory), + run_native_event(profile, trace_memory=trace_memory), + run_native_event_prepared(profile, trace_memory=trace_memory), + run_portfolio_legacy(profile, trace_memory=trace_memory), + run_native_portfolio(profile, trace_memory=trace_memory), + ] + if include_nautilus: + records.append(run_nautilus(profile, trace_memory=trace_memory)) + else: + records.append(_skipped("nautilus", profile, "pass --include-nautilus to run optional backend")) + return records + + +def run_native_vectorized(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord: + try: + import pandas as pd + + from quantbt import AccountConfig, BacktestEngineV2 + + idx, frames = _make_market_frames(profile.bars, profile.symbols) + signals = _make_signals(idx, profile.symbols) + transitions = _count_signal_transitions(signals.values()) + + def workload(): + engine = BacktestEngineV2( + data=frames, + signals=signals, + backend="native_vectorized", + account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0), + alloc_per_trade=10_000.0, + hedge_type="signal_notional", + use_funding=False, + ) + return engine.result.equity.iloc[-1] + + return _measure( + backend="native_vectorized", + profile=profile, + workload=workload, + order_count=transitions, + event_count=profile.bars * profile.symbols, + signal_transitions=transitions, + trace_memory=trace_memory, + ) + except Exception as exc: + return _failed("native_vectorized", profile, exc) + + +def run_native_event(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord: + try: + from quantbt import AccountConfig, BacktestEngineV2 + + idx, frames = _make_market_frames(profile.bars, profile.symbols) + orders = _make_orders(idx, profile.order_count, profile.symbols) + + def workload(): + engine = BacktestEngineV2( + data=frames, + backend="native_event", + orders=orders, + account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0), + use_funding=False, + ) + return engine.result.equity.iloc[-1] + + return _measure( + backend="native_event", + profile=profile, + workload=workload, + order_count=len(orders), + event_count=profile.bars + len(orders), + signal_transitions=0, + trace_memory=trace_memory, + ) + except Exception as exc: + return _failed("native_event", profile, exc) + + +def run_native_event_prepared(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord: + try: + from quantbt import AccountConfig + from quantbt.backends import NativeEventBackend, NativeEventConfig + + idx, frames = _make_market_frames(profile.bars, profile.symbols) + orders = _make_orders(idx, profile.order_count, profile.symbols) + symbols = list(frames.keys()) + closes = {symbol: frame["close"] for symbol, frame in frames.items()} + highs = {symbol: frame["high"] for symbol, frame in frames.items()} + lows = {symbol: frame["low"] for symbol, frame in frames.items()} + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0), + use_funding=False, + ) + ) + market_arrays = backend.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + symbols=symbols, + ) + compiled_orders = backend.compile_orders(datetime_index=idx, orders=orders, symbols=symbols) + + def workload(): + result = backend.run_orders( + datetime_index=idx, + orders=orders, + closes=closes, + highs=highs, + lows=lows, + symbols=symbols, + market_arrays=market_arrays, + compiled_orders=compiled_orders, + ) + return result.equity.iloc[-1] + + return _measure( + backend="native_event_prepared", + profile=profile, + workload=workload, + order_count=len(orders), + event_count=profile.bars + len(orders), + signal_transitions=0, + trace_memory=trace_memory, + ) + except Exception as exc: + return _failed("native_event_prepared", profile, exc) + + +def run_portfolio_legacy(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord: + try: + from quantbt import AccountConfig, PortfolioBacktestEngine + + idx, frames = _make_market_frames(profile.bars, profile.symbols) + positions = _make_portfolio_positions(idx, profile.symbols) + closes = {symbol: frame["close"] for symbol, frame in frames.items()} + transitions = _count_signal_transitions(positions.values()) + + def workload(): + engine = PortfolioBacktestEngine( + positions=positions, + closes=closes, + highs=closes, + lows=closes, + datetime_index=idx, + mode="longshort", + account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0), + fee_rate=0.0, + alloc_per_trade=10_000.0, + use_funding=False, + ) + return engine.result.equity.iloc[-1] + + return _measure( + backend="portfolio_legacy", + profile=profile, + workload=workload, + order_count=transitions, + event_count=profile.bars * profile.symbols, + signal_transitions=transitions, + trace_memory=trace_memory, + ) + except Exception as exc: + return _failed("portfolio_legacy", profile, exc) + + +def run_native_portfolio(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord: + try: + from quantbt import AccountConfig, PortfolioBacktestEngine + + idx, frames = _make_market_frames(profile.bars, profile.symbols) + positions = _make_portfolio_positions(idx, profile.symbols) + closes = {symbol: frame["close"] for symbol, frame in frames.items()} + transitions = _count_signal_transitions(positions.values()) + + def workload(): + engine = PortfolioBacktestEngine( + positions=positions, + closes=closes, + highs=closes, + lows=closes, + datetime_index=idx, + mode="longshort", + backend="native_portfolio", + account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0), + fee_rate=0.0, + alloc_per_trade=10_000.0, + hedge_type="signal_notional", + use_funding=False, + ) + return engine.result.equity.iloc[-1] + + return _measure( + backend="native_portfolio", + profile=profile, + workload=workload, + order_count=transitions, + event_count=profile.bars * profile.symbols, + signal_transitions=transitions, + trace_memory=trace_memory, + ) + except Exception as exc: + return _failed("native_portfolio", profile, exc) + + +def run_nautilus(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord: + try: + from quantbt import AccountConfig, BacktestEngineV2 + from quantbt.adapters.nautilus import NautilusBacktestEngine + + NautilusBacktestEngine.check_available() + idx, frames = _make_market_frames(min(profile.bars, 10_000), 1) + symbol, frame = next(iter(frames.items())) + signal = _make_signals(idx, 1)[symbol] + transitions = _count_signal_transitions([signal]) + + def workload(): + engine = BacktestEngineV2( + data=frame, + signals=signal, + symbols=["BTCUSDT-PERP.BINANCE"], + backend="nautilus", + account=AccountConfig(initial_capital=10_000.0, leverage=10.0), + alloc_per_trade=1_000.0, + use_funding=False, + ) + return engine.result.equity.iloc[-1] + + nautilus_profile = BenchmarkProfile( + name=profile.name, + bars=len(idx), + symbols=1, + order_count=transitions, + repeats=max(1, min(profile.repeats, 2)), + ) + return _measure( + backend="nautilus", + profile=nautilus_profile, + workload=workload, + order_count=transitions, + event_count=len(idx), + signal_transitions=transitions, + trace_memory=trace_memory, + ) + except ImportError as exc: + return _skipped("nautilus", profile, str(exc)) + except Exception as exc: + return _failed("nautilus", profile, exc) + + +def _measure( + backend: str, + profile: BenchmarkProfile, + workload, + order_count: int, + event_count: int, + signal_transitions: int, + trace_memory: bool = True, +) -> BenchmarkRecord: + gc.collect() + rss_before = _rss_mb() + if trace_memory: + tracemalloc.start() + warmup_start = time.perf_counter() + workload() + warmup_seconds = time.perf_counter() - warmup_start + + runtimes: List[float] = [] + for _ in range(profile.repeats): + start = time.perf_counter() + workload() + runtimes.append(time.perf_counter() - start) + peak = 0 + if trace_memory: + current, peak = tracemalloc.get_traced_memory() + del current + tracemalloc.stop() + rss_after = _rss_mb() + + runtime = statistics.mean(runtimes) + record = BenchmarkRecord( + backend=backend, + profile=profile.name, + status="passed", + bars=profile.bars, + symbols=profile.symbols, + bar_symbols=profile.bars * profile.symbols, + order_count=order_count, + event_count=event_count, + signal_transitions=signal_transitions, + warmup_seconds=warmup_seconds, + runtime_seconds=runtime, + runtime_min_seconds=min(runtimes), + runtime_max_seconds=max(runtimes), + peak_memory_mb=(peak / (1024 * 1024)) if trace_memory else None, + rss_delta_mb=max(0.0, rss_after - rss_before), + throughput_bar_symbols_per_second=(profile.bars * profile.symbols / runtime) if runtime > 0.0 else None, + throughput_orders_per_second=(order_count / runtime) if runtime > 0.0 and order_count > 0 else None, + ) + return _attach_threshold(record) + + +def _make_market_frames(bars: int, symbols: int): + import numpy as np + import pandas as pd + + idx = pd.date_range("2020-01-01", periods=bars, freq="1min", tz="UTC") + base = 100.0 + np.cumsum(np.sin(np.arange(bars) / 37.0) * 0.05) + frames = {} + for j in range(symbols): + close = base + j * 0.25 + frames[f"SYM{j:03d}"] = pd.DataFrame( + { + "open": close, + "high": close * 1.001, + "low": close * 0.999, + "close": close, + "volume": 1_000.0 + j, + }, + index=idx, + ) + return idx, frames + + +def _make_signals(idx, symbols: int): + import numpy as np + import pandas as pd + + out = {} + n = len(idx) + grid = np.arange(n) + for j in range(symbols): + raw = np.where(((grid // (25 + j % 5)) + j) % 4 == 0, 1.0, 0.0) + out[f"SYM{j:03d}"] = pd.Series(raw, index=idx) + return out + + +def _make_portfolio_positions(idx, symbols: int): + import numpy as np + import pandas as pd + + out = {} + n = len(idx) + grid = np.arange(n) + for j in range(symbols): + active = np.where(((grid // (40 + j % 7)) + j) % 5 == 0, 1.0, 0.0) + sign = 1.0 if j % 2 == 0 else -1.0 + out[f"SYM{j:03d}"] = pd.Series(active * sign, index=idx) + return out + + +def _make_orders(idx, order_count: int, symbols: int): + import numpy as np + + from quantbt import OrderIntent, OrderSide, OrderType, TimeInForce + + if order_count <= 0: + return [] + positions = np.linspace(1, len(idx) - 1, num=order_count, dtype=int) + orders = [] + for k, bar in enumerate(positions): + side = OrderSide.BUY if k % 2 == 0 else OrderSide.SELL + orders.append( + OrderIntent( + timestamp=idx[int(bar)], + symbol=f"SYM{k % symbols:03d}", + side=side, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + ) + ) + return orders + + +def _count_signal_transitions(signals: Iterable) -> int: + count = 0 + for sig in signals: + values = sig.to_numpy() + if len(values) > 1: + count += int((values[1:] != values[:-1]).sum()) + return count + + +def write_outputs( + records: List[BenchmarkRecord], + profile: BenchmarkProfile, + json_out: Path, + md_out: Path, +) -> None: + json_out.parent.mkdir(parents=True, exist_ok=True) + md_out.parent.mkdir(parents=True, exist_ok=True) + payload = { + "profile": asdict(profile), + "records": [asdict(record) for record in records], + "thresholds": _load_thresholds(), + } + json_out.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + md_out.write_text(_markdown_report(records, profile), encoding="utf-8") + + +def _markdown_report(records: List[BenchmarkRecord], profile: BenchmarkProfile) -> str: + lines = [ + "# Phase 7 Benchmark Results", + "", + f"Profile: `{profile.name}`", + "", + "| backend | status | bars | symbols | orders | events | warmup s | runtime s | peak MB | throughput | threshold | note |", + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- |", + ] + for record in records: + threshold = "-" + if record.threshold_metric is not None: + verdict = "pass" if record.threshold_passed else "fail" + threshold = f"{record.threshold_metric}={_fmt(record.threshold_value)} <= {_fmt(record.threshold_limit)} ({verdict})" + lines.append( + "| {backend} | {status} | {bars} | {symbols} | {orders} | {events} | {warmup} | {runtime} | {peak} | {throughput} | {threshold} | {note} |".format( + backend=record.backend, + status=record.status, + bars=record.bars, + symbols=record.symbols, + orders=record.order_count, + events=record.event_count, + warmup=_fmt(record.warmup_seconds), + runtime=_fmt(record.runtime_seconds), + peak=_fmt(record.peak_memory_mb), + throughput=_fmt(record.throughput_bar_symbols_per_second), + threshold=threshold, + note=record.error or "", + ) + ) + lines.append("") + lines.append("Thresholds: see `benchmarks/phase7_thresholds.json`.") + return "\n".join(lines) + "\n" + + +def _record_line(record: BenchmarkRecord) -> str: + return ( + f"{record.backend}: {record.status} " + f"warmup={_fmt(record.warmup_seconds)}s runtime={_fmt(record.runtime_seconds)}s " + f"peak={_fmt(record.peak_memory_mb)}MB {record.error or ''}" + ) + + +def _fmt(value: Optional[float]) -> str: + if value is None or (isinstance(value, float) and math.isnan(value)): + return "-" + return f"{value:.6f}" + + +def _rss_mb() -> float: + try: + value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + except Exception: + return 0.0 + if sys.platform == "darwin": + return value / (1024 * 1024) + return value / 1024 + + +def _failed(backend: str, profile: BenchmarkProfile, exc: Exception) -> BenchmarkRecord: + return BenchmarkRecord( + backend=backend, + profile=profile.name, + status="failed", + bars=profile.bars, + symbols=profile.symbols, + bar_symbols=profile.bars * profile.symbols, + order_count=0, + event_count=0, + signal_transitions=0, + warmup_seconds=None, + runtime_seconds=None, + runtime_min_seconds=None, + runtime_max_seconds=None, + peak_memory_mb=None, + rss_delta_mb=None, + error=f"{type(exc).__name__}: {exc}", + ) + + +def _skipped(backend: str, profile: BenchmarkProfile, reason: str) -> BenchmarkRecord: + return BenchmarkRecord( + backend=backend, + profile=profile.name, + status="skipped", + bars=profile.bars, + symbols=profile.symbols, + bar_symbols=profile.bars * profile.symbols, + order_count=0, + event_count=0, + signal_transitions=0, + warmup_seconds=None, + runtime_seconds=None, + runtime_min_seconds=None, + runtime_max_seconds=None, + peak_memory_mb=None, + rss_delta_mb=None, + error=reason, + ) + + +def _load_thresholds() -> Dict: + path = PACKAGE_DIR / "benchmarks" / "phase7_thresholds.json" + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _attach_threshold(record: BenchmarkRecord) -> BenchmarkRecord: + thresholds = _load_thresholds() + backend_thresholds = thresholds.get(record.backend, {}) + if record.runtime_seconds is None: + return record + + metric = None + value = None + limit = None + if record.profile == "smoke" and "smoke_max_runtime_seconds" in backend_thresholds: + metric = "runtime_seconds" + value = record.runtime_seconds + limit = float(backend_thresholds["smoke_max_runtime_seconds"]) + elif record.backend in {"native_vectorized", "portfolio_legacy", "native_portfolio"}: + key = f"{record.profile}_max_seconds_per_million_bar_symbols" + if key in backend_thresholds and record.bar_symbols > 0: + metric = "seconds_per_million_bar_symbols" + value = record.runtime_seconds / (record.bar_symbols / 1_000_000.0) + limit = float(backend_thresholds[key]) + elif record.backend in {"native_event", "native_event_prepared"}: + key = f"{record.profile}_max_seconds_per_100k_orders" + if key in backend_thresholds and record.order_count > 0: + metric = "seconds_per_100k_orders" + value = record.runtime_seconds / (record.order_count / 100_000.0) + limit = float(backend_thresholds[key]) + elif record.backend == "nautilus": + key = f"{record.profile}_max_seconds_per_100k_bars" + if key in backend_thresholds and record.bars > 0: + metric = "seconds_per_100k_bars" + value = record.runtime_seconds / (record.bars / 100_000.0) + limit = float(backend_thresholds[key]) + + record.threshold_metric = metric + record.threshold_value = value + record.threshold_limit = limit + record.threshold_passed = None if value is None or limit is None else bool(value <= limit) + return record + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/benchmarks/run_portfolio_real_parity.py b/src/quantbt/benchmarks/run_portfolio_real_parity.py new file mode 100644 index 0000000..648c9ab --- /dev/null +++ b/src/quantbt/benchmarks/run_portfolio_real_parity.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +""" +Run a real-data-ready parity audit for the Phase 11 native portfolio backend. + +The script accepts an optional directory of OHLCV CSV/parquet files. When no +market directory is supplied it falls back to deterministic correlated OHLCV so +the audit remains reproducible in CI and in clean workspaces. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict, Iterable, List, Mapping, Optional, Tuple + +import numpy as np +import pandas as pd + +PACKAGE_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = PACKAGE_DIR.parent +if str(PROJECT_DIR) not in sys.path: + sys.path.insert(0, str(PROJECT_DIR)) + +from quantbt import ( # noqa: E402 + AccountConfig, + LEGACY_PORTFOLIO_SIZING_MODES, + NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES, + PortfolioBacktestEngine, + PortfolioDomainSpec, + validate_portfolio_result_contract, +) + + +LEGACY_PARITY_MODES = ("longshort", "market_neutral", "directional", "equal_weight") +LEGACY_PARITY_SIZING = ("signal_notional", "signal", "notional", "unit") +NATIVE_ONLY_SIZING = ("target_units", "target_notional", "fixed_notional") +NATIVE_EQUITY_SIZING = ("%_equity", "target_weight", "gross_exposure", "net_exposure") +NATIVE_ONLY_MODES = ("risk_parity", "beta_neutral") +UNSUPPORTED_NATIVE_SIZING = ("dca_ladder",) + + +def load_market_data( + data_dir: Optional[Path], + *, + bars: int, + symbols: Iterable[str], + seed: int, +) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series], Dict[str, pd.Series], Dict[str, pd.Series], str]: + if data_dir is not None: + loaded = _load_ohlcv_directory(data_dir, bars=bars) + if loaded is not None: + return (*loaded, "data_dir") + generated = _generate_realistic_ohlcv(bars=bars, symbols=tuple(symbols), seed=seed) + return (*generated, "deterministic_mock_real") + + +def build_position_signals(closes: Mapping[str, pd.Series]) -> Dict[str, pd.Series]: + close_frame = pd.DataFrame(closes).astype(float) + common_close = close_frame.mean(axis=1) + common_ret = np.log(common_close).diff() + common_fast = common_ret.rolling(12, min_periods=12).mean() + common_slow = common_ret.rolling(72, min_periods=72).mean() + common_vol = common_ret.rolling(72, min_periods=72).std().replace(0.0, np.nan) + common_z = ((common_fast - common_slow) / common_vol).shift(1).fillna(0.0) + common_raw = np.where(common_z > 0.10, 1.0, np.where(common_z < -0.10, -1.0, 0.0)) + + out: Dict[str, pd.Series] = {} + for i, (symbol, close) in enumerate(closes.items()): + scale = 1.0 + 0.25 * (i % 3) + direction = -1.0 if i % 2 else 1.0 + out[symbol] = pd.Series(common_raw * scale * direction, index=close.index, name=symbol) + return out + + +def run_suite( + *, + data_dir: Optional[Path] = None, + bars: int = 2_000, + symbols: Iterable[str] = ("BTC", "ETH", "SOL", "BNB"), + seed: int = 42, + initial_capital: float = 250_000.0, + leverage: float = 5.0, + fee_rate: float = 0.0004, + tolerance: float = 1e-8, +) -> Dict: + idx, closes, highs, lows, data_source = load_market_data(data_dir, bars=bars, symbols=symbols, seed=seed) + positions = build_position_signals(closes) + symbol_list = list(closes.keys()) + alloc = {symbol: 10_000.0 * (1.0 + 0.25 * (i % 4)) for i, symbol in enumerate(symbol_list)} + account = AccountConfig(initial_capital=initial_capital, leverage=leverage, maintenance_ratio=0.005) + + parity_records = [] + for mode in LEGACY_PARITY_MODES: + for sizing in LEGACY_PARITY_SIZING: + legacy = _run_portfolio( + positions, + closes, + highs, + lows, + idx, + mode=mode, + backend="legacy_portfolio", + hedge_type=sizing, + account=account, + alloc_per_trade=alloc, + fee_rate=fee_rate, + ) + native = _run_portfolio( + positions, + closes, + highs, + lows, + idx, + mode=mode, + backend="native_portfolio", + hedge_type=sizing, + account=account, + alloc_per_trade=alloc, + fee_rate=fee_rate, + ) + contract = validate_portfolio_result_contract( + native, + PortfolioDomainSpec(mode=mode, sizing_mode=sizing), + tolerance=tolerance, + raise_on_fail=False, + ) + record = { + "mode": mode, + "sizing_mode": sizing, + "legacy_final_equity": float(legacy.equity.iloc[-1]), + "native_final_equity": float(native.equity.iloc[-1]), + "max_abs_equity_diff": _max_abs_series_diff(native.equity, legacy.equity), + "max_abs_position_diff": _max_abs_frame_diff(native.positions, legacy.positions), + "max_abs_target_units_diff": _max_abs_metadata_frame_diff(native, legacy, "target_units_report"), + "max_abs_accepted_units_diff": _max_abs_metadata_frame_diff(native, legacy, "accepted_units_report"), + "max_abs_accepted_notional_diff": _max_abs_metadata_frame_diff( + native, legacy, "accepted_notional_report" + ), + "contract_passed": bool(contract["passed"]), + } + record["passed"] = ( + record["max_abs_equity_diff"] <= tolerance + and record["max_abs_position_diff"] <= tolerance + and record["max_abs_target_units_diff"] <= tolerance + and record["max_abs_accepted_units_diff"] <= tolerance + and record["max_abs_accepted_notional_diff"] <= tolerance + and record["contract_passed"] + ) + parity_records.append(record) + + native_only_records = [] + native_only_positions = { + "target_units": positions, + "target_notional": {symbol: series * alloc[symbol] for symbol, series in positions.items()}, + "fixed_notional": positions, + "%_equity": positions, + "target_weight": positions, + "gross_exposure": positions, + "net_exposure": {symbol: series.abs() for symbol, series in positions.items()}, + } + for sizing in (*NATIVE_ONLY_SIZING, *NATIVE_EQUITY_SIZING): + case_alloc = 1.0 if sizing in {"gross_exposure", "net_exposure"} else 0.5 if sizing == "%_equity" else alloc + result = _run_portfolio( + native_only_positions[sizing], + closes, + highs, + lows, + idx, + mode="longshort", + backend="native_portfolio", + hedge_type=sizing, + account=account, + alloc_per_trade=case_alloc, + fee_rate=fee_rate, + ) + contract = validate_portfolio_result_contract( + result, + PortfolioDomainSpec(mode="longshort", sizing_mode=sizing), + tolerance=tolerance, + raise_on_fail=False, + ) + native_only_records.append( + { + "mode": "longshort", + "sizing_mode": sizing, + "final_equity": float(result.equity.iloc[-1]), + "max_gross_leverage": _safe_max(result.metadata["exposure_report"]["gross_leverage"]), + "fee_total": float(result.metadata["fee_total"]), + "turnover_total": float(result.metadata["turnover_total"]), + "contract_passed": bool(contract["passed"]), + "passed": bool(contract["passed"]), + } + ) + + for mode in NATIVE_ONLY_MODES: + result = _run_portfolio( + positions, + closes, + highs, + lows, + idx, + mode=mode, + backend="native_portfolio", + hedge_type="gross_exposure", + account=account, + alloc_per_trade=1.0, + fee_rate=fee_rate, + ) + contract = validate_portfolio_result_contract( + result, + PortfolioDomainSpec(mode=mode, sizing_mode="gross_exposure"), + tolerance=tolerance, + raise_on_fail=False, + ) + native_only_records.append( + { + "mode": mode, + "sizing_mode": "gross_exposure", + "final_equity": float(result.equity.iloc[-1]), + "max_gross_leverage": _safe_max(result.metadata["exposure_report"]["gross_leverage"]), + "fee_total": float(result.metadata["fee_total"]), + "turnover_total": float(result.metadata["turnover_total"]), + "contract_passed": bool(contract["passed"]), + "passed": bool(contract["passed"]), + } + ) + + unsupported_records = [] + for sizing in UNSUPPORTED_NATIVE_SIZING: + unsupported_records.append(_probe_unsupported_sizing(positions, closes, highs, lows, idx, sizing, account, alloc, fee_rate)) + + parity_passed = all(item["passed"] for item in parity_records) + native_only_passed = all(item["passed"] for item in native_only_records) + unsupported_passed = all(item["rejected"] for item in unsupported_records) + return { + "status": "pass" if parity_passed and native_only_passed and unsupported_passed else "fail", + "data_source": data_source, + "bars": int(len(idx)), + "symbols": symbol_list, + "initial_capital": float(initial_capital), + "leverage": float(leverage), + "fee_rate_round_trip": float(fee_rate), + "native_supported_modes": list((*LEGACY_PARITY_MODES, *NATIVE_ONLY_MODES)), + "native_supported_sizing_modes": sorted(NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES), + "legacy_compatible_sizing_modes": sorted(LEGACY_PORTFOLIO_SIZING_MODES), + "native_unsupported_sizing_modes": list(UNSUPPORTED_NATIVE_SIZING), + "parity_records": parity_records, + "native_only_records": native_only_records, + "unsupported_records": unsupported_records, + "summary": { + "legacy_parity_cases": len(parity_records), + "legacy_parity_passed": parity_passed, + "native_only_cases": len(native_only_records), + "native_only_passed": native_only_passed, + "unsupported_cases": len(unsupported_records), + "unsupported_rejected": unsupported_passed, + "max_abs_equity_diff": max(item["max_abs_equity_diff"] for item in parity_records), + "max_abs_position_diff": max(item["max_abs_position_diff"] for item in parity_records), + "max_abs_target_units_diff": max(item["max_abs_target_units_diff"] for item in parity_records), + "max_abs_accepted_notional_diff": max(item["max_abs_accepted_notional_diff"] for item in parity_records), + }, + } + + +def make_markdown_report(report: Dict) -> str: + summary = report["summary"] + lines = [ + "# Native Portfolio Real-Parity Audit", + "", + f"Status: **{report['status']}**", + f"Data source: `{report['data_source']}`", + f"Shape: `{report['bars']}` bars x `{len(report['symbols'])}` symbols", + f"Symbols: `{', '.join(report['symbols'])}`", + "", + "## Summary", + "", + f"- Legacy-compatible parity cases: `{summary['legacy_parity_cases']}`", + f"- Legacy parity passed: `{summary['legacy_parity_passed']}`", + f"- Native-only domain cases: `{summary['native_only_cases']}`", + f"- Native-only contract passed: `{summary['native_only_passed']}`", + f"- Unsupported sizing rejected: `{summary['unsupported_rejected']}`", + f"- Max abs equity diff: `{summary['max_abs_equity_diff']:.12g}`", + f"- Max abs position diff: `{summary['max_abs_position_diff']:.12g}`", + f"- Max abs target units diff: `{summary['max_abs_target_units_diff']:.12g}`", + f"- Max abs accepted notional diff: `{summary['max_abs_accepted_notional_diff']:.12g}`", + "", + "## Supported Surface", + "", + f"- Modes: `{', '.join(report['native_supported_modes'])}`", + f"- Sizing: `{', '.join(report['native_supported_sizing_modes'])}`", + f"- Explicitly rejected: `{', '.join(report['native_unsupported_sizing_modes'])}`", + "", + "## Legacy-Compatible Parity", + "", + "| mode | sizing | legacy equity | native equity | max equity diff | max position diff | pass |", + "|---|---:|---:|---:|---:|---:|---:|", + ] + for item in report["parity_records"]: + lines.append( + "| {mode} | {sizing_mode} | {legacy_final_equity:.6f} | {native_final_equity:.6f} | " + "{max_abs_equity_diff:.3g} | {max_abs_position_diff:.3g} | {passed} |".format(**item) + ) + lines.extend( + [ + "", + "## Native-Only Contract Checks", + "", + "| mode | sizing | final equity | max gross leverage | fee total | turnover total | pass |", + "|---|---:|---:|---:|---:|---:|---:|", + ] + ) + for item in report["native_only_records"]: + lines.append( + "| {mode} | {sizing_mode} | {final_equity:.6f} | {max_gross_leverage:.6f} | " + "{fee_total:.6f} | {turnover_total:.6f} | {passed} |".format(**item) + ) + return "\n".join(lines) + "\n" + + +def _run_portfolio( + positions: Mapping[str, pd.Series], + closes: Mapping[str, pd.Series], + highs: Mapping[str, pd.Series], + lows: Mapping[str, pd.Series], + idx: pd.DatetimeIndex, + *, + mode: str, + backend: str, + hedge_type: str, + account: AccountConfig, + alloc_per_trade: Mapping[str, float], + fee_rate: float, +): + engine = PortfolioBacktestEngine( + positions=dict(positions), + closes=dict(closes), + highs=dict(highs), + lows=dict(lows), + datetime_index=idx, + mode=mode, + backend=backend, + account=account, + fee_rate=fee_rate, + alloc_per_trade=dict(alloc_per_trade) if isinstance(alloc_per_trade, Mapping) else float(alloc_per_trade), + contract_size=1.0, + hedge_type=hedge_type, + asset_type="crypto", + use_funding=False, + ) + return engine.result + + +def _probe_unsupported_sizing( + positions: Mapping[str, pd.Series], + closes: Mapping[str, pd.Series], + highs: Mapping[str, pd.Series], + lows: Mapping[str, pd.Series], + idx: pd.DatetimeIndex, + sizing: str, + account: AccountConfig, + alloc_per_trade: Mapping[str, float], + fee_rate: float, +) -> Dict: + try: + _run_portfolio( + positions, + closes, + highs, + lows, + idx, + mode="longshort", + backend="native_portfolio", + hedge_type=sizing, + account=account, + alloc_per_trade=alloc_per_trade, + fee_rate=fee_rate, + ) + except (NotImplementedError, ValueError) as exc: + return {"sizing_mode": sizing, "rejected": True, "error": type(exc).__name__, "message": str(exc)} + return {"sizing_mode": sizing, "rejected": False, "error": None, "message": "unexpectedly accepted"} + + +def _generate_realistic_ohlcv( + *, + bars: int, + symbols: Tuple[str, ...], + seed: int, +) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series], Dict[str, pd.Series], Dict[str, pd.Series]]: + rng = np.random.default_rng(seed) + idx = pd.date_range("2021-01-01", periods=bars, freq="1h", tz="UTC") + market = rng.normal(0.00005, 0.010, size=bars) + closes: Dict[str, pd.Series] = {} + highs: Dict[str, pd.Series] = {} + lows: Dict[str, pd.Series] = {} + start_prices = np.linspace(32_000.0, 250.0, num=len(symbols)) + for i, symbol in enumerate(symbols): + idio = rng.normal(0.0, 0.006 + 0.001 * i, size=bars) + seasonal = 0.0002 * np.sin(np.linspace(0.0, 8.0 * np.pi, bars) + i) + log_ret = 0.65 * market + 0.35 * idio + seasonal + price = start_prices[i] * np.exp(np.cumsum(log_ret)) + spread = np.abs(rng.normal(0.0015, 0.0005, size=bars)) + close = pd.Series(price, index=idx, name=symbol) + closes[symbol] = close + highs[symbol] = pd.Series(price * (1.0 + spread), index=idx, name=symbol) + lows[symbol] = pd.Series(price * (1.0 - spread), index=idx, name=symbol) + return idx, closes, highs, lows + + +def _load_ohlcv_directory( + data_dir: Path, + *, + bars: int, +) -> Optional[Tuple[pd.DatetimeIndex, Dict[str, pd.Series], Dict[str, pd.Series], Dict[str, pd.Series]]]: + if not data_dir.exists(): + return None + frames = {} + for path in sorted([*data_dir.glob("*.csv"), *data_dir.glob("*.parquet"), *data_dir.glob("*.feather")]): + frame = _read_ohlcv_file(path) + if frame is None: + continue + frames[path.stem.upper()] = frame + if len(frames) < 2: + return None + + common_index = None + for frame in frames.values(): + common_index = frame.index if common_index is None else common_index.intersection(frame.index) + if common_index is None or len(common_index) < 50: + return None + common_index = common_index.sort_values()[-bars:] + closes = {symbol: frame.reindex(common_index)["close"].ffill().dropna() for symbol, frame in frames.items()} + valid_index = common_index + for series in closes.values(): + valid_index = valid_index.intersection(series.index) + valid_index = valid_index.sort_values() + closes = {symbol: frame.reindex(valid_index)["close"].ffill() for symbol, frame in frames.items()} + highs = {symbol: frame.reindex(valid_index)["high"].ffill() for symbol, frame in frames.items()} + lows = {symbol: frame.reindex(valid_index)["low"].ffill() for symbol, frame in frames.items()} + return valid_index, closes, highs, lows + + +def _read_ohlcv_file(path: Path) -> Optional[pd.DataFrame]: + try: + if path.suffix == ".parquet": + frame = pd.read_parquet(path) + elif path.suffix == ".feather": + frame = pd.read_feather(path) + else: + frame = pd.read_csv(path) + except Exception: + return None + + frame = frame.copy() + frame.columns = [str(col).lower() for col in frame.columns] + if "close" not in frame.columns: + return None + if "high" not in frame.columns: + frame["high"] = frame["close"] + if "low" not in frame.columns: + frame["low"] = frame["close"] + + dt_col = next((col for col in ("datetime", "timestamp", "date", "time") if col in frame.columns), None) + if dt_col is not None: + idx = pd.to_datetime(frame[dt_col], utc=True, errors="coerce") + else: + idx = pd.to_datetime(frame.index, utc=True, errors="coerce") + frame.index = idx + frame = frame.loc[frame.index.notna(), ["close", "high", "low"]].astype(float).sort_index() + frame = frame[~frame.index.duplicated(keep="last")] + return frame.dropna() + + +def _max_abs_series_diff(left: pd.Series, right: pd.Series) -> float: + a, b = left.align(right, join="inner") + if len(a) == 0: + return float("inf") + return float(np.max(np.abs(a.to_numpy(dtype=float) - b.to_numpy(dtype=float)))) + + +def _max_abs_frame_diff(left: pd.DataFrame, right: pd.DataFrame) -> float: + a, b = left.align(right, join="inner", axis=None) + if a.empty and b.empty: + return 0.0 + if a.empty or b.empty: + return float("inf") + return float(np.max(np.abs(a.to_numpy(dtype=float) - b.to_numpy(dtype=float)))) + + +def _max_abs_metadata_frame_diff(left, right, key: str) -> float: + return _max_abs_frame_diff(left.metadata[key], right.metadata[key]) + + +def _safe_max(series: pd.Series) -> float: + return float(series.max()) if len(series) else 0.0 + + +def _json_default(value): + if isinstance(value, (np.bool_,)): + return bool(value) + if isinstance(value, (np.integer,)): + return int(value) + if isinstance(value, (np.floating,)): + return float(value) + if isinstance(value, (pd.Timestamp,)): + return value.isoformat() + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data-dir", type=Path, default=None, help="Directory containing OHLCV CSV/parquet files.") + parser.add_argument("--bars", type=int, default=2_000) + parser.add_argument("--symbols", default="BTC,ETH,SOL,BNB") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "portfolio_real_parity_report.json") + parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "portfolio_real_parity_report.md") + args = parser.parse_args(argv) + + report = run_suite( + data_dir=args.data_dir, + bars=args.bars, + symbols=tuple(item.strip() for item in args.symbols.split(",") if item.strip()), + seed=args.seed, + ) + markdown = make_markdown_report(report) + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.md_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, indent=2, default=_json_default) + "\n", encoding="utf-8") + args.md_out.write_text(markdown, encoding="utf-8") + print(markdown) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/quantbt/core/__init__.py b/src/quantbt/core/__init__.py new file mode 100644 index 0000000..17bbfcd --- /dev/null +++ b/src/quantbt/core/__init__.py @@ -0,0 +1,323 @@ +from .engine import _engine_units, _engine_pct_equity, _engine_dca_ladder, _engine_portfolio +from .event import _engine_event_v1 +from .vectorized import _engine_units_v2 +from .types import BacktestResult +from .results import ( + BacktestResultV2, + NativeAccountingArrays, + NativeEventScalarScoreResult, + NativeEventScoreResult, +) +from .execution_contract import ( + EXECUTION_CONTRACT_REGISTRY, + AmbiguityPolicy, + ExecutionContract, + FillPhase, + FundingPhase, + IntrabarSameBarPolicy, + LiquidationPriority, + MarketFillPolicy, + SignalPhase, + StopGapPolicy, + TakeProfitGapPolicy, + TrailingUpdatePhase, + get_execution_contract, +) +from .market_tape import MarketValidationCertificate, PreparedMarketTape, prepare_market_tape +from .intrabar_reference import ( + IntrabarEventFlag, + IntrabarFill, + IntrabarFillReason, + IntrabarIntentTape, + IntrabarLevelMode, + IntrabarReferenceResult, + IntrabarSizingMode, + run_intrabar_reference, +) +from .intrabar_session import ( + EntryPositionPolicy, + IntrabarSessionTape, + ProtectiveExitReentryPolicy, + SessionCounterBasis, + SessionExecutionPolicy, +) +from .intrabar_kernel import ( + FillReplayTape, + NativeFillReplayResult, + NativeIntrabarKernelResult, + run_fill_replay_kernel, + run_intrabar_kernel, + run_intrabar_session_kernel, +) +from .certification import ( + AlphaExecutionClassification, + CertificationLevel, + alpha_report_markdown, + build_alpha_certification_report, + certify_result_metadata, + classify_alpha_source, + scan_alpha_directory, +) +from .native_event_capabilities import ( + NATIVE_EVENT_CAPABILITY_MATRIX, + NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + capability_matrix_fingerprint, + native_event_capability_matrix, + normalize_native_event_capabilities, + validate_native_event_capability_matrix, +) +from .native_event_parity import ( + DEFAULT_NUMERIC_ATOL, + NativeEventParityCertificate, + NativeEventParityError, + assert_native_event_full_parity, +) +from .orders import ( + BasketIntent, + Fill, + OrderAction, + OrderActivationPolicy, + OrderCommand, + OrderIntent, + Trade, + order_intents_to_lifecycle_commands, +) +from .basket import FrozenBasketPlan, build_frozen_basket_orders +from .execution_depth import ( + NautilusExecutionDepthConfig, + PackageDepthPreflightResult, + SUPPORTED_DEPTH_MODELS, + l2_replay_available, + simulate_nautilus_order_package_depth, +) +from .structured_orders import ( + BracketOrderSpec, + DcaGridSpec, + StructuredOrderPlan, + build_bracket_order_plan, + build_dca_grid_order_plan, +) +from .reactive import ( + NativeActiveOrderSnapshot, + NativeCommandBatch, + NativeEventStrategy, + NativeEventStrategyError, + NativeEventStrategyProtocol, + NativeFillEvent, + NativeOrderEvent, + NativeStrategyContext, +) +from .arbitrage import ( + ArbExecutionPolicy, + ArbitrageLeg, + ArbitragePlan, + ArbitrageSpec, + ArbitrageType, + BasisArbitrageSpec, + CalendarSpreadSpec, + CarryModel, + CarryModelKind, + ContractType, + CostModel, + CostModelKind, + CrossExchangeArbSpec, + FundingArbitrageSpec, + HedgePolicy, + HedgePolicyKind, + IndexBasketArbSpec, + LifecycleModel, + LifecycleModelKind, + MarginModel, + MarginModelKind, + OptionsVolArbSpec, + PackageExecutionKind, + PackageRejection, + SignalModel, + SignalModelKind, + SizingPolicy, + SizingPolicyKind, + SpotPerpCashCarrySpec, + SpreadFormula, + SpreadFormulaKind, + StatArbPairSpec, + TriangularArbSpec, + build_arbitrage_order_plan, + round_down_to_step, +) +from .schema import ( + AccountConfig, + AssetType, + BasketExecutionPolicy, + BasketLegSpec, + BasketSpec, + ExecutionConfig, + FeeModel, + FillPricePolicy, + InstrumentSpec, + LiquiditySide, + MarginMode, + OmsMode, + OrderSide, + OrderType, + SameBarPolicy, + SignalSpec, + TimeInForce, +) +from .preprocessor import ( + validate_datetime, + align_series, + prepare_funding, + make_funding_mask, + build_arrays, +) + +__all__ = [ + "_engine_units", + "_engine_event_v1", + "_engine_units_v2", + "_engine_pct_equity", + "_engine_dca_ladder", + "_engine_portfolio", + "BacktestResult", + "BacktestResultV2", + "DEFAULT_NUMERIC_ATOL", + "NativeAccountingArrays", + "NativeEventScoreResult", + "NativeEventScalarScoreResult", + "NativeEventParityCertificate", + "NativeEventParityError", + "BracketOrderSpec", + "AccountConfig", + "AlphaExecutionClassification", + "NATIVE_EVENT_CAPABILITY_MATRIX", + "NATIVE_EVENT_CAPABILITY_MATRIX_VERSION", + "AmbiguityPolicy", + "ArbExecutionPolicy", + "ArbitrageLeg", + "ArbitragePlan", + "ArbitrageSpec", + "ArbitrageType", + "AssetType", + "BasisArbitrageSpec", + "BasketExecutionPolicy", + "BasketIntent", + "BasketLegSpec", + "BasketSpec", + "CalendarSpreadSpec", + "CertificationLevel", + "CarryModel", + "CarryModelKind", + "ContractType", + "CostModel", + "CostModelKind", + "CrossExchangeArbSpec", + "DcaGridSpec", + "EXECUTION_CONTRACT_REGISTRY", + "ExecutionConfig", + "ExecutionContract", + "FeeModel", + "Fill", + "FillPricePolicy", + "FillPhase", + "FundingPhase", + "FundingArbitrageSpec", + "FrozenBasketPlan", + "HedgePolicy", + "HedgePolicyKind", + "IndexBasketArbSpec", + "InstrumentSpec", + "IntrabarEventFlag", + "IntrabarFill", + "IntrabarFillReason", + "IntrabarIntentTape", + "IntrabarLevelMode", + "IntrabarReferenceResult", + "IntrabarSessionTape", + "IntrabarSizingMode", + "EntryPositionPolicy", + "ProtectiveExitReentryPolicy", + "SessionCounterBasis", + "SessionExecutionPolicy", + "IntrabarSameBarPolicy", + "FillReplayTape", + "LifecycleModel", + "LifecycleModelKind", + "LiquiditySide", + "LiquidationPriority", + "MarginMode", + "MarginModel", + "MarginModelKind", + "MarketFillPolicy", + "MarketValidationCertificate", + "NautilusExecutionDepthConfig", + "NativeFillReplayResult", + "NativeIntrabarKernelResult", + "NativeActiveOrderSnapshot", + "NativeCommandBatch", + "NativeEventStrategyError", + "NativeEventStrategy", + "NativeEventStrategyProtocol", + "NativeFillEvent", + "NativeOrderEvent", + "NativeStrategyContext", + "OmsMode", + "OrderAction", + "OrderActivationPolicy", + "OrderCommand", + "OrderIntent", + "OrderSide", + "OrderType", + "OptionsVolArbSpec", + "PackageExecutionKind", + "PackageDepthPreflightResult", + "PackageRejection", + "PreparedMarketTape", + "SameBarPolicy", + "SignalModel", + "SignalModelKind", + "SignalSpec", + "SignalPhase", + "SizingPolicy", + "SizingPolicyKind", + "SpotPerpCashCarrySpec", + "SpreadFormula", + "SpreadFormulaKind", + "StatArbPairSpec", + "StopGapPolicy", + "StructuredOrderPlan", + "TakeProfitGapPolicy", + "TimeInForce", + "Trade", + "TrailingUpdatePhase", + "TriangularArbSpec", + "alpha_report_markdown", + "build_arbitrage_order_plan", + "build_alpha_certification_report", + "build_bracket_order_plan", + "build_dca_grid_order_plan", + "build_frozen_basket_orders", + "certify_result_metadata", + "classify_alpha_source", + "get_execution_contract", + "order_intents_to_lifecycle_commands", + "prepare_market_tape", + "round_down_to_step", + "run_intrabar_reference", + "run_intrabar_kernel", + "run_intrabar_session_kernel", + "run_fill_replay_kernel", + "scan_alpha_directory", + "SUPPORTED_DEPTH_MODELS", + "l2_replay_available", + "simulate_nautilus_order_package_depth", + "validate_datetime", + "align_series", + "prepare_funding", + "make_funding_mask", + "build_arrays", + "assert_native_event_full_parity", + "capability_matrix_fingerprint", + "native_event_capability_matrix", + "normalize_native_event_capabilities", + "validate_native_event_capability_matrix", +] diff --git a/src/quantbt/core/arbitrage.py b/src/quantbt/core/arbitrage.py new file mode 100644 index 0000000..b9ac009 --- /dev/null +++ b/src/quantbt/core/arbitrage.py @@ -0,0 +1,767 @@ +""" +quantbt.core.arbitrage +---------------------- +Phase A/B arbitrage domain schema and executable order-plan helpers. + +This module intentionally stops short of a full ArbitrageBacktestEngine. It +defines the public domain objects and deterministic package order planning +needed by golden tests before engine implementation begins. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from math import floor, isfinite +from typing import Dict, Optional, Tuple + +import numpy as np +import pandas as pd + +from .orders import OrderIntent +from .preprocessor import align_series, validate_datetime +from .schema import OrderSide, OrderType, TimeInForce + + +def _coerce_enum(enum_cls, value): + if isinstance(value, enum_cls): + return value + return enum_cls(value) + + +class ArbitrageType(str, Enum): + BASIS = "basis" + CALENDAR_SPREAD = "calendar_spread" + FUNDING = "funding" + STAT_ARB_PAIR = "stat_arb_pair" + INDEX_BASKET = "index_basket" + TRIANGULAR = "triangular" + CROSS_EXCHANGE = "cross_exchange" + SPOT_PERP_CASH_CARRY = "spot_perp_cash_carry" + OPTIONS_VOL = "options_vol" + + +class ContractType(str, Enum): + LINEAR = "linear" + INVERSE = "inverse" + QUANTO = "quanto" + SPOT = "spot" + OPTION = "option" + + +class HedgePolicyKind(str, Enum): + BASE_QTY_EQUAL = "base_qty_equal" + DELTA_NEUTRAL = "delta_neutral" + NOTIONAL_NEUTRAL = "notional_neutral" + BETA_NEUTRAL = "beta_neutral" + VEGA_NEUTRAL = "vega_neutral" + + +class SizingPolicyKind(str, Enum): + TARGET_NOTIONAL_TO_BASE_QTY = "target_notional_to_base_qty" + TARGET_GROSS_NOTIONAL = "target_gross_notional" + TARGET_BASE_QTY = "target_base_qty" + EQUITY_FRACTION = "equity_fraction" + + +class PackageExecutionKind(str, Enum): + ATOMIC_ALL_OR_NONE = "atomic_all_or_none" + BEST_EFFORT = "best_effort" + SEQUENTIAL = "sequential" + HEDGE_AFTER_PRIMARY = "hedge_after_primary" + REBALANCE_ONLY = "rebalance_only" + + +class SpreadFormulaKind(str, Enum): + PRICE_DIFF = "price_diff" + LOG_RESIDUAL = "log_residual" + RATIO = "ratio" + ANNUALIZED_BASIS = "annualized_basis" + FUNDING_SPREAD = "funding_spread" + BASKET_RESIDUAL = "basket_residual" + TRIANGULAR = "triangular" + OPTIONS_VOL = "options_vol" + CUSTOM = "custom" + + +class SignalModelKind(str, Enum): + EXTERNAL = "external" + THRESHOLD = "threshold" + ZSCORE = "zscore" + CUSTOM = "custom" + + +class CostModelKind(str, Enum): + PER_LEG_FEE = "per_leg_fee" + FLAT_BPS = "flat_bps" + SPREAD_PLUS_FEE = "spread_plus_fee" + CUSTOM = "custom" + + +class CarryModelKind(str, Enum): + NONE = "none" + FUNDING = "funding" + BORROW = "borrow" + CASH_YIELD = "cash_yield" + FUNDING_AND_BORROW = "funding_and_borrow" + CUSTOM = "custom" + + +class MarginModelKind(str, Enum): + GROSS = "gross" + HEDGED_OFFSET = "hedged_offset" + PORTFOLIO = "portfolio" + VENUE = "venue" + CUSTOM = "custom" + + +class LifecycleModelKind(str, Enum): + OPEN_ENDED = "open_ended" + EXPIRY_SETTLEMENT = "expiry_settlement" + ROLLING = "rolling" + EXERCISE = "exercise" + CUSTOM = "custom" + + +@dataclass(frozen=True) +class SpreadFormula: + kind: SpreadFormulaKind = SpreadFormulaKind.CUSTOM + base_symbol: Optional[str] = None + quote_symbol: Optional[str] = None + fair_value: Optional[float] = None + annualization_days: float = 365.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(SpreadFormulaKind, self.kind)) + if self.fair_value is not None and not isfinite(float(self.fair_value)): + raise ValueError("fair_value must be finite") + if self.annualization_days <= 0.0: + raise ValueError("annualization_days must be > 0") + + +@dataclass(frozen=True) +class SignalModel: + kind: SignalModelKind = SignalModelKind.EXTERNAL + entry_threshold: Optional[float] = None + exit_threshold: Optional[float] = None + lookback: Optional[int] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(SignalModelKind, self.kind)) + if self.lookback is not None and self.lookback <= 0: + raise ValueError("lookback must be > 0") + + +@dataclass(frozen=True) +class CostModel: + kind: CostModelKind = CostModelKind.PER_LEG_FEE + fee_bps: float = 0.0 + slippage_bps: float = 0.0 + spread_bps: float = 0.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(CostModelKind, self.kind)) + if self.fee_bps < 0.0 or self.slippage_bps < 0.0 or self.spread_bps < 0.0: + raise ValueError("cost bps values must be >= 0") + + +@dataclass(frozen=True) +class CarryModel: + kind: CarryModelKind = CarryModelKind.NONE + funding_interval_hours: Optional[float] = None + borrow_rate: float = 0.0 + cash_yield: float = 0.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(CarryModelKind, self.kind)) + if self.funding_interval_hours is not None and self.funding_interval_hours <= 0.0: + raise ValueError("funding_interval_hours must be > 0") + if self.borrow_rate < 0.0: + raise ValueError("borrow_rate must be >= 0") + + +@dataclass(frozen=True) +class MarginModel: + kind: MarginModelKind = MarginModelKind.GROSS + hedged_margin_offset: float = 0.0 + maintenance_ratio: Optional[float] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(MarginModelKind, self.kind)) + if not 0.0 <= self.hedged_margin_offset <= 1.0: + raise ValueError("hedged_margin_offset must be in [0, 1]") + if self.maintenance_ratio is not None and self.maintenance_ratio < 0.0: + raise ValueError("maintenance_ratio must be >= 0") + + +@dataclass(frozen=True) +class LifecycleModel: + kind: LifecycleModelKind = LifecycleModelKind.OPEN_ENDED + roll_days_before_expiry: Optional[int] = None + force_flat_before_expiry: bool = True + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(LifecycleModelKind, self.kind)) + if self.roll_days_before_expiry is not None and self.roll_days_before_expiry < 0: + raise ValueError("roll_days_before_expiry must be >= 0") + + +@dataclass(frozen=True) +class ArbitrageLeg: + symbol: str + ratio: float + role: str = "leg" + venue: Optional[str] = None + asset_class: str = "future" + quote_currency: str = "USDT" + base_currency: Optional[str] = None + contract_type: ContractType = ContractType.LINEAR + contract_size: float = 1.0 + qty_step: float = 0.0 + min_qty: float = 0.0 + min_notional: float = 0.0 + tick_size: float = 0.0 + fee_rate: Optional[float] = None + funding_enabled: bool = False + expiry: Optional[pd.Timestamp] = None + settlement_policy: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "contract_type", _coerce_enum(ContractType, self.contract_type)) + if not self.symbol: + raise ValueError("symbol is required") + if not isfinite(float(self.ratio)) or float(self.ratio) == 0.0: + raise ValueError("ratio must be finite and non-zero") + if self.contract_size <= 0.0: + raise ValueError("contract_size must be > 0") + if self.qty_step < 0.0: + raise ValueError("qty_step must be >= 0") + if self.min_qty < 0.0 or self.min_notional < 0.0: + raise ValueError("min_qty and min_notional must be >= 0") + if self.tick_size < 0.0: + raise ValueError("tick_size must be >= 0") + if self.fee_rate is not None and self.fee_rate < 0.0: + raise ValueError("fee_rate must be >= 0") + if self.expiry is not None: + expiry = pd.Timestamp(self.expiry) + if expiry.tz is None: + expiry = expiry.tz_localize("UTC") + else: + expiry = expiry.tz_convert("UTC") + object.__setattr__(self, "expiry", expiry) + if self.contract_type in (ContractType.LINEAR, ContractType.INVERSE, ContractType.QUANTO): + if self.asset_class not in ("future", "perp", "derivative", "crypto"): + raise ValueError("derivative contract legs must use future/perp/derivative asset_class") + + +@dataclass(frozen=True) +class HedgePolicy: + kind: HedgePolicyKind + freeze_on_entry: bool = True + rebalance_threshold: Optional[float] = None + rebalance_interval: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(HedgePolicyKind, self.kind)) + if self.rebalance_threshold is not None and self.rebalance_threshold < 0.0: + raise ValueError("rebalance_threshold must be >= 0") + + +@dataclass(frozen=True) +class SizingPolicy: + kind: SizingPolicyKind + notional: Optional[float] = None + base_qty: Optional[float] = None + equity_fraction: Optional[float] = None + reference_symbol: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(SizingPolicyKind, self.kind)) + if self.notional is not None and self.notional <= 0.0: + raise ValueError("notional must be > 0") + if self.base_qty is not None and self.base_qty <= 0.0: + raise ValueError("base_qty must be > 0") + if self.equity_fraction is not None and self.equity_fraction <= 0.0: + raise ValueError("equity_fraction must be > 0") + if self.kind in (SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, SizingPolicyKind.TARGET_GROSS_NOTIONAL): + if self.notional is None: + raise ValueError(f"{self.kind.value} requires notional") + if self.kind is SizingPolicyKind.TARGET_BASE_QTY and self.base_qty is None: + raise ValueError("target_base_qty requires base_qty") + + +@dataclass(frozen=True) +class ArbExecutionPolicy: + kind: PackageExecutionKind = PackageExecutionKind.ATOMIC_ALL_OR_NONE + allow_partial_fill: bool = False + order_type: OrderType = OrderType.MARKET + tif: TimeInForce = TimeInForce.IOC + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "kind", _coerce_enum(PackageExecutionKind, self.kind)) + object.__setattr__(self, "order_type", _coerce_enum(OrderType, self.order_type)) + object.__setattr__(self, "tif", _coerce_enum(TimeInForce, self.tif)) + if self.kind is PackageExecutionKind.ATOMIC_ALL_OR_NONE and self.allow_partial_fill: + raise ValueError("atomic_all_or_none cannot allow partial fills") + if self.kind is PackageExecutionKind.BEST_EFFORT and not self.allow_partial_fill: + object.__setattr__(self, "allow_partial_fill", True) + + +@dataclass(frozen=True) +class ArbitrageSpec: + arb_id: str + legs: Tuple[ArbitrageLeg, ...] + hedge_policy: HedgePolicy + sizing_policy: SizingPolicy + spread_formula: SpreadFormula = field(default_factory=SpreadFormula) + signal_model: SignalModel = field(default_factory=SignalModel) + cost_model: CostModel = field(default_factory=CostModel) + carry_model: CarryModel = field(default_factory=CarryModel) + margin_model: MarginModel = field(default_factory=MarginModel) + lifecycle_model: LifecycleModel = field(default_factory=LifecycleModel) + execution_policy: ArbExecutionPolicy = field(default_factory=ArbExecutionPolicy) + arb_type: ArbitrageType = ArbitrageType.BASIS + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "arb_type", _coerce_enum(ArbitrageType, self.arb_type)) + if not self.arb_id: + raise ValueError("arb_id is required") + if len(self.legs) < 2: + raise ValueError("arbitrage spec requires at least two legs") + symbols = [leg.symbol for leg in self.legs] + if len(set(symbols)) != len(symbols): + raise ValueError("arbitrage legs must have unique symbols") + roles = [leg.role for leg in self.legs if leg.role and leg.role != "leg"] + if len(set(roles)) != len(roles): + raise ValueError("arbitrage legs must have unique roles") + if self.sizing_policy.reference_symbol is not None and self.sizing_policy.reference_symbol not in symbols: + raise ValueError("sizing_policy.reference_symbol must be one of the leg symbols") + if self.spread_formula.base_symbol is not None and self.spread_formula.base_symbol not in symbols: + raise ValueError("spread_formula.base_symbol must be one of the leg symbols") + if self.spread_formula.quote_symbol is not None and self.spread_formula.quote_symbol not in symbols: + raise ValueError("spread_formula.quote_symbol must be one of the leg symbols") + if self.lifecycle_model.kind in (LifecycleModelKind.EXPIRY_SETTLEMENT, LifecycleModelKind.ROLLING): + expiring = [leg for leg in self.legs if leg.expiry is not None] + if not expiring: + raise ValueError("expiry lifecycle requires at least one leg expiry") + + +@dataclass(frozen=True) +class BasisArbitrageSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.BASIS + + def __post_init__(self) -> None: + super().__post_init__() + if self.hedge_policy.kind not in (HedgePolicyKind.BASE_QTY_EQUAL, HedgePolicyKind.DELTA_NEUTRAL): + raise ValueError("BasisArbitrageSpec requires base_qty_equal or delta_neutral hedge policy") + linear_legs = [leg for leg in self.legs if leg.contract_type is ContractType.LINEAR] + if len(linear_legs) != len(self.legs): + # Inverse/quanto support is planned, but Phase A keeps the clean + # USDM linear basis contract explicit. + raise NotImplementedError("Phase A BasisArbitrageSpec supports linear legs only") + + +@dataclass(frozen=True) +class CalendarSpreadSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.CALENDAR_SPREAD + + def __post_init__(self) -> None: + super().__post_init__() + expiries = [leg.expiry for leg in self.legs] + if any(expiry is None for expiry in expiries): + raise ValueError("CalendarSpreadSpec requires expiry on every leg") + if len(set(expiries)) < 2: + raise ValueError("CalendarSpreadSpec requires at least two distinct expiries") + + +@dataclass(frozen=True) +class FundingArbitrageSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.FUNDING + + def __post_init__(self) -> None: + super().__post_init__() + if not any(leg.funding_enabled for leg in self.legs): + raise ValueError("FundingArbitrageSpec requires at least one funding-enabled leg") + if self.carry_model.kind not in (CarryModelKind.NONE, CarryModelKind.FUNDING, CarryModelKind.FUNDING_AND_BORROW, CarryModelKind.CUSTOM): + raise ValueError("FundingArbitrageSpec requires a funding-compatible carry model") + + +@dataclass(frozen=True) +class StatArbPairSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.STAT_ARB_PAIR + + +@dataclass(frozen=True) +class IndexBasketArbSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.INDEX_BASKET + + def __post_init__(self) -> None: + super().__post_init__() + if len(self.legs) < 3: + raise ValueError("IndexBasketArbSpec requires at least three legs") + if self.sizing_policy.kind is not SizingPolicyKind.TARGET_GROSS_NOTIONAL: + raise ValueError("IndexBasketArbSpec requires target_gross_notional sizing") + + +@dataclass(frozen=True) +class TriangularArbSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.TRIANGULAR + + def __post_init__(self) -> None: + super().__post_init__() + if len(self.legs) != 3: + raise ValueError("TriangularArbSpec requires exactly three legs") + currencies = [] + for leg in self.legs: + if not leg.base_currency or not leg.quote_currency: + raise ValueError("TriangularArbSpec requires base_currency and quote_currency on every leg") + currencies.append((leg.base_currency, leg.quote_currency)) + unique_currencies = {currency for pair in currencies for currency in pair} + if len(unique_currencies) != 3: + raise ValueError("TriangularArbSpec requires exactly three currencies") + + +@dataclass(frozen=True) +class CrossExchangeArbSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.CROSS_EXCHANGE + + def __post_init__(self) -> None: + super().__post_init__() + venues = [leg.venue for leg in self.legs] + if any(venue is None or venue == "" for venue in venues): + raise ValueError("CrossExchangeArbSpec requires venue on every leg") + if len(set(venues)) < 2: + raise ValueError("CrossExchangeArbSpec requires at least two venues") + + +@dataclass(frozen=True) +class SpotPerpCashCarrySpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.SPOT_PERP_CASH_CARRY + + def __post_init__(self) -> None: + super().__post_init__() + has_spot = any(leg.contract_type is ContractType.SPOT for leg in self.legs) + has_derivative = any(leg.contract_type in (ContractType.LINEAR, ContractType.INVERSE, ContractType.QUANTO) for leg in self.legs) + if not has_spot or not has_derivative: + raise ValueError("SpotPerpCashCarrySpec requires at least one spot leg and one derivative leg") + if not any(leg.funding_enabled for leg in self.legs if leg.contract_type is not ContractType.SPOT): + raise ValueError("SpotPerpCashCarrySpec requires a funding-enabled derivative leg") + if self.hedge_policy.kind not in (HedgePolicyKind.BASE_QTY_EQUAL, HedgePolicyKind.DELTA_NEUTRAL): + raise ValueError("SpotPerpCashCarrySpec requires base_qty_equal or delta_neutral hedge policy") + + +@dataclass(frozen=True) +class OptionsVolArbSpec(ArbitrageSpec): + arb_type: ArbitrageType = ArbitrageType.OPTIONS_VOL + + def __post_init__(self) -> None: + super().__post_init__() + if not any(leg.contract_type is ContractType.OPTION for leg in self.legs): + raise ValueError("OptionsVolArbSpec requires at least one option leg") + if self.hedge_policy.kind not in (HedgePolicyKind.VEGA_NEUTRAL, HedgePolicyKind.DELTA_NEUTRAL): + raise ValueError("OptionsVolArbSpec requires vega_neutral or delta_neutral hedge policy") + + +@dataclass(frozen=True) +class PackageRejection: + timestamp: object + arb_id: str + reason: str + failed_legs: Tuple[str, ...] + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class ArbitragePlan: + spec: ArbitrageSpec + orders: Tuple[OrderIntent, ...] + target_units: pd.DataFrame + signals: pd.Series + entry_ratios: pd.DataFrame + rejections: Tuple[PackageRejection, ...] = () + metadata: Dict = field(default_factory=dict) + + @property + def rejection_report(self) -> pd.DataFrame: + rows = [ + { + "timestamp": rejection.timestamp, + "arb_id": rejection.arb_id, + "reason": rejection.reason, + "failed_legs": ",".join(rejection.failed_legs), + **rejection.metadata, + } + for rejection in self.rejections + ] + return pd.DataFrame(rows) + + +def build_arbitrage_order_plan( + datetime_index, + spec: ArbitrageSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + min_abs_delta: float = 1e-12, +) -> ArbitragePlan: + """ + Convert a scalar arbitrage signal into package leg orders. + + Phase A behavior is deterministic by design: + + * units are computed on signal transitions only; + * units are frozen while signal is unchanged; + * package precision/min-notional rejects are explicit; + * atomic policy rejects the whole package; + * best-effort policy keeps valid legs and records rejected legs. + """ + if min_abs_delta < 0.0: + raise ValueError("min_abs_delta must be >= 0") + + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in spec.legs] + if not set(symbols).issubset(closes.keys()): + missing = sorted(set(symbols) - set(closes.keys())) + raise ValueError(f"missing closes for arbitrage legs: {missing}") + + close_dict = align_series(closes, symbols, idx) + _validate_plan_market_data(close_dict, symbols, idx) + _reject_unsupported_contract_sizing(spec) + sig = _align_signal(signal, idx) + ratio_dict = _build_ratio_series(spec, hedge_ratios, symbols, idx) + + orders = [] + rejections = [] + current_units = {symbol: 0.0 for symbol in symbols} + current_signal = 0.0 + target_rows = [] + ratio_rows = [] + + for ts in idx: + raw_signal = float(sig.loc[ts]) + if abs(raw_signal) < min_abs_delta: + raw_signal = 0.0 + + changed = abs(raw_signal - current_signal) > min_abs_delta + if changed: + target_units = _compute_target_units(spec, raw_signal, symbols, ts, close_dict, ratio_dict) + failed = _validate_target_units(spec, target_units, ts, close_dict) + if failed: + rejections.append( + PackageRejection( + timestamp=ts, + arb_id=spec.arb_id, + reason="precision_or_min_notional", + failed_legs=tuple(failed.keys()), + metadata={"details": failed, "policy": spec.execution_policy.kind.value}, + ) + ) + if spec.execution_policy.kind is PackageExecutionKind.ATOMIC_ALL_OR_NONE: + target_units = dict(current_units) + elif spec.execution_policy.kind is PackageExecutionKind.BEST_EFFORT: + for failed_symbol in failed: + target_units[failed_symbol] = current_units[failed_symbol] + + for symbol in symbols: + delta = target_units[symbol] - current_units[symbol] + if abs(delta) <= min_abs_delta: + continue + side = OrderSide.BUY if delta > 0.0 else OrderSide.SELL + orders.append( + OrderIntent( + timestamp=ts, + symbol=symbol, + side=side, + order_type=spec.execution_policy.order_type, + qty=abs(delta), + tif=spec.execution_policy.tif, + tag=spec.arb_id, + metadata={ + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "package_policy": spec.execution_policy.kind.value, + "hedge_policy": spec.hedge_policy.kind.value, + "sizing_policy": spec.sizing_policy.kind.value, + "target_units": target_units[symbol], + "previous_units": current_units[symbol], + }, + ) + ) + current_units[symbol] = target_units[symbol] + + current_signal = raw_signal + + target_rows.append({symbol: current_units[symbol] for symbol in symbols}) + ratio_rows.append({symbol: float(ratio_dict[symbol].loc[ts]) for symbol in symbols}) + + return ArbitragePlan( + spec=spec, + orders=tuple(orders), + target_units=pd.DataFrame(target_rows, index=idx), + signals=sig, + entry_ratios=pd.DataFrame(ratio_rows, index=idx), + rejections=tuple(rejections), + metadata={ + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "execution_policy": spec.execution_policy.kind.value, + "hedge_policy": spec.hedge_policy.kind.value, + "sizing_policy": spec.sizing_policy.kind.value, + }, + ) + + +def round_down_to_step(value: float, step: float) -> float: + if value < 0.0: + raise ValueError("value must be >= 0") + if step < 0.0: + raise ValueError("step must be >= 0") + if step == 0.0: + return value + return floor((value + 1e-15) / step) * step + + +def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series: + if not isinstance(signal, pd.Series): + signal = pd.Series(signal, index=idx) + else: + signal = signal.copy() + if isinstance(signal.index, pd.DatetimeIndex): + signal.index = signal.index.tz_localize("UTC") if signal.index.tz is None else signal.index.tz_convert("UTC") + return signal.reindex(idx, method="ffill").fillna(0.0).astype(float) + + +def _build_ratio_series( + spec: ArbitrageSpec, + hedge_ratios: Optional[Dict[str, pd.Series]], + symbols: list[str], + idx: pd.DatetimeIndex, +) -> Dict[str, pd.Series]: + defaults = {leg.symbol: float(leg.ratio) for leg in spec.legs} + if hedge_ratios is None: + return {symbol: pd.Series(defaults[symbol], index=idx, dtype=float) for symbol in symbols} + + out = {} + for symbol in symbols: + value = hedge_ratios.get(symbol, defaults[symbol]) + if isinstance(value, pd.Series): + series = value.copy() + if isinstance(series.index, pd.DatetimeIndex): + series.index = series.index.tz_localize("UTC") if series.index.tz is None else series.index.tz_convert("UTC") + out[symbol] = series.reindex(idx, method="ffill").fillna(defaults[symbol]).astype(float) + else: + out[symbol] = pd.Series(float(value), index=idx, dtype=float) + return out + + +def _compute_target_units( + spec: ArbitrageSpec, + signal_value: float, + symbols: list[str], + timestamp, + closes: Dict[str, pd.Series], + ratios: Dict[str, pd.Series], +) -> Dict[str, float]: + if signal_value == 0.0: + return {symbol: 0.0 for symbol in symbols} + + side = 1.0 if signal_value > 0.0 else -1.0 + magnitude = abs(signal_value) + if spec.sizing_policy.kind is SizingPolicyKind.TARGET_BASE_QTY: + base_qty = float(spec.sizing_policy.base_qty) * magnitude + elif spec.sizing_policy.kind is SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY: + reference_symbol = spec.sizing_policy.reference_symbol or symbols[0] + reference_price = float(closes[reference_symbol].loc[timestamp]) + if reference_price <= 0.0: + base_qty = 0.0 + else: + raw_qty = float(spec.sizing_policy.notional) * magnitude / reference_price + base_qty = _round_to_common_step(raw_qty, spec.legs) + elif spec.sizing_policy.kind is SizingPolicyKind.TARGET_GROSS_NOTIONAL: + gross_unit_notional = 0.0 + for symbol in symbols: + gross_unit_notional += abs(float(ratios[symbol].loc[timestamp])) * float(closes[symbol].loc[timestamp]) + if gross_unit_notional <= 0.0: + base_qty = 0.0 + else: + base_qty = float(spec.sizing_policy.notional) * magnitude / gross_unit_notional + else: + raise NotImplementedError("equity_fraction sizing is reserved for engine phase") + + return { + symbol: base_qty * float(ratios[symbol].loc[timestamp]) * side + for symbol in symbols + } + + +def _validate_plan_market_data( + closes: Dict[str, pd.Series], + symbols: list[str], + idx: pd.DatetimeIndex, +) -> None: + for symbol in symbols: + prices = pd.to_numeric(closes[symbol], errors="coerce").reindex(idx) + values = prices.to_numpy(dtype=float) + bad = prices.isna().to_numpy() | ~np.isfinite(values) | (values <= 0.0) + if bool(bad.any()): + first_bad = prices.index[bad][0] + raise ValueError( + f"arbitrage closes must be finite and > 0 for {symbol!r}; " + f"first bad timestamp={first_bad}" + ) + + +def _reject_unsupported_contract_sizing(spec: ArbitrageSpec) -> None: + unsupported = [leg.symbol for leg in spec.legs if leg.contract_type in (ContractType.INVERSE, ContractType.QUANTO)] + if unsupported: + raise NotImplementedError( + "inverse/quanto contract sizing is not implemented in arbitrage order planning; " + f"unsupported legs={unsupported}" + ) + + +def _round_to_common_step(value: float, legs: Tuple[ArbitrageLeg, ...]) -> float: + out = value + for leg in legs: + out = round_down_to_step(out, leg.qty_step) + return out + + +def _validate_target_units( + spec: ArbitrageSpec, + target_units: Dict[str, float], + timestamp, + closes: Dict[str, pd.Series], +) -> Dict[str, Dict[str, float]]: + failed: Dict[str, Dict[str, float]] = {} + for leg in spec.legs: + qty = abs(float(target_units[leg.symbol])) + if qty == 0.0: + continue + price = float(closes[leg.symbol].loc[timestamp]) + notional = qty * price * leg.contract_size + reasons = {} + if leg.min_qty > 0.0 and qty < leg.min_qty: + reasons["min_qty"] = leg.min_qty + if leg.min_notional > 0.0 and notional < leg.min_notional: + reasons["min_notional"] = leg.min_notional + if leg.qty_step > 0.0: + rounded = round_down_to_step(qty, leg.qty_step) + if abs(qty - rounded) > 1e-12: + reasons["qty_step"] = leg.qty_step + if reasons: + reasons["qty"] = qty + reasons["notional"] = notional + failed[leg.symbol] = reasons + return failed diff --git a/src/quantbt/core/basket.py b/src/quantbt/core/basket.py new file mode 100644 index 0000000..727e7b7 --- /dev/null +++ b/src/quantbt/core/basket.py @@ -0,0 +1,234 @@ +""" +quantbt.core.basket +------------------- +Basket and pair-trading order-plan helpers. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Tuple + +import numpy as np +import pandas as pd + +from .orders import OrderIntent +from .preprocessor import align_series, validate_datetime +from .schema import BasketSpec, OrderSide, OrderType, TimeInForce + + +@dataclass(frozen=True) +class FrozenBasketPlan: + basket: BasketSpec + orders: Tuple[OrderIntent, ...] + target_units: pd.DataFrame + signals: pd.Series + entry_ratios: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + +def build_frozen_basket_orders( + datetime_index, + basket: BasketSpec, + signal: pd.Series, + closes: Dict[str, pd.Series], + hedge_ratios: Optional[Dict[str, pd.Series]] = None, + order_type: OrderType = OrderType.MARKET, + tif: TimeInForce = TimeInForce.IOC, + rebalance_threshold: Optional[float] = None, + min_abs_delta: float = 1e-12, +) -> FrozenBasketPlan: + """ + Convert a scalar basket signal into leg orders with hedge freezing. + + Ratios are interpreted as unit ratios per one basket unit. At every signal + transition, units are recomputed from the entry bar prices and then held + unchanged until the next signal transition. Price drift alone does not + generate micro-rebalancing orders. + """ + if order_type is not OrderType.MARKET: + raise NotImplementedError("Phase 4 basket order generation supports market orders") + if min_abs_delta < 0.0: + raise ValueError("min_abs_delta must be >= 0") + if rebalance_threshold is not None and rebalance_threshold < 0.0: + raise ValueError("rebalance_threshold must be >= 0") + + idx = validate_datetime(datetime_index) + symbols = [leg.symbol for leg in basket.legs] + if len(set(symbols)) != len(symbols): + raise ValueError("basket legs must have unique symbols") + if not set(symbols).issubset(closes.keys()): + missing = sorted(set(symbols) - set(closes.keys())) + raise ValueError(f"missing closes for basket legs: {missing}") + + close_dict = align_series(closes, symbols, idx) + sig = _align_signal(signal, idx) + ratio_dict = _build_ratio_series(basket, hedge_ratios, symbols, idx) + + orders = [] + current_units = {s: 0.0 for s in symbols} + current_signal = 0.0 + target_rows = [] + ratio_rows = [] + + for ts in idx: + raw_signal = float(sig.loc[ts]) + if abs(raw_signal) < min_abs_delta: + raw_signal = 0.0 + + signal_changed = abs(raw_signal - current_signal) > min_abs_delta + ratio_drift = _max_ratio_drift(current_units, ratio_dict, symbols, ts) + should_rebalance = ( + rebalance_threshold is not None + and raw_signal != 0.0 + and not signal_changed + and ratio_drift > rebalance_threshold + ) + if signal_changed or should_rebalance: + target_units = _compute_entry_units( + basket=basket, + signal_value=raw_signal, + symbols=symbols, + timestamp=ts, + closes=close_dict, + ratios=ratio_dict, + ) + + for sym in symbols: + delta = target_units[sym] - current_units[sym] + if abs(delta) <= min_abs_delta: + continue + side = OrderSide.BUY if delta > 0.0 else OrderSide.SELL + orders.append( + OrderIntent( + timestamp=ts, + symbol=sym, + side=side, + order_type=order_type, + qty=abs(delta), + tif=tif, + tag=basket.basket_id, + metadata={ + "basket_id": basket.basket_id, + "basket_signal": raw_signal, + "basket_policy": basket.execution_policy.value, + "hedge_frozen": basket.freeze_hedge, + "rebalance": should_rebalance, + "ratio_drift": ratio_drift, + "target_units": target_units[sym], + "previous_units": current_units[sym], + }, + ) + ) + current_units[sym] = target_units[sym] + + current_signal = raw_signal + + target_rows.append({s: current_units[s] for s in symbols}) + ratio_rows.append({s: float(ratio_dict[s].loc[ts]) for s in symbols}) + + return FrozenBasketPlan( + basket=basket, + orders=tuple(orders), + target_units=pd.DataFrame(target_rows, index=idx), + signals=sig, + entry_ratios=pd.DataFrame(ratio_rows, index=idx), + metadata={ + "basket_id": basket.basket_id, + "gross_notional": basket.gross_notional, + "freeze_hedge": basket.freeze_hedge, + "execution_policy": basket.execution_policy.value, + "hedged_margin_offset": basket.hedged_margin_offset, + "rebalance_threshold": rebalance_threshold, + }, + ) + + +def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series: + if not isinstance(signal, pd.Series): + signal = pd.Series(signal, index=idx) + else: + signal = signal.copy() + if isinstance(signal.index, pd.DatetimeIndex): + if signal.index.tz is None: + signal.index = signal.index.tz_localize("UTC") + else: + signal.index = signal.index.tz_convert("UTC") + return signal.reindex(idx, method="ffill").fillna(0.0).astype(float) + + +def _build_ratio_series( + basket: BasketSpec, + hedge_ratios: Optional[Dict[str, pd.Series]], + symbols: list[str], + idx: pd.DatetimeIndex, +) -> Dict[str, pd.Series]: + defaults = {leg.symbol: float(leg.ratio) for leg in basket.legs} + if hedge_ratios is None: + return {s: pd.Series(defaults[s], index=idx, dtype=float) for s in symbols} + + out = {} + for s in symbols: + value = hedge_ratios.get(s, defaults[s]) + if isinstance(value, pd.Series): + ser = value.copy() + if isinstance(ser.index, pd.DatetimeIndex): + if ser.index.tz is None: + ser.index = ser.index.tz_localize("UTC") + else: + ser.index = ser.index.tz_convert("UTC") + out[s] = ser.reindex(idx, method="ffill").fillna(defaults[s]).astype(float) + else: + out[s] = pd.Series(float(value), index=idx, dtype=float) + return out + + +def _compute_entry_units( + basket: BasketSpec, + signal_value: float, + symbols: list[str], + timestamp, + closes: Dict[str, pd.Series], + ratios: Dict[str, pd.Series], +) -> Dict[str, float]: + if signal_value == 0.0: + return {s: 0.0 for s in symbols} + + gross_unit_notional = 0.0 + for s in symbols: + price = float(closes[s].loc[timestamp]) + ratio = float(ratios[s].loc[timestamp]) + gross_unit_notional += abs(ratio) * price + + if not np.isfinite(gross_unit_notional) or gross_unit_notional <= 0.0: + return {s: 0.0 for s in symbols} + + basket_units = basket.gross_notional * abs(signal_value) / gross_unit_notional + signal_side = 1.0 if signal_value > 0.0 else -1.0 + return { + s: basket_units * float(ratios[s].loc[timestamp]) * signal_side + for s in symbols + } + + +def _max_ratio_drift( + current_units: Dict[str, float], + ratios: Dict[str, pd.Series], + symbols: list[str], + timestamp, +) -> float: + if not symbols: + return 0.0 + ref_symbol = symbols[0] + frozen_ref = float(current_units[ref_symbol]) + current_ref = float(ratios[ref_symbol].loc[timestamp]) + if abs(frozen_ref) <= 1e-12 or abs(current_ref) <= 1e-12: + return 0.0 + + max_drift = 0.0 + for symbol in symbols[1:]: + frozen_ratio = float(current_units[symbol]) / frozen_ref + current_ratio = float(ratios[symbol].loc[timestamp]) / current_ref + denom = max(abs(frozen_ratio), 1e-12) + max_drift = max(max_drift, abs(current_ratio - frozen_ratio) / denom) + return max_drift diff --git a/src/quantbt/core/certification.py b/src/quantbt/core/certification.py new file mode 100644 index 0000000..1453f21 --- /dev/null +++ b/src/quantbt/core/certification.py @@ -0,0 +1,274 @@ +""" +Alpha execution-contract certification helpers. + +These helpers are intentionally lightweight and conservative. They do not try +to prove a strategy has no look-ahead bias from source text alone; they identify +which execution contract a file appears to require and what certification level +an already-run result can claim from its metadata. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from enum import IntEnum +from pathlib import Path +import re +from typing import Dict, Iterable, List, Optional, Sequence + + +class CertificationLevel(IntEnum): + LEGACY = 0 + ACCOUNTING_REPLAY = 1 + ENGINE_CAUSAL = 2 + CROSS_BACKEND = 3 + EXTERNAL_VALIDATION = 4 + + +LEVEL_DESCRIPTIONS = { + CertificationLevel.LEGACY: "legacy_or_unspecified_execution_contract", + CertificationLevel.ACCOUNTING_REPLAY: "explicit_fills_accounted_but_fill_generation_not_certified", + CertificationLevel.ENGINE_CAUSAL: "engine_owned_causal_execution_with_oracle_or_kernel_parity", + CertificationLevel.CROSS_BACKEND: "native_engine_matches_native_event_on_known_scenarios", + CertificationLevel.EXTERNAL_VALIDATION: "external_or_lower_timeframe_validation_available", +} + + +INTRABAR_MARKERS = ( + "exit_price", + "exit_type", + "stop_loss", + "stoploss", + "take_profit", + "takeprofit", + "trailing", + "trailing_stop", + "use_sl", + "use_tp", + "slpercent", + "tppercent", + "high[", + "low[", +) +FILL_REPLAY_MARKERS = ("fill_replay", "fills_df", "compact_fill", "bar_index", "sequence") +GRID_MARKERS = ("dca_ladder", "grid", "safety_order", "take_profit_price", "stop_loss_price") +NEXT_OPEN_MARKERS = ("next_open", "open[t+1]", "shift(1)", "open.shift") +CLOSE_TARGET_MARKERS = ("native_vectorized", "signal_notional", "pos_weight", "target_weight") + + +@dataclass(frozen=True) +class AlphaExecutionClassification: + alpha_id: str + path: str + required_engine: str + current_backend: str + certification_status: str + certification_level: int + markers: tuple[str, ...] = () + notes: tuple[str, ...] = () + uses_intrabar_high_low: bool = False + uses_stop: bool = False + uses_take_profit: bool = False + uses_trailing: bool = False + uses_custom_exit_price: bool = False + uses_explicit_fills: bool = False + uses_grid_or_dca: bool = False + metadata: Dict = field(default_factory=dict) + + def to_dict(self) -> Dict: + return asdict(self) + + +def classify_alpha_source(source: str, *, alpha_id: str = "unknown", path: str = "") -> AlphaExecutionClassification: + text = source.lower() + markers = _matched_markers(text) + current_backend = _detect_current_backend(text) + uses_explicit_fills = any(marker in text for marker in FILL_REPLAY_MARKERS) + uses_grid_or_dca = any(marker in text for marker in GRID_MARKERS) + uses_stop = any(marker in text for marker in ("stop_loss", "stoploss", "slpercent", "use_sl")) + uses_take_profit = any(marker in text for marker in ("take_profit", "takeprofit", "tppercent", "use_tp")) + uses_trailing = "trailing" in text + uses_custom_exit_price = "exit_price" in text or "exit_type" in text + uses_intrabar_high_low = bool(re.search(r"\bhigh\s*\[|\blow\s*\[|df\s*\[\s*['\"]high|df\s*\[\s*['\"]low", text)) + + if uses_grid_or_dca: + required_engine = "event_lifecycle_v2" + level = CertificationLevel.LEGACY + status = "needs_specialized_event_or_nautilus_certification" + elif uses_explicit_fills and not (uses_stop or uses_take_profit or uses_trailing): + required_engine = "fill_replay_v1" + level = CertificationLevel.ACCOUNTING_REPLAY + status = "can_start_with_accounting_replay" + elif uses_stop or uses_take_profit or uses_trailing or uses_custom_exit_price or uses_intrabar_high_low: + required_engine = "intrabar_bracket_v1" + level = CertificationLevel.LEGACY + status = "requires_intrabar_migration" + elif any(marker in text for marker in NEXT_OPEN_MARKERS): + required_engine = "next_open_v1" + level = CertificationLevel.LEGACY + status = "requires_next_open_contract" + elif any(marker in text for marker in CLOSE_TARGET_MARKERS): + required_engine = "close_target_v2" + level = CertificationLevel.ENGINE_CAUSAL if current_backend in {"native_vectorized", "close_target_v2"} else CertificationLevel.LEGACY + status = "close_target_candidate" + else: + required_engine = "unknown" + level = CertificationLevel.LEGACY + status = "manual_review_required" + + notes = _notes_for_classification(required_engine, current_backend, markers) + return AlphaExecutionClassification( + alpha_id=alpha_id, + path=path, + required_engine=required_engine, + current_backend=current_backend, + certification_status=status, + certification_level=int(level), + markers=tuple(markers), + notes=tuple(notes), + uses_intrabar_high_low=uses_intrabar_high_low, + uses_stop=uses_stop, + uses_take_profit=uses_take_profit, + uses_trailing=uses_trailing, + uses_custom_exit_price=uses_custom_exit_price, + uses_explicit_fills=uses_explicit_fills, + uses_grid_or_dca=uses_grid_or_dca, + ) + + +def scan_alpha_directory(root: str | Path, *, suffixes: Sequence[str] = (".py", ".ipynb", ".md"), max_bytes: int = 2_000_000) -> List[AlphaExecutionClassification]: + base = Path(root) + if not base.exists(): + raise FileNotFoundError(str(base)) + out: list[AlphaExecutionClassification] = [] + for path in sorted(p for p in base.rglob("*") if p.is_file() and p.suffix.lower() in suffixes): + if any(part.startswith(".") for part in path.relative_to(base).parts): + continue + if path.stat().st_size > max_bytes: + out.append( + AlphaExecutionClassification( + alpha_id=path.stem, + path=str(path), + required_engine="unknown", + current_backend="unknown", + certification_status="skipped_large_file", + certification_level=int(CertificationLevel.LEGACY), + notes=("file exceeds scanner max_bytes",), + ) + ) + continue + text = path.read_text(encoding="utf-8", errors="ignore") + out.append(classify_alpha_source(text, alpha_id=path.stem, path=str(path))) + return out + + +def certify_result_metadata(metadata: Dict) -> Dict: + engine = str(metadata.get("engine_id") or metadata.get("engine") or "").lower() + backend = str(metadata.get("backend") or metadata.get("backend_alias") or "").lower() + if engine == "fill_replay_v1": + level = CertificationLevel.ACCOUNTING_REPLAY + status = "accounting_certified" + elif engine == "intrabar_bracket_v1": + level = CertificationLevel.ENGINE_CAUSAL + status = "engine_causal_certified" + if metadata.get("cross_backend_parity_passed"): + level = CertificationLevel.CROSS_BACKEND + status = "cross_backend_certified" + elif backend == "nautilus" or "nautilus" in engine: + level = CertificationLevel.EXTERNAL_VALIDATION + status = "external_validation_route" + elif engine == "close_target_v2": + level = CertificationLevel.ENGINE_CAUSAL + status = "close_target_certified" + if str(metadata.get("certification_status", "")).startswith("uncertified"): + level = CertificationLevel.LEGACY + status = str(metadata.get("certification_status")) + else: + level = CertificationLevel.LEGACY + status = "uncertified_or_unknown" + return { + "engine_id": engine or "unknown", + "backend": backend or "unknown", + "certification_level": int(level), + "certification_label": f"LEVEL {int(level)}", + "certification_status": status, + "description": LEVEL_DESCRIPTIONS[level], + } + + +def build_alpha_certification_report(items: Iterable[AlphaExecutionClassification]) -> Dict: + rows = [item.to_dict() for item in items] + by_engine: Dict[str, int] = {} + by_status: Dict[str, int] = {} + for row in rows: + by_engine[row["required_engine"]] = by_engine.get(row["required_engine"], 0) + 1 + by_status[row["certification_status"]] = by_status.get(row["certification_status"], 0) + 1 + return { + "total": len(rows), + "by_required_engine": by_engine, + "by_status": by_status, + "items": rows, + } + + +def alpha_report_markdown(report: Dict) -> str: + lines = [ + "# Alpha Execution Certification Report", + "", + f"- Total files scanned: `{report['total']}`", + "", + "## By Required Engine", + "", + "| Engine | Count |", + "|---|---:|", + ] + for engine, count in sorted(report["by_required_engine"].items()): + lines.append(f"| `{engine}` | {count} |") + lines.extend(["", "## By Status", "", "| Status | Count |", "|---|---:|"]) + for status, count in sorted(report["by_status"].items()): + lines.append(f"| `{status}` | {count} |") + lines.extend(["", "## Files", "", "| Alpha | Required engine | Current backend | Status | Markers |", "|---|---|---|---|---|"]) + for item in report["items"]: + markers = ", ".join(item["markers"][:8]) + if len(item["markers"]) > 8: + markers += ", ..." + lines.append( + f"| `{item['alpha_id']}` | `{item['required_engine']}` | `{item['current_backend']}` | " + f"`{item['certification_status']}` | {markers or '-'} |" + ) + return "\n".join(lines) + "\n" + + +def _matched_markers(text: str) -> list[str]: + all_markers = sorted(set(INTRABAR_MARKERS + FILL_REPLAY_MARKERS + GRID_MARKERS + NEXT_OPEN_MARKERS + CLOSE_TARGET_MARKERS)) + return [marker for marker in all_markers if marker in text] + + +def _detect_current_backend(text: str) -> str: + if "nautilus_validation" in text or "backend=\"nautilus\"" in text or "backend='nautilus'" in text: + return "nautilus" + if "intrabar_bracket" in text: + return "native_intrabar" + if "fill_replay" in text: + return "fill_replay_v1" + if "native_event" in text: + return "native_event" + if "native_vectorized" in text: + return "native_vectorized" + if "%_equity" in text or "pct_equity" in text: + return "legacy_pct_equity" + if "backtestengine(" in text: + return "legacy" + return "unknown" + + +def _notes_for_classification(required_engine: str, current_backend: str, markers: Sequence[str]) -> list[str]: + notes: list[str] = [] + if required_engine == "intrabar_bracket_v1" and current_backend in {"native_vectorized", "legacy", "legacy_pct_equity"}: + notes.append("intrabar markers found on a close-target/legacy route; migrate to intrabar intent or fill replay") + if required_engine == "fill_replay_v1": + notes.append("accounting can be validated from explicit fills, but fill generation remains alpha-owned") + if required_engine == "event_lifecycle_v2": + notes.append("multi-order/grid/DCA behavior should stay on event lifecycle or Nautilus validation") + if not markers: + notes.append("no execution-sensitive markers detected; manual review still required before production certification") + return notes diff --git a/src/quantbt/core/constraints.py b/src/quantbt/core/constraints.py new file mode 100644 index 0000000..fc101fe --- /dev/null +++ b/src/quantbt/core/constraints.py @@ -0,0 +1,155 @@ +"""Exchange/instrument quantity constraints shared by all backends.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional, Sequence, Union + +import numpy as np + +from .schema import InstrumentSpec + + +NumberOrMap = Union[float, Dict[str, float], None] + + +@dataclass(frozen=True) +class QuantityConstraints: + """Per-symbol exchange quantity rules. + + `contract_size` remains the PnL/notional multiplier. Fractional crypto + acceptance is controlled by `qty_step`/`lot_size`, `min_qty`, and + `min_notional`. QuantBT rounds target quantities down by default, matching + the conservative side of common exchange filters. + """ + + symbols: tuple[str, ...] + qty_step: np.ndarray + min_qty: np.ndarray + min_notional: np.ndarray + + @property + def enabled(self) -> bool: + return bool( + np.any(self.qty_step > 0.0) + or np.any(self.min_qty > 0.0) + or np.any(self.min_notional > 0.0) + ) + + def as_dict(self) -> Dict[str, Dict[str, float]]: + return { + symbol: { + "qty_step": float(self.qty_step[i]), + "lot_size": float(self.qty_step[i]), + "min_qty": float(self.min_qty[i]), + "min_notional": float(self.min_notional[i]), + } + for i, symbol in enumerate(self.symbols) + } + + +def build_quantity_constraints( + symbols: Sequence[str], + *, + instruments: Optional[Union[Dict[str, InstrumentSpec], Sequence[InstrumentSpec]]] = None, + qty_step: NumberOrMap = None, + lot_size: NumberOrMap = None, + slot_size: NumberOrMap = None, + min_qty: NumberOrMap = None, + min_notional: NumberOrMap = None, +) -> QuantityConstraints: + """Resolve quantity constraints from explicit kwargs and InstrumentSpec. + + Explicit kwargs override `InstrumentSpec`. `slot_size` is accepted as a + backward-compatible alias for `lot_size`. + """ + + symbol_list = tuple(symbols) + inst_map = _instrument_map(instruments) + step_source = qty_step if qty_step is not None else (lot_size if lot_size is not None else slot_size) + + steps = [] + min_qtys = [] + min_notionals = [] + for symbol in symbol_list: + inst = inst_map.get(symbol) + default_step = 0.0 if inst is None else float(inst.lot_size) + default_min_qty = 0.0 if inst is None else float(inst.min_qty) + default_min_notional = 0.0 if inst is None else float(inst.min_notional) + steps.append(_value_for(step_source, symbol, default_step)) + min_qtys.append(_value_for(min_qty, symbol, default_min_qty)) + min_notionals.append(_value_for(min_notional, symbol, default_min_notional)) + + out = QuantityConstraints( + symbols=symbol_list, + qty_step=np.asarray(steps, dtype=np.float64), + min_qty=np.asarray(min_qtys, dtype=np.float64), + min_notional=np.asarray(min_notionals, dtype=np.float64), + ) + if np.any(out.qty_step < 0.0) or np.any(out.min_qty < 0.0) or np.any(out.min_notional < 0.0): + raise ValueError("qty_step/lot_size, min_qty, and min_notional must be >= 0") + return out + + +def quantize_target_units_matrix( + target_units: np.ndarray, + prices: np.ndarray, + contract_sizes: np.ndarray, + constraints: QuantityConstraints, +) -> np.ndarray: + """Round target-unit matrix down to exchange-acceptable quantities.""" + + if not constraints.enabled: + return np.ascontiguousarray(target_units, dtype=np.float64) + out = np.asarray(target_units, dtype=np.float64).copy(order="C") + prices_arr = np.asarray(prices, dtype=np.float64) + cs = np.asarray(contract_sizes, dtype=np.float64) + for j in range(out.shape[1]): + step = float(constraints.qty_step[j]) + mnq = float(constraints.min_qty[j]) + mnn = float(constraints.min_notional[j]) + for i in range(out.shape[0]): + out[i, j] = quantize_signed_quantity(out[i, j], prices_arr[i, j], cs[j], step, mnq, mnn) + return np.ascontiguousarray(out, dtype=np.float64) + + +def quantize_signed_quantity( + qty: float, + price: float, + contract_size: float = 1.0, + qty_step: float = 0.0, + min_qty: float = 0.0, + min_notional: float = 0.0, +) -> float: + """Round a signed quantity down and zero it if below exchange minima.""" + + q = float(qty) + if q == 0.0: + return 0.0 + sign = 1.0 if q > 0.0 else -1.0 + abs_q = abs(q) + if qty_step > 0.0: + abs_q = np.floor((abs_q / float(qty_step)) + 1e-12) * float(qty_step) + if abs_q <= 0.0: + return 0.0 + if min_qty > 0.0 and abs_q + 1e-12 < min_qty: + return 0.0 + if min_notional > 0.0 and abs_q * float(price) * float(contract_size) + 1e-12 < min_notional: + return 0.0 + return sign * abs_q + + +def _instrument_map(instruments) -> Dict[str, InstrumentSpec]: + if instruments is None: + return {} + if isinstance(instruments, dict): + return {symbol: spec for symbol, spec in instruments.items() if spec is not None} + return {spec.symbol: spec for spec in instruments} + + +def _value_for(value: NumberOrMap, symbol: str, default: float) -> float: + if value is None: + return float(default) + if isinstance(value, dict): + return float(value.get(symbol, default)) + return float(value) diff --git a/src/quantbt/core/engine.py b/src/quantbt/core/engine.py new file mode 100644 index 0000000..bd10e90 --- /dev/null +++ b/src/quantbt/core/engine.py @@ -0,0 +1,1144 @@ +""" +quantbt.core.engine +------------------- +Numba-compiled simulation kernels. + +Kernel entry points +~~~~~~~~~~~~~~~~~~~ +_engine_units signals are pre-scaled target units (notional / unit / signal_notional) +_engine_pct_equity signals are raw weight fractions; units derived from live equity each bar +_engine_dca_ladder structural DCA/grid level with High/Low limit fills +_engine_portfolio multi-symbol portfolio loop with cross-margin buying-power gate + +Simulation contract +~~~~~~~~~~~~~~~~~~~ +equity realised + unrealised MTM, updated close-to-close every bar +liquidation intrabar worst-case: Low for longs, High for shorts +maintenance_margin abs(pos) × price × cs × mm_rate (Binance notional-based formula) +funding fires once per is_funding_bar=True bar (caller marks the FIRST bar of each 8h window) +fee_rate ONE-WAY rate; caller passes fee/2 if fee is round-trip +slippage fraction applied at execution price; always a cost +""" + +import numpy as np +from numba import njit + + +@njit(cache=True) +def _quantize_signed_qty(qty: float, price: float, contract_size: float, qty_step: float, min_qty: float, min_notional: float) -> float: + if qty == 0.0: + return 0.0 + sign = 1.0 + if qty < 0.0: + sign = -1.0 + q = abs(qty) + if qty_step > 0.0: + q = np.floor((q / qty_step) + 1e-12) * qty_step + if q <= 0.0: + return 0.0 + if min_qty > 0.0 and q + 1e-12 < min_qty: + return 0.0 + if min_notional > 0.0 and q * price * contract_size + 1e-12 < min_notional: + return 0.0 + return sign * q + + +@njit(cache=True) +def _engine_units( + n_bars: int, + n_syms: int, + highs: np.ndarray, # (n_bars, n_syms) float64 + lows: np.ndarray, # (n_bars, n_syms) float64 + closes: np.ndarray, # (n_bars, n_syms) float64 + signals: np.ndarray, # (n_bars, n_syms) float64 pre-scaled target units + funding_rates: np.ndarray, # (n_bars, n_syms) float64 + is_funding_bar: np.ndarray, # (n_bars,) bool + init_capital: float, + leverage: float, + maint_ratio: float, + fee_rate: float, + contract_sizes: np.ndarray, # (n_syms,) float64 + slippage: float, +): + equity_curve = np.zeros(n_bars, dtype=np.float64) + equity = init_capital + current_pos = np.zeros(n_syms, dtype=np.float64) + liq_flag = False + liq_idx = -1 + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + continue + + # 1 ── Mark-to-market (close-to-close) ─────────────────────────── + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity += p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + + # 2 ── Intrabar liquidation check ───────────────────────────────── + worst_equity = equity + maint_req = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p == 0.0: + continue + worst_p = lows[i, s] if p > 0.0 else highs[i, s] + worst_equity += p * (worst_p - closes[i, s]) * contract_sizes[s] + # Binance: maintenance_margin = notional × mm_rate + maint_req += abs(p) * worst_p * contract_sizes[s] * maint_ratio + + if maint_req > 0.0 and worst_equity <= maint_req: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + equity_curve[i] = 0.0 + continue + + # 3 ── Funding fee ───────────────────────────────────────────────── + if is_funding_bar[i]: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + # Long pays positive rate; short earns positive rate + equity -= p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + + # Funding can push equity below maintenance before new orders. + close_maint_req = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_maint_req += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_maint_req > 0.0 and equity <= close_maint_req: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + equity_curve[i] = 0.0 + continue + + # 4 ── Execute signal changes ────────────────────────────────────── + cur_im = 0.0 + for s in range(n_syms): + cur_im += abs(current_pos[s]) * closes[i, s] * contract_sizes[s] / leverage + + avail = equity - cur_im + if avail < 0.0: + avail = 0.0 + + for s in range(n_syms): + target = signals[i, s] + if abs(target - current_pos[s]) < 1e-12: + continue + + delta = target - current_pos[s] + exec_p = closes[i, s] * (1.0 + slippage if delta > 0.0 else 1.0 - slippage) + + fee_cost = abs(delta) * exec_p * contract_sizes[s] * fee_rate + # equity already marked to close; exec_p deviates → always a cost + slip_cost = abs(delta) * abs(exec_p - closes[i, s]) * contract_sizes[s] + + old_im = abs(current_pos[s]) * closes[i, s] * contract_sizes[s] / leverage + new_im = abs(target) * exec_p * contract_sizes[s] / leverage + required = (new_im - old_im) + fee_cost + slip_cost + + if required > avail: + continue # order rejected: insufficient margin + + equity -= fee_cost + slip_cost + current_pos[s] = target + avail -= required + + equity_curve[i] = equity + + return equity_curve, liq_flag, liq_idx + + +@njit(cache=True) +def _engine_pct_equity( + n_bars: int, + n_syms: int, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + signals: np.ndarray, # (n_bars, n_syms) raw weight e.g. 1.0 / -0.5 / 0.0 + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverage: float, + maint_ratio: float, + fee_rate: float, + contract_sizes: np.ndarray, + slippage: float, + alloc_pct: np.ndarray, # (n_syms,) fraction of equity, in (0, 1] + qty_steps: np.ndarray, + min_qtys: np.ndarray, + min_notionals: np.ndarray, +): + """ + Target units = equity × alloc_pct[s] × weight[i,s] / (close[i,s] × cs[s]) + Recalculated only when weight changes; no drift-rebalancing between bars. + """ + equity_curve = np.zeros(n_bars, dtype=np.float64) + equity = init_capital + current_pos = np.zeros(n_syms, dtype=np.float64) + liq_flag = False + liq_idx = -1 + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + continue + + # MTM + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity += p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + + # Liquidation + worst_equity = equity + maint_req = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p == 0.0: + continue + worst_p = lows[i, s] if p > 0.0 else highs[i, s] + worst_equity += p * (worst_p - closes[i, s]) * contract_sizes[s] + maint_req += abs(p) * worst_p * contract_sizes[s] * maint_ratio + + if maint_req > 0.0 and worst_equity <= maint_req: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + equity_curve[i] = 0.0 + continue + + # Funding + if is_funding_bar[i]: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity -= p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + + close_maint_req = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_maint_req += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_maint_req > 0.0 and equity <= close_maint_req: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + equity_curve[i] = 0.0 + continue + + # Execute on weight-change only + cur_im = 0.0 + for s in range(n_syms): + cur_im += abs(current_pos[s]) * closes[i, s] * contract_sizes[s] / leverage + + avail = equity - cur_im + if avail < 0.0: + avail = 0.0 + + for s in range(n_syms): + if signals[i, s] == signals[i - 1, s]: + continue + + denom = closes[i, s] * contract_sizes[s] + if denom == 0.0: + continue + + target = (equity * alloc_pct[s] * signals[i, s]) / denom + target = _quantize_signed_qty( + target, closes[i, s], contract_sizes[s], qty_steps[s], min_qtys[s], min_notionals[s] + ) + + if abs(target - current_pos[s]) < 1e-12: + continue + + delta = target - current_pos[s] + exec_p = closes[i, s] * (1.0 + slippage if delta > 0.0 else 1.0 - slippage) + + fee_cost = abs(delta) * exec_p * contract_sizes[s] * fee_rate + slip_cost = abs(delta) * abs(exec_p - closes[i, s]) * contract_sizes[s] + + old_im = abs(current_pos[s]) * closes[i, s] * contract_sizes[s] / leverage + new_im = abs(target) * exec_p * contract_sizes[s] / leverage + required = (new_im - old_im) + fee_cost + slip_cost + + if required > avail: + continue + + equity -= fee_cost + slip_cost + current_pos[s] = target + avail -= required + + equity_curve[i] = equity + + return equity_curve, liq_flag, liq_idx + + +@njit(cache=True) +def _dca_check_liquidation( + n_syms: int, + i: int, + equity: float, + current_pos: np.ndarray, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + contract_sizes: np.ndarray, + maint_ratio: float, +): + worst_equity = equity + maint_req = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p == 0.0: + continue + worst_p = lows[i, s] if p > 0.0 else highs[i, s] + worst_equity += p * (worst_p - closes[i, s]) * contract_sizes[s] + maint_req += abs(p) * worst_p * contract_sizes[s] * maint_ratio + + return maint_req > 0.0 and worst_equity <= maint_req + + +@njit(cache=True) +def _engine_dca_ladder( + n_bars: int, + n_syms: int, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + signals: np.ndarray, # signed desired structural level + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverage: float, + maint_ratio: float, + fee_rate: float, + contract_sizes: np.ndarray, + market_slippage: float, + base_notional: np.ndarray, + safety_notional: np.ndarray, + step_pct: np.ndarray, + step_scale: np.ndarray, + volume_scale: np.ndarray, + max_safety_orders: int, + take_profit_pct: np.ndarray, + allow_same_bar_exit: bool, + qty_steps: np.ndarray, + min_qtys: np.ndarray, + min_notionals: np.ndarray, +): + """ + DCA ladder execution model. + + signals are structural caps, not target units: + +N enables a long ladder up to level N, -N enables a short ladder. + level 1 is the base order; levels 2..N are safety orders. + + Base orders and signal-zero exits execute as market-at-close with + market_slippage. Safety orders and take-profit exits are limit fills at + their trigger prices when High/Low touches them. + """ + equity_curve = np.zeros(n_bars, dtype=np.float64) + pos_out = np.zeros((n_bars, n_syms), dtype=np.float64) + level_out = np.zeros((n_bars, n_syms), dtype=np.float64) + + equity = init_capital + current_pos = np.zeros(n_syms, dtype=np.float64) + current_side = np.zeros(n_syms, dtype=np.int64) + current_lvl = np.zeros(n_syms, dtype=np.int64) + anchor_price = np.zeros(n_syms, dtype=np.float64) + avg_entry = np.zeros(n_syms, dtype=np.float64) + + liq_flag = False + liq_idx = -1 + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + for s in range(n_syms): + pos_out[i, s] = 0.0 + level_out[i, s] = 0.0 + continue + + # 1. Mark existing positions to current close. + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity += p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + + # 2. Existing-book liquidation before any new ladder orders. + if _dca_check_liquidation( + n_syms, i, equity, current_pos, highs, lows, closes, + contract_sizes, maint_ratio + ): + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + current_side[s] = 0 + current_lvl[s] = 0 + pos_out[i, s] = 0.0 + level_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + # 3. Funding on the position carried into the funding timestamp. + if is_funding_bar[i]: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity -= p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + + close_maint_req = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_maint_req += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_maint_req > 0.0 and equity <= close_maint_req: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + current_side[s] = 0 + current_lvl[s] = 0 + pos_out[i, s] = 0.0 + level_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + # 4. Execute structural DCA state. + for s in range(n_syms): + c = closes[i, s] + hi = highs[i, s] + lo = lows[i, s] + cs = contract_sizes[s] + + if c <= 0.0 or cs <= 0.0: + continue + + raw_sig = signals[i, s] + desired_side = 0 + if raw_sig > 0.0: + desired_side = 1 + elif raw_sig < 0.0: + desired_side = -1 + + desired_level = int(abs(raw_sig)) + max_level = max_safety_orders + 1 + if desired_level > max_level: + desired_level = max_level + + # Existing ladder TP has priority over a later close/flip signal. + if current_lvl[s] > 0 and take_profit_pct[s] > 0.0: + tp = avg_entry[s] * ( + 1.0 + take_profit_pct[s] if current_side[s] > 0 + else 1.0 - take_profit_pct[s] + ) + hit_tp = False + if current_side[s] > 0 and hi >= tp: + hit_tp = True + elif current_side[s] < 0 and lo <= tp: + hit_tp = True + + if hit_tp: + delta = -current_pos[s] + fee_cost = abs(delta) * tp * cs * fee_rate + equity += delta * (c - tp) * cs - fee_cost + current_pos[s] = 0.0 + current_side[s] = 0 + current_lvl[s] = 0 + anchor_price[s] = 0.0 + avg_entry[s] = 0.0 + + # Signal-side change or signal flat closes the existing ladder. + if current_lvl[s] > 0 and desired_side != current_side[s]: + delta = -current_pos[s] + exec_p = c + if delta > 0.0: + exec_p = c * (1.0 + market_slippage) + elif delta < 0.0: + exec_p = c * (1.0 - market_slippage) + fee_cost = abs(delta) * exec_p * cs * fee_rate + equity += delta * (c - exec_p) * cs - fee_cost + current_pos[s] = 0.0 + current_side[s] = 0 + current_lvl[s] = 0 + anchor_price[s] = 0.0 + avg_entry[s] = 0.0 + + if desired_side == 0 or desired_level == 0: + continue + + filled_this_bar = False + started_this_bar = False + + # Base order: market-at-close when a cycle starts. + if current_lvl[s] == 0: + delta = desired_side * base_notional[s] / c + exec_p = c * (1.0 + market_slippage if delta > 0.0 else 1.0 - market_slippage) + delta = _quantize_signed_qty(delta, exec_p, cs, qty_steps[s], min_qtys[s], min_notionals[s]) + if delta == 0.0: + continue + + cur_im = 0.0 + for k in range(n_syms): + cur_im += abs(current_pos[k]) * closes[i, k] * contract_sizes[k] / leverage + avail = equity - cur_im + im_needed = abs(delta) * exec_p * cs / leverage + + fee_cost = abs(delta) * exec_p * cs * fee_rate + slip_cost = abs(delta) * abs(exec_p - c) * cs + + if im_needed + fee_cost + slip_cost <= avail: + equity += delta * (c - exec_p) * cs - fee_cost + current_pos[s] = delta + current_side[s] = desired_side + current_lvl[s] = 1 + anchor_price[s] = exec_p + avg_entry[s] = exec_p + filled_this_bar = True + started_this_bar = True + + # Safety orders: limit fills at the structural grid prices. + while current_lvl[s] > 0 and current_lvl[s] < desired_level and not started_this_bar: + next_level = current_lvl[s] + 1 + ao_idx = next_level - 2 + + dev = 0.0 + step = step_pct[s] + for k in range(ao_idx + 1): + dev += step + step *= step_scale[s] + + trigger = anchor_price[s] * (1.0 - dev if current_side[s] > 0 else 1.0 + dev) + if trigger <= 0.0: + break + + touched = False + if current_side[s] > 0 and lo <= trigger: + touched = True + elif current_side[s] < 0 and hi >= trigger: + touched = True + + if not touched: + break + + notional = safety_notional[s] + mult = 1.0 + for k in range(ao_idx): + mult *= volume_scale[s] + notional *= mult + + delta = current_side[s] * notional / trigger + delta = _quantize_signed_qty(delta, trigger, cs, qty_steps[s], min_qtys[s], min_notionals[s]) + if delta == 0.0: + break + + cur_im = 0.0 + for k in range(n_syms): + cur_im += abs(current_pos[k]) * closes[i, k] * contract_sizes[k] / leverage + avail = equity - cur_im + im_needed = abs(delta) * trigger * cs / leverage + fee_cost = abs(delta) * trigger * cs * fee_rate + + if im_needed + fee_cost > avail: + break + + old_abs = abs(current_pos[s]) + add_abs = abs(delta) + equity += delta * (c - trigger) * cs - fee_cost + current_pos[s] += delta + avg_entry[s] = ((avg_entry[s] * old_abs) + (trigger * add_abs)) / (old_abs + add_abs) + current_lvl[s] = next_level + filled_this_bar = True + + # Take-profit: limit exit from weighted average entry. + if ( + current_lvl[s] > 0 + and take_profit_pct[s] > 0.0 + and (allow_same_bar_exit or not filled_this_bar) + ): + tp = avg_entry[s] * ( + 1.0 + take_profit_pct[s] if current_side[s] > 0 + else 1.0 - take_profit_pct[s] + ) + hit_tp = False + if current_side[s] > 0 and hi >= tp: + hit_tp = True + elif current_side[s] < 0 and lo <= tp: + hit_tp = True + + if hit_tp: + delta = -current_pos[s] + fee_cost = abs(delta) * tp * cs * fee_rate + equity += delta * (c - tp) * cs - fee_cost + current_pos[s] = 0.0 + current_side[s] = 0 + current_lvl[s] = 0 + anchor_price[s] = 0.0 + avg_entry[s] = 0.0 + + # 5. Conservative post-fill liquidation on same-bar extremes. + if _dca_check_liquidation( + n_syms, i, equity, current_pos, highs, lows, closes, + contract_sizes, maint_ratio + ): + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + current_side[s] = 0 + current_lvl[s] = 0 + pos_out[i, s] = 0.0 + level_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + for s in range(n_syms): + pos_out[i, s] = current_pos[s] + level_out[i, s] = current_side[s] * current_lvl[s] + + equity_curve[i] = equity + + return equity_curve, pos_out, level_out, liq_flag, liq_idx + + +@njit(cache=True) +def _engine_portfolio( + n_bars: int, + n_syms: int, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + target_pos: np.ndarray, + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverages: np.ndarray, + maint_ratio: float, + fee_rate: float, + slippage_rate: float, + contract_sizes: np.ndarray, + use_funding: bool, + tradable: np.ndarray, +): + """ + Numba portfolio simulation kernel. + + target_pos is a pre-built units matrix after portfolio allocation mode. + The kernel applies cross-margin buying-power gates and returns the actual + accepted positions, equity, per-symbol cumulative PnL, fees, and turnover. + """ + equity_curve = np.zeros(n_bars, dtype=np.float64) + pos_out = np.zeros((n_bars, n_syms), dtype=np.float64) + sym_pnl = np.zeros((n_bars, n_syms), dtype=np.float64) + fee_arr = np.zeros(n_bars, dtype=np.float64) + slip_arr = np.zeros(n_bars, dtype=np.float64) + turn_arr = np.zeros(n_bars, dtype=np.float64) + + current_pos = np.zeros(n_syms, dtype=np.float64) + current_pnl = np.zeros(n_syms, dtype=np.float64) + equity = init_capital + liq_flag = False + liq_idx = -1 + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + for s in range(n_syms): + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + continue + + # 1. Mark carried positions to close. + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + pnl = p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + equity += pnl + current_pnl[s] += pnl + + # 2. Intrabar liquidation using worst price. + worst_equity = equity + worst_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p == 0.0: + continue + worst_p = lows[i, s] if p > 0.0 else highs[i, s] + worst_equity += p * (worst_p - closes[i, s]) * contract_sizes[s] + worst_mm += abs(p) * worst_p * contract_sizes[s] * maint_ratio + + if worst_mm > 0.0 and worst_equity <= worst_mm: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + equity_curve[i] = 0.0 + continue + + # 3. Funding on carried positions. Funding rates are per event. + if is_funding_bar[i] and use_funding: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + fc = p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + equity -= fc + current_pnl[s] -= fc + + close_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_mm += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + equity_curve[i] = 0.0 + continue + + # 4. Cross-margin buying-power gate for target portfolio. + cur_im = 0.0 + target_im = 0.0 + target_mm = 0.0 + fee_est = 0.0 + slip_est = 0.0 + invalid_target = False + for s in range(n_syms): + c = closes[i, s] + cs = contract_sizes[s] + lev = leverages[s] + cur_im += abs(current_pos[s]) * c * cs / lev + target_im += abs(target_pos[i, s]) * c * cs / lev + target_mm += abs(target_pos[i, s]) * c * cs * maint_ratio + + delta = target_pos[i, s] - current_pos[s] + if abs(delta) > 1e-12: + if not tradable[i, s] or c <= 0.0 or not np.isfinite(c): + invalid_target = True + continue + exec_price = c * (1.0 + slippage_rate) if delta > 0.0 else c * (1.0 - slippage_rate) + trade_notional = abs(delta) * exec_price * cs + fee_est += trade_notional * fee_rate + slip_est += abs(delta) * c * cs * slippage_rate + + can_rebalance = True + post_trade_equity = equity - fee_est - slip_est + if invalid_target or post_trade_equity < target_im or post_trade_equity < target_mm: + can_rebalance = False + + # 5. Execute accepted target at close. + if can_rebalance: + for s in range(n_syms): + c = closes[i, s] + cs = contract_sizes[s] + delta = target_pos[i, s] - current_pos[s] + if abs(delta) > 1e-12: + exec_price = c * (1.0 + slippage_rate) if delta > 0.0 else c * (1.0 - slippage_rate) + tv = abs(delta) * exec_price * cs + fee = tv * fee_rate + slip = abs(delta) * c * cs * slippage_rate + equity -= fee + slip + current_pnl[s] -= fee + slip + fee_arr[i] += fee + slip_arr[i] += slip + + turn_arr[i] += tv + current_pos[s] = target_pos[i, s] + + # 6. Post-fee maintenance check. + close_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_mm += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + equity_curve[i] = 0.0 + continue + + for s in range(n_syms): + pos_out[i, s] = current_pos[s] + sym_pnl[i, s] = current_pnl[s] + + equity_curve[i] = equity + + return equity_curve, pos_out, sym_pnl, fee_arr, slip_arr, turn_arr, liq_flag, liq_idx + + +@njit(cache=True) +def _engine_portfolio_equity_sizing( + n_bars: int, + n_syms: int, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + raw_signals: np.ndarray, + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverages: np.ndarray, + maint_ratio: float, + fee_rate: float, + slippage_rate: float, + contract_sizes: np.ndarray, + use_funding: bool, + allocs: np.ndarray, + sizing_mode_id: int, + portfolio_mode_id: int, + use_pyramiding: bool, + exposure_scalar: float, + beta: np.ndarray, + inv_vol: np.ndarray, + qty_steps: np.ndarray, + min_qtys: np.ndarray, + min_notionals: np.ndarray, + tradable: np.ndarray, +): + """ + Portfolio kernel for sizing modes which depend on live equity. + + sizing_mode_id: + 0 = %_equity, signal * alloc[s] * equity + 1 = target_weight, signal * equity + 2 = gross_exposure, normalized signed signal with target gross equity * scalar + 3 = net_exposure, normalized signed signal with target net equity * scalar + + portfolio_mode_id: + 0 = longshort, 1 = market_neutral, 2 = directional, 3 = equal_weight, + 4 = risk_parity, 5 = beta_neutral. + """ + equity_curve = np.zeros(n_bars, dtype=np.float64) + target_out = np.zeros((n_bars, n_syms), dtype=np.float64) + pos_out = np.zeros((n_bars, n_syms), dtype=np.float64) + sym_pnl = np.zeros((n_bars, n_syms), dtype=np.float64) + fee_arr = np.zeros(n_bars, dtype=np.float64) + slip_arr = np.zeros(n_bars, dtype=np.float64) + turn_arr = np.zeros(n_bars, dtype=np.float64) + + current_pos = np.zeros(n_syms, dtype=np.float64) + current_pnl = np.zeros(n_syms, dtype=np.float64) + target_notional = np.zeros(n_syms, dtype=np.float64) + target_units = np.zeros(n_syms, dtype=np.float64) + equity = init_capital + liq_flag = False + liq_idx = -1 + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + for s in range(n_syms): + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + continue + + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + pnl = p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + equity += pnl + current_pnl[s] += pnl + + worst_equity = equity + worst_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p == 0.0: + continue + worst_p = lows[i, s] if p > 0.0 else highs[i, s] + worst_equity += p * (worst_p - closes[i, s]) * contract_sizes[s] + worst_mm += abs(p) * worst_p * contract_sizes[s] * maint_ratio + + if worst_mm > 0.0 and worst_equity <= worst_mm: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + equity_curve[i] = 0.0 + continue + + if is_funding_bar[i] and use_funding: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + fc = p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + equity -= fc + current_pnl[s] -= fc + + close_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_mm += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + equity_curve[i] = 0.0 + continue + + sum_abs_sig = 0.0 + sum_sig = 0.0 + for s in range(n_syms): + sig = raw_signals[i, s] + if not use_pyramiding: + if sig > 0.0: + sig = 1.0 + elif sig < 0.0: + sig = -1.0 + else: + sig = 0.0 + sum_abs_sig += abs(sig) + sum_sig += sig + target_notional[s] = sig + + for s in range(n_syms): + sig = target_notional[s] + if sizing_mode_id == 0: + target_notional[s] = sig * allocs[s] * equity + elif sizing_mode_id == 1: + target_notional[s] = sig * equity + elif sizing_mode_id == 2: + if sum_abs_sig > 0.0: + target_notional[s] = sig / sum_abs_sig * equity * exposure_scalar + else: + target_notional[s] = 0.0 + else: + if abs(sum_sig) > 1e-12: + target_notional[s] = sig / sum_sig * equity * exposure_scalar + else: + target_notional[s] = 0.0 + + _apply_portfolio_notional_mode(i, n_syms, portfolio_mode_id, target_notional, beta, inv_vol) + + for s in range(n_syms): + denom = closes[i, s] * contract_sizes[s] + if tradable[i, s] and denom != 0.0: + target_units[s] = target_notional[s] / denom + else: + target_units[s] = 0.0 + target_units[s] = _quantize_signed_qty( + target_units[s], closes[i, s], contract_sizes[s], qty_steps[s], min_qtys[s], min_notionals[s] + ) + target_out[i, s] = target_units[s] + + cur_im = 0.0 + target_im = 0.0 + target_mm = 0.0 + fee_est = 0.0 + slip_est = 0.0 + invalid_target = False + for s in range(n_syms): + c = closes[i, s] + cs = contract_sizes[s] + lev = leverages[s] + cur_im += abs(current_pos[s]) * c * cs / lev + target_im += abs(target_units[s]) * c * cs / lev + target_mm += abs(target_units[s]) * c * cs * maint_ratio + delta = target_units[s] - current_pos[s] + if abs(delta) > 1e-12: + if not tradable[i, s] or c <= 0.0 or not np.isfinite(c): + invalid_target = True + continue + exec_price = c * (1.0 + slippage_rate) if delta > 0.0 else c * (1.0 - slippage_rate) + trade_notional = abs(delta) * exec_price * cs + fee_est += trade_notional * fee_rate + slip_est += abs(delta) * c * cs * slippage_rate + + can_rebalance = True + post_trade_equity = equity - fee_est - slip_est + if invalid_target or post_trade_equity < target_im or post_trade_equity < target_mm: + can_rebalance = False + + if can_rebalance: + for s in range(n_syms): + c = closes[i, s] + cs = contract_sizes[s] + delta = target_units[s] - current_pos[s] + if abs(delta) > 1e-12: + exec_price = c * (1.0 + slippage_rate) if delta > 0.0 else c * (1.0 - slippage_rate) + tv = abs(delta) * exec_price * cs + fee = tv * fee_rate + slip = abs(delta) * c * cs * slippage_rate + equity -= fee + slip + current_pnl[s] -= fee + slip + fee_arr[i] += fee + slip_arr[i] += slip + turn_arr[i] += tv + current_pos[s] = target_units[s] + + close_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_mm += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + sym_pnl[i, s] = current_pnl[s] + equity_curve[i] = 0.0 + continue + + for s in range(n_syms): + pos_out[i, s] = current_pos[s] + sym_pnl[i, s] = current_pnl[s] + equity_curve[i] = equity + + return equity_curve, target_out, pos_out, sym_pnl, fee_arr, slip_arr, turn_arr, liq_flag, liq_idx + + +@njit(cache=True) +def _apply_portfolio_notional_mode( + i: int, + n_syms: int, + mode_id: int, + target_notional: np.ndarray, + beta: np.ndarray, + inv_vol: np.ndarray, +): + if mode_id == 1: + long_sum = 0.0 + short_sum = 0.0 + for s in range(n_syms): + v = target_notional[s] + if v > 0.0: + long_sum += v + elif v < 0.0: + short_sum += -v + if long_sum == 0.0 or short_sum == 0.0: + for s in range(n_syms): + target_notional[s] = 0.0 + return + target = (long_sum + short_sum) / 2.0 + long_scale = target / long_sum + short_scale = target / short_sum + for s in range(n_syms): + if target_notional[s] > 0.0: + target_notional[s] *= long_scale + elif target_notional[s] < 0.0: + target_notional[s] *= short_scale + elif mode_id == 2: + max_abs = 0.0 + dominant = -1 + for s in range(n_syms): + v = abs(target_notional[s]) + if v > max_abs: + max_abs = v + dominant = s + for s in range(n_syms): + if s != dominant: + target_notional[s] = 0.0 + elif mode_id == 3: + active = 0 + gross = 0.0 + for s in range(n_syms): + if target_notional[s] != 0.0: + active += 1 + gross += abs(target_notional[s]) + if active == 0: + return + target_abs = gross / active + for s in range(n_syms): + if target_notional[s] > 0.0: + target_notional[s] = target_abs + elif target_notional[s] < 0.0: + target_notional[s] = -target_abs + elif mode_id == 4: + gross = 0.0 + inv_sum = 0.0 + for s in range(n_syms): + if target_notional[s] != 0.0: + gross += abs(target_notional[s]) + inv_sum += inv_vol[i, s] + if gross == 0.0: + return + if inv_sum == 0.0: + for s in range(n_syms): + target_notional[s] = 0.0 + return + for s in range(n_syms): + if target_notional[s] > 0.0: + target_notional[s] = gross * inv_vol[i, s] / inv_sum + elif target_notional[s] < 0.0: + target_notional[s] = -gross * inv_vol[i, s] / inv_sum + elif mode_id == 5: + long_beta = 0.0 + short_beta = 0.0 + for s in range(n_syms): + b = beta[s] + v = target_notional[s] * b + if v > 0.0: + long_beta += v + elif v < 0.0: + short_beta += -v + if long_beta == 0.0 or short_beta == 0.0: + for s in range(n_syms): + target_notional[s] = 0.0 + return + target_beta = (long_beta + short_beta) / 2.0 + long_scale = target_beta / long_beta + short_scale = target_beta / short_beta + for s in range(n_syms): + v = target_notional[s] * beta[s] + if v > 0.0: + target_notional[s] *= long_scale + elif v < 0.0: + target_notional[s] *= short_scale diff --git a/src/quantbt/core/event.py b/src/quantbt/core/event.py new file mode 100644 index 0000000..0d64975 --- /dev/null +++ b/src/quantbt/core/event.py @@ -0,0 +1,869 @@ +""" +quantbt.core.event +------------------ +Numba kernels for the native event-driven backend. +""" + +from __future__ import annotations + +import numpy as np +from numba import njit + + +ORDER_STATUS_PENDING = 0 +ORDER_STATUS_FILLED = 1 +ORDER_STATUS_CANCELED = 2 +ORDER_STATUS_REJECTED = 3 + +ORDER_TYPE_MARKET = 0 +ORDER_TYPE_LIMIT = 1 +ORDER_TYPE_STOP_MARKET = 2 +ORDER_TYPE_STOP_LIMIT = 3 + +TIF_GTC = 0 +TIF_IOC = 1 +TIF_FOK = 2 +TIF_GTD = 3 + +SIDE_BUY = 1 +SIDE_SELL = -1 + +REJECT_NONE = 0 +REJECT_INSUFFICIENT_MARGIN = 1 +REJECT_UNSUPPORTED_ORDER_TYPE = 2 +REJECT_UNKNOWN_ORDER = 3 +REJECT_INVALID_AMEND = 4 +REJECT_REDUCE_ONLY_NO_POSITION = 5 +REJECT_UNSUPPORTED_ACTION = 6 + +LIQ_NONE = 0 +LIQ_INTRABAR = 1 +LIQ_AFTER_FUNDING = 2 +LIQ_AFTER_ORDER = 3 + +COMMAND_ACTION_PLACE = 0 +COMMAND_ACTION_CANCEL = 1 +COMMAND_ACTION_REPLACE = 2 +COMMAND_ACTION_AMEND = 3 +COMMAND_ACTION_CANCEL_ALL = 4 + +ACTIVATION_IMMEDIATE = 0 +ACTIVATION_ON_PARENT_FIRST_FILL = 1 +ACTIVATION_ON_PARENT_FULL_FILL = 2 + +ORDER_EVENT_PLACE = 0 +ORDER_EVENT_CANCEL = 1 +ORDER_EVENT_REPLACE = 2 +ORDER_EVENT_AMEND = 3 +ORDER_EVENT_FILL = 4 +ORDER_EVENT_EXPIRE = 5 +ORDER_EVENT_ACTIVATE = 6 +ORDER_EVENT_REJECT = 7 + + +@njit(cache=True) +def _event_close_margin( + n_syms: int, + current_pos: np.ndarray, + closes: np.ndarray, + contract_sizes: np.ndarray, + leverages: np.ndarray, + maint_ratio: float, + i: int, +): + init_margin = 0.0 + maint_margin = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + notional = abs(p) * closes[i, s] * contract_sizes[s] + init_margin += notional / leverages[s] + maint_margin += notional * maint_ratio + return init_margin, maint_margin + + +@njit(cache=True) +def _event_liquidated( + n_syms: int, + equity: float, + current_pos: np.ndarray, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + contract_sizes: np.ndarray, + maint_ratio: float, + i: int, +): + worst_equity = equity + worst_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p == 0.0: + continue + worst_p = lows[i, s] if p > 0.0 else highs[i, s] + worst_equity += p * (worst_p - closes[i, s]) * contract_sizes[s] + worst_mm += abs(p) * worst_p * contract_sizes[s] * maint_ratio + return worst_mm > 0.0 and worst_equity <= worst_mm + + +@njit(cache=True) +def _engine_event_v1( + n_bars: int, + n_syms: int, + n_orders: int, + order_ptr: np.ndarray, + order_symbol: np.ndarray, + order_side: np.ndarray, + order_type: np.ndarray, + order_qty: np.ndarray, + order_price: np.ndarray, + order_tif: np.ndarray, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverages: np.ndarray, + maint_ratio: float, + fee_rates: np.ndarray, + contract_sizes: np.ndarray, + slippage: float, + use_funding: bool, +): + equity_curve = np.zeros(n_bars, dtype=np.float64) + pos_out = np.zeros((n_bars, n_syms), dtype=np.float64) + fee_arr = np.zeros(n_bars, dtype=np.float64) + turnover_arr = np.zeros(n_bars, dtype=np.float64) + funding_arr = np.zeros(n_bars, dtype=np.float64) + init_margin = np.zeros(n_bars, dtype=np.float64) + maint_margin = np.zeros(n_bars, dtype=np.float64) + rejected_bar = np.zeros(n_bars, dtype=np.int64) + canceled_bar = np.zeros(n_bars, dtype=np.int64) + + order_status = np.full(n_orders, ORDER_STATUS_PENDING, dtype=np.int64) + reject_code = np.zeros(n_orders, dtype=np.int64) + fill_bar = np.full(n_orders, -1, dtype=np.int64) + fill_qty = np.zeros(n_orders, dtype=np.float64) + fill_price = np.zeros(n_orders, dtype=np.float64) + fill_fee = np.zeros(n_orders, dtype=np.float64) + + pending_ids = np.zeros(n_orders, dtype=np.int64) + pending_count = 0 + + current_pos = np.zeros(n_syms, dtype=np.float64) + equity = init_capital + liq_flag = False + liq_idx = -1 + liq_reason = LIQ_NONE + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + for s in range(n_syms): + pos_out[i, s] = 0.0 + continue + + # Mark carried positions to close. + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity += p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + + if _event_liquidated( + n_syms, equity, current_pos, highs, lows, closes, + contract_sizes, maint_ratio, i + ): + liq_flag = True + liq_idx = i + liq_reason = LIQ_INTRABAR + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + if is_funding_bar[i] and use_funding: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + cost = p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + equity -= cost + funding_arr[i] += cost + + _, close_mm = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_FUNDING + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + # Activate orders submitted for this bar. + for k in range(order_ptr[i], order_ptr[i + 1]): + pending_ids[pending_count] = k + pending_count += 1 + + write_count = 0 + for pidx in range(pending_count): + oid = pending_ids[pidx] + if order_status[oid] != ORDER_STATUS_PENDING: + continue + + sym = order_symbol[oid] + side = order_side[oid] + otype = order_type[oid] + tif = order_tif[oid] + + touched = False + exec_price = closes[i, sym] + + if otype == ORDER_TYPE_MARKET: + touched = True + exec_price = closes[i, sym] * (1.0 + slippage if side > 0 else 1.0 - slippage) + elif otype == ORDER_TYPE_LIMIT: + limit_p = order_price[oid] + if side > 0 and lows[i, sym] <= limit_p: + touched = True + exec_price = limit_p + elif side < 0 and highs[i, sym] >= limit_p: + touched = True + exec_price = limit_p + else: + order_status[oid] = ORDER_STATUS_REJECTED + reject_code[oid] = REJECT_UNSUPPORTED_ORDER_TYPE + rejected_bar[i] += 1 + continue + + if not touched: + if tif == TIF_GTC: + pending_ids[write_count] = oid + write_count += 1 + else: + order_status[oid] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + continue + + qty = order_qty[oid] + delta = qty * side + cs = contract_sizes[sym] + c = closes[i, sym] + trade_notional = abs(delta) * exec_price * cs + fee_cost = trade_notional * fee_rates[sym] + + cur_im, _ = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + old_im = abs(current_pos[sym]) * c * cs / leverages[sym] + new_im = abs(current_pos[sym] + delta) * exec_price * cs / leverages[sym] + margin_delta = new_im - old_im + required = fee_cost + if margin_delta > 0.0: + required += margin_delta + + if required > equity - cur_im: + order_status[oid] = ORDER_STATUS_REJECTED + reject_code[oid] = REJECT_INSUFFICIENT_MARGIN + rejected_bar[i] += 1 + continue + + # Equity is marked at close; fill price creates same-bar PnL. + equity += delta * (c - exec_price) * cs - fee_cost + current_pos[sym] += delta + + order_status[oid] = ORDER_STATUS_FILLED + fill_bar[oid] = i + fill_qty[oid] = qty + fill_price[oid] = exec_price + fill_fee[oid] = fee_cost + fee_arr[i] += fee_cost + turnover_arr[i] += trade_notional + + pending_count = write_count + + close_im, close_mm = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_ORDER + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + for s in range(n_syms): + pos_out[i, s] = current_pos[s] + init_margin[i] = close_im + maint_margin[i] = close_mm + equity_curve[i] = equity + + return ( + equity_curve, + pos_out, + fee_arr, + turnover_arr, + funding_arr, + init_margin, + maint_margin, + rejected_bar, + canceled_bar, + order_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + liq_flag, + liq_idx, + liq_reason, + ) + + +@njit(cache=True) +def _record_order_event( + event_count: int, + event_bar: np.ndarray, + event_command: np.ndarray, + event_type: np.ndarray, + event_status: np.ndarray, + event_related_command: np.ndarray, + bar: int, + command_idx: int, + event_code: int, + status: int, + related_command_idx: int, +): + if event_count < event_bar.shape[0]: + event_bar[event_count] = bar + event_command[event_count] = command_idx + event_type[event_count] = event_code + event_status[event_count] = status + event_related_command[event_count] = related_command_idx + return event_count + 1 + return event_count + + +@njit(cache=True) +def _event_margin_required( + n_syms: int, + current_pos: np.ndarray, + closes: np.ndarray, + contract_sizes: np.ndarray, + leverages: np.ndarray, + maint_ratio: float, + i: int, + sym: int, + delta: float, + exec_price: float, + fee_cost: float, +): + cur_im, _ = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + cs = contract_sizes[sym] + c = closes[i, sym] + old_im = abs(current_pos[sym]) * c * cs / leverages[sym] + new_im = abs(current_pos[sym] + delta) * exec_price * cs / leverages[sym] + margin_delta = new_im - old_im + required = fee_cost + if margin_delta > 0.0: + required += margin_delta + return required, cur_im + + +@njit(cache=True) +def _event_v2_touched_price( + otype: int, + side: int, + price: float, + trigger_price: float, + high: float, + low: float, + close: float, + slippage: float, +): + touched = False + exec_price = close + if otype == ORDER_TYPE_MARKET: + touched = True + exec_price = close * (1.0 + slippage if side > 0 else 1.0 - slippage) + elif otype == ORDER_TYPE_LIMIT: + if side > 0 and low <= price: + touched = True + exec_price = price + elif side < 0 and high >= price: + touched = True + exec_price = price + elif otype == ORDER_TYPE_STOP_MARKET: + if side > 0 and high >= trigger_price: + touched = True + exec_price = trigger_price * (1.0 + slippage) + elif side < 0 and low <= trigger_price: + touched = True + exec_price = trigger_price * (1.0 - slippage) + elif otype == ORDER_TYPE_STOP_LIMIT: + if side > 0 and high >= trigger_price and low <= price: + touched = True + exec_price = price + elif side < 0 and low <= trigger_price and high >= price: + touched = True + exec_price = price + return touched, exec_price + + +@njit(cache=True) +def _engine_event_v2( + n_bars: int, + n_syms: int, + n_commands: int, + n_ids: int, + command_ptr: np.ndarray, + command_action: np.ndarray, + command_symbol: np.ndarray, + command_side: np.ndarray, + command_type: np.ndarray, + command_qty: np.ndarray, + command_price: np.ndarray, + command_trigger_price: np.ndarray, + command_tif: np.ndarray, + command_reduce_only: np.ndarray, + command_order_id: np.ndarray, + command_target_order_id: np.ndarray, + command_parent_order_id: np.ndarray, + command_group_id: np.ndarray, + command_oco_group_id: np.ndarray, + command_activation: np.ndarray, + command_expires_bar: np.ndarray, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverages: np.ndarray, + maint_ratio: float, + fee_rates: np.ndarray, + contract_sizes: np.ndarray, + slippage: float, + use_funding: bool, +): + equity_curve = np.zeros(n_bars, dtype=np.float64) + pos_out = np.zeros((n_bars, n_syms), dtype=np.float64) + fee_arr = np.zeros(n_bars, dtype=np.float64) + turnover_arr = np.zeros(n_bars, dtype=np.float64) + funding_arr = np.zeros(n_bars, dtype=np.float64) + init_margin = np.zeros(n_bars, dtype=np.float64) + maint_margin = np.zeros(n_bars, dtype=np.float64) + rejected_bar = np.zeros(n_bars, dtype=np.int64) + canceled_bar = np.zeros(n_bars, dtype=np.int64) + + command_status = np.full(n_commands, ORDER_STATUS_PENDING, dtype=np.int64) + reject_code = np.zeros(n_commands, dtype=np.int64) + fill_bar = np.full(n_commands, -1, dtype=np.int64) + fill_qty = np.zeros(n_commands, dtype=np.float64) + fill_price = np.zeros(n_commands, dtype=np.float64) + fill_fee = np.zeros(n_commands, dtype=np.float64) + + active = np.zeros(n_commands, dtype=np.int64) + waiting_parent = np.zeros(n_commands, dtype=np.int64) + working_qty = np.copy(command_qty) + working_price = np.copy(command_price) + working_trigger = np.copy(command_trigger_price) + id_to_slot = np.full(n_ids, -1, dtype=np.int64) + + max_events = n_commands * 8 + n_bars + event_bar = np.full(max_events, -1, dtype=np.int64) + event_command = np.full(max_events, -1, dtype=np.int64) + event_type = np.full(max_events, -1, dtype=np.int64) + event_status = np.full(max_events, -1, dtype=np.int64) + event_related_command = np.full(max_events, -1, dtype=np.int64) + event_count = 0 + + current_pos = np.zeros(n_syms, dtype=np.float64) + equity = init_capital + liq_flag = False + liq_idx = -1 + liq_reason = LIQ_NONE + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + for s in range(n_syms): + pos_out[i, s] = 0.0 + continue + + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity += p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + + if _event_liquidated( + n_syms, equity, current_pos, highs, lows, closes, + contract_sizes, maint_ratio, i + ): + liq_flag = True + liq_idx = i + liq_reason = LIQ_INTRABAR + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + if is_funding_bar[i] and use_funding: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + cost = p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + equity -= cost + funding_arr[i] += cost + + _, close_mm = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_FUNDING + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + # Expire active GTD orders before processing the current bar. + for oid in range(n_commands): + if active[oid] == 1 and command_status[oid] == ORDER_STATUS_PENDING: + exp_bar = command_expires_bar[oid] + if exp_bar >= 0 and i >= exp_bar: + active[oid] = 0 + command_status[oid] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_EXPIRE, ORDER_STATUS_CANCELED, -1, + ) + + # Apply lifecycle commands submitted for this bar. + for k in range(command_ptr[i], command_ptr[i + 1]): + action = command_action[k] + if action == COMMAND_ACTION_PLACE: + oid_code = command_order_id[k] + if oid_code >= 0 and oid_code < n_ids: + id_to_slot[oid_code] = k + if command_activation[k] == ACTIVATION_IMMEDIATE: + active[k] = 1 + else: + waiting_parent[k] = 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_PLACE, ORDER_STATUS_PENDING, -1, + ) + elif action == COMMAND_ACTION_REPLACE: + target_code = command_target_order_id[k] + target = -1 + if target_code >= 0 and target_code < n_ids: + target = id_to_slot[target_code] + if target < 0 or command_status[target] != ORDER_STATUS_PENDING: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNKNOWN_ORDER + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, target, + ) + else: + active[target] = 0 + waiting_parent[target] = 0 + command_status[target] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + oid_code = command_order_id[k] + if oid_code >= 0 and oid_code < n_ids: + id_to_slot[oid_code] = k + if target_code >= 0 and target_code < n_ids: + id_to_slot[target_code] = k + active[k] = 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REPLACE, ORDER_STATUS_PENDING, target, + ) + elif action == COMMAND_ACTION_CANCEL: + target_code = command_target_order_id[k] + target = -1 + if target_code >= 0 and target_code < n_ids: + target = id_to_slot[target_code] + if target < 0 or command_status[target] != ORDER_STATUS_PENDING: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNKNOWN_ORDER + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, target, + ) + else: + active[target] = 0 + waiting_parent[target] = 0 + command_status[target] = ORDER_STATUS_CANCELED + command_status[k] = ORDER_STATUS_FILLED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_CANCEL, ORDER_STATUS_FILLED, target, + ) + elif action == COMMAND_ACTION_AMEND: + target_code = command_target_order_id[k] + target = -1 + if target_code >= 0 and target_code < n_ids: + target = id_to_slot[target_code] + if target < 0 or command_status[target] != ORDER_STATUS_PENDING: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNKNOWN_ORDER + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, target, + ) + else: + if command_qty[k] > 0.0: + working_qty[target] = command_qty[k] + if command_price[k] > 0.0: + working_price[target] = command_price[k] + if command_trigger_price[k] > 0.0: + working_trigger[target] = command_trigger_price[k] + command_status[k] = ORDER_STATUS_FILLED + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_AMEND, ORDER_STATUS_FILLED, target, + ) + elif action == COMMAND_ACTION_CANCEL_ALL: + for target in range(n_commands): + if ( + (active[target] == 1 or waiting_parent[target] == 1) + and command_status[target] == ORDER_STATUS_PENDING + ): + if ( + (command_symbol[k] < 0 or command_symbol[k] == command_symbol[target]) + and (command_side[k] == 0 or command_side[k] == command_side[target]) + and (command_type[k] < 0 or command_type[k] == command_type[target]) + and ( + command_parent_order_id[k] < 0 + or command_parent_order_id[k] == command_parent_order_id[target] + ) + and (command_group_id[k] < 0 or command_group_id[k] == command_group_id[target]) + and ( + command_oco_group_id[k] < 0 + or command_oco_group_id[k] == command_oco_group_id[target] + ) + ): + active[target] = 0 + waiting_parent[target] = 0 + command_status[target] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + command_status[k] = ORDER_STATUS_FILLED + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_CANCEL, ORDER_STATUS_FILLED, -1, + ) + else: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNSUPPORTED_ACTION + rejected_bar[i] += 1 + + # Match active order slots. Children activated by an earlier parent fill + # can fill in the same bar if they appear later in command order. + for oid in range(n_commands): + if active[oid] != 1 or command_status[oid] != ORDER_STATUS_PENDING: + continue + action = command_action[oid] + if action != COMMAND_ACTION_PLACE and action != COMMAND_ACTION_REPLACE: + continue + + sym = command_symbol[oid] + side = command_side[oid] + otype = command_type[oid] + tif = command_tif[oid] + + touched, exec_price = _event_v2_touched_price( + otype, side, working_price[oid], working_trigger[oid], + highs[i, sym], lows[i, sym], closes[i, sym], slippage, + ) + + if not touched: + if tif == TIF_GTC or tif == TIF_GTD: + continue + active[oid] = 0 + command_status[oid] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_CANCEL, ORDER_STATUS_CANCELED, -1, + ) + continue + + qty = working_qty[oid] + if command_reduce_only[oid] == 1: + current = current_pos[sym] + if current == 0.0 or (current > 0.0 and side > 0) or (current < 0.0 and side < 0): + active[oid] = 0 + command_status[oid] = ORDER_STATUS_CANCELED + reject_code[oid] = REJECT_REDUCE_ONLY_NO_POSITION + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_CANCEL, ORDER_STATUS_CANCELED, -1, + ) + continue + max_reduce = abs(current) + if qty > max_reduce: + qty = max_reduce + + delta = qty * side + cs = contract_sizes[sym] + c = closes[i, sym] + trade_notional = abs(delta) * exec_price * cs + fee_cost = trade_notional * fee_rates[sym] + + required, cur_im = _event_margin_required( + n_syms, current_pos, closes, contract_sizes, leverages, + maint_ratio, i, sym, delta, exec_price, fee_cost, + ) + if required > equity - cur_im: + active[oid] = 0 + command_status[oid] = ORDER_STATUS_REJECTED + reject_code[oid] = REJECT_INSUFFICIENT_MARGIN + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, -1, + ) + continue + + equity += delta * (c - exec_price) * cs - fee_cost + current_pos[sym] += delta + + active[oid] = 0 + command_status[oid] = ORDER_STATUS_FILLED + fill_bar[oid] = i + fill_qty[oid] = qty + fill_price[oid] = exec_price + fill_fee[oid] = fee_cost + fee_arr[i] += fee_cost + turnover_arr[i] += trade_notional + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_FILL, ORDER_STATUS_FILLED, -1, + ) + + order_id = command_order_id[oid] + for child in range(n_commands): + if waiting_parent[child] == 1 and command_parent_order_id[child] == order_id: + if ( + command_activation[child] == ACTIVATION_ON_PARENT_FIRST_FILL + or command_activation[child] == ACTIVATION_ON_PARENT_FULL_FILL + ): + waiting_parent[child] = 0 + active[child] = 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, child, + ORDER_EVENT_ACTIVATE, ORDER_STATUS_PENDING, oid, + ) + + oco_group = command_oco_group_id[oid] + if oco_group >= 0: + for sibling in range(n_commands): + if sibling != oid and active[sibling] == 1 and command_status[sibling] == ORDER_STATUS_PENDING: + if command_oco_group_id[sibling] == oco_group: + active[sibling] = 0 + waiting_parent[sibling] = 0 + command_status[sibling] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, sibling, + ORDER_EVENT_CANCEL, ORDER_STATUS_CANCELED, oid, + ) + + close_im, close_mm = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_ORDER + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + for s in range(n_syms): + pos_out[i, s] = current_pos[s] + init_margin[i] = close_im + maint_margin[i] = close_mm + equity_curve[i] = equity + + return ( + equity_curve, + pos_out, + fee_arr, + turnover_arr, + funding_arr, + init_margin, + maint_margin, + rejected_bar, + canceled_bar, + command_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + active, + waiting_parent, + working_qty, + working_price, + working_trigger, + event_count, + event_bar, + event_command, + event_type, + event_status, + event_related_command, + liq_flag, + liq_idx, + liq_reason, + ) diff --git a/src/quantbt/core/execution_contract.py b/src/quantbt/core/execution_contract.py new file mode 100644 index 0000000..386cb45 --- /dev/null +++ b/src/quantbt/core/execution_contract.py @@ -0,0 +1,197 @@ +""" +Execution contract taxonomy for QuantBT backtest engines. + +The contract object is deliberately small and serializable. It describes what a +backend promises to simulate; hot kernels receive integer codes compiled from +these records in later phases. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import Enum +from typing import Dict, Mapping + + +class SignalPhase(str, Enum): + BAR_OPEN = "bar_open" + BAR_CLOSE = "bar_close" + + +class FillPhase(str, Enum): + SAME_OPEN = "same_open" + SAME_CLOSE = "same_close" + NEXT_OPEN = "next_open" + NEXT_CLOSE = "next_close" + + +class MarketFillPolicy(str, Enum): + CLOSE = "close" + OPEN = "open" + NEXT_OPEN = "next_open" + + +class StopGapPolicy(str, Enum): + OPEN_WORSE_THAN_TRIGGER = "open_worse_than_trigger" + + +class TakeProfitGapPolicy(str, Enum): + LIMIT_PRICE_CONSERVATIVE = "limit_price_conservative" + OPEN_PRICE_IMPROVEMENT = "open_price_improvement" + + +class IntrabarSameBarPolicy(str, Enum): + CONSERVATIVE = "conservative" + STOP_FIRST = "stop_first" + TP_FIRST = "tp_first" + OHLC_PATH = "ohlc_path" + OLHC_PATH = "olhc_path" + REJECT_AMBIGUOUS = "reject_ambiguous" + LOWER_TIMEFRAME_REQUIRED = "lower_timeframe_required" + + +class TrailingUpdatePhase(str, Enum): + NONE = "none" + NEXT_BAR = "next_bar" + + +class FundingPhase(str, Enum): + POSITION_AT_EVENT = "position_at_event" + POSITION_AT_CLOSE = "position_at_close" + + +class LiquidationPriority(str, Enum): + LIQUIDATION_FIRST_AT_GAP = "liquidation_first_at_gap" + USER_STOP_FIRST = "user_stop_first" + + +class AmbiguityPolicy(str, Enum): + FLAG_AND_CONSERVATIVE = "flag_and_conservative" + REJECT = "reject" + LOWER_TIMEFRAME_REQUIRED = "lower_timeframe_required" + + +@dataclass(frozen=True) +class ExecutionContract: + engine_id: str + signal_phase: SignalPhase + entry_fill_phase: FillPhase + market_fill_policy: MarketFillPolicy + stop_gap_policy: StopGapPolicy = StopGapPolicy.OPEN_WORSE_THAN_TRIGGER + take_profit_gap_policy: TakeProfitGapPolicy = TakeProfitGapPolicy.LIMIT_PRICE_CONSERVATIVE + same_bar_policy: IntrabarSameBarPolicy = IntrabarSameBarPolicy.CONSERVATIVE + trailing_update_phase: TrailingUpdatePhase = TrailingUpdatePhase.NONE + funding_phase: FundingPhase = FundingPhase.POSITION_AT_EVENT + liquidation_priority: LiquidationPriority = LiquidationPriority.LIQUIDATION_FIRST_AT_GAP + close_on_last_bar: bool = True + ambiguity_policy: AmbiguityPolicy = AmbiguityPolicy.FLAG_AND_CONSERVATIVE + strict_data: bool = True + + def __post_init__(self) -> None: + if not self.engine_id: + raise ValueError("engine_id is required") + + @classmethod + def close_target(cls) -> "ExecutionContract": + return cls( + engine_id="close_target_v2", + signal_phase=SignalPhase.BAR_CLOSE, + entry_fill_phase=FillPhase.SAME_CLOSE, + market_fill_policy=MarketFillPolicy.CLOSE, + trailing_update_phase=TrailingUpdatePhase.NONE, + close_on_last_bar=False, + ) + + @classmethod + def next_open(cls) -> "ExecutionContract": + return cls( + engine_id="next_open_v1", + signal_phase=SignalPhase.BAR_CLOSE, + entry_fill_phase=FillPhase.NEXT_OPEN, + market_fill_policy=MarketFillPolicy.NEXT_OPEN, + trailing_update_phase=TrailingUpdatePhase.NONE, + ) + + @classmethod + def intrabar_bracket( + cls, + *, + same_bar_policy: IntrabarSameBarPolicy = IntrabarSameBarPolicy.CONSERVATIVE, + trailing_update_phase: TrailingUpdatePhase = TrailingUpdatePhase.NEXT_BAR, + take_profit_gap_policy: TakeProfitGapPolicy = TakeProfitGapPolicy.LIMIT_PRICE_CONSERVATIVE, + close_on_last_bar: bool = True, + ) -> "ExecutionContract": + return cls( + engine_id="intrabar_bracket_v1", + signal_phase=SignalPhase.BAR_CLOSE, + entry_fill_phase=FillPhase.NEXT_OPEN, + market_fill_policy=MarketFillPolicy.NEXT_OPEN, + same_bar_policy=same_bar_policy, + trailing_update_phase=trailing_update_phase, + take_profit_gap_policy=take_profit_gap_policy, + close_on_last_bar=close_on_last_bar, + ) + + @classmethod + def fill_replay(cls) -> "ExecutionContract": + return cls( + engine_id="fill_replay_v1", + signal_phase=SignalPhase.BAR_CLOSE, + entry_fill_phase=FillPhase.NEXT_OPEN, + market_fill_policy=MarketFillPolicy.NEXT_OPEN, + ) + + @classmethod + def event_lifecycle(cls) -> "ExecutionContract": + return cls( + engine_id="event_lifecycle_v2", + signal_phase=SignalPhase.BAR_CLOSE, + entry_fill_phase=FillPhase.NEXT_OPEN, + market_fill_policy=MarketFillPolicy.NEXT_OPEN, + ) + + def to_metadata(self) -> Dict: + payload = asdict(self) + for key, value in list(payload.items()): + if isinstance(value, Enum): + payload[key] = value.value + return payload + + @classmethod + def from_metadata(cls, metadata: Mapping | "ExecutionContract") -> "ExecutionContract": + if isinstance(metadata, ExecutionContract): + return metadata + payload = dict(metadata or {}) + if not payload: + raise ValueError("execution contract metadata is empty") + return cls( + engine_id=str(payload["engine_id"]), + signal_phase=SignalPhase(payload["signal_phase"]), + entry_fill_phase=FillPhase(payload["entry_fill_phase"]), + market_fill_policy=MarketFillPolicy(payload["market_fill_policy"]), + stop_gap_policy=StopGapPolicy(payload.get("stop_gap_policy", StopGapPolicy.OPEN_WORSE_THAN_TRIGGER.value)), + take_profit_gap_policy=TakeProfitGapPolicy(payload.get("take_profit_gap_policy", TakeProfitGapPolicy.LIMIT_PRICE_CONSERVATIVE.value)), + same_bar_policy=IntrabarSameBarPolicy(payload.get("same_bar_policy", IntrabarSameBarPolicy.CONSERVATIVE.value)), + trailing_update_phase=TrailingUpdatePhase(payload.get("trailing_update_phase", TrailingUpdatePhase.NONE.value)), + funding_phase=FundingPhase(payload.get("funding_phase", FundingPhase.POSITION_AT_EVENT.value)), + liquidation_priority=LiquidationPriority(payload.get("liquidation_priority", LiquidationPriority.LIQUIDATION_FIRST_AT_GAP.value)), + close_on_last_bar=bool(payload.get("close_on_last_bar", True)), + ambiguity_policy=AmbiguityPolicy(payload.get("ambiguity_policy", AmbiguityPolicy.FLAG_AND_CONSERVATIVE.value)), + strict_data=bool(payload.get("strict_data", True)), + ) + + +EXECUTION_CONTRACT_REGISTRY: Dict[str, ExecutionContract] = { + "close_target_v2": ExecutionContract.close_target(), + "next_open_v1": ExecutionContract.next_open(), + "intrabar_bracket_v1": ExecutionContract.intrabar_bracket(), + "fill_replay_v1": ExecutionContract.fill_replay(), + "event_lifecycle_v2": ExecutionContract.event_lifecycle(), +} + + +def get_execution_contract(engine_id: str) -> ExecutionContract: + key = str(engine_id).lower().strip() + if key not in EXECUTION_CONTRACT_REGISTRY: + raise KeyError(f"unknown execution contract {engine_id!r}") + return EXECUTION_CONTRACT_REGISTRY[key] diff --git a/src/quantbt/core/execution_depth.py b/src/quantbt/core/execution_depth.py new file mode 100644 index 0000000..aca54be --- /dev/null +++ b/src/quantbt/core/execution_depth.py @@ -0,0 +1,613 @@ +""" +Execution-depth preflight for Nautilus-style package validation. + +This module is intentionally dependency-free from NautilusTrader. It gives +QuantBT a deterministic, fast, auditable preflight layer for package orders +before a heavier event backend is used. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Dict, Iterable, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .orders import OrderIntent +from .schema import OrderSide, OrderType + + +SUPPORTED_DEPTH_MODELS = ("ohlcv_volume_cap", "synthetic_book", "l2_replay") + + +@dataclass(frozen=True) +class NautilusExecutionDepthConfig: + """ + Optional execution-depth policy for package-order validation. + + Defaults are intentionally conservative and mostly observational: existing + endpoints are unchanged unless callers explicitly run this preflight layer. + """ + + all_or_none_packages: bool = False + all_or_none_package_types: Tuple[str, ...] = ("basket_package", "arbitrage_package") + allow_partial_fills: bool = False + max_participation_rate: Optional[float] = None + queue_ahead_qty: float = 0.0 + latency_bars: int = 0 + depth_model: str = "ohlcv_volume_cap" + synthetic_spread_bps: float = 2.0 + synthetic_level_spacing_bps: Optional[float] = None + synthetic_levels: int = 5 + synthetic_base_depth_qty: Optional[float] = None + synthetic_base_depth_notional: Optional[float] = None + synthetic_depth_slope: float = 0.0 + activate_oco_after_entry_fill: bool = True + cancel_oco_sibling_on_first_exit_fill: bool = True + cap_reduce_only_to_position: bool = True + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.depth_model not in SUPPORTED_DEPTH_MODELS: + raise ValueError(f"depth_model must be one of {SUPPORTED_DEPTH_MODELS}") + if self.max_participation_rate is not None and not 0.0 <= self.max_participation_rate <= 1.0: + raise ValueError("max_participation_rate must be in [0, 1]") + if self.queue_ahead_qty < 0.0: + raise ValueError("queue_ahead_qty must be >= 0") + if self.latency_bars < 0: + raise ValueError("latency_bars must be >= 0") + if self.synthetic_spread_bps < 0.0: + raise ValueError("synthetic_spread_bps must be >= 0") + if self.synthetic_level_spacing_bps is not None and self.synthetic_level_spacing_bps < 0.0: + raise ValueError("synthetic_level_spacing_bps must be >= 0") + if self.synthetic_levels <= 0: + raise ValueError("synthetic_levels must be > 0") + if self.synthetic_base_depth_qty is not None and self.synthetic_base_depth_qty <= 0.0: + raise ValueError("synthetic_base_depth_qty must be > 0") + if self.synthetic_base_depth_notional is not None and self.synthetic_base_depth_notional <= 0.0: + raise ValueError("synthetic_base_depth_notional must be > 0") + if self.synthetic_depth_slope < -1.0: + raise ValueError("synthetic_depth_slope must be >= -1") + + +@dataclass(frozen=True) +class PackageDepthPreflightResult: + orders: Tuple[OrderIntent, ...] + order_report: pd.DataFrame + package_report: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + +def l2_replay_available(provider: object = None) -> bool: + """ + Return whether a real L2 replay provider is configured. + + QuantBT intentionally does not synthesize Level-3 venue claims. A provider + must expose venue snapshots, incremental book updates, and trade prints. + """ + required = ("snapshots", "updates", "trades") + return provider is not None and all(hasattr(provider, name) for name in required) + + +def simulate_nautilus_order_package_depth( + orders: Sequence[OrderIntent], + data: Dict[str, pd.DataFrame], + config: Optional[NautilusExecutionDepthConfig] = None, +) -> PackageDepthPreflightResult: + """ + Simulate lightweight package execution constraints on OHLCV bars. + + This is not a full matching engine. It is a deterministic package preflight + for domain checks that Nautilus package routes need before deeper adapter + integration: touch eligibility, latency, queue/volume caps, partial fills, + reduce-only caps, OCO sibling cancellation, and all-or-none package reject. + """ + cfg = config or NautilusExecutionDepthConfig() + if cfg.depth_model == "l2_replay": + raise NotImplementedError( + "depth_model='l2_replay' requires real venue L2 snapshots, incremental updates, " + "trade prints, and a provider adapter. Use depth_model='synthetic_book' for deterministic stress tests." + ) + if not orders: + return PackageDepthPreflightResult( + orders=tuple(), + order_report=_empty_order_report(), + package_report=_empty_package_report(), + metadata={"accepted_orders": 0, "input_orders": 0}, + ) + + frames = _normalize_data(data) + states = _State() + accepted: list[OrderIntent] = [] + rows: list[Dict] = [] + package_rows: list[Dict] = [] + + planned = [_PlannedOrder(order=order, effective_timestamp=_effective_timestamp(order, frames, cfg)) for order in orders] + planned.sort(key=lambda item: (item.effective_timestamp.value if item.effective_timestamp is not None else np.iinfo(np.int64).max)) + + groups: Dict[Tuple[pd.Timestamp, str], list[_PlannedOrder]] = {} + singles: list[_PlannedOrder] = [] + for item in planned: + package_id = _package_id(item.order) + package_type = _package_type(item.order) + if cfg.all_or_none_packages and package_type in set(cfg.all_or_none_package_types) and package_id: + key = (item.effective_timestamp or _utc_timestamp(item.order.timestamp), package_id) + groups.setdefault(key, []).append(item) + else: + singles.append(item) + + timeline = sorted( + [(key[0], "group", key, values) for key, values in groups.items()] + + [(item.effective_timestamp or _utc_timestamp(item.order.timestamp), "single", None, [item]) for item in singles], + key=lambda value: value[0].value, + ) + + for _, kind, group_key, items in timeline: + if kind == "group": + trial = states.copy() + trial_rows: list[Dict] = [] + trial_orders: list[OrderIntent] = [] + for item in items: + evaluated = _evaluate_order(item, frames, cfg, trial) + trial_rows.append(evaluated.row) + if evaluated.accepted_order is not None: + trial_orders.append(evaluated.accepted_order) + group_ok = bool(trial_orders) and all(row["status"] == "filled" for row in trial_rows) + package_id = group_key[1] if group_key else "" + if group_ok: + states = trial + accepted.extend(trial_orders) + rows.extend(trial_rows) + package_rows.append(_package_row(package_id, items, "accepted", "all_or_none_filled")) + else: + for row in trial_rows: + rejected = dict(row) + rejected["status"] = "rejected" + rejected["reject_reason"] = "all_or_none_package_rejected" + rejected["filled_qty"] = 0.0 + rows.append(rejected) + package_rows.append(_package_row(package_id, items, "rejected", "all_or_none_package_rejected")) + continue + + item = items[0] + evaluated = _evaluate_order(item, frames, cfg, states) + rows.append(evaluated.row) + if evaluated.accepted_order is not None: + accepted.append(evaluated.accepted_order) + + order_report = pd.DataFrame(rows, columns=_ORDER_REPORT_COLUMNS) + package_report = pd.DataFrame(package_rows, columns=_PACKAGE_REPORT_COLUMNS) + metadata = { + "input_orders": int(len(orders)), + "accepted_orders": int(len(accepted)), + "rejected_orders": int((order_report["status"] == "rejected").sum()) if not order_report.empty else 0, + "partial_orders": int((order_report["status"] == "partial").sum()) if not order_report.empty else 0, + "canceled_orders": int((order_report["status"] == "canceled").sum()) if not order_report.empty else 0, + "latency_bars": int(cfg.latency_bars), + "allow_partial_fills": bool(cfg.allow_partial_fills), + "all_or_none_packages": bool(cfg.all_or_none_packages), + "depth_model": str(cfg.depth_model), + "supported_depth_models": SUPPORTED_DEPTH_MODELS, + **cfg.metadata, + } + return PackageDepthPreflightResult( + orders=tuple(accepted), + order_report=order_report, + package_report=package_report, + metadata=metadata, + ) + + +@dataclass +class _State: + position: Dict[str, float] = field(default_factory=dict) + filled_tags: set[str] = field(default_factory=set) + canceled_oco_groups: set[str] = field(default_factory=set) + filled_oco_groups: set[str] = field(default_factory=set) + + def copy(self) -> "_State": + return _State( + position=dict(self.position), + filled_tags=set(self.filled_tags), + canceled_oco_groups=set(self.canceled_oco_groups), + filled_oco_groups=set(self.filled_oco_groups), + ) + + +@dataclass(frozen=True) +class _PlannedOrder: + order: OrderIntent + effective_timestamp: Optional[pd.Timestamp] + + +@dataclass(frozen=True) +class _EvaluatedOrder: + row: Dict + accepted_order: Optional[OrderIntent] + + +@dataclass(frozen=True) +class _DepthFill: + fillable: bool + fill_price: float + reason: str + available_qty: float + levels_consumed: int = 0 + participation_cap_qty: float = np.nan + + +_ORDER_REPORT_COLUMNS = [ + "timestamp", + "effective_timestamp", + "symbol", + "side", + "order_type", + "qty", + "filled_qty", + "fill_price", + "status", + "reject_reason", + "package_id", + "package_type", + "leg_role", + "oco_group_id", + "latency_bars", + "available_qty", + "depth_model", + "levels_consumed", + "spread_bps", + "queue_ahead_qty", + "participation_cap_qty", + "requested_notional", + "filled_notional", +] + +_PACKAGE_REPORT_COLUMNS = ["package_id", "package_type", "timestamp", "orders", "status", "reason"] + + +def _evaluate_order( + item: _PlannedOrder, + frames: Dict[str, pd.DataFrame], + cfg: NautilusExecutionDepthConfig, + state: _State, +) -> _EvaluatedOrder: + order = item.order + ts = item.effective_timestamp + base = _base_row(order, ts, cfg) + if ts is None: + return _reject(base, "latency_out_of_range") + if order.symbol not in frames: + return _reject(base, "missing_symbol_data") + frame = frames[order.symbol] + if ts not in frame.index: + return _reject(base, "timestamp_not_in_data") + if _is_oco_exit(order) and cfg.activate_oco_after_entry_fill: + parent_tag = order.metadata.get("parent_tag") + if parent_tag and parent_tag not in state.filled_tags: + return _reject(base, "parent_entry_not_filled") + oco_group = order.metadata.get("oco_group_id") + if oco_group and oco_group in state.canceled_oco_groups: + row = {**base, "status": "canceled", "reject_reason": "oco_sibling_already_filled"} + return _EvaluatedOrder(row=row, accepted_order=None) + + bar = frame.loc[ts] + depth_fill = _evaluate_depth_fill(order, bar, cfg) + if not depth_fill.fillable: + return _reject(base, depth_fill.reason) + + available = depth_fill.available_qty + requested = float(order.qty) + reduce_only_capped = False + if order.reduce_only and cfg.cap_reduce_only_to_position: + current = float(state.position.get(order.symbol, 0.0)) + if current == 0.0 or np.sign(current) == order.side.sign: + return _reject({**base, "available_qty": available}, "reduce_only_no_opposite_position") + available = min(available, abs(current)) + reduce_only_capped = available < requested + + if available <= 0.0: + return _reject({**base, "available_qty": available}, "no_queue_capacity") + filled_qty = min(requested, available) + if filled_qty < requested and not cfg.allow_partial_fills and not reduce_only_capped: + return _reject({**base, "available_qty": available}, "insufficient_queue_capacity") + + status = "partial" if filled_qty < requested else "filled" + accepted_order = order + if filled_qty != requested or ts != _utc_timestamp(order.timestamp): + metadata = { + **order.metadata, + "depth_original_qty": requested, + "depth_effective_timestamp": ts, + "depth_status": status, + "depth_model": cfg.depth_model, + } + accepted_order = replace(order, timestamp=ts, qty=float(filled_qty), metadata=metadata) + + _commit_fill(state, accepted_order) + if accepted_order.tag: + state.filled_tags.add(accepted_order.tag) + if oco_group and _is_oco_exit(order) and cfg.cancel_oco_sibling_on_first_exit_fill: + state.filled_oco_groups.add(str(oco_group)) + state.canceled_oco_groups.add(str(oco_group)) + + row = { + **base, + "filled_qty": float(filled_qty), + "fill_price": float(depth_fill.fill_price), + "status": status, + "reject_reason": "", + "available_qty": float(available), + "levels_consumed": int(depth_fill.levels_consumed), + "participation_cap_qty": float(depth_fill.participation_cap_qty), + "requested_notional": float(requested * depth_fill.fill_price), + "filled_notional": float(filled_qty * depth_fill.fill_price), + } + return _EvaluatedOrder(row=row, accepted_order=accepted_order) + + +def _commit_fill(state: _State, order: OrderIntent) -> None: + current = float(state.position.get(order.symbol, 0.0)) + delta = float(order.qty) * order.side.sign + if order.reduce_only and current != 0.0 and np.sign(current) != order.side.sign: + delta = np.sign(delta) * min(abs(delta), abs(current)) + state.position[order.symbol] = current + delta + + +def _fillability(order: OrderIntent, bar: pd.Series) -> tuple[bool, float, str]: + high = float(bar["high"]) + low = float(bar["low"]) + close = float(bar["close"]) + if order.order_type is OrderType.MARKET: + return True, close, "" + if order.order_type is OrderType.LIMIT: + price = float(order.price) + touched = low <= price if order.side is OrderSide.BUY else high >= price + return touched, price, "" if touched else "limit_not_touched" + if order.order_type is OrderType.STOP_MARKET: + trigger = float(order.trigger_price) + touched = high >= trigger if order.side is OrderSide.BUY else low <= trigger + return touched, trigger, "" if touched else "stop_not_triggered" + if order.order_type is OrderType.STOP_LIMIT: + trigger = float(order.trigger_price) + price = float(order.price) + triggered = high >= trigger if order.side is OrderSide.BUY else low <= trigger + touched = low <= price if order.side is OrderSide.BUY else high >= price + ok = triggered and touched + return ok, price, "" if ok else "stop_limit_not_triggered_or_touched" + return False, np.nan, "unsupported_order_type" + + +def _evaluate_depth_fill(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> _DepthFill: + if cfg.depth_model == "synthetic_book": + return _synthetic_book_fill(order, bar, cfg) + + fillable, fill_price, reason = _fillability(order, bar) + if not fillable: + return _DepthFill(False, fill_price, reason, 0.0) + available = _available_qty(order, bar, cfg) + participation_cap = _participation_cap_qty(bar, cfg) + return _DepthFill( + fillable=True, + fill_price=float(fill_price), + reason="", + available_qty=float(available), + levels_consumed=1, + participation_cap_qty=participation_cap, + ) + + +def _synthetic_book_fill(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> _DepthFill: + eligible, executable_price, reason = _fillability(order, bar) + if not eligible: + return _DepthFill(False, executable_price, reason, 0.0) + + close = float(bar["close"]) + if not np.isfinite(close) or close <= 0.0: + return _DepthFill(False, np.nan, "invalid_close_for_synthetic_book", 0.0) + + levels = _synthetic_book_levels(order, close, cfg) + if order.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): + limit_price = float(order.price) + if order.side is OrderSide.BUY: + levels = tuple((price, qty) for price, qty in levels if price <= limit_price) + else: + levels = tuple((price, qty) for price, qty in levels if price >= limit_price) + + participation_cap = _participation_cap_qty(bar, cfg) + requested = float(order.qty) + target_qty = min(requested, participation_cap) if np.isfinite(participation_cap) else requested + if target_qty <= 0.0: + return _DepthFill(True, executable_price, "", 0.0, participation_cap_qty=participation_cap) + + remaining_queue = float(cfg.queue_ahead_qty) + remaining = target_qty + filled = 0.0 + notional = 0.0 + consumed = 0 + for price, level_qty in levels: + qty_after_queue = float(level_qty) + if remaining_queue > 0.0: + queue_take = min(qty_after_queue, remaining_queue) + qty_after_queue -= queue_take + remaining_queue -= queue_take + if qty_after_queue <= 0.0: + consumed += 1 + continue + take = min(remaining, qty_after_queue) + if take <= 0.0: + break + filled += take + notional += take * float(price) + remaining -= take + consumed += 1 + if remaining <= 1e-15: + break + + if filled <= 0.0: + return _DepthFill(True, executable_price, "", 0.0, levels_consumed=consumed, participation_cap_qty=participation_cap) + return _DepthFill( + fillable=True, + fill_price=float(notional / filled), + reason="", + available_qty=float(filled), + levels_consumed=int(consumed), + participation_cap_qty=participation_cap, + ) + + +def _synthetic_book_levels( + order: OrderIntent, + reference_price: float, + cfg: NautilusExecutionDepthConfig, +) -> Tuple[Tuple[float, float], ...]: + half_spread = reference_price * float(cfg.synthetic_spread_bps) / 20_000.0 + spacing_bps = cfg.synthetic_level_spacing_bps + if spacing_bps is None: + spacing_bps = max(float(cfg.synthetic_spread_bps), 1.0) + spacing = reference_price * float(spacing_bps) / 10_000.0 + + if cfg.synthetic_base_depth_qty is not None: + base_qty = float(cfg.synthetic_base_depth_qty) + elif cfg.synthetic_base_depth_notional is not None: + base_qty = float(cfg.synthetic_base_depth_notional) / reference_price + else: + base_qty = float(order.qty) + + out: list[Tuple[float, float]] = [] + for level in range(int(cfg.synthetic_levels)): + if order.side is OrderSide.BUY: + price = reference_price + half_spread + level * spacing + else: + price = reference_price - half_spread - level * spacing + qty_multiplier = max(0.0, 1.0 + float(cfg.synthetic_depth_slope) * level) + out.append((float(price), float(base_qty * qty_multiplier))) + return tuple(out) + + +def _available_qty(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> float: + if cfg.max_participation_rate is None: + return float(order.qty) + volume = float(bar.get("volume", 0.0)) + capacity = max(0.0, volume * float(cfg.max_participation_rate) - float(cfg.queue_ahead_qty)) + return min(float(order.qty), capacity) + + +def _participation_cap_qty(bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> float: + if cfg.max_participation_rate is None: + return np.nan + volume = float(bar.get("volume", 0.0)) + return max(0.0, volume * float(cfg.max_participation_rate)) + + +def _effective_timestamp( + order: OrderIntent, + frames: Dict[str, pd.DataFrame], + cfg: NautilusExecutionDepthConfig, +) -> Optional[pd.Timestamp]: + ts = _utc_timestamp(order.timestamp) + if cfg.latency_bars == 0: + return ts + frame = frames.get(order.symbol) + if frame is None or frame.empty: + return None + index = frame.index + pos = index.searchsorted(ts) + if pos >= len(index) or index[pos] != ts: + return None + target = pos + int(cfg.latency_bars) + if target >= len(index): + return None + return pd.Timestamp(index[target]) + + +def _normalize_data(data: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: + out = {} + for symbol, frame in data.items(): + df = frame.copy() + if not isinstance(df.index, pd.DatetimeIndex): + df.index = pd.to_datetime(df.index) + df.index = df.index.tz_localize("UTC") if df.index.tz is None else df.index.tz_convert("UTC") + rename = {col: str(col).lower() for col in df.columns} + df = df.rename(columns=rename) + if "close" not in df: + raise ValueError(f"data for {symbol!r} must include close") + for col in ("open", "high", "low", "volume"): + if col not in df: + df[col] = df["close"] if col != "volume" else 0.0 + out[symbol] = df[["open", "high", "low", "close", "volume"]].sort_index() + return out + + +def _base_row(order: OrderIntent, effective_timestamp: Optional[pd.Timestamp], cfg: NautilusExecutionDepthConfig) -> Dict: + return { + "timestamp": _utc_timestamp(order.timestamp), + "effective_timestamp": effective_timestamp, + "symbol": order.symbol, + "side": order.side.value if isinstance(order.side, OrderSide) else str(order.side), + "order_type": order.order_type.value if isinstance(order.order_type, OrderType) else str(order.order_type), + "qty": float(order.qty), + "filled_qty": 0.0, + "fill_price": np.nan, + "status": "pending", + "reject_reason": "", + "package_id": _package_id(order), + "package_type": _package_type(order), + "leg_role": order.metadata.get("leg_role"), + "oco_group_id": order.metadata.get("oco_group_id"), + "latency_bars": int(cfg.latency_bars), + "available_qty": np.nan, + "depth_model": str(cfg.depth_model), + "levels_consumed": 0, + "spread_bps": float(cfg.synthetic_spread_bps) if cfg.depth_model == "synthetic_book" else np.nan, + "queue_ahead_qty": float(cfg.queue_ahead_qty), + "participation_cap_qty": np.nan, + "requested_notional": np.nan, + "filled_notional": 0.0, + } + + +def _reject(base: Dict, reason: str) -> _EvaluatedOrder: + return _EvaluatedOrder(row={**base, "status": "rejected", "reject_reason": reason}, accepted_order=None) + + +def _package_row(package_id: str, items: Sequence[_PlannedOrder], status: str, reason: str) -> Dict: + first = items[0].order if items else None + ts = _utc_timestamp(first.timestamp) if first is not None else pd.NaT + return { + "package_id": package_id, + "package_type": _package_type(first) if first is not None else "", + "timestamp": ts, + "orders": int(len(items)), + "status": status, + "reason": reason, + } + + +def _is_oco_exit(order: OrderIntent) -> bool: + return order.metadata.get("leg_role") in {"take_profit", "stop_loss"} and bool(order.metadata.get("oco_group_id")) + + +def _package_id(order: Optional[OrderIntent]) -> str: + if order is None: + return "" + return str(order.metadata.get("package_id") or order.metadata.get("basket_id") or order.metadata.get("arb_id") or "") + + +def _package_type(order: Optional[OrderIntent]) -> str: + if order is None: + return "" + return str(order.metadata.get("package_type") or order.metadata.get("structured_type") or "") + + +def _utc_timestamp(value) -> pd.Timestamp: + ts = pd.Timestamp(value) + return ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC") + + +def _empty_order_report() -> pd.DataFrame: + return pd.DataFrame(columns=_ORDER_REPORT_COLUMNS) + + +def _empty_package_report() -> pd.DataFrame: + return pd.DataFrame(columns=_PACKAGE_REPORT_COLUMNS) diff --git a/src/quantbt/core/intrabar_kernel.py b/src/quantbt/core/intrabar_kernel.py new file mode 100644 index 0000000..300fb7c --- /dev/null +++ b/src/quantbt/core/intrabar_kernel.py @@ -0,0 +1,1906 @@ +""" +Fast Numba kernels for Phase 31 intrabar execution contracts. + +The public Python reference oracle remains the readability source of truth. +This module mirrors that state machine with primitive arrays only: no Python +objects are created inside hot loops, and sparse fills are generated only by an +optional deterministic second pass. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Sequence + +import numpy as np +import pandas as pd +from numba import njit + +from .execution_contract import ExecutionContract, IntrabarSameBarPolicy, TakeProfitGapPolicy +from .intrabar_reference import IntrabarFill, IntrabarFillReason, IntrabarIntentTape, IntrabarLevelMode, IntrabarSizingMode, _validate_intrabar_contract_supported +from .intrabar_session import EntryPositionPolicy, IntrabarSessionTape, ProtectiveExitReentryPolicy, SessionCounterBasis, SessionExecutionPolicy +from .market_tape import PreparedMarketTape +from .schema import AccountConfig + + +LEVEL_ABSOLUTE_PRICE = 1 +LEVEL_PRICE_DISTANCE = 2 +LEVEL_PERCENT_DISTANCE = 3 + +SAME_BAR_CONSERVATIVE = 1 +SAME_BAR_STOP_FIRST = 2 +SAME_BAR_TP_FIRST = 3 +SAME_BAR_OHLC_PATH = 4 +SAME_BAR_OLHC_PATH = 5 +SAME_BAR_REJECT_AMBIGUOUS = 6 + +TP_LIMIT_CONSERVATIVE = 1 +TP_OPEN_PRICE_IMPROVEMENT = 2 + +FILL_ENTRY = 1 +FILL_TECHNICAL_EXIT = 2 +FILL_REVERSAL_EXIT = 3 +FILL_REVERSAL_ENTRY = 4 +FILL_STOP_LOSS = 5 +FILL_TAKE_PROFIT = 6 +FILL_LIQUIDATION = 7 +FILL_FINAL_CLOSE = 8 +FILL_SESSION_FORCED_EXIT = 9 + +FLAG_ENTRY_FILLED = 1 << 0 +FLAG_EXIT_FILLED = 1 << 1 +FLAG_STOP_FILLED = 1 << 2 +FLAG_TP_FILLED = 1 << 3 +FLAG_TECH_EXIT = 1 << 4 +FLAG_REVERSAL = 1 << 5 +FLAG_AMBIGUOUS = 1 << 6 +FLAG_FUNDING = 1 << 7 +FLAG_LIQUIDATION = 1 << 8 +FLAG_REJECTED = 1 << 9 +FLAG_ENTRY_SUPPRESSED = 1 << 10 +FLAG_SESSION_RESET = 1 << 11 +FLAG_SESSION_FORCED_EXIT = 1 << 12 +FLAG_ENTRY_WINDOW_BLOCKED = 1 << 13 +FLAG_ENTRY_QUOTA_BLOCKED = 1 << 14 +FLAG_FLAT_ONLY_BLOCKED = 1 << 15 +FLAG_STALE_SESSION_SIGNAL = 1 << 16 +FLAG_PROTECTIVE_REENTRY_BLOCKED = 1 << 17 + +SIZING_UNITS = 1 +SIZING_FIXED_NOTIONAL = 2 +SIZING_PCT_EQUITY = 3 +SIZING_RISK_PER_TRADE = 4 + +BAR_TS_CLOSE = 1 +BAR_TS_OPEN = 2 + +SESSION_ENTRY_CURRENT = 1 +SESSION_ENTRY_FLAT_ONLY = 2 +SESSION_ENTRY_REVERSE = 3 + +SESSION_COUNTER_FILLED = 1 +SESSION_COUNTER_ACCEPTED = 2 + +SESSION_REENTRY_ALLOW = 1 +SESSION_REENTRY_SUPPRESS_SIGNAL_BAR = 2 + + +@dataclass(frozen=True) +class NativeIntrabarKernelResult: + equity: pd.Series + position: pd.Series + average_entry: pd.Series + active_stop: pd.Series + active_take_profit: pd.Series + fees: pd.Series + funding: pd.Series + event_flags: pd.Series + initial_margin: pd.Series + maintenance_margin: pd.Series + fills: tuple[IntrabarFill, ...] = () + fills_report: pd.DataFrame = field(default_factory=pd.DataFrame) + ambiguity_count: int = 0 + rejected_count: int = 0 + fill_count: int = 0 + liquidated: bool = False + liquidation_bar: int = -1 + report_level: str = "standard" + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class FillReplayTape: + bar_index: np.ndarray + sequence: np.ndarray + side: np.ndarray + qty: np.ndarray + price: np.ndarray + fee: np.ndarray + reason: np.ndarray + + @classmethod + def from_frame(cls, frame: pd.DataFrame, *, fee_rate: float = 0.0, contract_size: float = 1.0) -> "FillReplayTape": + required = {"bar_index", "side", "qty", "price"} + missing = sorted(required - set(frame.columns)) + if missing: + raise ValueError(f"fill replay frame is missing columns {missing}") + sequence = frame["sequence"] if "sequence" in frame else pd.Series(np.arange(len(frame)), index=frame.index) + price = pd.to_numeric(frame["price"], errors="raise").to_numpy(dtype=np.float64) + qty = pd.to_numeric(frame["qty"], errors="raise").to_numpy(dtype=np.float64) + if "fee" in frame: + fee = pd.to_numeric(frame["fee"], errors="raise").to_numpy(dtype=np.float64) + else: + fee = np.abs(qty) * price * float(contract_size) * float(fee_rate) + reason = _reason_series_to_codes(frame["reason"]) if "reason" in frame else np.zeros(len(frame), dtype=np.int16) + return cls( + bar_index=np.ascontiguousarray(pd.to_numeric(frame["bar_index"], errors="raise").to_numpy(dtype=np.int64)), + sequence=np.ascontiguousarray(pd.to_numeric(sequence, errors="raise").to_numpy(dtype=np.int64)), + side=np.ascontiguousarray(np.sign(pd.to_numeric(frame["side"], errors="raise").to_numpy(dtype=np.float64)).astype(np.int8)), + qty=np.ascontiguousarray(qty, dtype=np.float64), + price=np.ascontiguousarray(price, dtype=np.float64), + fee=np.ascontiguousarray(fee, dtype=np.float64), + reason=np.ascontiguousarray(reason, dtype=np.int16), + ) + + +@dataclass(frozen=True) +class NativeFillReplayResult: + equity: pd.Series + position: pd.Series + fees: pd.Series + event_flags: pd.Series + fill_count: int + metadata: Dict = field(default_factory=dict) + + +def run_intrabar_kernel( + *, + tape: PreparedMarketTape, + intent: IntrabarIntentTape, + account: AccountConfig, + contract: Optional[ExecutionContract] = None, + fee_rate: float = 0.0, + slippage_rate: float = 0.0, + contract_size: float = 1.0, + sizing_mode: IntrabarSizingMode | str = IntrabarSizingMode.UNITS, + fixed_notional: float = 0.0, + equity_fraction: float = 0.0, + risk_fraction: float = 0.0, + qty_step: float = 0.0, + min_qty: float = 0.0, + min_notional: float = 0.0, + tick_size: float = 0.0, + report_level: str = "standard", +) -> NativeIntrabarKernelResult: + """ + Run the fast single-symbol `intrabar_bracket_v1` Numba kernel. + + `report_level="audit"` triggers the second pass and materializes sparse + fills. `minimal` and `standard` keep fill accounting as counters/flags only. + """ + if tape.n_symbols != 1: + raise NotImplementedError("intrabar fast kernel v1 supports exactly one symbol") + if len(intent.entry_side) != tape.n_bars: + raise ValueError("intent length must match market tape length") + if fee_rate < 0.0 or slippage_rate < 0.0: + raise ValueError("fee_rate and slippage_rate must be >= 0") + level = _normalize_report_level(report_level) + contract = contract or ExecutionContract.intrabar_bracket() + if contract.engine_id != "intrabar_bracket_v1": + raise ValueError("run_intrabar_kernel requires intrabar_bracket_v1 contract") + _validate_intrabar_contract_supported(contract) + if contract.same_bar_policy is IntrabarSameBarPolicy.REJECT_AMBIGUOUS: + raise NotImplementedError("fast intrabar kernel v1 does not support REJECT_AMBIGUOUS; use the reference oracle for debug rejection") + sizing_mode_value = IntrabarSizingMode(sizing_mode) + + arrays = _run_intrabar_pass( + record_fills=False, + fill_capacity=1, + tape=tape, + intent=intent, + account=account, + contract=contract, + fee_rate=fee_rate, + slippage_rate=slippage_rate, + contract_size=contract_size, + sizing_mode=sizing_mode_value, + fixed_notional=fixed_notional, + equity_fraction=equity_fraction, + risk_fraction=risk_fraction, + qty_step=qty_step, + min_qty=min_qty, + min_notional=min_notional, + tick_size=tick_size, + ) + ( + equity, + position, + avg_entry, + active_stop, + active_tp, + fees, + funding, + flags, + initial_margin, + maintenance_margin, + fill_count, + ambiguity_count, + rejected_count, + liquidated, + liquidation_bar, + _fill_bar, + _fill_seq, + _fill_side, + _fill_qty, + _fill_price, + _fill_fee, + _fill_reason, + ) = arrays + + fills: tuple[IntrabarFill, ...] = () + fills_report = pd.DataFrame() + if level == "audit": + audit = _run_intrabar_pass( + record_fills=True, + fill_capacity=int(fill_count), + tape=tape, + intent=intent, + account=account, + contract=contract, + fee_rate=fee_rate, + slippage_rate=slippage_rate, + contract_size=contract_size, + sizing_mode=sizing_mode_value, + fixed_notional=fixed_notional, + equity_fraction=equity_fraction, + risk_fraction=risk_fraction, + qty_step=qty_step, + min_qty=min_qty, + min_notional=min_notional, + tick_size=tick_size, + ) + _assert_intrabar_audit_parity(arrays, audit) + fills = _materialize_intrabar_fills( + timestamps_ns=tape.timestamps_ns, + fill_bar=audit[15], + fill_seq=audit[16], + fill_side=audit[17], + fill_qty=audit[18], + fill_price=audit[19], + fill_fee=audit[20], + fill_reason=audit[21], + fill_count=int(fill_count), + ) + fills_report = _fills_to_report(fills) + + idx = pd.DatetimeIndex(pd.to_datetime(tape.timestamps_ns, utc=True)) + symbol = tape.symbols[0] + metadata = { + "engine": "intrabar_bracket_v1", + "engine_id": "intrabar_bracket_v1", + "backend": "native_intrabar", + "backend_alias": "native_intrabar", + "kernel_version": "intrabar_numba_v1", + "execution_contract": contract.to_metadata(), + "data_signature": tape.signature, + "validation_certificate": tape.validation_certificate.__dict__.copy(), + "report_level": level, + "two_pass_audit": level == "audit", + "fill_count": int(fill_count), + "ambiguity_count": int(ambiguity_count), + "rejected_count": int(rejected_count), + "liquidated": bool(liquidated), + "liquidation_bar": int(liquidation_bar), + "funding_timing_certified": True, + "funding_event_alignment": "exact_bar_timestamp", + "bar_timestamp_semantics": tape.bar_timestamp_semantics, + "funding_event_price_reference": "open" if tape.bar_timestamp_semantics == "open" else "close", + "sizing_mode": sizing_mode_value.value, + "sizing": { + "fixed_notional": float(fixed_notional), + "equity_fraction": float(equity_fraction), + "risk_fraction": float(risk_fraction), + }, + "quantity_constraints": { + "qty_step": float(qty_step), + "min_qty": float(min_qty), + "min_notional": float(min_notional), + "tick_size": float(tick_size), + }, + } + return NativeIntrabarKernelResult( + equity=pd.Series(equity, index=idx, name="equity"), + position=pd.Series(position, index=idx, name=f"Position_{symbol}"), + average_entry=pd.Series(avg_entry, index=idx, name="average_entry"), + active_stop=pd.Series(active_stop, index=idx, name="active_stop"), + active_take_profit=pd.Series(active_tp, index=idx, name="active_take_profit"), + fees=pd.Series(fees, index=idx, name="fees"), + funding=pd.Series(funding, index=idx, name="funding"), + event_flags=pd.Series(flags, index=idx, name="event_flags"), + initial_margin=pd.Series(initial_margin, index=idx, name="initial_margin"), + maintenance_margin=pd.Series(maintenance_margin, index=idx, name="maintenance_margin"), + fills=fills, + fills_report=fills_report, + ambiguity_count=int(ambiguity_count), + rejected_count=int(rejected_count), + fill_count=int(fill_count), + liquidated=bool(liquidated), + liquidation_bar=int(liquidation_bar), + report_level=level, + metadata=metadata, + ) + + +def run_intrabar_session_kernel( + *, + tape: PreparedMarketTape, + intent: IntrabarIntentTape, + account: AccountConfig, + session_policy: SessionExecutionPolicy, + session_tape: IntrabarSessionTape, + contract: Optional[ExecutionContract] = None, + fee_rate: float = 0.0, + slippage_rate: float = 0.0, + contract_size: float = 1.0, + sizing_mode: IntrabarSizingMode | str = IntrabarSizingMode.UNITS, + fixed_notional: float = 0.0, + equity_fraction: float = 0.0, + risk_fraction: float = 0.0, + qty_step: float = 0.0, + min_qty: float = 0.0, + min_notional: float = 0.0, + tick_size: float = 0.0, + report_level: str = "standard", +) -> NativeIntrabarKernelResult: + """Run the fast session-aware single-symbol intrabar kernel.""" + if tape.n_symbols != 1: + raise NotImplementedError("session intrabar fast kernel v1 supports exactly one symbol") + if len(intent.entry_side) != tape.n_bars: + raise ValueError("intent length must match market tape length") + if len(session_tape.session_id) != tape.n_bars: + raise ValueError("session_tape length must match market tape length") + if fee_rate < 0.0 or slippage_rate < 0.0: + raise ValueError("fee_rate and slippage_rate must be >= 0") + level = _normalize_report_level(report_level) + contract = contract or ExecutionContract.intrabar_bracket() + if contract.engine_id != "intrabar_bracket_v1": + raise ValueError("run_intrabar_session_kernel requires intrabar_bracket_v1 contract") + _validate_intrabar_contract_supported(contract) + if contract.same_bar_policy is IntrabarSameBarPolicy.REJECT_AMBIGUOUS: + raise NotImplementedError("fast session intrabar kernel v1 does not support REJECT_AMBIGUOUS") + sizing_mode_value = IntrabarSizingMode(sizing_mode) + policy = SessionExecutionPolicy.from_metadata(session_policy.to_metadata()) + + arrays = _run_intrabar_session_pass( + record_fills=False, + fill_capacity=1, + tape=tape, + intent=intent, + account=account, + contract=contract, + fee_rate=fee_rate, + slippage_rate=slippage_rate, + contract_size=contract_size, + sizing_mode=sizing_mode_value, + fixed_notional=fixed_notional, + equity_fraction=equity_fraction, + risk_fraction=risk_fraction, + qty_step=qty_step, + min_qty=min_qty, + min_notional=min_notional, + tick_size=tick_size, + session_policy=policy, + session_tape=session_tape, + ) + ( + equity, + position, + avg_entry, + active_stop, + active_tp, + fees, + funding, + flags, + initial_margin, + maintenance_margin, + fill_count, + ambiguity_count, + rejected_count, + liquidated, + liquidation_bar, + _fill_bar, + _fill_seq, + _fill_side, + _fill_qty, + _fill_price, + _fill_fee, + _fill_reason, + session_reset_count, + session_forced_exit_count, + entry_window_blocked_count, + long_quota_blocked_count, + short_quota_blocked_count, + flat_only_blocked_count, + stale_session_signal_count, + reentry_suppressed_count, + ) = arrays + + fills: tuple[IntrabarFill, ...] = () + fills_report = pd.DataFrame() + if level == "audit": + audit = _run_intrabar_session_pass( + record_fills=True, + fill_capacity=int(fill_count), + tape=tape, + intent=intent, + account=account, + contract=contract, + fee_rate=fee_rate, + slippage_rate=slippage_rate, + contract_size=contract_size, + sizing_mode=sizing_mode_value, + fixed_notional=fixed_notional, + equity_fraction=equity_fraction, + risk_fraction=risk_fraction, + qty_step=qty_step, + min_qty=min_qty, + min_notional=min_notional, + tick_size=tick_size, + session_policy=policy, + session_tape=session_tape, + ) + _assert_intrabar_session_audit_parity(arrays, audit) + fills = _materialize_intrabar_fills( + timestamps_ns=tape.timestamps_ns, + fill_bar=audit[15], + fill_seq=audit[16], + fill_side=audit[17], + fill_qty=audit[18], + fill_price=audit[19], + fill_fee=audit[20], + fill_reason=audit[21], + fill_count=int(fill_count), + ) + fills_report = _fills_to_report(fills) + + idx = pd.DatetimeIndex(pd.to_datetime(tape.timestamps_ns, utc=True)) + symbol = tape.symbols[0] + metadata = { + "engine": "intrabar_session_bracket_v1", + "engine_id": "intrabar_session_bracket_v1", + "backend": "native_intrabar", + "backend_alias": "native_intrabar_session", + "kernel_version": "intrabar_session_numba_v1", + "execution_contract": contract.to_metadata(), + "data_signature": tape.signature, + "session_execution_enabled": True, + "session_policy": policy.to_metadata(), + "session_tape_signature": session_tape.signature, + "validation_certificate": tape.validation_certificate.__dict__.copy(), + "report_level": level, + "two_pass_audit": level == "audit", + "fill_count": int(fill_count), + "ambiguity_count": int(ambiguity_count), + "rejected_count": int(rejected_count), + "liquidated": bool(liquidated), + "liquidation_bar": int(liquidation_bar), + "session_reset_count": int(session_reset_count), + "session_forced_exit_count": int(session_forced_exit_count), + "entry_window_blocked_count": int(entry_window_blocked_count), + "long_quota_blocked_count": int(long_quota_blocked_count), + "short_quota_blocked_count": int(short_quota_blocked_count), + "flat_only_blocked_count": int(flat_only_blocked_count), + "stale_session_signal_count": int(stale_session_signal_count), + "reentry_suppressed_count": int(reentry_suppressed_count), + "funding_timing_certified": True, + "funding_event_alignment": "exact_bar_timestamp", + "bar_timestamp_semantics": tape.bar_timestamp_semantics, + "funding_event_price_reference": "open" if tape.bar_timestamp_semantics == "open" else "close", + "sizing_mode": sizing_mode_value.value, + "sizing": { + "fixed_notional": float(fixed_notional), + "equity_fraction": float(equity_fraction), + "risk_fraction": float(risk_fraction), + }, + "quantity_constraints": { + "qty_step": float(qty_step), + "min_qty": float(min_qty), + "min_notional": float(min_notional), + "tick_size": float(tick_size), + }, + } + return NativeIntrabarKernelResult( + equity=pd.Series(equity, index=idx, name="equity"), + position=pd.Series(position, index=idx, name=f"Position_{symbol}"), + average_entry=pd.Series(avg_entry, index=idx, name="average_entry"), + active_stop=pd.Series(active_stop, index=idx, name="active_stop"), + active_take_profit=pd.Series(active_tp, index=idx, name="active_take_profit"), + fees=pd.Series(fees, index=idx, name="fees"), + funding=pd.Series(funding, index=idx, name="funding"), + event_flags=pd.Series(flags, index=idx, name="event_flags"), + initial_margin=pd.Series(initial_margin, index=idx, name="initial_margin"), + maintenance_margin=pd.Series(maintenance_margin, index=idx, name="maintenance_margin"), + fills=fills, + fills_report=fills_report, + ambiguity_count=int(ambiguity_count), + rejected_count=int(rejected_count), + fill_count=int(fill_count), + liquidated=bool(liquidated), + liquidation_bar=int(liquidation_bar), + report_level=level, + metadata=metadata, + ) + + +def run_fill_replay_kernel( + *, + tape: PreparedMarketTape, + fill_tape: FillReplayTape, + account: AccountConfig, + contract_size: float = 1.0, +) -> NativeFillReplayResult: + """Replay explicit fills through fast accounting without certifying signal generation.""" + if tape.n_symbols != 1: + raise NotImplementedError("fill replay v1 supports exactly one symbol") + _validate_fill_replay_tape(fill_tape, tape.n_bars) + equity, position, fees, flags = _engine_fill_replay_v1( + tape.opens[:, 0], + tape.closes[:, 0], + fill_tape.bar_index, + fill_tape.sequence, + fill_tape.side, + fill_tape.qty, + fill_tape.price, + fill_tape.fee, + account.initial_capital, + float(contract_size), + ) + idx = pd.DatetimeIndex(pd.to_datetime(tape.timestamps_ns, utc=True)) + metadata = { + "engine": "fill_replay_v1", + "engine_id": "fill_replay_v1", + "backend": "native_intrabar", + "accounting_certified": True, + "price_accounting_certified": True, + "fee_accounting_certified": True, + "funding_certified": False, + "margin_certified": False, + "liquidation_certified": False, + "execution_generation_certified": False, + "causality_certified": False, + "data_signature": tape.signature, + "fill_count": int(len(fill_tape.bar_index)), + } + return NativeFillReplayResult( + equity=pd.Series(equity, index=idx, name="equity"), + position=pd.Series(position, index=idx, name=f"Position_{tape.symbols[0]}"), + fees=pd.Series(fees, index=idx, name="fees"), + event_flags=pd.Series(flags, index=idx, name="event_flags"), + fill_count=int(len(fill_tape.bar_index)), + metadata=metadata, + ) + + +def _run_intrabar_pass( + *, + record_fills: bool, + fill_capacity: int, + tape, + intent, + account, + contract, + fee_rate, + slippage_rate, + contract_size, + sizing_mode, + fixed_notional, + equity_fraction, + risk_fraction, + qty_step, + min_qty, + min_notional, + tick_size, +): + stop_value = _optional_float_array(intent.stop_value, tape.n_bars) + tp_value = _optional_float_array(intent.take_profit_value, tape.n_bars) + trailing_value = _optional_float_array(intent.trailing_value, tape.n_bars) + exit_long = _optional_bool_array(intent.exit_long if intent.exit_long is not None else intent.technical_exit, tape.n_bars) + exit_short = _optional_bool_array(intent.exit_short if intent.exit_short is not None else intent.technical_exit, tape.n_bars) + fill_bar = np.zeros(max(1, int(fill_capacity)), dtype=np.int64) + fill_seq = np.zeros(max(1, int(fill_capacity)), dtype=np.int16) + fill_side = np.zeros(max(1, int(fill_capacity)), dtype=np.int8) + fill_qty = np.zeros(max(1, int(fill_capacity)), dtype=np.float64) + fill_price = np.zeros(max(1, int(fill_capacity)), dtype=np.float64) + fill_fee = np.zeros(max(1, int(fill_capacity)), dtype=np.float64) + fill_reason = np.zeros(max(1, int(fill_capacity)), dtype=np.int16) + return _engine_intrabar_bracket_v1( + tape.opens[:, 0], + tape.highs[:, 0], + tape.lows[:, 0], + tape.closes[:, 0], + np.ascontiguousarray(intent.entry_side, dtype=np.int8), + np.ascontiguousarray(intent.entry_size, dtype=np.float64), + stop_value, + tp_value, + trailing_value, + exit_long, + exit_short, + tape.funding_rates[:, 0], + tape.funding_event_mask, + _bar_timestamp_semantics_code(tape.bar_timestamp_semantics), + float(account.initial_capital), + float(account.leverage), + float(account.maintenance_ratio), + float(account.margin_buffer), + float(contract_size), + float(fee_rate), + float(slippage_rate), + _sizing_mode_code(sizing_mode), + float(fixed_notional), + float(equity_fraction), + float(risk_fraction), + float(qty_step), + float(min_qty), + float(min_notional), + float(tick_size), + _level_mode_code(intent.level_mode), + _same_bar_policy_code(contract.same_bar_policy), + _tp_policy_code(contract.take_profit_gap_policy), + bool(contract.close_on_last_bar), + bool(record_fills), + fill_bar, + fill_seq, + fill_side, + fill_qty, + fill_price, + fill_fee, + fill_reason, + ) + + +def _run_intrabar_session_pass( + *, + record_fills: bool, + fill_capacity: int, + tape, + intent, + account, + contract, + fee_rate, + slippage_rate, + contract_size, + sizing_mode, + fixed_notional, + equity_fraction, + risk_fraction, + qty_step, + min_qty, + min_notional, + tick_size, + session_policy, + session_tape, +): + stop_value = _optional_float_array(intent.stop_value, tape.n_bars) + tp_value = _optional_float_array(intent.take_profit_value, tape.n_bars) + trailing_value = _optional_float_array(intent.trailing_value, tape.n_bars) + exit_long = _optional_bool_array(intent.exit_long if intent.exit_long is not None else intent.technical_exit, tape.n_bars) + exit_short = _optional_bool_array(intent.exit_short if intent.exit_short is not None else intent.technical_exit, tape.n_bars) + fill_bar = np.zeros(max(1, int(fill_capacity)), dtype=np.int64) + fill_seq = np.zeros(max(1, int(fill_capacity)), dtype=np.int16) + fill_side = np.zeros(max(1, int(fill_capacity)), dtype=np.int8) + fill_qty = np.zeros(max(1, int(fill_capacity)), dtype=np.float64) + fill_price = np.zeros(max(1, int(fill_capacity)), dtype=np.float64) + fill_fee = np.zeros(max(1, int(fill_capacity)), dtype=np.float64) + fill_reason = np.zeros(max(1, int(fill_capacity)), dtype=np.int16) + return _engine_intrabar_session_bracket_v1( + tape.opens[:, 0], + tape.highs[:, 0], + tape.lows[:, 0], + tape.closes[:, 0], + np.ascontiguousarray(intent.entry_side, dtype=np.int8), + np.ascontiguousarray(intent.entry_size, dtype=np.float64), + stop_value, + tp_value, + trailing_value, + exit_long, + exit_short, + tape.funding_rates[:, 0], + tape.funding_event_mask, + _bar_timestamp_semantics_code(tape.bar_timestamp_semantics), + np.ascontiguousarray(session_tape.session_id, dtype=np.int64), + np.ascontiguousarray(session_tape.entry_allowed_at_open, dtype=np.bool_), + np.ascontiguousarray(session_tape.force_flat_at_open, dtype=np.bool_), + _session_entry_policy_code(session_policy.entry_position_policy), + _session_counter_basis_code(session_policy.counter_basis), + _session_reentry_policy_code(session_policy.protective_exit_reentry_policy), + -1 if session_policy.max_long_entries_per_session is None else int(session_policy.max_long_entries_per_session), + -1 if session_policy.max_short_entries_per_session is None else int(session_policy.max_short_entries_per_session), + bool(session_policy.cancel_pending_on_session_change), + bool(session_policy.suppress_entry_on_force_flat_bar), + float(account.initial_capital), + float(account.leverage), + float(account.maintenance_ratio), + float(account.margin_buffer), + float(contract_size), + float(fee_rate), + float(slippage_rate), + _sizing_mode_code(sizing_mode), + float(fixed_notional), + float(equity_fraction), + float(risk_fraction), + float(qty_step), + float(min_qty), + float(min_notional), + float(tick_size), + _level_mode_code(intent.level_mode), + _same_bar_policy_code(contract.same_bar_policy), + _tp_policy_code(contract.take_profit_gap_policy), + bool(contract.close_on_last_bar), + bool(record_fills), + fill_bar, + fill_seq, + fill_side, + fill_qty, + fill_price, + fill_fee, + fill_reason, + ) + + +@njit(cache=True, nogil=True) +def _engine_intrabar_bracket_v1( + opens, + highs, + lows, + closes, + entry_side, + entry_size, + stop_value, + tp_value, + trailing_value, + exit_long, + exit_short, + funding_rates, + funding_mask, + bar_timestamp_semantics, + initial_capital, + leverage, + maintenance_ratio, + margin_buffer, + contract_size, + fee_rate, + slippage_rate, + sizing_mode, + fixed_notional, + equity_fraction, + risk_fraction, + qty_step, + min_qty, + min_notional, + tick_size, + level_mode, + same_bar_policy, + tp_gap_policy, + close_on_last_bar, + record_fills, + fill_bar, + fill_seq, + fill_side, + fill_qty, + fill_price, + fill_fee, + fill_reason, +): + n = closes.shape[0] + equity_arr = np.zeros(n, dtype=np.float64) + pos_arr = np.zeros(n, dtype=np.float64) + avg_arr = np.zeros(n, dtype=np.float64) + stop_arr = np.zeros(n, dtype=np.float64) + tp_arr = np.zeros(n, dtype=np.float64) + fee_arr = np.zeros(n, dtype=np.float64) + funding_arr = np.zeros(n, dtype=np.float64) + flags_arr = np.zeros(n, dtype=np.uint16) + init_margin = np.zeros(n, dtype=np.float64) + maint_margin = np.zeros(n, dtype=np.float64) + + equity = initial_capital + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + fill_count = 0 + ambiguity_count = 0 + rejected_count = 0 + liquidated = False + liquidation_bar = -1 + + equity_arr[0] = equity + for t in range(1, n): + if liquidated: + equity_arr[t] = 0.0 + continue + + seq = 0 + open_ref = opens[t] + close_ref = closes[t] + last_ref = open_ref + + if position != 0.0: + equity += position * (open_ref - closes[t - 1]) * contract_size + + if position != 0.0 and _maintenance_breached_numba(equity, position, open_ref, contract_size, maintenance_ratio): + side = -1 if position > 0.0 else 1 + price = _market_price_numba(open_ref, side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, FILL_LIQUIDATION, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + flags_arr[t] |= FLAG_EXIT_FILLED | FLAG_LIQUIDATION + liquidated = True + liquidation_bar = t + equity = 0.0 + equity_arr[t] = 0.0 + continue + + if bar_timestamp_semantics == BAR_TS_OPEN and position != 0.0 and funding_mask[t]: + funding_cost = position * open_ref * contract_size * funding_rates[t] + equity -= funding_cost + funding_arr[t] = funding_cost + flags_arr[t] |= FLAG_FUNDING + + pending_side = entry_side[t - 1] + pending_size = entry_size[t - 1] + pending_exit = (position > 0.0 and exit_long[t - 1]) or (position < 0.0 and exit_short[t - 1]) + exit_same_side_conflict = pending_exit and pending_side != 0 and position != 0.0 and _sign_numba(position) == pending_side + + if position != 0.0 and (pending_exit or (pending_side != 0 and _sign_numba(position) != pending_side)): + reason = FILL_REVERSAL_EXIT if pending_side != 0 and _sign_numba(position) != pending_side else FILL_TECHNICAL_EXIT + side = -1 if position > 0.0 else 1 + price = _market_price_numba(open_ref, side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, reason, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + seq += 1 + flags_arr[t] |= FLAG_EXIT_FILLED + if reason == FILL_TECHNICAL_EXIT: + flags_arr[t] |= FLAG_TECH_EXIT + else: + flags_arr[t] |= FLAG_REVERSAL + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + if pending_side != 0 and pending_size > 0.0 and position == 0.0: + side = 1 if pending_side > 0 else -1 + price = _market_price_numba(open_ref, side, slippage_rate, tick_size) + if exit_same_side_conflict: + qty = 0.0 + else: + qty = _compile_entry_quantity_numba( + pending_size, + price, + equity, + contract_size, + sizing_mode, + fixed_notional, + equity_fraction, + risk_fraction, + stop_value[t - 1], + level_mode, + side, + tick_size, + ) + qty = abs(_quantize_signed_quantity_numba(qty, price, contract_size, qty_step, min_qty, min_notional)) + if exit_same_side_conflict: + flags_arr[t] |= FLAG_ENTRY_SUPPRESSED + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + if qty <= 0.0: + flags_arr[t] |= FLAG_REJECTED + rejected_count += 1 + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + if not _has_initial_margin_numba(equity, qty, price, contract_size, leverage, margin_buffer): + flags_arr[t] |= FLAG_REJECTED + rejected_count += 1 + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + fee = qty * price * contract_size * fee_rate + equity -= fee + fee_arr[t] += fee + position = qty * side + avg_entry = price + last_ref = price + active_stop, active_tp = _initial_bracket_numba(stop_value[t - 1], tp_value[t - 1], trailing_value[t - 1], side, price, level_mode, tick_size) + reason = FILL_REVERSAL_ENTRY if (flags_arr[t] & FLAG_REVERSAL) != 0 else FILL_ENTRY + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, reason, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + seq += 1 + flags_arr[t] |= FLAG_ENTRY_FILLED + + if position != 0.0: + exit_side, exit_price, exit_reason, ambiguous = _resolve_intrabar_exit_numba( + 1 if position > 0.0 else -1, + open_ref, + highs[t], + lows[t], + active_stop, + active_tp, + same_bar_policy, + tp_gap_policy, + slippage_rate, + tick_size, + ) + if exit_reason != 0: + if ambiguous: + flags_arr[t] |= FLAG_AMBIGUOUS + ambiguity_count += 1 + qty = abs(position) + fee = qty * exit_price * contract_size * fee_rate + equity += position * (exit_price - last_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, exit_side, qty, exit_price, fee, exit_reason, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + seq += 1 + flags_arr[t] |= FLAG_EXIT_FILLED + if exit_reason == FILL_STOP_LOSS: + flags_arr[t] |= FLAG_STOP_FILLED + else: + flags_arr[t] |= FLAG_TP_FILLED + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + if position != 0.0: + if _maintenance_breached_worst_numba(equity, position, last_ref, highs[t], lows[t], contract_size, maintenance_ratio): + side = -1 if position > 0.0 else 1 + worst = lows[t] if position > 0.0 else highs[t] + price = _market_price_numba(worst, side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - last_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, FILL_LIQUIDATION, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + flags_arr[t] |= FLAG_EXIT_FILLED | FLAG_LIQUIDATION + liquidated = True + liquidation_bar = t + equity = 0.0 + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + else: + equity += position * (close_ref - last_ref) * contract_size + active_stop = _update_trailing_numba(trailing_value[t], position, close_ref, active_stop, level_mode, tick_size) + + if liquidated: + equity_arr[t] = 0.0 + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + continue + + if bar_timestamp_semantics == BAR_TS_CLOSE and position != 0.0 and funding_mask[t]: + funding_cost = position * close_ref * contract_size * funding_rates[t] + equity -= funding_cost + funding_arr[t] = funding_cost + flags_arr[t] |= FLAG_FUNDING + + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + init_margin[t] = abs(position) * close_ref * contract_size / leverage + maint_margin[t] = abs(position) * close_ref * contract_size * maintenance_ratio + + if close_on_last_bar and position != 0.0 and not liquidated: + t = n - 1 + side = -1 if position > 0.0 else 1 + price = _market_price_numba(closes[t], side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - closes[t]) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, 99, side, qty, price, fee, FILL_FINAL_CLOSE, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + position = 0.0 + equity_arr[t] = equity + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + init_margin[t] = 0.0 + maint_margin[t] = 0.0 + + return ( + equity_arr, + pos_arr, + avg_arr, + stop_arr, + tp_arr, + fee_arr, + funding_arr, + flags_arr, + init_margin, + maint_margin, + fill_count, + ambiguity_count, + rejected_count, + liquidated, + liquidation_bar, + fill_bar, + fill_seq, + fill_side, + fill_qty, + fill_price, + fill_fee, + fill_reason, + ) + + +@njit(cache=True, nogil=True) +def _engine_intrabar_session_bracket_v1( + opens, + highs, + lows, + closes, + entry_side, + entry_size, + stop_value, + tp_value, + trailing_value, + exit_long, + exit_short, + funding_rates, + funding_mask, + bar_timestamp_semantics, + session_id, + entry_allowed_at_open, + force_flat_at_open, + entry_position_policy, + counter_basis, + protective_reentry_policy, + max_long_entries_per_session, + max_short_entries_per_session, + cancel_pending_on_session_change, + suppress_entry_on_force_flat_bar, + initial_capital, + leverage, + maintenance_ratio, + margin_buffer, + contract_size, + fee_rate, + slippage_rate, + sizing_mode, + fixed_notional, + equity_fraction, + risk_fraction, + qty_step, + min_qty, + min_notional, + tick_size, + level_mode, + same_bar_policy, + tp_gap_policy, + close_on_last_bar, + record_fills, + fill_bar, + fill_seq, + fill_side, + fill_qty, + fill_price, + fill_fee, + fill_reason, +): + n = closes.shape[0] + equity_arr = np.zeros(n, dtype=np.float64) + pos_arr = np.zeros(n, dtype=np.float64) + avg_arr = np.zeros(n, dtype=np.float64) + stop_arr = np.zeros(n, dtype=np.float64) + tp_arr = np.zeros(n, dtype=np.float64) + fee_arr = np.zeros(n, dtype=np.float64) + funding_arr = np.zeros(n, dtype=np.float64) + flags_arr = np.zeros(n, dtype=np.uint32) + init_margin = np.zeros(n, dtype=np.float64) + maint_margin = np.zeros(n, dtype=np.float64) + + equity = initial_capital + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + fill_count = 0 + ambiguity_count = 0 + rejected_count = 0 + liquidated = False + liquidation_bar = -1 + + current_session_id = session_id[0] if n > 0 else 0 + long_entry_count = 0 + short_entry_count = 0 + protective_exit_on_previous_bar = False + session_reset_count = 0 + session_forced_exit_count = 0 + entry_window_blocked_count = 0 + long_quota_blocked_count = 0 + short_quota_blocked_count = 0 + flat_only_blocked_count = 0 + stale_session_signal_count = 0 + reentry_suppressed_count = 0 + + equity_arr[0] = equity + for t in range(1, n): + if liquidated: + equity_arr[t] = 0.0 + continue + + seq = 0 + open_ref = opens[t] + close_ref = closes[t] + last_ref = open_ref + + if position != 0.0: + equity += position * (open_ref - closes[t - 1]) * contract_size + + reentry_block_from_previous_bar = False + if session_id[t] != current_session_id: + current_session_id = session_id[t] + long_entry_count = 0 + short_entry_count = 0 + protective_exit_on_previous_bar = False + flags_arr[t] |= FLAG_SESSION_RESET + session_reset_count += 1 + reentry_block_from_previous_bar = protective_exit_on_previous_bar + protective_exit_on_previous_bar = False + + if position != 0.0 and _maintenance_breached_numba(equity, position, open_ref, contract_size, maintenance_ratio): + side = -1 if position > 0.0 else 1 + price = _market_price_numba(open_ref, side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, FILL_LIQUIDATION, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + flags_arr[t] |= FLAG_EXIT_FILLED | FLAG_LIQUIDATION + liquidated = True + liquidation_bar = t + equity = 0.0 + equity_arr[t] = 0.0 + continue + + if bar_timestamp_semantics == BAR_TS_OPEN and position != 0.0 and funding_mask[t]: + funding_cost = position * open_ref * contract_size * funding_rates[t] + equity -= funding_cost + funding_arr[t] = funding_cost + flags_arr[t] |= FLAG_FUNDING + + force_flat_bar = force_flat_at_open[t] + if force_flat_bar and position != 0.0: + side = -1 if position > 0.0 else 1 + price = _market_price_numba(open_ref, side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, FILL_SESSION_FORCED_EXIT, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + seq += 1 + flags_arr[t] |= FLAG_EXIT_FILLED | FLAG_SESSION_FORCED_EXIT + session_forced_exit_count += 1 + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + pending_side = entry_side[t - 1] + pending_size = entry_size[t - 1] + pending_exit = (position > 0.0 and exit_long[t - 1]) or (position < 0.0 and exit_short[t - 1]) + + if cancel_pending_on_session_change and pending_side != 0 and session_id[t - 1] != session_id[t]: + pending_side = 0 + pending_size = 0.0 + flags_arr[t] |= FLAG_STALE_SESSION_SIGNAL | FLAG_ENTRY_SUPPRESSED + stale_session_signal_count += 1 + + if pending_side != 0 and position != 0.0 and entry_position_policy == SESSION_ENTRY_FLAT_ONLY: + pending_side = 0 + pending_size = 0.0 + flags_arr[t] |= FLAG_FLAT_ONLY_BLOCKED | FLAG_ENTRY_SUPPRESSED + flat_only_blocked_count += 1 + + exit_same_side_conflict = pending_exit and pending_side != 0 and position != 0.0 and _sign_numba(position) == pending_side + reversal_allowed = entry_position_policy != SESSION_ENTRY_FLAT_ONLY + + if position != 0.0 and (pending_exit or (reversal_allowed and pending_side != 0 and _sign_numba(position) != pending_side)): + reason = FILL_REVERSAL_EXIT if pending_side != 0 and _sign_numba(position) != pending_side else FILL_TECHNICAL_EXIT + side = -1 if position > 0.0 else 1 + price = _market_price_numba(open_ref, side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, reason, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + seq += 1 + flags_arr[t] |= FLAG_EXIT_FILLED + if reason == FILL_TECHNICAL_EXIT: + flags_arr[t] |= FLAG_TECH_EXIT + else: + flags_arr[t] |= FLAG_REVERSAL + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + if pending_side != 0 and pending_size > 0.0 and position == 0.0: + side = 1 if pending_side > 0 else -1 + price = _market_price_numba(open_ref, side, slippage_rate, tick_size) + entry_blocked = False + if force_flat_bar and suppress_entry_on_force_flat_bar: + entry_blocked = True + flags_arr[t] |= FLAG_SESSION_FORCED_EXIT | FLAG_ENTRY_SUPPRESSED + elif not entry_allowed_at_open[t]: + entry_blocked = True + entry_window_blocked_count += 1 + flags_arr[t] |= FLAG_ENTRY_WINDOW_BLOCKED | FLAG_ENTRY_SUPPRESSED + elif protective_reentry_policy == SESSION_REENTRY_SUPPRESS_SIGNAL_BAR and reentry_block_from_previous_bar: + entry_blocked = True + reentry_suppressed_count += 1 + flags_arr[t] |= FLAG_PROTECTIVE_REENTRY_BLOCKED | FLAG_ENTRY_SUPPRESSED + elif side > 0 and max_long_entries_per_session >= 0 and long_entry_count >= max_long_entries_per_session: + entry_blocked = True + long_quota_blocked_count += 1 + flags_arr[t] |= FLAG_ENTRY_QUOTA_BLOCKED | FLAG_ENTRY_SUPPRESSED + elif side < 0 and max_short_entries_per_session >= 0 and short_entry_count >= max_short_entries_per_session: + entry_blocked = True + short_quota_blocked_count += 1 + flags_arr[t] |= FLAG_ENTRY_QUOTA_BLOCKED | FLAG_ENTRY_SUPPRESSED + + if exit_same_side_conflict or entry_blocked: + flags_arr[t] |= FLAG_ENTRY_SUPPRESSED + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + init_margin[t] = abs(position) * close_ref * contract_size / leverage + maint_margin[t] = abs(position) * close_ref * contract_size * maintenance_ratio + continue + + qty = _compile_entry_quantity_numba( + pending_size, + price, + equity, + contract_size, + sizing_mode, + fixed_notional, + equity_fraction, + risk_fraction, + stop_value[t - 1], + level_mode, + side, + tick_size, + ) + qty = abs(_quantize_signed_quantity_numba(qty, price, contract_size, qty_step, min_qty, min_notional)) + if qty <= 0.0: + flags_arr[t] |= FLAG_REJECTED + rejected_count += 1 + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + if not _has_initial_margin_numba(equity, qty, price, contract_size, leverage, margin_buffer): + flags_arr[t] |= FLAG_REJECTED + rejected_count += 1 + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + fee = qty * price * contract_size * fee_rate + equity -= fee + fee_arr[t] += fee + position = qty * side + avg_entry = price + last_ref = price + active_stop, active_tp = _initial_bracket_numba(stop_value[t - 1], tp_value[t - 1], trailing_value[t - 1], side, price, level_mode, tick_size) + reason = FILL_REVERSAL_ENTRY if (flags_arr[t] & FLAG_REVERSAL) != 0 else FILL_ENTRY + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, reason, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + seq += 1 + flags_arr[t] |= FLAG_ENTRY_FILLED + if side > 0: + long_entry_count += 1 + else: + short_entry_count += 1 + + if position != 0.0: + exit_side, exit_price, exit_reason, ambiguous = _resolve_intrabar_exit_numba( + 1 if position > 0.0 else -1, + open_ref, + highs[t], + lows[t], + active_stop, + active_tp, + same_bar_policy, + tp_gap_policy, + slippage_rate, + tick_size, + ) + if exit_reason != 0: + if ambiguous: + flags_arr[t] |= FLAG_AMBIGUOUS + ambiguity_count += 1 + qty = abs(position) + fee = qty * exit_price * contract_size * fee_rate + equity += position * (exit_price - last_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, exit_side, qty, exit_price, fee, exit_reason, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + seq += 1 + flags_arr[t] |= FLAG_EXIT_FILLED + if exit_reason == FILL_STOP_LOSS: + flags_arr[t] |= FLAG_STOP_FILLED + protective_exit_on_previous_bar = True + else: + flags_arr[t] |= FLAG_TP_FILLED + protective_exit_on_previous_bar = True + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + if position != 0.0: + if _maintenance_breached_worst_numba(equity, position, last_ref, highs[t], lows[t], contract_size, maintenance_ratio): + side = -1 if position > 0.0 else 1 + worst = lows[t] if position > 0.0 else highs[t] + price = _market_price_numba(worst, side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - last_ref) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, seq, side, qty, price, fee, FILL_LIQUIDATION, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + flags_arr[t] |= FLAG_EXIT_FILLED | FLAG_LIQUIDATION + liquidated = True + liquidation_bar = t + equity = 0.0 + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + else: + equity += position * (close_ref - last_ref) * contract_size + active_stop = _update_trailing_numba(trailing_value[t], position, close_ref, active_stop, level_mode, tick_size) + + if liquidated: + equity_arr[t] = 0.0 + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + continue + + if bar_timestamp_semantics == BAR_TS_CLOSE and position != 0.0 and funding_mask[t]: + funding_cost = position * close_ref * contract_size * funding_rates[t] + equity -= funding_cost + funding_arr[t] = funding_cost + flags_arr[t] |= FLAG_FUNDING + + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + init_margin[t] = abs(position) * close_ref * contract_size / leverage + maint_margin[t] = abs(position) * close_ref * contract_size * maintenance_ratio + + if close_on_last_bar and position != 0.0 and not liquidated: + t = n - 1 + side = -1 if position > 0.0 else 1 + price = _market_price_numba(closes[t], side, slippage_rate, tick_size) + qty = abs(position) + fee = qty * price * contract_size * fee_rate + equity += position * (price - closes[t]) * contract_size - fee + fee_arr[t] += fee + fill_count = _record_fill_numba(record_fills, fill_count, t, 99, side, qty, price, fee, FILL_FINAL_CLOSE, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason) + position = 0.0 + equity_arr[t] = equity + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + init_margin[t] = 0.0 + maint_margin[t] = 0.0 + + return ( + equity_arr, + pos_arr, + avg_arr, + stop_arr, + tp_arr, + fee_arr, + funding_arr, + flags_arr, + init_margin, + maint_margin, + fill_count, + ambiguity_count, + rejected_count, + liquidated, + liquidation_bar, + fill_bar, + fill_seq, + fill_side, + fill_qty, + fill_price, + fill_fee, + fill_reason, + session_reset_count, + session_forced_exit_count, + entry_window_blocked_count, + long_quota_blocked_count, + short_quota_blocked_count, + flat_only_blocked_count, + stale_session_signal_count, + reentry_suppressed_count, + ) + + +@njit(cache=True, nogil=True) +def _engine_fill_replay_v1(opens, closes, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, initial_capital, contract_size): + n = closes.shape[0] + equity_arr = np.zeros(n, dtype=np.float64) + pos_arr = np.zeros(n, dtype=np.float64) + fee_arr = np.zeros(n, dtype=np.float64) + flags_arr = np.zeros(n, dtype=np.uint16) + equity = initial_capital + position = 0.0 + ptr = 0 + n_fills = fill_bar.shape[0] + prev_close = opens[0] + for t in range(n): + current_ref = opens[t] + if t > 0 and position != 0.0: + equity += position * (opens[t] - prev_close) * contract_size + while ptr < n_fills and fill_bar[ptr] == t: + price = fill_price[ptr] + side = fill_side[ptr] + qty = fill_qty[ptr] + fee = fill_fee[ptr] + if position != 0.0: + equity += position * (price - current_ref) * contract_size + equity -= fee + fee_arr[t] += fee + position += side * qty + current_ref = price + flags_arr[t] |= FLAG_ENTRY_FILLED if side > 0 else FLAG_EXIT_FILLED + ptr += 1 + if position != 0.0: + equity += position * (closes[t] - current_ref) * contract_size + equity_arr[t] = equity + pos_arr[t] = position + prev_close = closes[t] + return equity_arr, pos_arr, fee_arr, flags_arr + + +@njit(cache=True, nogil=True) +def _market_price_numba(price, side, slippage_rate, tick_size): + raw = price * (1.0 + slippage_rate if side > 0 else 1.0 - slippage_rate) + return _quantize_price_numba(raw, side, tick_size) + + +@njit(cache=True, nogil=True) +def _sign_numba(value): + if value > 0.0: + return 1 + if value < 0.0: + return -1 + return 0 + + +@njit(cache=True, nogil=True) +def _has_initial_margin_numba(equity, qty, price, contract_size, leverage, margin_buffer): + required = abs(qty) * price * contract_size / leverage + return equity >= required * (1.0 + margin_buffer) + + +@njit(cache=True, nogil=True) +def _maintenance_breached_numba(equity, position, price, contract_size, maintenance_ratio): + maintenance = abs(position) * price * contract_size * maintenance_ratio + return maintenance > 0.0 and equity <= maintenance + + +@njit(cache=True, nogil=True) +def _maintenance_breached_worst_numba(equity, position, reference_price, high, low, contract_size, maintenance_ratio): + worst = low if position > 0.0 else high + worst_equity = equity + position * (worst - reference_price) * contract_size + maintenance = abs(position) * worst * contract_size * maintenance_ratio + return maintenance > 0.0 and worst_equity <= maintenance + + +@njit(cache=True, nogil=True) +def _initial_bracket_numba(stop_value, tp_value, trailing_value, side, fill_price, level_mode, tick_size): + stop = np.nan + tp = np.nan + if np.isfinite(stop_value) and stop_value > 0.0: + stop = _level_price_numba(fill_price, side, stop_value, level_mode, True, tick_size) + if np.isfinite(tp_value) and tp_value > 0.0: + tp = _level_price_numba(fill_price, side, tp_value, level_mode, False, tick_size) + if np.isfinite(trailing_value) and trailing_value > 0.0: + trailing_stop = _level_price_numba(fill_price, side, trailing_value, level_mode, True, tick_size) + if not np.isfinite(stop): + stop = trailing_stop + elif side > 0: + stop = max(stop, trailing_stop) + else: + stop = min(stop, trailing_stop) + return stop, tp + + +@njit(cache=True, nogil=True) +def _level_price_numba(price, side, value, level_mode, is_stop, tick_size): + direction = -1.0 if (side > 0 and is_stop) or (side < 0 and not is_stop) else 1.0 + if level_mode == LEVEL_ABSOLUTE_PRICE: + return _quantize_price_numba(value, -side, tick_size) + if level_mode == LEVEL_PRICE_DISTANCE: + return _quantize_price_numba(price + direction * value, -side, tick_size) + return _quantize_price_numba(price * (1.0 + direction * value), -side, tick_size) + + +@njit(cache=True, nogil=True) +def _resolve_intrabar_exit_numba(side, open_price, high, low, stop_price, tp_price, same_bar_policy, tp_gap_policy, slippage_rate, tick_size): + has_stop = np.isfinite(stop_price) and stop_price > 0.0 + has_tp = np.isfinite(tp_price) and tp_price > 0.0 + if side > 0: + stop_hit = has_stop and low <= stop_price + tp_hit = has_tp and high >= tp_price + stop_gap = has_stop and open_price <= stop_price + tp_gap = has_tp and open_price >= tp_price + exit_side = -1 + else: + stop_hit = has_stop and high >= stop_price + tp_hit = has_tp and low <= tp_price + stop_gap = has_stop and open_price >= stop_price + tp_gap = has_tp and open_price <= tp_price + exit_side = 1 + if not stop_hit and not tp_hit: + return 0, 0.0, 0, False + ambiguous = stop_hit and tp_hit + if ambiguous and same_bar_policy == SAME_BAR_REJECT_AMBIGUOUS: + return 0, 0.0, -1, True + stop_first = ( + same_bar_policy == SAME_BAR_CONSERVATIVE + or same_bar_policy == SAME_BAR_STOP_FIRST + or (side > 0 and same_bar_policy == SAME_BAR_OLHC_PATH) + or (side < 0 and same_bar_policy == SAME_BAR_OHLC_PATH) + ) + if stop_hit and ((not tp_hit) or stop_first): + price = open_price if stop_gap else stop_price + return exit_side, _market_price_numba(price, exit_side, slippage_rate, tick_size), FILL_STOP_LOSS, ambiguous + if tp_hit: + price = open_price if tp_gap and tp_gap_policy == TP_OPEN_PRICE_IMPROVEMENT else tp_price + return exit_side, _quantize_price_numba(price, exit_side, tick_size), FILL_TAKE_PROFIT, ambiguous + return 0, 0.0, 0, False + + +@njit(cache=True, nogil=True) +def _update_trailing_numba(trailing_value, position, close_price, current_stop, level_mode, tick_size): + if not np.isfinite(trailing_value) or trailing_value <= 0.0: + return current_stop + side = 1 if position > 0.0 else -1 + candidate = _level_price_numba(close_price, side, trailing_value, level_mode, True, tick_size) + if not np.isfinite(current_stop): + return candidate + return max(current_stop, candidate) if side > 0 else min(current_stop, candidate) + + +@njit(cache=True, nogil=True) +def _quantize_price_numba(price, side, tick_size): + if tick_size <= 0.0 or not np.isfinite(price): + return price + if side > 0: + return np.ceil((price / tick_size) - 1e-12) * tick_size + return np.floor((price / tick_size) + 1e-12) * tick_size + + +@njit(cache=True, nogil=True) +def _compile_entry_quantity_numba(size_weight, fill_price, equity, contract_size, sizing_mode, fixed_notional, equity_fraction, risk_fraction, stop_value, level_mode, side, tick_size): + weight = abs(size_weight) + if sizing_mode == SIZING_UNITS: + return weight + if fill_price <= 0.0 or contract_size <= 0.0: + return 0.0 + if sizing_mode == SIZING_FIXED_NOTIONAL: + return fixed_notional * weight / (fill_price * contract_size) + if sizing_mode == SIZING_PCT_EQUITY: + return equity * equity_fraction * weight / (fill_price * contract_size) + if sizing_mode == SIZING_RISK_PER_TRADE: + if not np.isfinite(stop_value) or stop_value <= 0.0: + return 0.0 + stop_price = _level_price_numba(fill_price, side, stop_value, level_mode, True, tick_size) + stop_distance = abs(fill_price - stop_price) + if stop_distance <= 0.0: + return 0.0 + return equity * risk_fraction * weight / (stop_distance * contract_size) + return 0.0 + + +@njit(cache=True, nogil=True) +def _quantize_signed_quantity_numba(qty, price, contract_size, qty_step, min_qty, min_notional): + if qty == 0.0: + return 0.0 + sign = 1.0 if qty > 0.0 else -1.0 + abs_q = abs(qty) + if qty_step > 0.0: + abs_q = np.floor((abs_q / qty_step) + 1e-12) * qty_step + if abs_q <= 0.0: + return 0.0 + if min_qty > 0.0 and abs_q + 1e-12 < min_qty: + return 0.0 + if min_notional > 0.0 and abs_q * price * contract_size + 1e-12 < min_notional: + return 0.0 + return sign * abs_q + + +@njit(cache=True, nogil=True) +def _record_fill_numba(record, count, bar, seq, side, qty, price, fee, reason, fill_bar, fill_seq, fill_side, fill_qty, fill_price, fill_fee, fill_reason): + if record and count < fill_bar.shape[0]: + fill_bar[count] = bar + fill_seq[count] = seq + fill_side[count] = side + fill_qty[count] = qty + fill_price[count] = price + fill_fee[count] = fee + fill_reason[count] = reason + return count + 1 + + +def _optional_float_array(value, n: int) -> np.ndarray: + if value is None: + return np.full(n, np.nan, dtype=np.float64) + return np.ascontiguousarray(value, dtype=np.float64) + + +def _optional_bool_array(value, n: int) -> np.ndarray: + if value is None: + return np.zeros(n, dtype=np.bool_) + return np.ascontiguousarray(value, dtype=np.bool_) + + +def _level_mode_code(mode) -> int: + value = mode.value if hasattr(mode, "value") else str(mode) + if value == IntrabarLevelMode.ABSOLUTE_PRICE.value: + return LEVEL_ABSOLUTE_PRICE + if value == IntrabarLevelMode.PRICE_DISTANCE.value: + return LEVEL_PRICE_DISTANCE + if value == IntrabarLevelMode.PERCENT_DISTANCE.value: + return LEVEL_PERCENT_DISTANCE + raise NotImplementedError(f"unsupported intrabar level mode={mode!r}") + + +def _sizing_mode_code(mode) -> int: + value = mode.value if hasattr(mode, "value") else str(mode) + mapping = { + IntrabarSizingMode.UNITS.value: SIZING_UNITS, + IntrabarSizingMode.FIXED_NOTIONAL.value: SIZING_FIXED_NOTIONAL, + IntrabarSizingMode.PCT_EQUITY.value: SIZING_PCT_EQUITY, + IntrabarSizingMode.RISK_PER_TRADE.value: SIZING_RISK_PER_TRADE, + } + if value not in mapping: + raise NotImplementedError(f"unsupported intrabar sizing_mode={mode!r}") + return mapping[value] + + +def _bar_timestamp_semantics_code(value: str) -> int: + semantics = str(value or "close").lower().strip() + if semantics == "close": + return BAR_TS_CLOSE + if semantics == "open": + return BAR_TS_OPEN + raise ValueError("bar_timestamp_semantics must be 'open' or 'close'") + + +def _session_entry_policy_code(policy) -> int: + value = policy.value if hasattr(policy, "value") else str(policy) + if value == EntryPositionPolicy.CURRENT_BEHAVIOR.value: + return SESSION_ENTRY_CURRENT + if value == EntryPositionPolicy.FLAT_ONLY.value: + return SESSION_ENTRY_FLAT_ONLY + if value == EntryPositionPolicy.REVERSE.value: + return SESSION_ENTRY_REVERSE + raise NotImplementedError(f"unsupported session entry_position_policy={policy!r}") + + +def _session_counter_basis_code(policy) -> int: + value = policy.value if hasattr(policy, "value") else str(policy) + if value == SessionCounterBasis.FILLED_ENTRY.value: + return SESSION_COUNTER_FILLED + if value == SessionCounterBasis.ACCEPTED_ENTRY.value: + return SESSION_COUNTER_ACCEPTED + raise NotImplementedError(f"unsupported session counter_basis={policy!r}") + + +def _session_reentry_policy_code(policy) -> int: + value = policy.value if hasattr(policy, "value") else str(policy) + if value == ProtectiveExitReentryPolicy.ALLOW.value: + return SESSION_REENTRY_ALLOW + if value == ProtectiveExitReentryPolicy.SUPPRESS_SIGNAL_BAR.value: + return SESSION_REENTRY_SUPPRESS_SIGNAL_BAR + raise NotImplementedError(f"unsupported protective_exit_reentry_policy={policy!r}") + + +def _same_bar_policy_code(policy) -> int: + value = policy.value if hasattr(policy, "value") else str(policy) + mapping = { + IntrabarSameBarPolicy.CONSERVATIVE.value: SAME_BAR_CONSERVATIVE, + IntrabarSameBarPolicy.STOP_FIRST.value: SAME_BAR_STOP_FIRST, + IntrabarSameBarPolicy.TP_FIRST.value: SAME_BAR_TP_FIRST, + IntrabarSameBarPolicy.OHLC_PATH.value: SAME_BAR_OHLC_PATH, + IntrabarSameBarPolicy.OLHC_PATH.value: SAME_BAR_OLHC_PATH, + IntrabarSameBarPolicy.REJECT_AMBIGUOUS.value: SAME_BAR_REJECT_AMBIGUOUS, + } + if value not in mapping: + raise NotImplementedError(f"unsupported same-bar policy={policy!r}") + return mapping[value] + + +def _tp_policy_code(policy) -> int: + value = policy.value if hasattr(policy, "value") else str(policy) + if value == TakeProfitGapPolicy.LIMIT_PRICE_CONSERVATIVE.value: + return TP_LIMIT_CONSERVATIVE + if value == TakeProfitGapPolicy.OPEN_PRICE_IMPROVEMENT.value: + return TP_OPEN_PRICE_IMPROVEMENT + raise NotImplementedError(f"unsupported take-profit gap policy={policy!r}") + + +def _normalize_report_level(report_level: str) -> str: + level = str(report_level or "standard").lower().strip() + aliases = {"full": "audit", "debug": "audit", "optimizer": "minimal", "scoring": "minimal"} + level = aliases.get(level, level) + if level not in {"minimal", "standard", "audit"}: + raise ValueError("report_level must be minimal, standard, or audit") + return level + + +def _assert_intrabar_audit_parity(first, second, atol: float = 1e-9) -> None: + for i, name in enumerate(("equity", "position", "average_entry", "active_stop", "active_take_profit", "fees", "funding", "flags")): + if not np.allclose(first[i], second[i], atol=atol, rtol=0.0): + raise AssertionError(f"intrabar audit replay drifted from pass 1 for {name}") + for i, name in ((10, "fill_count"), (11, "ambiguity_count"), (12, "rejected_count"), (13, "liquidated"), (14, "liquidation_bar")): + if first[i] != second[i]: + raise AssertionError(f"intrabar audit replay drifted from pass 1 for {name}") + + +def _assert_intrabar_session_audit_parity(first, second, atol: float = 1e-9) -> None: + _assert_intrabar_audit_parity(first, second, atol=atol) + for i, name in ( + (22, "session_reset_count"), + (23, "session_forced_exit_count"), + (24, "entry_window_blocked_count"), + (25, "long_quota_blocked_count"), + (26, "short_quota_blocked_count"), + (27, "flat_only_blocked_count"), + (28, "stale_session_signal_count"), + (29, "reentry_suppressed_count"), + ): + if first[i] != second[i]: + raise AssertionError(f"intrabar session audit replay drifted from pass 1 for {name}") + + +def _materialize_intrabar_fills( + *, + timestamps_ns: np.ndarray, + fill_bar: np.ndarray, + fill_seq: np.ndarray, + fill_side: np.ndarray, + fill_qty: np.ndarray, + fill_price: np.ndarray, + fill_fee: np.ndarray, + fill_reason: np.ndarray, + fill_count: int, +) -> tuple[IntrabarFill, ...]: + idx = pd.DatetimeIndex(pd.to_datetime(timestamps_ns, utc=True)) + out = [] + for i in range(fill_count): + bar = int(fill_bar[i]) + out.append( + IntrabarFill( + bar_index=bar, + sequence=int(fill_seq[i]), + timestamp=pd.Timestamp(idx[bar]), + side=int(fill_side[i]), + qty=float(fill_qty[i]), + price=float(fill_price[i]), + fee=float(fill_fee[i]), + reason=_reason_code_to_enum(int(fill_reason[i])), + ) + ) + return tuple(out) + + +def _fills_to_report(fills: Sequence[IntrabarFill]) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "bar_index": fill.bar_index, + "sequence": fill.sequence, + "timestamp": fill.timestamp, + "side": fill.side, + "qty": fill.qty, + "price": fill.price, + "fee": fill.fee, + "reason": fill.reason.value, + } + for fill in fills + ] + ) + + +def _reason_code_to_enum(code: int) -> IntrabarFillReason: + mapping = { + FILL_ENTRY: IntrabarFillReason.ENTRY, + FILL_TECHNICAL_EXIT: IntrabarFillReason.TECHNICAL_EXIT, + FILL_REVERSAL_EXIT: IntrabarFillReason.REVERSAL_EXIT, + FILL_REVERSAL_ENTRY: IntrabarFillReason.REVERSAL_ENTRY, + FILL_STOP_LOSS: IntrabarFillReason.STOP_LOSS, + FILL_TAKE_PROFIT: IntrabarFillReason.TAKE_PROFIT, + FILL_LIQUIDATION: IntrabarFillReason.LIQUIDATION, + FILL_FINAL_CLOSE: IntrabarFillReason.FINAL_CLOSE, + FILL_SESSION_FORCED_EXIT: IntrabarFillReason.SESSION_FORCED_EXIT, + } + return mapping.get(code, IntrabarFillReason.ENTRY) + + +def _reason_series_to_codes(series: pd.Series) -> np.ndarray: + out = np.zeros(len(series), dtype=np.int16) + mapping = {reason.value: code for code, reason in ( + (FILL_ENTRY, IntrabarFillReason.ENTRY), + (FILL_TECHNICAL_EXIT, IntrabarFillReason.TECHNICAL_EXIT), + (FILL_REVERSAL_EXIT, IntrabarFillReason.REVERSAL_EXIT), + (FILL_REVERSAL_ENTRY, IntrabarFillReason.REVERSAL_ENTRY), + (FILL_STOP_LOSS, IntrabarFillReason.STOP_LOSS), + (FILL_TAKE_PROFIT, IntrabarFillReason.TAKE_PROFIT), + (FILL_LIQUIDATION, IntrabarFillReason.LIQUIDATION), + (FILL_FINAL_CLOSE, IntrabarFillReason.FINAL_CLOSE), + (FILL_SESSION_FORCED_EXIT, IntrabarFillReason.SESSION_FORCED_EXIT), + )} + for i, value in enumerate(series.astype(str)): + out[i] = mapping.get(value, 0) + return out + + +def _validate_fill_replay_tape(fill_tape: FillReplayTape, n_bars: int) -> None: + if not (len(fill_tape.bar_index) == len(fill_tape.sequence) == len(fill_tape.side) == len(fill_tape.qty) == len(fill_tape.price) == len(fill_tape.fee)): + raise ValueError("fill replay arrays must have matching lengths") + if len(fill_tape.bar_index) == 0: + return + if np.any(fill_tape.bar_index < 0) or np.any(fill_tape.bar_index >= n_bars): + raise ValueError("fill replay bar_index is out of market tape range") + if not np.isfinite(fill_tape.qty).all() or not np.isfinite(fill_tape.price).all() or not np.isfinite(fill_tape.fee).all(): + raise ValueError("fill replay qty/price/fee must be finite") + if np.any(fill_tape.qty <= 0.0) or np.any(fill_tape.price <= 0.0) or np.any(fill_tape.fee < 0.0): + raise ValueError("fill replay qty/price must be positive and fee non-negative") + prev_bar = int(fill_tape.bar_index[0]) + prev_seq = int(fill_tape.sequence[0]) + for bar, seq in zip(fill_tape.bar_index[1:], fill_tape.sequence[1:]): + bar_i = int(bar) + seq_i = int(seq) + if bar_i < prev_bar or (bar_i == prev_bar and seq_i < prev_seq): + raise ValueError("fill replay tape must be sorted by bar_index then sequence") + prev_bar = bar_i + prev_seq = seq_i diff --git a/src/quantbt/core/intrabar_reference.py b/src/quantbt/core/intrabar_reference.py new file mode 100644 index 0000000..3a267e6 --- /dev/null +++ b/src/quantbt/core/intrabar_reference.py @@ -0,0 +1,907 @@ +""" +Readable Python oracle for the Phase 31 intrabar execution contract. + +This is not a performance engine. It is the reference state machine used to +prove the later Numba kernel. Strategy output is intentionally compact: +entry side/size plus optional stop, take-profit, trailing distance, and +technical-exit arrays. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, IntFlag +from typing import Dict, Optional, Sequence + +import numpy as np +import pandas as pd + +from .execution_contract import ExecutionContract, IntrabarSameBarPolicy, TakeProfitGapPolicy +from .constraints import quantize_signed_quantity +from .intrabar_session import ( + EntryPositionPolicy, + IntrabarSessionTape, + ProtectiveExitReentryPolicy, + SessionCounterBasis, + SessionExecutionPolicy, +) +from .market_tape import PreparedMarketTape +from .schema import AccountConfig + + +class IntrabarLevelMode(str, Enum): + ABSOLUTE_PRICE = "absolute_price" + PRICE_DISTANCE = "price_distance" + PERCENT_DISTANCE = "percent_distance" + + +class IntrabarSizingMode(str, Enum): + UNITS = "units" + FIXED_NOTIONAL = "fixed_notional" + PCT_EQUITY = "pct_equity" + RISK_PER_TRADE = "risk_per_trade" + + +class IntrabarFillReason(str, Enum): + ENTRY = "entry" + TECHNICAL_EXIT = "technical_exit" + REVERSAL_EXIT = "reversal_exit" + REVERSAL_ENTRY = "reversal_entry" + STOP_LOSS = "stop_loss" + TAKE_PROFIT = "take_profit" + LIQUIDATION = "liquidation" + FINAL_CLOSE = "final_close" + SESSION_FORCED_EXIT = "session_forced_exit" + + +class IntrabarEventFlag(IntFlag): + NONE = 0 + ENTRY_FILLED = 1 << 0 + EXIT_FILLED = 1 << 1 + STOP_FILLED = 1 << 2 + TP_FILLED = 1 << 3 + TECH_EXIT = 1 << 4 + REVERSAL = 1 << 5 + AMBIGUOUS = 1 << 6 + FUNDING = 1 << 7 + LIQUIDATION = 1 << 8 + REJECTED = 1 << 9 + ENTRY_SUPPRESSED = 1 << 10 + SESSION_RESET = 1 << 11 + SESSION_FORCED_EXIT = 1 << 12 + ENTRY_WINDOW_BLOCKED = 1 << 13 + ENTRY_QUOTA_BLOCKED = 1 << 14 + FLAT_ONLY_BLOCKED = 1 << 15 + STALE_SESSION_SIGNAL = 1 << 16 + PROTECTIVE_REENTRY_BLOCKED = 1 << 17 + + +@dataclass(frozen=True) +class IntrabarIntentTape: + entry_side: np.ndarray + entry_size: np.ndarray + stop_value: Optional[np.ndarray] = None + take_profit_value: Optional[np.ndarray] = None + trailing_value: Optional[np.ndarray] = None + technical_exit: Optional[np.ndarray] = None + exit_long: Optional[np.ndarray] = None + exit_short: Optional[np.ndarray] = None + level_mode: IntrabarLevelMode = IntrabarLevelMode.PERCENT_DISTANCE + + def __post_init__(self) -> None: + n = len(self.entry_side) + if len(self.entry_size) != n: + raise ValueError("entry_size must have the same length as entry_side") + for name in ("stop_value", "take_profit_value", "trailing_value", "technical_exit", "exit_long", "exit_short"): + value = getattr(self, name) + if value is not None and len(value) != n: + raise ValueError(f"{name} must have the same length as entry_side") + + @classmethod + def from_arrays( + cls, + *, + entry_side: Sequence, + entry_size: Sequence, + stop_value: Optional[Sequence] = None, + take_profit_value: Optional[Sequence] = None, + trailing_value: Optional[Sequence] = None, + technical_exit: Optional[Sequence] = None, + exit_long: Optional[Sequence] = None, + exit_short: Optional[Sequence] = None, + level_mode: IntrabarLevelMode = IntrabarLevelMode.PERCENT_DISTANCE, + ) -> "IntrabarIntentTape": + legacy_exit = None if technical_exit is None else np.ascontiguousarray(technical_exit, dtype=np.bool_) + return cls( + entry_side=np.ascontiguousarray(entry_side, dtype=np.int8), + entry_size=np.ascontiguousarray(entry_size, dtype=np.float64), + stop_value=_optional_float_array(stop_value), + take_profit_value=_optional_float_array(take_profit_value), + trailing_value=_optional_float_array(trailing_value), + technical_exit=legacy_exit, + exit_long=legacy_exit if exit_long is None and legacy_exit is not None else _optional_bool_array(exit_long), + exit_short=legacy_exit if exit_short is None and legacy_exit is not None else _optional_bool_array(exit_short), + level_mode=level_mode, + ) + + @classmethod + def from_frame( + cls, + frame: pd.DataFrame, + *, + entry_side_col: str = "entry_side", + signal_col: Optional[str] = None, + entry_size_col: str = "entry_size", + stop_col: str = "stop_value", + take_profit_col: str = "take_profit_value", + trailing_col: str = "trailing_value", + technical_exit_col: str = "technical_exit", + exit_long_col: str = "exit_long", + exit_short_col: str = "exit_short", + level_mode: IntrabarLevelMode = IntrabarLevelMode.PERCENT_DISTANCE, + ) -> "IntrabarIntentTape": + """Build intrabar intents from an alpha output frame. + + This is an adapter convenience only. Strategy code still owns signal + causality; the intrabar kernel still owns fills, SL/TP/trailing, fee, + funding, margin, and liquidation semantics. + """ + + if not isinstance(frame, pd.DataFrame): + raise TypeError("frame must be a pandas DataFrame") + if entry_side_col in frame: + side = np.sign(frame[entry_side_col].fillna(0.0).to_numpy(dtype=float)).astype(np.int8) + else: + raw_col = signal_col or ("signal" if "signal" in frame else "entry") + if raw_col not in frame: + raise ValueError(f"frame must contain {entry_side_col!r}, {raw_col!r}, or provide signal_col") + raw = frame[raw_col].fillna(0.0).to_numpy(dtype=float) + side = np.sign(raw).astype(np.int8) + if entry_size_col in frame: + size = np.abs(frame[entry_size_col].fillna(0.0).to_numpy(dtype=float)) + else: + size = np.abs(side.astype(np.float64)) + + def optional(name: str): + return frame[name].to_numpy() if name in frame else None + + return cls.from_arrays( + entry_side=side, + entry_size=size, + stop_value=optional(stop_col), + take_profit_value=optional(take_profit_col), + trailing_value=optional(trailing_col), + technical_exit=optional(technical_exit_col), + exit_long=optional(exit_long_col), + exit_short=optional(exit_short_col), + level_mode=level_mode, + ) + + +@dataclass(frozen=True) +class IntrabarFill: + bar_index: int + sequence: int + timestamp: pd.Timestamp + side: int + qty: float + price: float + fee: float + reason: IntrabarFillReason + + +@dataclass(frozen=True) +class IntrabarReferenceResult: + equity: pd.Series + position: pd.Series + average_entry: pd.Series + active_stop: pd.Series + active_take_profit: pd.Series + fees: pd.Series + funding: pd.Series + event_flags: pd.Series + fills: tuple[IntrabarFill, ...] + ambiguity_count: int + rejected_count: int = 0 + liquidated: bool = False + liquidation_bar: int = -1 + metadata: Dict = field(default_factory=dict) + + +def run_intrabar_reference( + *, + tape: PreparedMarketTape, + intent: IntrabarIntentTape, + account: AccountConfig, + contract: Optional[ExecutionContract] = None, + fee_rate: float = 0.0, + slippage_rate: float = 0.0, + contract_size: float = 1.0, + sizing_mode: IntrabarSizingMode | str = IntrabarSizingMode.UNITS, + fixed_notional: float = 0.0, + equity_fraction: float = 0.0, + risk_fraction: float = 0.0, + qty_step: float = 0.0, + min_qty: float = 0.0, + min_notional: float = 0.0, + tick_size: float = 0.0, + session_policy: Optional[SessionExecutionPolicy] = None, + session_tape: Optional[IntrabarSessionTape] = None, +) -> IntrabarReferenceResult: + """ + Execute a single-symbol intrabar bracket tape with causal next-open timing. + + Decision arrays at index `t-1` become executable at `open[t]`. + """ + if tape.n_symbols != 1: + raise NotImplementedError("Phase 31B intrabar oracle certifies single-symbol tapes only") + if len(intent.entry_side) != tape.n_bars: + raise ValueError("intent length must match market tape length") + if account.initial_capital <= 0.0: + raise ValueError("initial_capital must be > 0") + if fee_rate < 0.0 or slippage_rate < 0.0: + raise ValueError("fee_rate and slippage_rate must be >= 0") + if (session_policy is None) != (session_tape is None): + raise ValueError("session_policy and session_tape must be provided together") + session_enabled = session_policy is not None + if session_enabled and len(session_tape.session_id) != tape.n_bars: + raise ValueError("session_tape length must match market tape length") + contract = contract or ExecutionContract.intrabar_bracket() + if contract.engine_id != "intrabar_bracket_v1": + raise ValueError("run_intrabar_reference requires intrabar_bracket_v1 contract") + _validate_intrabar_contract_supported(contract) + sizing_code = IntrabarSizingMode(sizing_mode) + + idx = pd.DatetimeIndex(pd.to_datetime(tape.timestamps_ns, utc=True)) + opens = tape.opens[:, 0] + highs = tape.highs[:, 0] + lows = tape.lows[:, 0] + closes = tape.closes[:, 0] + funding_rates = tape.funding_rates[:, 0] + funding_mask = tape.funding_event_mask + timestamp_semantics = str(getattr(tape, "bar_timestamp_semantics", "close")).lower().strip() + if timestamp_semantics not in {"open", "close"}: + raise ValueError("bar_timestamp_semantics must be 'open' or 'close'") + funding_at_open = timestamp_semantics == "open" + + n = tape.n_bars + equity_arr = np.zeros(n, dtype=np.float64) + pos_arr = np.zeros(n, dtype=np.float64) + avg_arr = np.zeros(n, dtype=np.float64) + stop_arr = np.zeros(n, dtype=np.float64) + tp_arr = np.zeros(n, dtype=np.float64) + fee_arr = np.zeros(n, dtype=np.float64) + funding_arr = np.zeros(n, dtype=np.float64) + flags_arr = np.zeros(n, dtype=np.uint32) + + equity = float(account.initial_capital) + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + fills: list[IntrabarFill] = [] + ambiguity_count = 0 + rejected_count = 0 + liquidated = False + liquidation_bar = -1 + current_session_id = int(session_tape.session_id[0]) if session_enabled and n else 0 + long_entry_count = 0 + short_entry_count = 0 + protective_exit_on_previous_bar = False + session_reset_count = 0 + session_forced_exit_count = 0 + entry_window_blocked_count = 0 + long_quota_blocked_count = 0 + short_quota_blocked_count = 0 + flat_only_blocked_count = 0 + stale_session_signal_count = 0 + reentry_suppressed_count = 0 + + equity_arr[0] = equity + for t in range(1, n): + if liquidated: + equity_arr[t] = 0.0 + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + continue + + seq = 0 + open_ref = float(opens[t]) + close_ref = float(closes[t]) + last_ref = open_ref + if position != 0.0: + equity += position * (open_ref - float(closes[t - 1])) * contract_size + + reentry_block_from_previous_bar = False + if session_enabled: + bar_session_id = int(session_tape.session_id[t]) + if bar_session_id != current_session_id: + current_session_id = bar_session_id + long_entry_count = 0 + short_entry_count = 0 + protective_exit_on_previous_bar = False + flags_arr[t] |= int(IntrabarEventFlag.SESSION_RESET) + session_reset_count += 1 + reentry_block_from_previous_bar = bool(protective_exit_on_previous_bar) + protective_exit_on_previous_bar = False + + if position != 0.0 and _maintenance_breached(equity, position, open_ref, contract_size, account.maintenance_ratio): + side = -1 if position > 0.0 else 1 + price = _market_price(open_ref, side, slippage_rate, tick_size=tick_size) + fee = abs(position) * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fills.append(_fill(t, seq, idx[t], side, abs(position), price, fee, IntrabarFillReason.LIQUIDATION)) + flags_arr[t] |= int(IntrabarEventFlag.EXIT_FILLED | IntrabarEventFlag.LIQUIDATION) + liquidated = True + liquidation_bar = t + equity = 0.0 + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + equity_arr[t] = 0.0 + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + continue + + if funding_at_open and position != 0.0 and funding_mask[t]: + funding_cost = position * open_ref * contract_size * funding_rates[t] + equity -= funding_cost + funding_arr[t] = funding_cost + flags_arr[t] |= int(IntrabarEventFlag.FUNDING) + + force_flat_bar = bool(session_enabled and session_tape.force_flat_at_open[t]) + if force_flat_bar and position != 0.0: + side = -1 if position > 0.0 else 1 + price = _market_price(open_ref, side, slippage_rate, tick_size=tick_size) + fee = abs(position) * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fills.append(_fill(t, seq, idx[t], side, abs(position), price, fee, IntrabarFillReason.SESSION_FORCED_EXIT)) + seq += 1 + flags_arr[t] |= int(IntrabarEventFlag.EXIT_FILLED | IntrabarEventFlag.SESSION_FORCED_EXIT) + session_forced_exit_count += 1 + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + pending_side = int(intent.entry_side[t - 1]) + pending_size = float(intent.entry_size[t - 1]) + pending_exit = _pending_exit(intent, t - 1, position) + stale_session_signal = bool( + session_enabled + and session_policy.cancel_pending_on_session_change + and pending_side != 0 + and int(session_tape.session_id[t - 1]) != int(session_tape.session_id[t]) + ) + if stale_session_signal: + pending_side = 0 + pending_size = 0.0 + flags_arr[t] |= int(IntrabarEventFlag.STALE_SESSION_SIGNAL | IntrabarEventFlag.ENTRY_SUPPRESSED) + stale_session_signal_count += 1 + if ( + session_enabled + and pending_side != 0 + and position != 0.0 + and session_policy.entry_position_policy is EntryPositionPolicy.FLAT_ONLY + ): + pending_side = 0 + pending_size = 0.0 + flags_arr[t] |= int(IntrabarEventFlag.FLAT_ONLY_BLOCKED | IntrabarEventFlag.ENTRY_SUPPRESSED) + flat_only_blocked_count += 1 + exit_same_side_conflict = bool( + pending_exit and pending_side != 0 and position != 0.0 and np.sign(position) == pending_side + ) + reversal_allowed = not ( + session_enabled and session_policy.entry_position_policy is EntryPositionPolicy.FLAT_ONLY + ) + + if position != 0.0 and (pending_exit or (reversal_allowed and pending_side != 0 and np.sign(position) != pending_side)): + reason = IntrabarFillReason.REVERSAL_EXIT if pending_side != 0 and np.sign(position) != pending_side else IntrabarFillReason.TECHNICAL_EXIT + side = -1 if position > 0.0 else 1 + price = _market_price(open_ref, side, slippage_rate, tick_size=tick_size) + fee = abs(position) * price * contract_size * fee_rate + equity += position * (price - open_ref) * contract_size - fee + fee_arr[t] += fee + fills.append(_fill(t, seq, idx[t], side, abs(position), price, fee, reason)) + seq += 1 + flags_arr[t] |= int(IntrabarEventFlag.EXIT_FILLED) + if reason is IntrabarFillReason.TECHNICAL_EXIT: + flags_arr[t] |= int(IntrabarEventFlag.TECH_EXIT) + else: + flags_arr[t] |= int(IntrabarEventFlag.REVERSAL) + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + if pending_side != 0 and pending_size > 0.0 and position == 0.0: + side = 1 if pending_side > 0 else -1 + price = _market_price(open_ref, side, slippage_rate, tick_size=tick_size) + entry_blocked = False + if session_enabled: + if force_flat_bar and session_policy.suppress_entry_on_force_flat_bar: + entry_blocked = True + flags_arr[t] |= int(IntrabarEventFlag.SESSION_FORCED_EXIT | IntrabarEventFlag.ENTRY_SUPPRESSED) + elif not bool(session_tape.entry_allowed_at_open[t]): + entry_blocked = True + entry_window_blocked_count += 1 + flags_arr[t] |= int(IntrabarEventFlag.ENTRY_WINDOW_BLOCKED | IntrabarEventFlag.ENTRY_SUPPRESSED) + elif ( + session_policy.protective_exit_reentry_policy is ProtectiveExitReentryPolicy.SUPPRESS_SIGNAL_BAR + and reentry_block_from_previous_bar + ): + entry_blocked = True + reentry_suppressed_count += 1 + flags_arr[t] |= int(IntrabarEventFlag.PROTECTIVE_REENTRY_BLOCKED | IntrabarEventFlag.ENTRY_SUPPRESSED) + elif side > 0 and session_policy.max_long_entries_per_session is not None and long_entry_count >= session_policy.max_long_entries_per_session: + entry_blocked = True + long_quota_blocked_count += 1 + flags_arr[t] |= int(IntrabarEventFlag.ENTRY_QUOTA_BLOCKED | IntrabarEventFlag.ENTRY_SUPPRESSED) + elif side < 0 and session_policy.max_short_entries_per_session is not None and short_entry_count >= session_policy.max_short_entries_per_session: + entry_blocked = True + short_quota_blocked_count += 1 + flags_arr[t] |= int(IntrabarEventFlag.ENTRY_QUOTA_BLOCKED | IntrabarEventFlag.ENTRY_SUPPRESSED) + if exit_same_side_conflict or entry_blocked: + qty = 0.0 + else: + qty = _compile_entry_quantity( + size_weight=float(pending_size), + fill_price=price, + equity=equity, + contract_size=contract_size, + sizing_mode=sizing_code, + fixed_notional=fixed_notional, + equity_fraction=equity_fraction, + risk_fraction=risk_fraction, + stop_value=None if intent.stop_value is None else float(intent.stop_value[t - 1]), + level_mode=intent.level_mode, + side=side, + tick_size=tick_size, + ) + qty = abs( + quantize_signed_quantity( + qty, + price, + contract_size=contract_size, + qty_step=qty_step, + min_qty=min_qty, + min_notional=min_notional, + ) + ) + if exit_same_side_conflict or entry_blocked: + flags_arr[t] |= int(IntrabarEventFlag.ENTRY_SUPPRESSED) + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + if qty <= 0.0: + flags_arr[t] |= int(IntrabarEventFlag.REJECTED) + rejected_count += 1 + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + if not _has_initial_margin(equity, qty, price, contract_size, account.leverage, account.margin_buffer): + flags_arr[t] |= int(IntrabarEventFlag.REJECTED) + rejected_count += 1 + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + continue + fee = qty * price * contract_size * fee_rate + equity -= fee + fee_arr[t] += fee + position = qty * side + avg_entry = price + last_ref = price + active_stop, active_tp = _initial_bracket(intent, t - 1, side, price, tick_size=tick_size) + reason = IntrabarFillReason.REVERSAL_ENTRY if flags_arr[t] & int(IntrabarEventFlag.REVERSAL) else IntrabarFillReason.ENTRY + fills.append(_fill(t, seq, idx[t], side, qty, price, fee, reason)) + seq += 1 + flags_arr[t] |= int(IntrabarEventFlag.ENTRY_FILLED) + if session_enabled and session_policy.counter_basis in {SessionCounterBasis.FILLED_ENTRY, SessionCounterBasis.ACCEPTED_ENTRY}: + if side > 0: + long_entry_count += 1 + else: + short_entry_count += 1 + + if position != 0.0: + exit_info = _resolve_intrabar_exit( + side=1 if position > 0.0 else -1, + open_price=open_ref, + high=float(highs[t]), + low=float(lows[t]), + stop_price=active_stop, + tp_price=active_tp, + same_bar_policy=contract.same_bar_policy, + take_profit_gap_policy=contract.take_profit_gap_policy, + slippage_rate=slippage_rate, + tick_size=tick_size, + ) + if exit_info is not None: + exit_side, exit_price, reason, ambiguous = exit_info + if ambiguous: + flags_arr[t] |= int(IntrabarEventFlag.AMBIGUOUS) + ambiguity_count += 1 + qty = abs(position) + fee = qty * exit_price * contract_size * fee_rate + equity += position * (exit_price - last_ref) * contract_size - fee + fee_arr[t] += fee + fills.append(_fill(t, seq, idx[t], exit_side, qty, exit_price, fee, reason)) + seq += 1 + flags_arr[t] |= int(IntrabarEventFlag.EXIT_FILLED) + if reason is IntrabarFillReason.STOP_LOSS: + flags_arr[t] |= int(IntrabarEventFlag.STOP_FILLED) + if session_enabled: + protective_exit_on_previous_bar = True + else: + flags_arr[t] |= int(IntrabarEventFlag.TP_FILLED) + if session_enabled: + protective_exit_on_previous_bar = True + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + + if position != 0.0: + if _maintenance_breached_at_worst( + equity, + position, + last_ref, + high=float(highs[t]), + low=float(lows[t]), + contract_size=contract_size, + maintenance_ratio=account.maintenance_ratio, + ): + side = -1 if position > 0.0 else 1 + worst = float(lows[t]) if position > 0.0 else float(highs[t]) + price = _market_price(worst, side, slippage_rate, tick_size=tick_size) + fee = abs(position) * price * contract_size * fee_rate + equity += position * (price - last_ref) * contract_size - fee + fee_arr[t] += fee + fills.append(_fill(t, seq, idx[t], side, abs(position), price, fee, IntrabarFillReason.LIQUIDATION)) + flags_arr[t] |= int(IntrabarEventFlag.EXIT_FILLED | IntrabarEventFlag.LIQUIDATION) + liquidated = True + liquidation_bar = t + equity = 0.0 + position = 0.0 + avg_entry = 0.0 + active_stop = np.nan + active_tp = np.nan + else: + equity += position * (close_ref - last_ref) * contract_size + active_stop = _update_trailing(intent, t, position, close_ref, active_stop, tick_size=tick_size) + + if liquidated: + equity_arr[t] = 0.0 + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + continue + + if not funding_at_open and position != 0.0 and funding_mask[t]: + funding_cost = position * close_ref * contract_size * funding_rates[t] + equity -= funding_cost + funding_arr[t] = funding_cost + flags_arr[t] |= int(IntrabarEventFlag.FUNDING) + + equity_arr[t] = equity + pos_arr[t] = position + avg_arr[t] = avg_entry + stop_arr[t] = 0.0 if not np.isfinite(active_stop) else active_stop + tp_arr[t] = 0.0 if not np.isfinite(active_tp) else active_tp + + if contract.close_on_last_bar and position != 0.0: + t = n - 1 + side = -1 if position > 0.0 else 1 + price = _market_price(float(closes[t]), side, slippage_rate, tick_size=tick_size) + fee = abs(position) * price * contract_size * fee_rate + equity += position * (price - float(closes[t])) * contract_size - fee + fee_arr[t] += fee + fills.append(_fill(t, 99, idx[t], side, abs(position), price, fee, IntrabarFillReason.FINAL_CLOSE)) + position = 0.0 + equity_arr[t] = equity + pos_arr[t] = 0.0 + avg_arr[t] = 0.0 + stop_arr[t] = 0.0 + tp_arr[t] = 0.0 + + return IntrabarReferenceResult( + equity=pd.Series(equity_arr, index=idx, name="equity"), + position=pd.Series(pos_arr, index=idx, name=f"Position_{tape.symbols[0]}"), + average_entry=pd.Series(avg_arr, index=idx, name="average_entry"), + active_stop=pd.Series(stop_arr, index=idx, name="active_stop"), + active_take_profit=pd.Series(tp_arr, index=idx, name="active_take_profit"), + fees=pd.Series(fee_arr, index=idx, name="fees"), + funding=pd.Series(funding_arr, index=idx, name="funding"), + event_flags=pd.Series(flags_arr, index=idx, name="event_flags"), + fills=tuple(fills), + ambiguity_count=int(ambiguity_count), + rejected_count=int(rejected_count), + liquidated=bool(liquidated), + liquidation_bar=int(liquidation_bar), + metadata={ + "engine": "intrabar_reference_v1", + "engine_id": "intrabar_reference_v1", + "execution_contract": contract.to_metadata(), + "data_signature": tape.signature, + "fill_count": len(fills), + "ambiguity_count": int(ambiguity_count), + "rejected_count": int(rejected_count), + "liquidated": bool(liquidated), + "liquidation_bar": int(liquidation_bar), + "oracle": True, + "funding_timing_certified": True, + "funding_event_alignment": "exact_bar_timestamp", + "bar_timestamp_semantics": timestamp_semantics, + "funding_event_price_reference": "open" if funding_at_open else "close", + "sizing_mode": sizing_code.value, + "quantity_constraints": { + "qty_step": float(qty_step), + "min_qty": float(min_qty), + "min_notional": float(min_notional), + "tick_size": float(tick_size), + }, + **( + { + "session_execution_enabled": True, + "session_policy": session_policy.to_metadata(), + "session_tape_signature": session_tape.signature, + "session_reset_count": int(session_reset_count), + "session_forced_exit_count": int(session_forced_exit_count), + "entry_window_blocked_count": int(entry_window_blocked_count), + "long_quota_blocked_count": int(long_quota_blocked_count), + "short_quota_blocked_count": int(short_quota_blocked_count), + "flat_only_blocked_count": int(flat_only_blocked_count), + "stale_session_signal_count": int(stale_session_signal_count), + "reentry_suppressed_count": int(reentry_suppressed_count), + } + if session_enabled + else {"session_execution_enabled": False} + ), + }, + ) + + +def _optional_float_array(value) -> Optional[np.ndarray]: + if value is None: + return None + return np.ascontiguousarray(value, dtype=np.float64) + + +def _optional_bool_array(value) -> Optional[np.ndarray]: + if value is None: + return None + return np.ascontiguousarray(value, dtype=np.bool_) + + +def _fill(bar, seq, ts, side, qty, price, fee, reason) -> IntrabarFill: + return IntrabarFill( + bar_index=int(bar), + sequence=int(seq), + timestamp=pd.Timestamp(ts), + side=int(side), + qty=float(qty), + price=float(price), + fee=float(fee), + reason=reason, + ) + + +def _market_price(open_price: float, side: int, slippage_rate: float, *, tick_size: float = 0.0) -> float: + raw = float(open_price * (1.0 + slippage_rate if side > 0 else 1.0 - slippage_rate)) + return _quantize_price(raw, side, tick_size) + + +def _has_initial_margin(equity: float, qty: float, price: float, contract_size: float, leverage: float, margin_buffer: float) -> bool: + required = abs(qty) * price * contract_size / leverage + return bool(equity >= required * (1.0 + margin_buffer)) + + +def _maintenance_breached(equity: float, position: float, price: float, contract_size: float, maintenance_ratio: float) -> bool: + maintenance = abs(position) * price * contract_size * maintenance_ratio + return bool(maintenance > 0.0 and equity <= maintenance) + + +def _maintenance_breached_at_worst( + equity: float, + position: float, + reference_price: float, + *, + high: float, + low: float, + contract_size: float, + maintenance_ratio: float, +) -> bool: + worst = low if position > 0.0 else high + worst_equity = equity + position * (worst - reference_price) * contract_size + maintenance = abs(position) * worst * contract_size * maintenance_ratio + return bool(maintenance > 0.0 and worst_equity <= maintenance) + + +def _initial_bracket(intent: IntrabarIntentTape, signal_bar: int, side: int, fill_price: float, *, tick_size: float = 0.0) -> tuple[float, float]: + stop = np.nan + tp = np.nan + if intent.stop_value is not None and np.isfinite(intent.stop_value[signal_bar]) and intent.stop_value[signal_bar] > 0.0: + stop = _level_price(fill_price, side, float(intent.stop_value[signal_bar]), intent.level_mode, is_stop=True, tick_size=tick_size) + if ( + intent.take_profit_value is not None + and np.isfinite(intent.take_profit_value[signal_bar]) + and intent.take_profit_value[signal_bar] > 0.0 + ): + tp = _level_price(fill_price, side, float(intent.take_profit_value[signal_bar]), intent.level_mode, is_stop=False, tick_size=tick_size) + if intent.trailing_value is not None and np.isfinite(intent.trailing_value[signal_bar]) and intent.trailing_value[signal_bar] > 0.0: + trailing_stop = _level_price(fill_price, side, float(intent.trailing_value[signal_bar]), intent.level_mode, is_stop=True, tick_size=tick_size) + stop = trailing_stop if not np.isfinite(stop) else (max(stop, trailing_stop) if side > 0 else min(stop, trailing_stop)) + return stop, tp + + +def _level_price(price: float, side: int, value: float, mode: IntrabarLevelMode, *, is_stop: bool, tick_size: float = 0.0) -> float: + direction = -1.0 if (side > 0 and is_stop) or (side < 0 and not is_stop) else 1.0 + if mode is IntrabarLevelMode.ABSOLUTE_PRICE: + return _quantize_price(float(value), -side, tick_size) + if mode is IntrabarLevelMode.PRICE_DISTANCE: + return _quantize_price(float(price + direction * value), -side, tick_size) + if mode is IntrabarLevelMode.PERCENT_DISTANCE: + return _quantize_price(float(price * (1.0 + direction * value)), -side, tick_size) + raise NotImplementedError(f"unsupported level mode={mode!r}") + + +def _resolve_intrabar_exit( + *, + side: int, + open_price: float, + high: float, + low: float, + stop_price: float, + tp_price: float, + same_bar_policy: IntrabarSameBarPolicy, + take_profit_gap_policy: TakeProfitGapPolicy, + slippage_rate: float, + tick_size: float = 0.0, +): + has_stop = np.isfinite(stop_price) and stop_price > 0.0 + has_tp = np.isfinite(tp_price) and tp_price > 0.0 + if side > 0: + stop_hit = has_stop and low <= stop_price + tp_hit = has_tp and high >= tp_price + stop_gap = has_stop and open_price <= stop_price + tp_gap = has_tp and open_price >= tp_price + exit_side = -1 + else: + stop_hit = has_stop and high >= stop_price + tp_hit = has_tp and low <= tp_price + stop_gap = has_stop and open_price >= stop_price + tp_gap = has_tp and open_price <= tp_price + exit_side = 1 + if not stop_hit and not tp_hit: + return None + ambiguous = bool(stop_hit and tp_hit) + if ambiguous and same_bar_policy is IntrabarSameBarPolicy.REJECT_AMBIGUOUS: + raise ValueError("same bar stop/take-profit ambiguity requires lower timeframe or explicit policy") + stop_first = same_bar_policy in { + IntrabarSameBarPolicy.CONSERVATIVE, + IntrabarSameBarPolicy.STOP_FIRST, + IntrabarSameBarPolicy.OLHC_PATH if side > 0 else IntrabarSameBarPolicy.OHLC_PATH, + } + if stop_hit and (not tp_hit or stop_first): + price = open_price if stop_gap else stop_price + price = _market_price(float(price), exit_side, slippage_rate, tick_size=tick_size) + return exit_side, price, IntrabarFillReason.STOP_LOSS, ambiguous + if tp_hit: + if tp_gap and take_profit_gap_policy is TakeProfitGapPolicy.OPEN_PRICE_IMPROVEMENT: + price = open_price + else: + price = tp_price + return exit_side, _quantize_price(float(price), exit_side, tick_size), IntrabarFillReason.TAKE_PROFIT, ambiguous + return None + + +def _update_trailing(intent: IntrabarIntentTape, signal_bar: int, position: float, close_price: float, current_stop: float, *, tick_size: float = 0.0) -> float: + if intent.trailing_value is None: + return current_stop + value = float(intent.trailing_value[signal_bar]) + if not np.isfinite(value) or value <= 0.0: + return current_stop + side = 1 if position > 0.0 else -1 + candidate = _level_price(close_price, side, value, intent.level_mode, is_stop=True, tick_size=tick_size) + if not np.isfinite(current_stop): + return candidate + return max(current_stop, candidate) if side > 0 else min(current_stop, candidate) + + +def _pending_exit(intent: IntrabarIntentTape, signal_bar: int, position: float) -> bool: + if position > 0.0 and intent.exit_long is not None: + return bool(intent.exit_long[signal_bar]) + if position < 0.0 and intent.exit_short is not None: + return bool(intent.exit_short[signal_bar]) + if intent.technical_exit is not None: + return bool(intent.technical_exit[signal_bar]) + return False + + +def _compile_entry_quantity( + *, + size_weight: float, + fill_price: float, + equity: float, + contract_size: float, + sizing_mode: IntrabarSizingMode, + fixed_notional: float, + equity_fraction: float, + risk_fraction: float, + stop_value: Optional[float], + level_mode: IntrabarLevelMode, + side: int, + tick_size: float = 0.0, +) -> float: + weight = abs(float(size_weight)) + if sizing_mode is IntrabarSizingMode.UNITS: + return weight + if sizing_mode is IntrabarSizingMode.FIXED_NOTIONAL: + notional = float(fixed_notional) * weight + return notional / (fill_price * contract_size) if fill_price > 0.0 and contract_size > 0.0 else 0.0 + if sizing_mode is IntrabarSizingMode.PCT_EQUITY: + notional = float(equity) * float(equity_fraction) * weight + return notional / (fill_price * contract_size) if fill_price > 0.0 and contract_size > 0.0 else 0.0 + if sizing_mode is IntrabarSizingMode.RISK_PER_TRADE: + if stop_value is None or not np.isfinite(stop_value) or stop_value <= 0.0: + return 0.0 + stop_price = _level_price(fill_price, side, float(stop_value), level_mode, is_stop=True, tick_size=tick_size) + stop_distance = abs(fill_price - stop_price) + risk_budget = float(equity) * float(risk_fraction) * weight + return risk_budget / (stop_distance * contract_size) if stop_distance > 0.0 and contract_size > 0.0 else 0.0 + raise NotImplementedError(f"unsupported intrabar sizing_mode={sizing_mode!r}") + + +def _quantize_price(price: float, side: int, tick_size: float) -> float: + tick = float(tick_size) + if tick <= 0.0 or not np.isfinite(price): + return float(price) + if side > 0: + return float(np.ceil((float(price) / tick) - 1e-12) * tick) + return float(np.floor((float(price) / tick) + 1e-12) * tick) + + +def _validate_intrabar_contract_supported(contract: ExecutionContract) -> None: + from .execution_contract import ( + AmbiguityPolicy, + FillPhase, + FundingPhase, + LiquidationPriority, + MarketFillPolicy, + SignalPhase, + StopGapPolicy, + TrailingUpdatePhase, + ) + + if contract.signal_phase is not SignalPhase.BAR_CLOSE: + raise NotImplementedError("intrabar_bracket_v1 supports signal_phase=bar_close only") + if contract.entry_fill_phase is not FillPhase.NEXT_OPEN: + raise NotImplementedError("intrabar_bracket_v1 supports entry_fill_phase=next_open only") + if contract.market_fill_policy is not MarketFillPolicy.NEXT_OPEN: + raise NotImplementedError("intrabar_bracket_v1 supports market_fill_policy=next_open only") + if contract.stop_gap_policy is not StopGapPolicy.OPEN_WORSE_THAN_TRIGGER: + raise NotImplementedError("intrabar_bracket_v1 supports stop_gap_policy=open_worse_than_trigger only") + if contract.trailing_update_phase is not TrailingUpdatePhase.NEXT_BAR: + raise NotImplementedError("intrabar_bracket_v1 supports trailing_update_phase=next_bar only") + if contract.funding_phase is not FundingPhase.POSITION_AT_EVENT: + raise NotImplementedError("intrabar_bracket_v1 supports funding_phase=position_at_event only") + if contract.liquidation_priority is not LiquidationPriority.LIQUIDATION_FIRST_AT_GAP: + raise NotImplementedError("intrabar_bracket_v1 supports liquidation_priority=liquidation_first_at_gap only") + if contract.ambiguity_policy not in {AmbiguityPolicy.FLAG_AND_CONSERVATIVE, AmbiguityPolicy.REJECT}: + raise NotImplementedError("intrabar_bracket_v1 supports ambiguity_policy flag_and_conservative or reject only") diff --git a/src/quantbt/core/intrabar_session.py b/src/quantbt/core/intrabar_session.py new file mode 100644 index 0000000..b9d57fa --- /dev/null +++ b/src/quantbt/core/intrabar_session.py @@ -0,0 +1,156 @@ +"""Session-aware intrabar execution primitives. + +These objects are intentionally data-only. Calendar, timezone, and entry-window +logic are normalized before the execution kernel so the hot path never needs to +parse datetimes. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Sequence + +import numpy as np +import pandas as pd + + +class EntryPositionPolicy(str, Enum): + CURRENT_BEHAVIOR = "current_behavior" + FLAT_ONLY = "flat_only" + REVERSE = "reverse" + + +class SessionCounterBasis(str, Enum): + FILLED_ENTRY = "filled_entry" + ACCEPTED_ENTRY = "accepted_entry" + + +class ProtectiveExitReentryPolicy(str, Enum): + ALLOW = "allow" + SUPPRESS_SIGNAL_BAR = "suppress_signal_bar" + + +@dataclass(frozen=True) +class SessionExecutionPolicy: + entry_position_policy: EntryPositionPolicy = EntryPositionPolicy.CURRENT_BEHAVIOR + max_long_entries_per_session: Optional[int] = None + max_short_entries_per_session: Optional[int] = None + counter_basis: SessionCounterBasis = SessionCounterBasis.FILLED_ENTRY + cancel_pending_on_session_change: bool = True + suppress_entry_on_force_flat_bar: bool = True + protective_exit_reentry_policy: ProtectiveExitReentryPolicy = ProtectiveExitReentryPolicy.ALLOW + + def __post_init__(self) -> None: + object.__setattr__(self, "entry_position_policy", EntryPositionPolicy(self.entry_position_policy)) + object.__setattr__(self, "counter_basis", SessionCounterBasis(self.counter_basis)) + object.__setattr__(self, "protective_exit_reentry_policy", ProtectiveExitReentryPolicy(self.protective_exit_reentry_policy)) + for name in ("max_long_entries_per_session", "max_short_entries_per_session"): + value = getattr(self, name) + if value is not None and int(value) < 0: + raise ValueError(f"{name} must be >= 0 when provided") + if value is not None: + object.__setattr__(self, name, int(value)) + + def to_metadata(self) -> dict: + return { + "entry_position_policy": self.entry_position_policy.value, + "max_long_entries_per_session": self.max_long_entries_per_session, + "max_short_entries_per_session": self.max_short_entries_per_session, + "counter_basis": self.counter_basis.value, + "cancel_pending_on_session_change": bool(self.cancel_pending_on_session_change), + "suppress_entry_on_force_flat_bar": bool(self.suppress_entry_on_force_flat_bar), + "protective_exit_reentry_policy": self.protective_exit_reentry_policy.value, + } + + @classmethod + def from_metadata(cls, metadata: Optional[dict]) -> Optional["SessionExecutionPolicy"]: + if metadata is None: + return None + if isinstance(metadata, SessionExecutionPolicy): + return metadata + return cls(**dict(metadata)) + + +@dataclass(frozen=True) +class IntrabarSessionTape: + session_id: np.ndarray + entry_allowed_at_open: np.ndarray + force_flat_at_open: np.ndarray + signature: str = "" + + def __post_init__(self) -> None: + session_id = np.ascontiguousarray(self.session_id, dtype=np.int64) + entry_allowed = np.ascontiguousarray(self.entry_allowed_at_open, dtype=np.bool_) + force_flat = np.ascontiguousarray(self.force_flat_at_open, dtype=np.bool_) + n = len(session_id) + if len(entry_allowed) != n or len(force_flat) != n: + raise ValueError("session tape arrays must have the same length") + session_id.setflags(write=False) + entry_allowed.setflags(write=False) + force_flat.setflags(write=False) + object.__setattr__(self, "session_id", session_id) + object.__setattr__(self, "entry_allowed_at_open", entry_allowed) + object.__setattr__(self, "force_flat_at_open", force_flat) + signature = self.signature or self._build_signature(session_id, entry_allowed, force_flat) + object.__setattr__(self, "signature", signature) + + @classmethod + def from_index( + cls, + index: Sequence, + *, + timezone: str = "UTC", + session_key: str = "local_date", + entry_windows: Sequence[tuple[str, str]] = (), + force_flat_time: Optional[str] = None, + ) -> "IntrabarSessionTape": + idx = pd.DatetimeIndex(pd.to_datetime(index)) + if idx.tz is None: + if not timezone: + raise ValueError("timezone is required for naive session indexes") + idx = idx.tz_localize(timezone) + local = idx.tz_convert(timezone) + if session_key != "local_date": + raise NotImplementedError("IntrabarSessionTape.from_index currently supports session_key='local_date'") + dates = pd.Index(local.date) + _, session_id = np.unique(dates.astype(str), return_inverse=True) + minutes = local.hour.to_numpy(dtype=np.int64) * 60 + local.minute.to_numpy(dtype=np.int64) + if entry_windows: + entry_allowed = np.zeros(len(local), dtype=np.bool_) + for start, end in entry_windows: + start_min = _parse_hhmm(start) + end_min = _parse_hhmm(end) + entry_allowed |= (minutes >= start_min) & (minutes <= end_min) + else: + entry_allowed = np.ones(len(local), dtype=np.bool_) + force_flat = np.zeros(len(local), dtype=np.bool_) + if force_flat_time is not None: + force_flat[:] = minutes == _parse_hhmm(force_flat_time) + return cls( + session_id=np.ascontiguousarray(session_id, dtype=np.int64), + entry_allowed_at_open=entry_allowed, + force_flat_at_open=force_flat, + ) + + @staticmethod + def _build_signature(session_id: np.ndarray, entry_allowed: np.ndarray, force_flat: np.ndarray) -> str: + h = hashlib.blake2b(digest_size=16) + for arr in (session_id, entry_allowed, force_flat): + h.update(np.ascontiguousarray(arr).view(np.uint8)) + payload = { + "session_id": str(session_id.dtype), + "entry_allowed": str(entry_allowed.dtype), + "force_flat": str(force_flat.dtype), + "rows": int(len(session_id)), + "hash": h.hexdigest(), + } + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() + + +def _parse_hhmm(value: str) -> int: + hour, minute = str(value).split(":", 1) + return int(hour) * 60 + int(minute) + diff --git a/src/quantbt/core/market_tape.py b/src/quantbt/core/market_tape.py new file mode 100644 index 0000000..a01a2d8 --- /dev/null +++ b/src/quantbt/core/market_tape.py @@ -0,0 +1,480 @@ +""" +Strict market tape preparation for execution-certified engines. + +This module intentionally does not reuse the compatibility preprocessor. The +existing preprocessor is permissive for legacy notebooks; Phase 31 engines need +explicit validation and a certificate before kernels run. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +from typing import Dict, Optional, Sequence, Union + +import numpy as np +import pandas as pd + + +SeriesMap = Dict[str, pd.Series] +FrameMap = Dict[str, pd.DataFrame] + + +@dataclass(frozen=True) +class MarketValidationCertificate: + signature: str + row_count: int + symbol_count: int + timezone: str + first_timestamp_ns: int + last_timestamp_ns: int + finite_ok: bool + ohlc_ok: bool + monotonic_ok: bool + unique_ok: bool + alignment_ok: bool + bar_timestamp_semantics: str = "close" + validator_version: str = "market_tape_v1" + + +@dataclass(frozen=True) +class PreparedMarketTape: + timestamps_ns: np.ndarray + symbols: tuple[str, ...] + opens: np.ndarray + highs: np.ndarray + lows: np.ndarray + closes: np.ndarray + volumes: np.ndarray + funding_rates: np.ndarray + funding_event_mask: np.ndarray + bar_timestamp_semantics: str + signature: str + validation_certificate: MarketValidationCertificate + + @property + def n_bars(self) -> int: + return int(self.opens.shape[0]) + + @property + def n_symbols(self) -> int: + return int(self.opens.shape[1]) + + +def prepare_market_tape( + *, + data: Optional[Union[pd.DataFrame, FrameMap]] = None, + opens: Optional[Union[pd.Series, SeriesMap]] = None, + highs: Optional[Union[pd.Series, SeriesMap]] = None, + lows: Optional[Union[pd.Series, SeriesMap]] = None, + closes: Optional[Union[pd.Series, SeriesMap]] = None, + volumes: Optional[Union[pd.Series, SeriesMap]] = None, + datetime_index: Optional[pd.DatetimeIndex] = None, + symbols: Optional[Sequence[str]] = None, + funding_rate: Union[float, pd.Series, Dict[str, Union[float, pd.Series]]] = 0.0, + funding_event_timestamps: Optional[Union[pd.DatetimeIndex, Sequence]] = None, + funding_event_rates: Optional[Union[Sequence, pd.Series, Dict[str, Union[Sequence, pd.Series]]]] = None, + use_funding: bool = True, + validation_mode: str = "strict", + missing_funding_policy: str = "raise", + source_timezone: Optional[str] = None, + bar_timestamp_semantics: str = "close", +) -> PreparedMarketTape: + """ + Build a strict, immutable OHLCV/funding tape. + + `validation_mode="strict"` rejects unsorted, duplicate, missing, NaN, and + invalid OHLC data. It does not forward-fill or fallback high/low to close. + """ + mode = str(validation_mode).lower().strip() + if mode not in {"strict", "trusted_prepared", "debug"}: + raise ValueError("validation_mode must be strict, trusted_prepared, or debug") + timestamp_semantics = _normalize_bar_timestamp_semantics(bar_timestamp_semantics) + if isinstance(data, PreparedMarketTape): + if data.bar_timestamp_semantics != timestamp_semantics: + raise ValueError( + "prepared market tape bar_timestamp_semantics does not match requested semantics" + ) + return data + + frames, symbol_list = _frames_from_inputs( + data=data, + opens=opens, + highs=highs, + lows=lows, + closes=closes, + volumes=volumes, + datetime_index=datetime_index, + symbols=symbols, + source_timezone=source_timezone, + ) + if not symbol_list: + raise ValueError("at least one symbol is required") + idx = frames[symbol_list[0]].index + _validate_index(idx, name=symbol_list[0]) + for symbol in symbol_list[1:]: + if not frames[symbol].index.equals(idx): + raise ValueError(f"symbol {symbol!r} index is not aligned to {symbol_list[0]!r}") + + n = len(idx) + m = len(symbol_list) + opens_m = np.empty((n, m), dtype=np.float64) + highs_m = np.empty((n, m), dtype=np.float64) + lows_m = np.empty((n, m), dtype=np.float64) + closes_m = np.empty((n, m), dtype=np.float64) + volumes_m = np.empty((n, m), dtype=np.float64) + for j, symbol in enumerate(symbol_list): + frame = frames[symbol] + _validate_ohlcv_frame(frame, symbol) + opens_m[:, j] = frame["open"].to_numpy(dtype=np.float64) + highs_m[:, j] = frame["high"].to_numpy(dtype=np.float64) + lows_m[:, j] = frame["low"].to_numpy(dtype=np.float64) + closes_m[:, j] = frame["close"].to_numpy(dtype=np.float64) + volumes_m[:, j] = frame["volume"].to_numpy(dtype=np.float64) + + ohlcv = np.stack((opens_m, highs_m, lows_m, closes_m, volumes_m), axis=2) + finite_ok = bool(np.isfinite(ohlcv).all()) + if not finite_ok: + raise ValueError("OHLCV contains NaN or infinite values") + ohlc_ok = bool( + ( + (lows_m <= opens_m) + & (lows_m <= closes_m) + & (highs_m >= opens_m) + & (highs_m >= closes_m) + & (highs_m >= lows_m) + & (opens_m > 0.0) + & (highs_m > 0.0) + & (lows_m > 0.0) + & (closes_m > 0.0) + & (volumes_m >= 0.0) + ).all() + ) + if not ohlc_ok: + raise ValueError("invalid OHLCV invariant") + + timestamps_ns = idx.view("int64").astype(np.int64, copy=True) + funding_m, funding_mask = _prepare_funding_matrix( + funding_rate=funding_rate, + funding_event_timestamps=funding_event_timestamps, + funding_event_rates=funding_event_rates, + use_funding=use_funding, + symbols=symbol_list, + idx=idx, + missing_funding_policy=missing_funding_policy, + source_timezone=source_timezone, + ) + signature = _signature( + timestamps_ns, + symbol_list, + opens_m, + highs_m, + lows_m, + closes_m, + volumes_m, + funding_m, + funding_mask.astype(np.float64), + metadata=f"bar_timestamp_semantics={timestamp_semantics}", + ) + cert = MarketValidationCertificate( + signature=signature, + row_count=int(n), + symbol_count=int(m), + timezone=str(idx.tz), + first_timestamp_ns=int(timestamps_ns[0]), + last_timestamp_ns=int(timestamps_ns[-1]), + finite_ok=finite_ok, + ohlc_ok=ohlc_ok, + monotonic_ok=True, + unique_ok=True, + alignment_ok=True, + bar_timestamp_semantics=timestamp_semantics, + ) + arrays = (timestamps_ns, opens_m, highs_m, lows_m, closes_m, volumes_m, funding_m, funding_mask) + for arr in arrays: + arr.setflags(write=False) + return PreparedMarketTape( + timestamps_ns=np.ascontiguousarray(timestamps_ns), + symbols=tuple(symbol_list), + opens=np.ascontiguousarray(opens_m), + highs=np.ascontiguousarray(highs_m), + lows=np.ascontiguousarray(lows_m), + closes=np.ascontiguousarray(closes_m), + volumes=np.ascontiguousarray(volumes_m), + funding_rates=np.ascontiguousarray(funding_m), + funding_event_mask=np.ascontiguousarray(funding_mask), + bar_timestamp_semantics=timestamp_semantics, + signature=signature, + validation_certificate=cert, + ) + + +def _frames_from_inputs( + *, + data, + opens, + highs, + lows, + closes, + volumes, + datetime_index, + symbols, + source_timezone, +) -> tuple[FrameMap, list[str]]: + if data is not None: + if isinstance(data, pd.DataFrame): + symbol_list = list(symbols or ["DEFAULT"]) + if len(symbol_list) != 1: + raise ValueError("single DataFrame market tape requires one symbol") + return {symbol_list[0]: _standard_frame(data, datetime_index, source_timezone=source_timezone)}, symbol_list + symbol_list = list(symbols or data.keys()) + return {symbol: _standard_frame(data[symbol], datetime_index=None, source_timezone=source_timezone) for symbol in symbol_list}, symbol_list + + if closes is None: + raise ValueError("closes or data is required") + if isinstance(closes, pd.Series): + symbol_list = list(symbols or ["DEFAULT"]) + if len(symbol_list) != 1: + raise ValueError("single Series market tape requires one symbol") + symbol = symbol_list[0] + idx = _strict_index(datetime_index if datetime_index is not None else closes.index, name=symbol, source_timezone=source_timezone) + frame = pd.DataFrame( + { + "open": _series_for_symbol(opens, symbol, idx, required=True), + "high": _series_for_symbol(highs, symbol, idx, required=True), + "low": _series_for_symbol(lows, symbol, idx, required=True), + "close": _align_exact(closes, idx, "close"), + "volume": _series_for_symbol(volumes, symbol, idx, required=False), + }, + index=idx, + ) + return {symbol: frame}, symbol_list + + symbol_list = list(symbols or closes.keys()) + idx = _strict_index(datetime_index if datetime_index is not None else closes[symbol_list[0]].index, name=symbol_list[0], source_timezone=source_timezone) + frames = {} + for symbol in symbol_list: + frames[symbol] = pd.DataFrame( + { + "open": _series_for_symbol(opens, symbol, idx, required=True), + "high": _series_for_symbol(highs, symbol, idx, required=True), + "low": _series_for_symbol(lows, symbol, idx, required=True), + "close": _align_exact(closes[symbol], idx, "close"), + "volume": _series_for_symbol(volumes, symbol, idx, required=False), + }, + index=idx, + ) + return frames, symbol_list + + +def _standard_frame(data: pd.DataFrame, datetime_index=None, *, source_timezone: Optional[str] = None) -> pd.DataFrame: + frame = data.copy().rename( + columns={ + "Datetime": "timestamp", + "Date": "timestamp", + "Timestamp": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Volume": "volume", + } + ) + if datetime_index is not None: + frame.index = _strict_index(datetime_index, name="datetime_index", source_timezone=source_timezone) + elif "timestamp" in frame.columns: + frame = frame.set_index(_strict_index(frame["timestamp"], name="timestamp", source_timezone=source_timezone)) + else: + frame.index = _strict_index(frame.index, name="data", source_timezone=source_timezone) + required = {"open", "high", "low", "close"} + missing = sorted(required - set(frame.columns)) + if missing: + raise ValueError(f"market data is missing required columns {missing}") + if "volume" not in frame.columns: + frame["volume"] = 0.0 + frame = frame[["open", "high", "low", "close", "volume"]].copy() + frame.index = _strict_index(frame.index, name="data", source_timezone=source_timezone) + return frame + + +def _strict_index(value, *, name: str, source_timezone: Optional[str] = None) -> pd.DatetimeIndex: + raw = pd.DatetimeIndex(pd.to_datetime(value, errors="raise")) + if raw.tz is None: + if source_timezone is None: + raise ValueError(f"{name} index is timezone-naive; pass source_timezone for strict market tape") + raw = raw.tz_localize(source_timezone) + idx = raw.tz_convert("UTC") + _validate_index(idx, name=name) + return idx + + +def _validate_index(idx: pd.DatetimeIndex, *, name: str) -> None: + if len(idx) == 0: + raise ValueError(f"{name} index is empty") + values = idx.view("int64") + if not bool(np.all(values[1:] > values[:-1])): + if bool(pd.Index(values).duplicated().any()): + raise ValueError(f"{name} index contains duplicate timestamps") + raise ValueError(f"{name} index must be strictly increasing") + if idx.tz is None: + raise ValueError(f"{name} index must be timezone-aware") + + +def _validate_ohlcv_frame(frame: pd.DataFrame, symbol: str) -> None: + if len(frame) == 0: + raise ValueError(f"{symbol} market data is empty") + missing = [col for col in ("open", "high", "low", "close", "volume") if col not in frame] + if missing: + raise ValueError(f"{symbol} market data is missing columns {missing}") + + +def _series_for_symbol(data, symbol: str, idx: pd.DatetimeIndex, *, required: bool) -> pd.Series: + if data is None: + if required: + raise ValueError(f"{symbol} requires explicit open/high/low/close for strict market tape") + return pd.Series(0.0, index=idx, name="volume") + if isinstance(data, pd.Series): + series = data + else: + if symbol not in data: + if required: + raise KeyError(f"{symbol!r} missing from strict market tape input") + return pd.Series(0.0, index=idx, name="volume") + series = data[symbol] + return _align_exact(series, idx, symbol) + + +def _align_exact(series: pd.Series, idx: pd.DatetimeIndex, name: str) -> pd.Series: + s = series.copy() + s.index = _strict_index(s.index, name=name) + if not s.index.equals(idx): + raise ValueError(f"{name} series index is not exactly aligned") + return pd.to_numeric(s, errors="raise").astype(float) + + +def _prepare_funding_matrix( + *, + funding_rate, + funding_event_timestamps, + funding_event_rates, + use_funding: bool, + symbols: list[str], + idx: pd.DatetimeIndex, + missing_funding_policy: str, + source_timezone: Optional[str], +) -> tuple[np.ndarray, np.ndarray]: + n = len(idx) + m = len(symbols) + funding = np.zeros((n, m), dtype=np.float64) + mask = np.zeros(n, dtype=np.bool_) + if not use_funding: + return funding, mask + policy = str(missing_funding_policy or "raise").lower().strip() + if policy not in {"raise", "zero"}: + raise ValueError("missing_funding_policy must be raise or zero") + if funding_event_timestamps is not None or funding_event_rates is not None: + if funding_event_timestamps is None or funding_event_rates is None: + raise ValueError("funding_event_timestamps and funding_event_rates must be provided together") + return _funding_from_events( + event_timestamps=funding_event_timestamps, + event_rates=funding_event_rates, + symbols=symbols, + idx=idx, + source_timezone=source_timezone, + ) + if isinstance(funding_rate, dict): + for j, symbol in enumerate(symbols): + if symbol not in funding_rate: + if policy == "zero": + continue + raise KeyError(f"funding_rate dict is missing symbol {symbol!r}") + value = funding_rate[symbol] + if isinstance(value, pd.Series): + funding[:, j] = _align_exact(value, idx, f"funding:{symbol}").to_numpy(dtype=np.float64) + else: + scalar = float(value) + if policy != "zero": + raise ValueError("strict funding requires event timestamps/rates or an aligned Series; scalar funding is not event-causal") + funding[:, j] = scalar + elif isinstance(funding_rate, pd.Series): + series = _align_exact(funding_rate, idx, "funding") + funding[:, :] = series.to_numpy(dtype=np.float64)[:, None] + else: + scalar = float(funding_rate) + if scalar != 0.0 or policy != "zero": + raise ValueError("strict funding requires funding events or an aligned Series; use_funding=False or missing_funding_policy='zero' for no funding") + funding[:, :] = 0.0 + mask[1:] = funding[1:].any(axis=1) + return funding, mask + + +def _funding_from_events( + *, + event_timestamps, + event_rates, + symbols: list[str], + idx: pd.DatetimeIndex, + source_timezone: Optional[str], +) -> tuple[np.ndarray, np.ndarray]: + event_idx = _strict_index(event_timestamps, name="funding_events", source_timezone=source_timezone) + if len(event_idx) == 0: + return np.zeros((len(idx), len(symbols)), dtype=np.float64), np.zeros(len(idx), dtype=np.bool_) + event_ns = event_idx.view("int64") + if isinstance(event_rates, dict): + rates_by_symbol = {} + for symbol in symbols: + if symbol not in event_rates: + raise KeyError(f"funding_event_rates dict is missing symbol {symbol!r}") + rates_by_symbol[symbol] = _event_rate_values(event_rates[symbol], event_idx, symbol) + else: + values = _event_rate_values(event_rates, event_idx, "funding_events") + rates_by_symbol = {symbol: values for symbol in symbols} + + funding = np.zeros((len(idx), len(symbols)), dtype=np.float64) + mask = np.zeros(len(idx), dtype=np.bool_) + idx_ns = idx.view("int64") + for k, ts_ns in enumerate(event_ns): + bar = int(np.searchsorted(idx_ns, ts_ns, side="left")) + if bar >= len(idx_ns) or idx_ns[bar] != ts_ns: + raise ValueError("funding events must align exactly to a market bar timestamp for POSITION_AT_EVENT certification") + if bar == 0: + raise ValueError("funding event at the first bar cannot be certified because no prior position interval exists") + for j, symbol in enumerate(symbols): + rate = float(rates_by_symbol[symbol][k]) + if rate != 0.0: + funding[bar, j] += rate + mask[bar] = True + return funding, mask + + +def _event_rate_values(value, event_idx: pd.DatetimeIndex, name: str) -> np.ndarray: + if isinstance(value, pd.Series): + series = value.copy() + series.index = _strict_index(series.index, name=f"funding_event_rates:{name}") + if not series.index.equals(event_idx): + raise ValueError(f"funding event rates for {name} must align exactly to funding_event_timestamps") + return pd.to_numeric(series, errors="raise").to_numpy(dtype=np.float64) + arr = np.asarray(value, dtype=np.float64) + if arr.ndim == 0: + raise ValueError("funding_event_rates scalar is not valid; pass one rate per funding event") + if len(arr) != len(event_idx): + raise ValueError("funding_event_rates length must match funding_event_timestamps") + return np.ascontiguousarray(arr, dtype=np.float64) + + +def _normalize_bar_timestamp_semantics(value: str) -> str: + semantics = str(value or "close").lower().strip() + if semantics not in {"open", "close"}: + raise ValueError("bar_timestamp_semantics must be 'open' or 'close'") + return semantics + + +def _signature(timestamps_ns: np.ndarray, symbols: list[str], *arrays: np.ndarray, metadata: str = "") -> str: + h = hashlib.sha256() + h.update(np.ascontiguousarray(timestamps_ns).view(np.uint8)) + h.update("|".join(symbols).encode("utf-8")) + if metadata: + h.update(str(metadata).encode("utf-8")) + for arr in arrays: + h.update(np.ascontiguousarray(arr).view(np.uint8)) + return h.hexdigest() diff --git a/src/quantbt/core/native_event_capabilities.py b/src/quantbt/core/native_event_capabilities.py new file mode 100644 index 0000000..75df199 --- /dev/null +++ b/src/quantbt/core/native_event_capabilities.py @@ -0,0 +1,121 @@ +"""Canonical native-event capability contract. + +The Rust extension exposes a low-level capability map whose names are tied to +its release history (for example ``rust_batched_tape``). Public selectors, +tests, and documentation need a stable vocabulary instead. This module is +the single Python-side source of truth for the currently certified +single-symbol R2 surface. Full-contract 0.4 flags are additive and only +normalize to the wider vocabulary when the extension advertises the complete +capability gate. +""" + +from __future__ import annotations + +from hashlib import sha256 +import json +from types import MappingProxyType +from typing import Mapping + + +NATIVE_EVENT_CAPABILITY_MATRIX_VERSION = "full-contract-v2-0.4" + +_CAPABILITIES = { + "single_symbol": True, + "market": True, + "limit": True, + "stop_market": True, + "stop_limit": True, + "place": True, + "cancel": True, + "amend": True, + "replace": True, + "reduce_only": True, + "quantity_constraints": True, + "gtc": True, + "gtd": True, + "ioc": True, + "fok": True, + "parent_child": True, + "oco": True, + "funding": True, + "liquidation": True, + "multi_symbol": True, +} + +NATIVE_EVENT_CAPABILITY_MATRIX: Mapping[str, bool] = MappingProxyType(_CAPABILITIES) + + +def native_event_capability_matrix() -> dict[str, bool]: + """Return a mutable copy of the canonical capability matrix.""" + + return dict(NATIVE_EVENT_CAPABILITY_MATRIX) + + +def capability_matrix_fingerprint() -> str: + """Return a reproducible SHA-256 fingerprint for the capability contract.""" + + payload = { + "version": NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + "capabilities": dict(sorted(NATIVE_EVENT_CAPABILITY_MATRIX.items())), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return sha256(encoded).hexdigest() + + +def normalize_native_event_capabilities(raw: Mapping[str, object] | None) -> dict[str, bool]: + """Map extension-specific flags into the stable public vocabulary. + + Unknown raw flags are intentionally ignored. A raw flag cannot silently + enable a capability that is outside the certified matrix; a later release + must update this module and its tests first. + """ + + source = {str(key): bool(value) for key, value in (raw or {}).items()} + lifecycle = source.get("reactive_session", False) or source.get("r1_single_symbol", False) + place_cancel = source.get("r1_place_cancel_market_limit_gtc", False) + r2 = source.get("r2_stop_amend_replace_reduce_only_constraints", False) + batched = source.get("rust_batched_tape", False) or source.get("rust_batched_tape_audit", False) + full = source.get("native_event_v2_full_contract", False) + + normalized = native_event_capability_matrix() + normalized["single_symbol"] = bool(full or lifecycle or batched) + normalized["market"] = bool(full or place_cancel or batched) + normalized["limit"] = bool(full or place_cancel or batched) + normalized["stop_market"] = bool(full or r2) + normalized["stop_limit"] = bool(full or r2) + normalized["place"] = bool(full or place_cancel or batched) + normalized["cancel"] = bool(full or place_cancel or batched) + normalized["amend"] = bool(full or r2) + normalized["replace"] = bool(full or r2) + normalized["reduce_only"] = bool(full or r2) + normalized["quantity_constraints"] = bool(full or r2) + normalized["gtc"] = bool(full or place_cancel or batched) + if full: + normalized.update({ + "gtd": True, "ioc": True, "fok": True, "parent_child": True, + "oco": True, "funding": True, "liquidation": True, "multi_symbol": True, + }) + else: + normalized.update({ + "gtd": False, "ioc": False, "fok": False, "parent_child": False, + "oco": False, "funding": False, "liquidation": False, "multi_symbol": False, + }) + return normalized + + +def validate_native_event_capability_matrix(matrix: Mapping[str, object]) -> None: + """Raise if a consumer attempts to advertise an unknown capability.""" + + unknown = sorted(set(matrix) - set(NATIVE_EVENT_CAPABILITY_MATRIX)) + if unknown: + raise ValueError(f"unknown native-event capability fields: {unknown}") + + +__all__ = [ + "NATIVE_EVENT_CAPABILITY_MATRIX_VERSION", + "NATIVE_EVENT_CAPABILITY_MATRIX", + "capability_matrix_fingerprint", + "native_event_capability_matrix", + "normalize_native_event_capabilities", + "validate_native_event_capability_matrix", +] diff --git a/src/quantbt/core/native_event_parity.py b/src/quantbt/core/native_event_parity.py new file mode 100644 index 0000000..a3fc339 --- /dev/null +++ b/src/quantbt/core/native_event_parity.py @@ -0,0 +1,336 @@ +"""Strict parity certificates for native-event execution artifacts. + +This module deliberately lives above the execution kernels. It compares +observable lifecycle/accounting artifacts and therefore can certify Python, +Rust, and replay results without making any backend responsible for another +backend's object model. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import json +from typing import Any, Mapping + +import numpy as np +import pandas as pd + +from .native_event_capabilities import NATIVE_EVENT_CAPABILITY_MATRIX + + +DEFAULT_NUMERIC_ATOL = 1e-12 +_NUMERIC_FIELDS = ( + "equity", + "positions", + "fees", + "funding", + "turnover", + "initial_margin", + "maintenance_margin", +) +_DISCRETE_FIELDS = ( + "liquidated", + "liquidation_bar", +) + + +class NativeEventParityError(AssertionError): + """Raised when two native-event artifacts are not lifecycle-equivalent.""" + + +@dataclass(frozen=True) +class NativeEventParityCertificate: + """Serializable summary returned by :func:`assert_native_event_full_parity`.""" + + passed: bool + numeric_atol: float + compared_fields: tuple[str, ...] + missing_fields: tuple[str, ...] + candidate_fingerprint: str + oracle_fingerprint: str + command_fingerprint: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "passed": self.passed, + "numeric_atol": self.numeric_atol, + "compared_fields": list(self.compared_fields), + "missing_fields": list(self.missing_fields), + "candidate_fingerprint": self.candidate_fingerprint, + "oracle_fingerprint": self.oracle_fingerprint, + "command_fingerprint": self.command_fingerprint, + } + + +def _metadata(result: object) -> Mapping[str, object]: + value = getattr(result, "metadata", None) + return value if isinstance(value, Mapping) else {} + + +def _array(value: object, *, name: str) -> np.ndarray | None: + if value is None: + return None + if isinstance(value, pd.Series): + return value.to_numpy(copy=True) + if isinstance(value, pd.DataFrame): + if name == "positions": + columns = [column for column in value.columns if str(column).startswith("Position_")] + if columns: + return value[columns].to_numpy(copy=True) + if name in value: + return value[name].to_numpy(copy=True) + return value.to_numpy(copy=True) + return np.asarray(value).copy() + + +def _result_field(result: object, name: str) -> np.ndarray | object | None: + value = getattr(result, name, None) + if name == "turnover" and value is None: + diagnostics = getattr(result, "diagnostics", None) + value = diagnostics.get("turnover") if isinstance(diagnostics, pd.DataFrame) else None + if name in {"initial_margin", "maintenance_margin"} and value is None: + margin = getattr(result, "margin", None) + if isinstance(margin, pd.DataFrame): + value = margin.get(name) + if value is None: + value = _metadata(result).get(name) + return _array(value, name=name) if name not in _DISCRETE_FIELDS else value + + +def _stable_bytes(value: object) -> bytes: + if isinstance(value, Mapping): + value = {str(key): value[key] for key in sorted(value, key=str)} + return json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode("utf-8") + if isinstance(value, (str, int, float, bool)) or value is None: + return json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode("utf-8") + array = np.asarray(value) + if array.dtype.kind in "OUS": + payload = [str(item) for item in array.reshape(-1)] + return json.dumps({"shape": array.shape, "values": payload}, separators=(",", ":")).encode("utf-8") + contiguous = np.ascontiguousarray(array) + return b"|".join((str(contiguous.dtype).encode(), repr(contiguous.shape).encode(), contiguous.tobytes())) + + +def _fingerprint(fields: Mapping[str, object]) -> str: + digest = sha256() + for name in sorted(fields): + digest.update(name.encode("utf-8")) + digest.update(b"=") + digest.update(_stable_bytes(fields[name])) + digest.update(b"\n") + return digest.hexdigest() + + +def _record_value(record: object, name: str, default: object = None) -> object: + if isinstance(record, Mapping): + return record.get(name, default) + return getattr(record, name, default) + + +def _fill_records(result: object) -> list[tuple[object, ...]] | None: + arrays = {name: getattr(result, f"fill_{name}", None) for name in ( + "bar", "order_id", "side", "qty", "price", "fee" + )} + if all(value is not None for value in arrays.values()): + length = len(np.asarray(arrays["bar"])) + return [ + tuple(np.asarray(arrays[name])[idx].item() for name in arrays) + for idx in range(length) + ] + fills = getattr(result, "fills", None) + if fills is None: + fills = _metadata(result).get("fills") + if fills is None: + return None + records = [] + for fill in fills: + side = _record_value(fill, "side") + side = getattr(side, "value", side) + records.append( + ( + str(_record_value(fill, "timestamp")), + str(_record_value(fill, "symbol")), + side, + float(_record_value(fill, "qty")), + float(_record_value(fill, "price")), + float(_record_value(fill, "fee", 0.0)), + str(_record_value(fill, "order_id")), + ) + ) + return records + + +def _event_records(result: object) -> list[tuple[object, ...]] | None: + arrays = { + name: getattr(result, f"event_{name}", None) + for name in ("bar", "kind", "status", "order_id", "target_id") + } + if all(value is not None for value in arrays.values()): + length = len(np.asarray(arrays["bar"])) + return [ + tuple(np.asarray(arrays[name])[idx].item() for name in arrays) + for idx in range(length) + ] + ledger = _metadata(result).get("compact_order_event_ledger") + if ledger is None: + ledger = _metadata(result).get("event_ledger") + if ledger is None: + return None + if isinstance(ledger, Mapping): + names = ("bar", "kind", "status", "order_id", "target_id") + if all(name in ledger for name in names): + arrays = {name: np.asarray(ledger[name]) for name in names} + return [tuple(arrays[name][idx].item() for name in names) for idx in range(len(arrays["bar"]))] + names = ("bar", "event_type", "status", "command_index", "related_command_index") + if all(hasattr(ledger, name) for name in names): + arrays = {name: np.asarray(getattr(ledger, name)) for name in names} + return [ + ( + arrays["bar"][idx].item(), + arrays["event_type"][idx].item(), + arrays["status"][idx].item(), + arrays["command_index"][idx].item(), + arrays["related_command_index"][idx].item(), + ) + for idx in range(len(arrays["bar"])) + ] + return None + + +def _command_fingerprint(command_tape: object) -> str: + if isinstance(command_tape, Mapping): + fields = command_tape + else: + names = ( + "effective_bar", "command_ptr", "command_action", "command_order_id", + "command_status", "command_symbol", "command_sequence", + ) + fields = {name: getattr(command_tape, name) for name in names if hasattr(command_tape, name)} + if not fields: + raise ValueError("command_tape must expose at least one deterministic command field") + return _fingerprint({str(name): value for name, value in fields.items()}) + + +def _snapshot(result: object) -> dict[str, object]: + fields: dict[str, object] = {} + for name in _NUMERIC_FIELDS: + value = _result_field(result, name) + if value is not None: + fields[name] = value + fills = _fill_records(result) + if fills is not None: + fields["fills"] = fills + events = _event_records(result) + if events is not None: + fields["events"] = events + for name in _DISCRETE_FIELDS: + value = getattr(result, name, None) + if value is not None: + fields[name] = value + return fields + + +def assert_native_event_full_parity( + candidate: object, + oracle: object, + *, + numeric_atol: float = DEFAULT_NUMERIC_ATOL, + capabilities: Mapping[str, object] | None = None, + command_tape: object | tuple[object, object] | None = None, + require_full: bool = True, +) -> dict[str, object]: + """Compare complete observable lifecycle and accounting artifacts. + + Discrete lifecycle artifacts (fills, event order, statuses, and boolean + state) must match exactly. Numeric paths use ``rtol=0`` and the supplied + absolute tolerance. ``require_full=True`` requires fills and event ledgers + on both sides; use ``False`` only for an explicitly scalar/minimal run. + ``command_tape`` may be one shared tape or ``(candidate, oracle)``. + """ + + atol = float(numeric_atol) + if atol < 0.0 or not np.isfinite(atol): + raise ValueError("numeric_atol must be finite and >= 0") + left = _snapshot(candidate) + right = _snapshot(oracle) + capabilities = dict(NATIVE_EVENT_CAPABILITY_MATRIX if capabilities is None else capabilities) + compared: list[str] = [] + missing: list[str] = [] + + for name in _NUMERIC_FIELDS: + left_value = left.get(name) + right_value = right.get(name) + required = name in {"equity", "positions", "fees", "turnover", "initial_margin", "maintenance_margin"} + if name == "funding": + required = bool(capabilities.get("funding", False)) + if left_value is None or right_value is None: + if required: + missing.append(name) + continue + lhs = np.asarray(left_value) + rhs = np.asarray(right_value) + if lhs.shape != rhs.shape: + raise NativeEventParityError(f"{name} shape mismatch: {lhs.shape} != {rhs.shape}") + if not np.allclose(lhs, rhs, rtol=0.0, atol=atol, equal_nan=True): + difference = float(np.nanmax(np.abs(lhs.astype(float) - rhs.astype(float)))) + raise NativeEventParityError(f"{name} mismatch: max_abs_diff={difference:.17g}, atol={atol:.17g}") + compared.append(name) + + for name in _DISCRETE_FIELDS: + if name not in left or name not in right: + if name in {"liquidated", "liquidation_bar"} and not capabilities.get("liquidation", False): + continue + missing.append(name) + continue + if left[name] != right[name]: + raise NativeEventParityError(f"{name} mismatch: {left[name]!r} != {right[name]!r}") + compared.append(name) + + for name in ("fills", "events"): + lhs = left.get(name) + rhs = right.get(name) + if lhs is None or rhs is None: + if require_full: + missing.append(name) + continue + if lhs != rhs: + raise NativeEventParityError(f"{name} lifecycle sequence mismatch") + compared.append(name) + + command_fingerprint = None + if command_tape is not None: + if isinstance(command_tape, tuple): + if len(command_tape) != 2: + raise ValueError("command_tape tuple must contain (candidate_tape, oracle_tape)") + left_command = _command_fingerprint(command_tape[0]) + right_command = _command_fingerprint(command_tape[1]) + if left_command != right_command: + raise NativeEventParityError("command sequence/effective-bar fingerprint mismatch") + command_fingerprint = left_command + else: + command_fingerprint = _command_fingerprint(command_tape) + compared.append("command_tape") + + if missing: + raise NativeEventParityError(f"parity artifacts missing: {sorted(set(missing))}") + candidate_fingerprint = _fingerprint(left) + oracle_fingerprint = _fingerprint(right) + certificate = NativeEventParityCertificate( + passed=True, + numeric_atol=atol, + compared_fields=tuple(compared), + missing_fields=tuple(missing), + candidate_fingerprint=candidate_fingerprint, + oracle_fingerprint=oracle_fingerprint, + command_fingerprint=command_fingerprint, + ) + return certificate.to_dict() + + +__all__ = [ + "DEFAULT_NUMERIC_ATOL", + "NativeEventParityCertificate", + "NativeEventParityError", + "assert_native_event_full_parity", +] diff --git a/src/quantbt/core/order_compiler.py b/src/quantbt/core/order_compiler.py new file mode 100644 index 0000000..98abc67 --- /dev/null +++ b/src/quantbt/core/order_compiler.py @@ -0,0 +1,422 @@ +""" +OrderIntent compiler for native event kernels. + +The compiler is an internal performance helper: it converts immutable order +intent objects into contiguous ndarray inputs while preserving the old event +kernel semantics. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import hashlib +from typing import Dict, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .event import ( + ORDER_TYPE_LIMIT, + ORDER_TYPE_MARKET, + ORDER_TYPE_STOP_LIMIT, + ORDER_TYPE_STOP_MARKET, + TIF_FOK, + TIF_GTC, + TIF_GTD, + TIF_IOC, +) +from .orders import OrderAction, OrderActivationPolicy, OrderCommand, OrderIntent +from .preprocessor import MarketDataSignature, market_data_signature +from .schema import OrderSide, OrderType, TimeInForce + + +COMMAND_ACTION_PLACE = 0 +COMMAND_ACTION_CANCEL = 1 +COMMAND_ACTION_REPLACE = 2 +COMMAND_ACTION_AMEND = 3 +COMMAND_ACTION_CANCEL_ALL = 4 + +ACTIVATION_IMMEDIATE = 0 +ACTIVATION_ON_PARENT_FIRST_FILL = 1 +ACTIVATION_ON_PARENT_FULL_FILL = 2 + + +@dataclass(frozen=True) +class CompiledOrderArrays: + index_signature: MarketDataSignature + symbols: Tuple[str, ...] + sorted_orders: Tuple[Tuple[int, OrderIntent], ...] + order_ptr: np.ndarray + order_symbol: np.ndarray + order_side: np.ndarray + order_type: np.ndarray + order_qty: np.ndarray + order_price: np.ndarray + order_tif: np.ndarray + original_index: np.ndarray + + @property + def n_orders(self) -> int: + return int(len(self.original_index)) + + +@dataclass(frozen=True) +class CompiledOrderCommandArrays: + """ + Array contract for native-event lifecycle commands. + + This v2 compiler is intentionally separate from `CompiledOrderArrays` so + the legacy v1 kernel remains byte-for-byte compatible with old endpoints. + """ + + index_signature: MarketDataSignature + symbols: Tuple[str, ...] + sorted_commands: Tuple[Tuple[int, OrderCommand], ...] + command_ptr: np.ndarray + command_bar: np.ndarray + command_action: np.ndarray + command_symbol: np.ndarray + command_side: np.ndarray + command_type: np.ndarray + command_qty: np.ndarray + command_price: np.ndarray + command_trigger_price: np.ndarray + command_tif: np.ndarray + command_reduce_only: np.ndarray + command_order_id: np.ndarray + command_target_order_id: np.ndarray + command_parent_order_id: np.ndarray + command_group_id: np.ndarray + command_oco_group_id: np.ndarray + command_activation: np.ndarray + command_expires_bar: np.ndarray + original_index: np.ndarray + id_values: Tuple[str, ...] + tape_fingerprint: str = "" + + @property + def n_commands(self) -> int: + return int(len(self.original_index)) + + +def command_tape_fingerprint(compiled: CompiledOrderCommandArrays) -> str: + """Return a complete identity for the immutable primitive command tape. + + The digest includes fields used by Rust validation as well as execution. + It is computed when the compiler creates a tape so repeated score calls do + not hash every array on the measured execution path. + """ + + digest = hashlib.blake2b(digest_size=16) + digest.update(repr(compiled.index_signature).encode("utf-8")) + digest.update(repr(tuple(compiled.symbols)).encode("utf-8")) + digest.update(repr(tuple(compiled.id_values)).encode("utf-8")) + for name in ( + "command_ptr", + "command_bar", + "command_action", + "command_symbol", + "command_side", + "command_type", + "command_qty", + "command_price", + "command_trigger_price", + "command_tif", + "command_reduce_only", + "command_order_id", + "command_target_order_id", + "command_parent_order_id", + "command_group_id", + "command_oco_group_id", + "command_activation", + "command_expires_bar", + "original_index", + ): + array = np.ascontiguousarray(getattr(compiled, name)) + digest.update(name.encode("ascii")) + digest.update(str(array.dtype).encode("ascii")) + digest.update(str(array.shape).encode("ascii")) + digest.update(array.tobytes()) + return digest.hexdigest() + + +def compile_order_intents( + idx: pd.DatetimeIndex, + orders: Sequence[OrderIntent], + symbol_to_col: Dict[str, int], +) -> CompiledOrderArrays: + """ + Compile order intents into the exact array contract expected by event v1. + + The sort is stable by effective bar, matching Python's previous + `sorted(enumerate(orders), key=bar_index)` behavior. + """ + n_orders = len(orders) + order_bar_unsorted = np.zeros(n_orders, dtype=np.int64) + symbol_unsorted = np.zeros(n_orders, dtype=np.int64) + side_unsorted = np.zeros(n_orders, dtype=np.int64) + type_unsorted = np.zeros(n_orders, dtype=np.int64) + qty_unsorted = np.zeros(n_orders, dtype=np.float64) + price_unsorted = np.zeros(n_orders, dtype=np.float64) + tif_unsorted = np.zeros(n_orders, dtype=np.int64) + original_unsorted = np.arange(n_orders, dtype=np.int64) + + idx_ns = idx.view("int64") + ts_ns = np.zeros(n_orders, dtype=np.int64) + for k, order in enumerate(orders): + if order.symbol not in symbol_to_col: + raise ValueError(f"order symbol {order.symbol!r} is not in symbols") + ts = pd.Timestamp(order.timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + ts_ns[k] = ts.value + symbol_unsorted[k] = symbol_to_col[order.symbol] + side_unsorted[k] = _side_code(order.side) + type_unsorted[k] = _order_type_code(order.order_type) + qty_unsorted[k] = float(order.qty) + price_unsorted[k] = 0.0 if order.price is None else float(order.price) + tif_unsorted[k] = _tif_code(order.tif) + + order_bar_unsorted = np.searchsorted(idx_ns, ts_ns, side="left").astype(np.int64) + if n_orders > 0 and int(order_bar_unsorted.max()) >= len(idx): + raise ValueError("order timestamp is after the available data") + order_sort = np.argsort(order_bar_unsorted, kind="stable") + + order_bar = np.ascontiguousarray(order_bar_unsorted[order_sort], dtype=np.int64) + order_symbol = np.ascontiguousarray(symbol_unsorted[order_sort], dtype=np.int64) + order_side = np.ascontiguousarray(side_unsorted[order_sort], dtype=np.int64) + order_type = np.ascontiguousarray(type_unsorted[order_sort], dtype=np.int64) + order_qty = np.ascontiguousarray(qty_unsorted[order_sort], dtype=np.float64) + order_price = np.ascontiguousarray(price_unsorted[order_sort], dtype=np.float64) + order_tif = np.ascontiguousarray(tif_unsorted[order_sort], dtype=np.int64) + original_index = np.ascontiguousarray(original_unsorted[order_sort], dtype=np.int64) + + order_ptr = np.zeros(len(idx) + 1, dtype=np.int64) + if n_orders > 0: + counts = np.bincount(order_bar + 1, minlength=len(idx) + 1) + order_ptr[:] = np.cumsum(counts, dtype=np.int64) + + sorted_orders = tuple((int(orig_idx), orders[int(orig_idx)]) for orig_idx in original_index) + return CompiledOrderArrays( + index_signature=market_data_signature(idx, list(symbol_to_col.keys())), + symbols=tuple(symbol_to_col.keys()), + sorted_orders=sorted_orders, + order_ptr=order_ptr, + order_symbol=order_symbol, + order_side=order_side, + order_type=order_type, + order_qty=order_qty, + order_price=order_price, + order_tif=order_tif, + original_index=original_index, + ) + + +def compile_order_commands( + idx: pd.DatetimeIndex, + commands: Sequence[OrderCommand], + symbol_to_col: Dict[str, int], +) -> CompiledOrderCommandArrays: + """ + Compile lifecycle commands into contiguous arrays for native-event v2. + + The compiler validates timestamps/symbols, keeps a stable command order + within each bar, and maps sparse string IDs to dense integer codes. No fill + or accounting logic is performed here; this is only the deterministic input + contract for a lifecycle kernel or adapter. + """ + n_commands = len(commands) + command_bar_unsorted = np.zeros(n_commands, dtype=np.int64) + action_unsorted = np.zeros(n_commands, dtype=np.int64) + symbol_unsorted = np.full(n_commands, -1, dtype=np.int64) + side_unsorted = np.zeros(n_commands, dtype=np.int64) + type_unsorted = np.full(n_commands, -1, dtype=np.int64) + qty_unsorted = np.zeros(n_commands, dtype=np.float64) + price_unsorted = np.zeros(n_commands, dtype=np.float64) + trigger_unsorted = np.zeros(n_commands, dtype=np.float64) + tif_unsorted = np.full(n_commands, TIF_GTC, dtype=np.int64) + reduce_only_unsorted = np.zeros(n_commands, dtype=np.int64) + order_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + target_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + parent_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + group_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + oco_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + activation_unsorted = np.zeros(n_commands, dtype=np.int64) + expires_bar_unsorted = np.full(n_commands, -1, dtype=np.int64) + original_unsorted = np.arange(n_commands, dtype=np.int64) + + id_map: Dict[str, int] = {} + idx_ns = idx.view("int64") + ts_ns = np.zeros(n_commands, dtype=np.int64) + for k, command in enumerate(commands): + ts_ns[k] = _timestamp_ns(command.timestamp) + action_unsorted[k] = _action_code(command.action) + if command.symbol is not None: + if command.symbol not in symbol_to_col: + raise ValueError(f"command symbol {command.symbol!r} is not in symbols") + symbol_unsorted[k] = symbol_to_col[command.symbol] + if command.side is not None: + side_unsorted[k] = _side_code(command.side) + if command.order_type is not None: + type_unsorted[k] = _command_order_type_code(command.order_type) + if command.qty is not None: + qty_unsorted[k] = float(command.qty) + price_unsorted[k] = 0.0 if command.price is None else float(command.price) + trigger_unsorted[k] = 0.0 if command.trigger_price is None else float(command.trigger_price) + tif_unsorted[k] = _tif_code(command.tif) + reduce_only_unsorted[k] = 1 if command.reduce_only else 0 + order_id_unsorted[k] = _id_code(command.order_id, id_map) + target_id_unsorted[k] = _id_code(command.target_order_id, id_map) + parent_id_unsorted[k] = _id_code(command.parent_order_id, id_map) + group_id_unsorted[k] = _id_code(command.group_id, id_map) + oco_id_unsorted[k] = _id_code(command.oco_group_id, id_map) + activation_unsorted[k] = _activation_code(command.activation_policy) + if command.expires_at is not None: + expires_bar_unsorted[k] = int(np.searchsorted(idx_ns, _timestamp_ns(command.expires_at), side="left")) + + command_bar_unsorted = np.searchsorted(idx_ns, ts_ns, side="left").astype(np.int64) + if n_commands > 0 and int(command_bar_unsorted.max()) >= len(idx): + raise ValueError("command timestamp is after the available data") + order_sort = np.argsort(command_bar_unsorted, kind="stable") + + command_bar = np.ascontiguousarray(command_bar_unsorted[order_sort], dtype=np.int64) + command_ptr = np.zeros(len(idx) + 1, dtype=np.int64) + if n_commands > 0: + counts = np.bincount(command_bar + 1, minlength=len(idx) + 1) + command_ptr[:] = np.cumsum(counts, dtype=np.int64) + + original_index = np.ascontiguousarray(original_unsorted[order_sort], dtype=np.int64) + sorted_commands = tuple((int(orig_idx), commands[int(orig_idx)]) for orig_idx in original_index) + id_values = tuple(sorted(id_map, key=id_map.get)) + compiled = CompiledOrderCommandArrays( + index_signature=market_data_signature(idx, list(symbol_to_col.keys())), + symbols=tuple(symbol_to_col.keys()), + sorted_commands=sorted_commands, + command_ptr=command_ptr, + command_bar=np.ascontiguousarray(command_bar, dtype=np.int64), + command_action=np.ascontiguousarray(action_unsorted[order_sort], dtype=np.int64), + command_symbol=np.ascontiguousarray(symbol_unsorted[order_sort], dtype=np.int64), + command_side=np.ascontiguousarray(side_unsorted[order_sort], dtype=np.int64), + command_type=np.ascontiguousarray(type_unsorted[order_sort], dtype=np.int64), + command_qty=np.ascontiguousarray(qty_unsorted[order_sort], dtype=np.float64), + command_price=np.ascontiguousarray(price_unsorted[order_sort], dtype=np.float64), + command_trigger_price=np.ascontiguousarray(trigger_unsorted[order_sort], dtype=np.float64), + command_tif=np.ascontiguousarray(tif_unsorted[order_sort], dtype=np.int64), + command_reduce_only=np.ascontiguousarray(reduce_only_unsorted[order_sort], dtype=np.int64), + command_order_id=np.ascontiguousarray(order_id_unsorted[order_sort], dtype=np.int64), + command_target_order_id=np.ascontiguousarray(target_id_unsorted[order_sort], dtype=np.int64), + command_parent_order_id=np.ascontiguousarray(parent_id_unsorted[order_sort], dtype=np.int64), + command_group_id=np.ascontiguousarray(group_id_unsorted[order_sort], dtype=np.int64), + command_oco_group_id=np.ascontiguousarray(oco_id_unsorted[order_sort], dtype=np.int64), + command_activation=np.ascontiguousarray(activation_unsorted[order_sort], dtype=np.int64), + command_expires_bar=np.ascontiguousarray(expires_bar_unsorted[order_sort], dtype=np.int64), + original_index=original_index, + id_values=id_values, + ) + for name in ( + "command_ptr", + "command_bar", + "command_action", + "command_symbol", + "command_side", + "command_type", + "command_qty", + "command_price", + "command_trigger_price", + "command_tif", + "command_reduce_only", + "command_order_id", + "command_target_order_id", + "command_parent_order_id", + "command_group_id", + "command_oco_group_id", + "command_activation", + "command_expires_bar", + "original_index", + ): + getattr(compiled, name).flags.writeable = False + return replace(compiled, tape_fingerprint=command_tape_fingerprint(compiled)) + + +def order_intents_to_commands(orders: Sequence[OrderIntent]) -> Tuple[OrderCommand, ...]: + """Convert legacy intents to immediate PLACE lifecycle commands.""" + return tuple(OrderCommand.from_intent(order) for order in orders) + + +def _side_code(side: OrderSide) -> int: + return 1 if side is OrderSide.BUY else -1 + + +def _order_type_code(order_type: OrderType) -> int: + if order_type is OrderType.MARKET: + return ORDER_TYPE_MARKET + if order_type is OrderType.LIMIT: + return ORDER_TYPE_LIMIT + raise NotImplementedError(f"unsupported order_type={order_type!r}") + + +def _command_order_type_code(order_type: OrderType) -> int: + if order_type is OrderType.MARKET: + return ORDER_TYPE_MARKET + if order_type is OrderType.LIMIT: + return ORDER_TYPE_LIMIT + if order_type is OrderType.STOP_MARKET: + return ORDER_TYPE_STOP_MARKET + if order_type is OrderType.STOP_LIMIT: + return ORDER_TYPE_STOP_LIMIT + raise NotImplementedError(f"unsupported order_type={order_type!r}") + + +def _tif_code(tif: TimeInForce) -> int: + if tif is TimeInForce.GTC: + return TIF_GTC + if tif is TimeInForce.IOC: + return TIF_IOC + if tif is TimeInForce.FOK: + return TIF_FOK + if tif is TimeInForce.GTD: + return TIF_GTD + raise NotImplementedError(f"unsupported tif={tif!r}") + + +def _action_code(action: OrderAction) -> int: + if action is OrderAction.PLACE: + return COMMAND_ACTION_PLACE + if action is OrderAction.CANCEL: + return COMMAND_ACTION_CANCEL + if action is OrderAction.REPLACE: + return COMMAND_ACTION_REPLACE + if action is OrderAction.AMEND: + return COMMAND_ACTION_AMEND + if action is OrderAction.CANCEL_ALL: + return COMMAND_ACTION_CANCEL_ALL + raise NotImplementedError(f"unsupported action={action!r}") + + +def _activation_code(policy: OrderActivationPolicy) -> int: + if policy is OrderActivationPolicy.IMMEDIATE: + return ACTIVATION_IMMEDIATE + if policy is OrderActivationPolicy.ON_PARENT_FIRST_FILL: + return ACTIVATION_ON_PARENT_FIRST_FILL + if policy is OrderActivationPolicy.ON_PARENT_FULL_FILL: + return ACTIVATION_ON_PARENT_FULL_FILL + raise NotImplementedError(f"unsupported activation_policy={policy!r}") + + +def _id_code(value: str | None, id_map: Dict[str, int]) -> int: + if value is None or value == "": + return -1 + if value not in id_map: + id_map[value] = len(id_map) + return id_map[value] + + +def _timestamp_ns(value: object) -> int: + ts = pd.Timestamp(value) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + return int(ts.value) diff --git a/src/quantbt/core/orders.py b/src/quantbt/core/orders.py new file mode 100644 index 0000000..5bcc610 --- /dev/null +++ b/src/quantbt/core/orders.py @@ -0,0 +1,319 @@ +""" +quantbt.core.orders +------------------- +Order, fill, and trade records used by event-driven backends and result V2. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Sequence, Tuple + +from .schema import LiquiditySide, OrderSide, OrderType, TimeInForce + + +class OrderAction(str, Enum): + """Lifecycle command consumed by the native-event v2 compiler.""" + + PLACE = "place" + CANCEL = "cancel" + REPLACE = "replace" + AMEND = "amend" + CANCEL_ALL = "cancel_all" + + +class OrderActivationPolicy(str, Enum): + """When a placed child order becomes eligible for matching.""" + + IMMEDIATE = "immediate" + ON_PARENT_FIRST_FILL = "on_parent_first_fill" + ON_PARENT_FULL_FILL = "on_parent_full_fill" + + +@dataclass(frozen=True) +class OrderIntent: + timestamp: object + symbol: str + side: OrderSide + order_type: OrderType + qty: float + price: Optional[float] = None + trigger_price: Optional[float] = None + tif: TimeInForce = TimeInForce.GTC + reduce_only: bool = False + order_id: Optional[str] = None + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.symbol: + raise ValueError("symbol is required") + if self.qty <= 0.0: + raise ValueError("qty must be > 0") + if self.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): + if self.price is None or self.price <= 0.0: + raise ValueError("limit orders require price > 0") + if self.order_type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT): + if self.trigger_price is None or self.trigger_price <= 0.0: + raise ValueError("stop orders require trigger_price > 0") + + @property + def signed_qty(self) -> float: + return self.qty * self.side.sign + + +@dataclass(frozen=True) +class OrderCommand: + """ + Canonical order-lifecycle command for native-event v2 and adapters. + + `OrderIntent` remains the backwards-compatible shorthand for an immediate + PLACE command. Phase 30A only defines and compiles this contract; lifecycle + matching is wired into a dedicated v2 engine phase. + """ + + timestamp: object + action: OrderAction = OrderAction.PLACE + symbol: Optional[str] = None + side: Optional[OrderSide] = None + order_type: Optional[OrderType] = None + qty: Optional[float] = None + price: Optional[float] = None + trigger_price: Optional[float] = None + tif: TimeInForce = TimeInForce.GTC + reduce_only: bool = False + order_id: Optional[str] = None + target_order_id: Optional[str] = None + parent_order_id: Optional[str] = None + group_id: Optional[str] = None + oco_group_id: Optional[str] = None + activation_policy: OrderActivationPolicy = OrderActivationPolicy.IMMEDIATE + expires_at: Optional[object] = None + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + tag_prefix: Optional[str] = None + + def __post_init__(self) -> None: + action = _normalize_order_action(self.action) + object.__setattr__(self, "action", action) + + activation = _normalize_activation_policy(self.activation_policy) + object.__setattr__(self, "activation_policy", activation) + + if action in (OrderAction.PLACE, OrderAction.REPLACE): + if not self.symbol: + raise ValueError(f"{action.value} command requires symbol") + if self.side is None: + raise ValueError(f"{action.value} command requires side") + if self.order_type is None: + raise ValueError(f"{action.value} command requires order_type") + if self.qty is None or self.qty <= 0.0: + raise ValueError(f"{action.value} command requires qty > 0") + if self.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): + if self.price is None or self.price <= 0.0: + raise ValueError("limit commands require price > 0") + if self.order_type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT): + if self.trigger_price is None or self.trigger_price <= 0.0: + raise ValueError("stop commands require trigger_price > 0") + if action is OrderAction.REPLACE and not self.target_order_id: + raise ValueError("replace command requires target_order_id") + elif action in (OrderAction.CANCEL, OrderAction.AMEND): + if not self.target_order_id: + raise ValueError(f"{action.value} command requires target_order_id") + if action is OrderAction.AMEND: + if self.qty is not None and self.qty <= 0.0: + raise ValueError("amend qty must be > 0") + if self.price is not None and self.price <= 0.0: + raise ValueError("amend price must be > 0") + if self.trigger_price is not None and self.trigger_price <= 0.0: + raise ValueError("amend trigger_price must be > 0") + elif action is OrderAction.CANCEL_ALL: + pass + else: + raise NotImplementedError(f"unsupported order action={action!r}") + + @classmethod + def from_intent(cls, intent: OrderIntent) -> "OrderCommand": + return cls( + timestamp=intent.timestamp, + action=OrderAction.PLACE, + symbol=intent.symbol, + side=intent.side, + order_type=intent.order_type, + qty=float(intent.qty), + price=intent.price, + trigger_price=intent.trigger_price, + tif=intent.tif, + reduce_only=intent.reduce_only, + order_id=intent.order_id, + tag=intent.tag, + metadata=dict(intent.metadata), + ) + + def to_intent(self) -> OrderIntent: + if self.action is not OrderAction.PLACE: + raise ValueError("only place commands can be converted to OrderIntent") + if self.symbol is None or self.side is None or self.order_type is None or self.qty is None: + raise ValueError("place command is incomplete") + return OrderIntent( + timestamp=self.timestamp, + symbol=self.symbol, + side=self.side, + order_type=self.order_type, + qty=float(self.qty), + price=self.price, + trigger_price=self.trigger_price, + tif=self.tif, + reduce_only=self.reduce_only, + order_id=self.order_id, + tag=self.tag, + metadata=dict(self.metadata), + ) + + @property + def signed_qty(self) -> float: + if self.side is None or self.qty is None: + return 0.0 + return float(self.qty) * self.side.sign + + +@dataclass(frozen=True) +class BasketIntent: + timestamp: object + basket_id: str + signal: float + gross_notional: Optional[float] = None + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.basket_id: + raise ValueError("basket_id is required") + + +@dataclass(frozen=True) +class Fill: + timestamp: object + symbol: str + side: OrderSide + qty: float + price: float + fee: float = 0.0 + liquidity: LiquiditySide = LiquiditySide.TAKER + order_id: Optional[str] = None + trade_id: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.symbol: + raise ValueError("symbol is required") + if self.qty <= 0.0: + raise ValueError("qty must be > 0") + if self.price <= 0.0: + raise ValueError("price must be > 0") + if self.fee < 0.0: + raise ValueError("fee must be >= 0") + + @property + def signed_qty(self) -> float: + return self.qty * self.side.sign + + @property + def notional(self) -> float: + return self.qty * self.price + + +@dataclass(frozen=True) +class Trade: + symbol: str + qty: float + side: OrderSide + opened_at: object + closed_at: object + avg_entry: float + avg_exit: float + realized_pnl: float + fees: float = 0.0 + trade_id: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.symbol: + raise ValueError("symbol is required") + if self.qty <= 0.0: + raise ValueError("qty must be > 0") + if self.avg_entry <= 0.0 or self.avg_exit <= 0.0: + raise ValueError("avg_entry and avg_exit must be > 0") + if self.fees < 0.0: + raise ValueError("fees must be >= 0") + + +def _normalize_order_action(action: OrderAction | str) -> OrderAction: + if isinstance(action, OrderAction): + return action + return OrderAction(str(action)) + + +def _normalize_activation_policy(policy: OrderActivationPolicy | str) -> OrderActivationPolicy: + if isinstance(policy, OrderActivationPolicy): + return policy + return OrderActivationPolicy(str(policy)) + + +def order_intents_to_lifecycle_commands( + orders: Sequence[OrderIntent], + *, + linked_metadata: bool = True, +) -> Tuple[OrderCommand, ...]: + """ + Convert `OrderIntent` records into lifecycle-v2 `OrderCommand` records. + + Structured package builders already carry parent/OCO information in + metadata. This helper lifts those fields into the explicit command contract + while preserving all old order intent fields for compatibility. + """ + commands = [] + tag_to_id = {} + for idx, order in enumerate(orders): + order_id = order.order_id or order.tag or f"order-{idx}" + tag_to_id[order.tag] = order_id + + for idx, order in enumerate(orders): + metadata = dict(order.metadata) + order_id = order.order_id or order.tag or f"order-{idx}" + parent_id = None + oco_group_id = None + activation = OrderActivationPolicy.IMMEDIATE + group_id = None + if linked_metadata: + group_id = metadata.get("group_id") or metadata.get("package_id") or metadata.get("arb_id") + leg_role = str(metadata.get("leg_role", "")).lower().strip() + if order.reduce_only or leg_role in {"take_profit", "stop_loss", "exit"}: + oco_group_id = metadata.get("oco_group_id") + parent_ref = metadata.get("parent_order_id") or metadata.get("parent_tag") + if parent_ref is not None: + parent_id = tag_to_id.get(parent_ref, str(parent_ref)) + activation = OrderActivationPolicy.ON_PARENT_FIRST_FILL + commands.append( + OrderCommand( + timestamp=order.timestamp, + action=OrderAction.PLACE, + symbol=order.symbol, + side=order.side, + order_type=order.order_type, + qty=float(order.qty), + price=order.price, + trigger_price=order.trigger_price, + tif=order.tif, + reduce_only=order.reduce_only, + order_id=order_id, + parent_order_id=parent_id, + group_id=None if group_id is None else str(group_id), + oco_group_id=None if oco_group_id is None else str(oco_group_id), + activation_policy=activation, + tag=order.tag, + metadata=metadata, + ) + ) + return tuple(commands) diff --git a/src/quantbt/core/portfolio.py b/src/quantbt/core/portfolio.py new file mode 100644 index 0000000..1cf5056 --- /dev/null +++ b/src/quantbt/core/portfolio.py @@ -0,0 +1,392 @@ +""" +Portfolio domain contracts for the native portfolio upgrade. + +This module does not execute a backtest. It defines the institutional-grade +contract that the future native portfolio engine must satisfy while legacy +portfolio behavior remains the compatibility oracle. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Iterable, Optional, Set + +import numpy as np +import pandas as pd + +from ..reporting.portfolio_audit import build_portfolio_domain_audit + + +class PortfolioMode(str, Enum): + LONGSHORT = "longshort" + MARKET_NEUTRAL = "market_neutral" + DIRECTIONAL = "directional" + EQUAL_WEIGHT = "equal_weight" + RISK_PARITY = "risk_parity" + BETA_NEUTRAL = "beta_neutral" + + +class PortfolioSizingMode(str, Enum): + SIGNAL_NOTIONAL = "signal_notional" + SIGNAL = "signal" + NOTIONAL = "notional" + UNIT = "unit" + PCT_EQUITY = "%_equity" + TARGET_WEIGHT = "target_weight" + TARGET_NOTIONAL = "target_notional" + TARGET_UNITS = "target_units" + FIXED_NOTIONAL = "fixed_notional" + GROSS_EXPOSURE = "gross_exposure" + NET_EXPOSURE = "net_exposure" + DCA_LADDER = "dca_ladder" + + +class PortfolioRebalancePolicy(str, Enum): + ON_SIGNAL_CHANGE = "on_signal_change" + EVERY_BAR = "every_bar" + THRESHOLD = "threshold" + SCHEDULED = "scheduled" + + +LEGACY_PORTFOLIO_MODES: Set[str] = {mode.value for mode in PortfolioMode} +LEGACY_COMPATIBLE_PORTFOLIO_MODES: Set[str] = { + PortfolioMode.LONGSHORT.value, + PortfolioMode.MARKET_NEUTRAL.value, + PortfolioMode.DIRECTIONAL.value, + PortfolioMode.EQUAL_WEIGHT.value, +} +LEGACY_PORTFOLIO_SIZING_MODES: Set[str] = { + PortfolioSizingMode.SIGNAL_NOTIONAL.value, + PortfolioSizingMode.SIGNAL.value, + PortfolioSizingMode.NOTIONAL.value, + PortfolioSizingMode.UNIT.value, +} +NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES: Set[str] = {mode.value for mode in PortfolioSizingMode} +NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES: Set[str] = { + *LEGACY_PORTFOLIO_SIZING_MODES, + PortfolioSizingMode.PCT_EQUITY.value, + PortfolioSizingMode.TARGET_WEIGHT.value, + PortfolioSizingMode.TARGET_NOTIONAL.value, + PortfolioSizingMode.TARGET_UNITS.value, + PortfolioSizingMode.FIXED_NOTIONAL.value, + PortfolioSizingMode.GROSS_EXPOSURE.value, + PortfolioSizingMode.NET_EXPOSURE.value, +} + + +@dataclass(frozen=True) +class PortfolioDomainSpec: + """ + Declarative contract for a portfolio backtest. + + Phase 11 uses this as a validation layer around legacy portfolio results. + Phase 11/native portfolio should use the same spec as its input contract. + """ + + mode: str = PortfolioMode.LONGSHORT.value + sizing_mode: str = PortfolioSizingMode.SIGNAL_NOTIONAL.value + rebalance_policy: str = PortfolioRebalancePolicy.ON_SIGNAL_CHANGE.value + allow_short: bool = True + require_gross_net_reports: bool = True + require_symbol_pnl_report: bool = True + require_margin_report: bool = True + target_gross_exposure: Optional[float] = None + target_net_exposure: Optional[float] = None + max_gross_leverage: Optional[float] = None + max_net_exposure_abs: Optional[float] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + mode = normalize_portfolio_mode(self.mode) + sizing = normalize_portfolio_sizing_mode(self.sizing_mode) + rebalance = normalize_rebalance_policy(self.rebalance_policy) + object.__setattr__(self, "mode", mode) + object.__setattr__(self, "sizing_mode", sizing) + object.__setattr__(self, "rebalance_policy", rebalance) + + if self.target_gross_exposure is not None and self.target_gross_exposure < 0.0: + raise ValueError("target_gross_exposure must be >= 0") + if self.max_gross_leverage is not None and self.max_gross_leverage < 0.0: + raise ValueError("max_gross_leverage must be >= 0") + if self.max_net_exposure_abs is not None and self.max_net_exposure_abs < 0.0: + raise ValueError("max_net_exposure_abs must be >= 0") + + @property + def legacy_compatible(self) -> bool: + return self.mode in LEGACY_COMPATIBLE_PORTFOLIO_MODES and self.sizing_mode in LEGACY_PORTFOLIO_SIZING_MODES + + @property + def native_planned(self) -> bool: + return self.mode in LEGACY_PORTFOLIO_MODES and self.sizing_mode in NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES + + +def normalize_portfolio_mode(mode: str) -> str: + value = str(mode).lower().strip() + aliases = { + "long_short": PortfolioMode.LONGSHORT.value, + "long/short": PortfolioMode.LONGSHORT.value, + "dollar_neutral": PortfolioMode.MARKET_NEUTRAL.value, + "marketneutral": PortfolioMode.MARKET_NEUTRAL.value, + "equal": PortfolioMode.EQUAL_WEIGHT.value, + "equalweight": PortfolioMode.EQUAL_WEIGHT.value, + "riskparity": PortfolioMode.RISK_PARITY.value, + "inverse_vol": PortfolioMode.RISK_PARITY.value, + "inverse_volatility": PortfolioMode.RISK_PARITY.value, + "betaneutral": PortfolioMode.BETA_NEUTRAL.value, + "beta_neutral_basic": PortfolioMode.BETA_NEUTRAL.value, + } + value = aliases.get(value, value) + if value not in LEGACY_PORTFOLIO_MODES: + raise ValueError(f"unsupported portfolio mode: {mode!r}") + return value + + +def normalize_portfolio_sizing_mode(mode: str) -> str: + value = str(mode).lower().strip() + aliases = { + "pct_equity": PortfolioSizingMode.PCT_EQUITY.value, + "percent_equity": PortfolioSizingMode.PCT_EQUITY.value, + "signal_notional": PortfolioSizingMode.SIGNAL_NOTIONAL.value, + "signal": PortfolioSizingMode.SIGNAL.value, + "units": PortfolioSizingMode.TARGET_UNITS.value, + "target_unit": PortfolioSizingMode.TARGET_UNITS.value, + "dollar": PortfolioSizingMode.NOTIONAL.value, + "portfolio_target_weight": PortfolioSizingMode.TARGET_WEIGHT.value, + "portfolio_target_notional": PortfolioSizingMode.TARGET_NOTIONAL.value, + "portfolio_target_units": PortfolioSizingMode.TARGET_UNITS.value, + "target_weights": PortfolioSizingMode.TARGET_WEIGHT.value, + "target_notionals": PortfolioSizingMode.TARGET_NOTIONAL.value, + "target_unit": PortfolioSizingMode.TARGET_UNITS.value, + "gross": PortfolioSizingMode.GROSS_EXPOSURE.value, + "net": PortfolioSizingMode.NET_EXPOSURE.value, + } + value = aliases.get(value, value) + if value not in NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES: + raise ValueError(f"unsupported portfolio sizing mode: {mode!r}") + return value + + +def normalize_rebalance_policy(policy: str) -> str: + value = str(policy).lower().strip() + aliases = { + "on_transition": PortfolioRebalancePolicy.ON_SIGNAL_CHANGE.value, + "signal_change": PortfolioRebalancePolicy.ON_SIGNAL_CHANGE.value, + "bar": PortfolioRebalancePolicy.EVERY_BAR.value, + } + value = aliases.get(value, value) + valid = {item.value for item in PortfolioRebalancePolicy} + if value not in valid: + raise ValueError(f"unsupported portfolio rebalance policy: {policy!r}") + return value + + +def portfolio_capability_matrix() -> pd.DataFrame: + rows = [] + for mode in sorted(LEGACY_PORTFOLIO_MODES): + for sizing in sorted(NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES): + rows.append( + { + "mode": mode, + "sizing_mode": sizing, + "legacy_supported": mode in LEGACY_COMPATIBLE_PORTFOLIO_MODES + and sizing in LEGACY_PORTFOLIO_SIZING_MODES, + "native_supported": sizing in NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES, + "native_roadmap": True, + "nautilus_validation_phase": "phase_4", + } + ) + return pd.DataFrame(rows) + + +def validate_portfolio_result_contract( + result, + spec: PortfolioDomainSpec, + *, + tolerance: float = 1e-8, + raise_on_fail: bool = False, +) -> Dict: + """ + Validate a completed portfolio result against the Phase 11 domain contract. + + The report combines accounting reconciliation from + `build_portfolio_domain_audit` with mode-specific exposure invariants. + """ + metadata = getattr(result, "metadata", {}) or {} + exposure = _frame(metadata.get("exposure_report")) + accepted_notional = _frame(metadata.get("accepted_notional_report")) + accepted_units = _frame(metadata.get("accepted_units_report")) + symbol_pnl = _frame(metadata.get("symbol_pnl_report")) + base_audit = build_portfolio_domain_audit(result, tolerance=tolerance, raise_on_fail=False) + + checks = { + "base_accounting_audit": bool(base_audit.get("passed")), + "mode_matches_spec": metadata.get("mode") == spec.mode, + "sizing_matches_spec": metadata.get("hedge_type") == spec.sizing_mode, + "has_exposure_report": not exposure.empty if spec.require_gross_net_reports else True, + "has_symbol_pnl_report": not symbol_pnl.empty if spec.require_symbol_pnl_report else True, + "has_margin_columns": _has_columns(exposure, {"initial_margin", "maintenance_margin"}) if spec.require_margin_report else True, + "short_policy_respected": _short_policy_respected(accepted_units, spec.allow_short), + "gross_leverage_limit_respected": _max_column(exposure, "gross_leverage") <= spec.max_gross_leverage + tolerance + if spec.max_gross_leverage is not None and not exposure.empty + else True, + "net_exposure_limit_respected": _max_abs_column(exposure, "net_exposure_pct") <= spec.max_net_exposure_abs + tolerance + if spec.max_net_exposure_abs is not None and not exposure.empty + else True, + } + checks.update(_mode_specific_checks(spec.mode, exposure, accepted_notional, tolerance)) + + passed = all(bool(v) for v in checks.values()) + report = { + "status": "pass" if passed else "fail", + "passed": passed, + "spec": { + "mode": spec.mode, + "sizing_mode": spec.sizing_mode, + "rebalance_policy": spec.rebalance_policy, + "legacy_compatible": spec.legacy_compatible, + "native_planned": spec.native_planned, + }, + "checks": checks, + "base_audit": base_audit, + } + if raise_on_fail and not passed: + raise AssertionError(f"portfolio contract validation failed: {report}") + return report + + +def _mode_specific_checks(mode: str, exposure: pd.DataFrame, accepted_notional: pd.DataFrame, tolerance: float) -> Dict[str, bool]: + if exposure.empty: + return { + "market_neutral_balanced": mode != PortfolioMode.MARKET_NEUTRAL.value, + "directional_single_active": mode != PortfolioMode.DIRECTIONAL.value, + "equal_weight_balanced": mode != PortfolioMode.EQUAL_WEIGHT.value, + "risk_parity_balanced": mode != PortfolioMode.RISK_PARITY.value, + "beta_neutral_balanced": mode != PortfolioMode.BETA_NEUTRAL.value, + } + + if mode == PortfolioMode.MARKET_NEUTRAL.value: + active = exposure["gross_notional"].abs() > tolerance + residual = (exposure.loc[active, "long_notional"] - exposure.loc[active, "short_notional"]).abs() + return { + "market_neutral_balanced": residual.empty or bool(residual.max() <= tolerance), + "directional_single_active": True, + "equal_weight_balanced": True, + "risk_parity_balanced": True, + "beta_neutral_balanced": True, + } + + if mode == PortfolioMode.DIRECTIONAL.value: + if accepted_notional.empty: + single_active = False + else: + active_counts = (accepted_notional.abs() > tolerance).sum(axis=1) + single_active = bool((active_counts <= 1).all()) + return { + "market_neutral_balanced": True, + "directional_single_active": single_active, + "equal_weight_balanced": True, + "risk_parity_balanced": True, + "beta_neutral_balanced": True, + } + + if mode == PortfolioMode.EQUAL_WEIGHT.value: + balanced = _equal_weight_balanced(accepted_notional, tolerance) + return { + "market_neutral_balanced": True, + "directional_single_active": True, + "equal_weight_balanced": balanced, + "risk_parity_balanced": True, + "beta_neutral_balanced": True, + } + + if mode == PortfolioMode.RISK_PARITY.value: + risk_ok = _risk_parity_balanced(_frame_from_exposure_attr(exposure, "risk_contribution_report"), tolerance) + return { + "market_neutral_balanced": True, + "directional_single_active": True, + "equal_weight_balanced": True, + "risk_parity_balanced": risk_ok, + "beta_neutral_balanced": True, + } + + if mode == PortfolioMode.BETA_NEUTRAL.value: + if "beta_exposure_notional" in exposure: + active = exposure["gross_notional"].abs() > tolerance + beta_abs = exposure.loc[active, "beta_exposure_notional"].abs() + beta_ok = beta_abs.empty or bool(beta_abs.max() <= tolerance) + else: + beta_ok = False + return { + "market_neutral_balanced": True, + "directional_single_active": True, + "equal_weight_balanced": True, + "risk_parity_balanced": True, + "beta_neutral_balanced": beta_ok, + } + + return { + "market_neutral_balanced": True, + "directional_single_active": True, + "equal_weight_balanced": True, + "risk_parity_balanced": True, + "beta_neutral_balanced": True, + } + + +def _equal_weight_balanced(accepted_notional: pd.DataFrame, tolerance: float) -> bool: + if accepted_notional.empty: + return False + abs_notional = accepted_notional.abs() + for _, row in abs_notional.iterrows(): + active = row[row > tolerance] + if len(active) <= 1: + continue + if float(active.max() - active.min()) > tolerance: + return False + return True + + +def _short_policy_respected(accepted_units: pd.DataFrame, allow_short: bool) -> bool: + if allow_short or accepted_units.empty: + return True + return bool((accepted_units >= -1e-12).all().all()) + + +def _has_columns(frame: pd.DataFrame, columns: Iterable[str]) -> bool: + return set(columns).issubset(frame.columns) + + +def _max_column(frame: pd.DataFrame, column: str) -> float: + if column not in frame: + return 0.0 + values = pd.to_numeric(frame[column], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna() + return float(values.max()) if not values.empty else 0.0 + + +def _max_abs_column(frame: pd.DataFrame, column: str) -> float: + if column not in frame: + return 0.0 + values = pd.to_numeric(frame[column], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna().abs() + return float(values.max()) if not values.empty else 0.0 + + +def _frame(value) -> pd.DataFrame: + return value if isinstance(value, pd.DataFrame) else pd.DataFrame() + + +def _frame_from_exposure_attr(exposure: pd.DataFrame, attr_name: str) -> pd.DataFrame: + value = exposure.attrs.get(attr_name) if isinstance(exposure, pd.DataFrame) else None + return value if isinstance(value, pd.DataFrame) else pd.DataFrame() + + +def _risk_parity_balanced(risk_contribution: pd.DataFrame, tolerance: float) -> bool: + if risk_contribution.empty: + return False + for _, row in risk_contribution.iterrows(): + active = row[row > tolerance] + if len(active) <= 1: + continue + if float(active.max() - active.min()) > max(tolerance, 1e-6): + return False + return True diff --git a/src/quantbt/core/preprocessor.py b/src/quantbt/core/preprocessor.py new file mode 100644 index 0000000..0f01425 --- /dev/null +++ b/src/quantbt/core/preprocessor.py @@ -0,0 +1,256 @@ +""" +quantbt.core.preprocessor +-------------------------- +Data alignment and numpy array assembly for the simulation kernels. +Keeps BacktestEngine clean; all pandas wrangling lives here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional, Union + +import numpy as np +import pandas as pd + + +@dataclass(frozen=True) +class MarketDataSignature: + length: int + first_timestamp_ns: Optional[int] + last_timestamp_ns: Optional[int] + symbols: tuple + shape: tuple + + +@dataclass(frozen=True) +class PreparedMarketArrays: + idx: pd.DatetimeIndex + symbols: tuple + closes: np.ndarray + highs: np.ndarray + lows: np.ndarray + funding: np.ndarray + is_funding_bar: np.ndarray + signature: MarketDataSignature + + +def validate_datetime(dt_input) -> pd.DatetimeIndex: + """Return a sorted, unique, UTC DatetimeIndex from any sensible input.""" + if isinstance(dt_input, pd.DatetimeIndex): + idx = dt_input + else: + idx = pd.to_datetime(pd.Series(dt_input), errors="coerce", utc=True) + idx = pd.DatetimeIndex(idx).drop_duplicates().sort_values() + if idx.tz is None: + idx = idx.tz_localize("UTC") + else: + idx = idx.tz_convert("UTC") + return idx + + +def align_series( + data: Union[pd.Series, Dict[str, pd.Series]], + symbols: list, + idx: pd.DatetimeIndex, + fill_val: float = np.nan, + fallback: Optional[Dict[str, pd.Series]] = None, +) -> Dict[str, pd.Series]: + """ + Reindex each symbol's series to idx using forward-fill. + If data is a bare Series (single-symbol case), map it to symbols[0]. + """ + is_single = (len(symbols) == 1 and symbols[0] == "DEFAULT") + out: Dict[str, pd.Series] = {} + + for sym in symbols: + if isinstance(data, dict): + s = data.get(sym) + elif is_single: + s = data + else: + s = None + + if s is None: + if fallback is not None: + out[sym] = fallback[sym] + else: + out[sym] = pd.Series(fill_val, index=idx) + continue + + if not isinstance(s, pd.Series): + s = pd.Series(s, index=idx) + else: + # ensure UTC + if isinstance(s.index, pd.DatetimeIndex): + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + else: + s.index = s.index.tz_convert("UTC") + s = s[~s.index.duplicated(keep="first")] + s = s.reindex(idx, method="ffill") + + out[sym] = s + + return out + + +def prepare_funding( + fr_input: Union[float, int, pd.Series, Dict], + symbols: list, + idx: pd.DatetimeIndex, +) -> Dict[str, pd.Series]: + """Build per-symbol funding-rate series aligned to idx.""" + out: Dict[str, pd.Series] = {} + for sym in symbols: + if isinstance(fr_input, dict): + if sym not in fr_input: + raise KeyError( + f"funding_rate dict is missing symbol {sym!r}; pass 0.0 explicitly " + "or set use_funding=False to avoid synthetic funding defaults" + ) + val = fr_input[sym] + elif isinstance(fr_input, pd.Series): + val = fr_input + else: + val = fr_input + + if isinstance(val, (float, int)): + out[sym] = pd.Series(float(val), index=idx) + else: + if isinstance(val.index, pd.DatetimeIndex): + if val.index.tz is None: + val.index = val.index.tz_localize("UTC") + else: + val.index = val.index.tz_convert("UTC") + out[sym] = val.reindex(idx, method="ffill").fillna(0.0) + + return out + + +def make_funding_mask(idx: pd.DatetimeIndex) -> np.ndarray: + """ + Boolean mask: True on the FIRST bar that enters each funding window. + Windows are [00:00, 08:00, 16:00) UTC. Works for any bar frequency. + + Compared to np.isin(hour, [0,8,16]) this fires exactly once per window + instead of once per bar within the hour. + """ + hours = idx.hour.to_numpy() + mask = np.zeros(len(idx), dtype=np.bool_) + funding_hours = {0, 8, 16} + for i in range(1, len(idx)): + if hours[i] in funding_hours and hours[i] != hours[i - 1]: + mask[i] = True + return mask + + +def build_arrays( + symbols: list, + idx: pd.DatetimeIndex, + closes_dict: Dict[str, pd.Series], + highs_dict: Dict[str, pd.Series], + lows_dict: Dict[str, pd.Series], + signals_dict: Dict[str, pd.Series], + funding_dict: Dict[str, pd.Series], +) -> tuple: + """ + Pack all per-symbol Series into contiguous float64 numpy arrays + ready for the numba kernels. + + Returns + ------- + closes, highs, lows, signals, funding each shape (n_bars, n_syms) + is_funding_bar shape (n_bars,) bool + """ + market = build_market_arrays( + symbols=symbols, + idx=idx, + closes_dict=closes_dict, + highs_dict=highs_dict, + lows_dict=lows_dict, + funding_dict=funding_dict, + ) + signals = build_signal_matrix(symbols=symbols, idx=idx, signals_dict=signals_dict) + return market.closes, market.highs, market.lows, signals, market.funding, market.is_funding_bar + + +def build_market_arrays( + symbols: list, + idx: pd.DatetimeIndex, + closes_dict: Dict[str, pd.Series], + highs_dict: Dict[str, pd.Series], + lows_dict: Dict[str, pd.Series], + funding_dict: Dict[str, pd.Series], +) -> PreparedMarketArrays: + """ + Pack immutable market arrays without allocating a dummy signal matrix. + + This is the safe prepared-data object used by event-driven runs and future + optimizer caches. It stores arrays plus an explicit signature; it does not + cache results or infer validity from mutable pandas object identity. + """ + n = len(idx) + s = len(symbols) + closes = np.zeros((n, s), dtype=np.float64) + highs = np.zeros((n, s), dtype=np.float64) + lows = np.zeros((n, s), dtype=np.float64) + funding = np.zeros((n, s), dtype=np.float64) + + for k, sym in enumerate(symbols): + c_ser = closes_dict[sym].fillna(0) + c = c_ser.values + closes[:, k] = c + # fillna with close series (same index), then extract values + highs[:, k] = highs_dict[sym].fillna(c_ser).values + lows[:, k] = lows_dict[sym].fillna(c_ser).values + funding[:, k] = funding_dict[sym].fillna(0).values + + is_funding_bar = make_funding_mask(idx) + closes = np.ascontiguousarray(closes, dtype=np.float64) + highs = np.ascontiguousarray(highs, dtype=np.float64) + lows = np.ascontiguousarray(lows, dtype=np.float64) + funding = np.ascontiguousarray(funding, dtype=np.float64) + is_funding_bar = np.ascontiguousarray(is_funding_bar, dtype=np.bool_) + for arr in (closes, highs, lows, funding, is_funding_bar): + arr.setflags(write=False) + return PreparedMarketArrays( + idx=idx, + symbols=tuple(symbols), + closes=closes, + highs=highs, + lows=lows, + funding=funding, + is_funding_bar=is_funding_bar, + signature=market_data_signature(idx, symbols), + ) + + +def build_signal_matrix( + symbols: list, + idx: pd.DatetimeIndex, + signals_dict: Dict[str, pd.Series], +) -> np.ndarray: + n = len(idx) + s = len(symbols) + signals = np.zeros((n, s), dtype=np.float64) + for k, sym in enumerate(symbols): + signals[:, k] = signals_dict[sym].fillna(0).values + return np.ascontiguousarray(signals, dtype=np.float64) + + +def market_data_signature(idx: pd.DatetimeIndex, symbols: list) -> MarketDataSignature: + if len(idx) == 0: + first = None + last = None + else: + values = idx.view("int64") + first = int(values[0]) + last = int(values[-1]) + return MarketDataSignature( + length=int(len(idx)), + first_timestamp_ns=first, + last_timestamp_ns=last, + symbols=tuple(symbols), + shape=(int(len(idx)), int(len(symbols))), + ) diff --git a/src/quantbt/core/reactive.py b/src/quantbt/core/reactive.py new file mode 100644 index 0000000..2aea26b --- /dev/null +++ b/src/quantbt/core/reactive.py @@ -0,0 +1,173 @@ +""" +Reactive native-event strategy context. + +These records are intentionally lightweight and read-only. Strategies inspect +engine state after each bar and return `OrderCommand` objects for the next bar. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Mapping, Optional, Protocol, Sequence, Tuple, runtime_checkable + +import numpy as np +import pandas as pd + +from .orders import OrderCommand +from .schema import OrderSide + + +@dataclass(frozen=True) +class NativeFillEvent: + timestamp: pd.Timestamp + symbol: str + side: OrderSide + qty: float + price: float + fee: float + order_id: Optional[str] = None + tag: Optional[str] = None + campaign_id: Optional[str] = None + cycle_id: Optional[str] = None + level_id: Optional[str] = None + parent_order_id: Optional[str] = None + oco_group_id: Optional[str] = None + metadata: Mapping = field(default_factory=dict) + + +@dataclass(frozen=True) +class NativeOrderEvent: + timestamp: pd.Timestamp + bar: int + event_name: str + status: int + order_id: Optional[str] = None + target_order_id: Optional[str] = None + parent_order_id: Optional[str] = None + oco_group_id: Optional[str] = None + tag: Optional[str] = None + campaign_id: Optional[str] = None + cycle_id: Optional[str] = None + level_id: Optional[str] = None + original_index: int = -1 + related_original_index: int = -1 + metadata: Mapping = field(default_factory=dict) + + +@dataclass(frozen=True) +class NativeActiveOrderSnapshot: + order_id: Optional[str] + symbol: Optional[str] + side: Optional[str] + order_type: Optional[str] + status: int + remaining_qty: float + price: float + trigger_price: float + reduce_only: bool + parent_order_id: Optional[str] = None + group_id: Optional[str] = None + oco_group_id: Optional[str] = None + tag: Optional[str] = None + campaign_id: Optional[str] = None + cycle_id: Optional[str] = None + level_id: Optional[str] = None + + +@dataclass(frozen=True, slots=True) +class NativeCommandBatch: + """Optional compact callback container for reactive command batches. + + Existing strategies may continue returning ``list[OrderCommand]`` or a + tuple. This wrapper makes the batch boundary explicit for strategies that + already build a fixed command tuple, without changing command semantics or + the public ``OrderCommand`` type. + """ + + commands: Tuple[OrderCommand, ...] = field(default_factory=tuple) + + @classmethod + def from_commands(cls, commands: Sequence[OrderCommand]) -> "NativeCommandBatch": + return cls(tuple(commands)) + + def __iter__(self): + return iter(self.commands) + + def __len__(self) -> int: + return len(self.commands) + + def __bool__(self) -> bool: + return bool(self.commands) + + +@dataclass(frozen=True) +class NativeStrategyContext: + bar_index: int + timestamp: pd.Timestamp + open: np.ndarray + high: np.ndarray + low: np.ndarray + close: np.ndarray + volume: np.ndarray + equity: float + available_equity: float + initial_margin: float + maintenance_margin: float + positions: Mapping[str, float] + fills_this_bar: Sequence[NativeFillEvent] + order_events_this_bar: Sequence[NativeOrderEvent] + active_orders: Sequence[NativeActiveOrderSnapshot] + liquidated: bool + symbols: Tuple[str, ...] = field(default_factory=tuple) + size_order: Callable[..., float] = field(default=lambda **_: 0.0, repr=False, compare=False) + + +class NativeEventStrategyError(RuntimeError): + """Raised when a reactive strategy callback fails.""" + + def __init__(self, callback: str, bar_index: int, timestamp: pd.Timestamp, original: Exception): + self.callback = callback + self.bar_index = int(bar_index) + self.timestamp = timestamp + self.original = original + super().__init__( + f"native-event strategy callback {callback!r} failed at " + f"bar_index={bar_index}, timestamp={timestamp}: {type(original).__name__}: {original}" + ) + + +class NativeEventStrategyProtocol: + """ + Optional protocol-like base class for user strategies. + + Subclassing is not required; duck typing is used by the backend. + """ + + def initialize(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + return () + + def on_bar_close(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + return () + + def finalize(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + return () + + +@runtime_checkable +class NativeEventStrategy(Protocol): + """Public structural protocol for stateful native-event strategies. + + Implementations are discovered by duck typing; subclassing this protocol + is optional. A strategy may optionally declare + ``native_context_requirements`` to reduce callback context materialization + for score/optimization runs. + """ + + def initialize(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + ... + + def on_bar_close(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + ... + + def finalize(self, context: NativeStrategyContext) -> Sequence[OrderCommand]: + ... diff --git a/src/quantbt/core/results.py b/src/quantbt/core/results.py new file mode 100644 index 0000000..9eefec0 --- /dev/null +++ b/src/quantbt/core/results.py @@ -0,0 +1,341 @@ +""" +quantbt.core.results +-------------------- +Richer result contract for upgraded backends. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Mapping, Sequence + +import numpy as np +import pandas as pd + +from .orders import Fill, OrderIntent, Trade +from .types import BacktestResult + + +@dataclass +class BacktestResultV2: + equity: pd.Series + returns: pd.Series + positions: pd.DataFrame + closes: pd.DataFrame + symbols: List[str] + initial_capital: float + leverage: float = 1.0 + liquidated: bool = False + liquidation_bar: int = -1 + orders: Sequence[OrderIntent] = field(default_factory=tuple) + fills: Sequence[Fill] = field(default_factory=tuple) + trades: Sequence[Trade] = field(default_factory=tuple) + fees: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) + funding: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) + margin: pd.DataFrame = field(default_factory=pd.DataFrame) + diagnostics: pd.DataFrame = field(default_factory=pd.DataFrame) + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.initial_capital <= 0.0: + raise ValueError("initial_capital must be > 0") + if self.leverage <= 0.0: + raise ValueError("leverage must be > 0") + if not isinstance(self.equity.index, pd.DatetimeIndex): + raise ValueError("equity must be indexed by DatetimeIndex") + if len(self.returns) != len(self.equity): + raise ValueError("returns must have the same length as equity") + if len(self.positions) != len(self.equity): + raise ValueError("positions must have the same length as equity") + if len(self.closes) != len(self.equity): + raise ValueError("closes must have the same length as equity") + + @property + def drawdown(self) -> pd.Series: + peak = self.equity.cummax() + return (peak - self.equity) / peak.replace(0, np.nan) + + @property + def daily_equity(self) -> pd.Series: + return self.equity.resample("1D").last().ffill().dropna() + + @property + def daily_returns(self) -> pd.Series: + return self.daily_equity.pct_change().dropna() + + def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """Return the standard QuantBT metrics dictionary for this result.""" + from .scopes import scoped_result + from ..metrics.performance import full_report + + return full_report(scoped_result(self, scope=scope), trading_days=trading_days) + + def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """Print a legacy-style metrics report and return the metrics dict.""" + from ..endpoint import format_metrics_report + + report = self.full_report(trading_days=trading_days, scope=scope) + print(format_metrics_report(report)) + return report + + def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = "auto"): + """Plot cumulative return and drawdown for this result.""" + from ..viz import quick_plot + + return quick_plot(self, theme=theme, figsize=figsize, scope=scope) + + def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"): + """Render the QuantBT tearsheet for this result.""" + from ..viz import tearsheet + + return tearsheet(self, theme=theme, benchmark=benchmark, scope=scope) + + @classmethod + def from_legacy(cls, result: BacktestResult) -> "BacktestResultV2": + return cls( + equity=result.equity.copy(), + returns=result.returns.copy(), + positions=result.positions.copy(), + closes=result.closes.copy(), + symbols=list(result.symbols), + initial_capital=float(result.initial_capital), + leverage=float(result.leverage), + liquidated=bool(result.liquidated), + liquidation_bar=int(result.liquidation_bar), + metadata=dict(result.metadata), + ) + + def to_legacy(self) -> BacktestResult: + return BacktestResult( + equity=self.equity.copy(), + returns=self.returns.copy(), + positions=self.positions.copy(), + closes=self.closes.copy(), + symbols=list(self.symbols), + initial_capital=float(self.initial_capital), + leverage=float(self.leverage), + liquidated=bool(self.liquidated), + liquidation_bar=int(self.liquidation_bar), + metadata=dict(self.metadata), + ) + + +@dataclass(frozen=True) +class NativeAccountingArrays: + timestamps: np.ndarray + equity: np.ndarray + returns: np.ndarray + positions: np.ndarray + fees: np.ndarray + funding: np.ndarray + initial_margin: np.ndarray + maintenance_margin: np.ndarray + symbols: tuple[str, ...] + initial_capital: float + leverage: float = 1.0 + liquidated: bool = False + liquidation_bar: int = -1 + + @classmethod + def from_result(cls, result: BacktestResultV2) -> "NativeAccountingArrays": + position_cols = [f"Position_{symbol}" for symbol in result.symbols] + return cls( + timestamps=result.equity.index.view("int64").copy(), + equity=result.equity.to_numpy(dtype=np.float64, copy=True), + returns=result.returns.to_numpy(dtype=np.float64, copy=True), + positions=result.positions[position_cols].to_numpy(dtype=np.float64, copy=True), + fees=result.fees.to_numpy(dtype=np.float64, copy=True), + funding=result.funding.to_numpy(dtype=np.float64, copy=True), + initial_margin=result.margin.get("initial_margin", pd.Series(0.0, index=result.equity.index)).to_numpy( + dtype=np.float64, + copy=True, + ), + maintenance_margin=result.margin.get( + "maintenance_margin", + pd.Series(0.0, index=result.equity.index), + ).to_numpy(dtype=np.float64, copy=True), + symbols=tuple(result.symbols), + initial_capital=float(result.initial_capital), + leverage=float(result.leverage), + liquidated=bool(result.liquidated), + liquidation_bar=int(result.liquidation_bar), + ) + + @property + def datetime_index(self) -> pd.DatetimeIndex: + return pd.DatetimeIndex(self.timestamps) + + +@dataclass(frozen=True) +class NativeEventScoreResult: + accounting: NativeAccountingArrays + final_positions: np.ndarray + fill_count: int + rejection_count: int + cancellation_count: int + liquidated: bool + liquidation_bar: int + metrics: Mapping[str, float] + metadata: Mapping[str, object] = field(default_factory=dict) + + @property + def equity(self) -> np.ndarray: + return self.accounting.equity + + @property + def returns(self) -> np.ndarray: + return self.accounting.returns + + @property + def positions(self) -> np.ndarray: + return self.accounting.positions + + @property + def fees(self) -> np.ndarray: + return self.accounting.fees + + @property + def funding(self) -> np.ndarray: + return self.accounting.funding + + @property + def initial_margin(self) -> np.ndarray: + return self.accounting.initial_margin + + @property + def maintenance_margin(self) -> np.ndarray: + return self.accounting.maintenance_margin + + def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: + if str(scope).lower().strip() not in {"auto", "full"}: + raise ValueError("NativeEventScoreResult supports scope='auto' or scope='full'") + + from ..metrics.performance import compute_performance_metrics + + return compute_performance_metrics( + timestamps=self.accounting.datetime_index, + equity=self.accounting.equity, + returns=self.accounting.returns, + positions=self.accounting.positions, + symbols=self.accounting.symbols, + initial_capital=float(self.accounting.initial_capital), + liquidated=bool(self.liquidated), + trading_days=trading_days, + ) + + +@dataclass(frozen=True, slots=True) +class NativeEventScalarScoreResult: + """Low-retention score contract for prepared native-event optimization. + + Unlike :class:`NativeEventScoreResult`, this result does not retain an + equity, position, fee, funding, or margin path. The reactive session + computes the same report metrics online and keeps only scalar accounting + state. Public audit runs and the compatibility ``score()`` contract keep + using ``NativeEventScoreResult`` with ndarray accounting. + """ + + final_equity: float + final_positions: np.ndarray + fill_count: int + rejection_count: int + cancellation_count: int + liquidated: bool + liquidation_bar: int + metrics: Mapping[str, float] + metadata: Mapping[str, object] = field(default_factory=dict) + + def _lifecycle_counter(self, name: str, default: int = 0) -> int: + counters = self.metadata.get("lifecycle_counters", {}) + return int(counters.get(name, default)) if isinstance(counters, Mapping) else int(default) + + @property + def event_count(self) -> int: + """Number of lifecycle events emitted by the scalar run.""" + + return self._lifecycle_counter("event_count") + + @property + def rejected_count(self) -> int: + """Number of rejected commands in the scalar run.""" + + return int(self.rejection_count) + + @property + def canceled_count(self) -> int: + """Number of canceled commands in the scalar run.""" + + return int(self.cancellation_count) + + @property + def max_initial_margin(self) -> float: + return float(self.metrics.get("max_initial_margin", 0.0)) + + @property + def max_maintenance_margin(self) -> float: + return float(self.metrics.get("max_maintenance_margin", 0.0)) + + @property + def total_fee(self) -> float: + return float(self.metadata.get("total_fee", 0.0)) + + @property + def total_turnover(self) -> float: + return float(self.metadata.get("total_turnover", 0.0)) + + def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """Return the online report captured for this score run. + + A scalar score has no path from which to recompute a different + annualization convention. Callers requesting a different + ``trading_days`` value must rerun the score with that value. + """ + if str(scope).lower().strip() not in {"auto", "full"}: + raise ValueError("NativeEventScalarScoreResult supports scope='auto' or scope='full'") + recorded_days = int(self.metadata.get("trading_days", trading_days)) + if int(trading_days) != recorded_days: + raise ValueError( + "scalar score metrics were computed with trading_days=" + f"{recorded_days}; rerun the score to use trading_days={int(trading_days)}" + ) + return dict(self.metrics) + + +@dataclass +class OptionBacktestResult(BacktestResultV2): + """ + Backtest result contract for native option simulations. + + It intentionally remains a `BacktestResultV2` so existing report helpers + keep working, while exposing option-domain audit tables explicitly. + """ + + fills_report: pd.DataFrame = field(default_factory=pd.DataFrame) + packages_report: pd.DataFrame = field(default_factory=pd.DataFrame) + cash_report: pd.DataFrame = field(default_factory=pd.DataFrame) + marks_report: pd.DataFrame = field(default_factory=pd.DataFrame) + greeks_report: pd.DataFrame = field(default_factory=pd.DataFrame) + settlements_report: pd.DataFrame = field(default_factory=pd.DataFrame) + margin_report: pd.DataFrame = field(default_factory=pd.DataFrame) + attribution_report: pd.DataFrame = field(default_factory=pd.DataFrame) + hedge_report: pd.DataFrame = field(default_factory=pd.DataFrame) + option_equity: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) + combined_equity: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) + combined_returns: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) + run_manifest: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + super().__post_init__() + self.metadata.setdefault("fills_report", self.fills_report) + self.metadata.setdefault("packages_report", self.packages_report) + self.metadata.setdefault("cash_report", self.cash_report) + self.metadata.setdefault("marks_report", self.marks_report) + self.metadata.setdefault("greeks_report", self.greeks_report) + self.metadata.setdefault("settlements_report", self.settlements_report) + self.metadata.setdefault("margin_report", self.margin_report) + self.metadata.setdefault("attribution_report", self.attribution_report) + self.metadata.setdefault("hedge_report", self.hedge_report) + self.metadata.setdefault("option_equity", self.option_equity) + self.metadata.setdefault("combined_equity", self.combined_equity) + self.metadata.setdefault("combined_returns", self.combined_returns) + self.metadata.setdefault("run_manifest", self.run_manifest) diff --git a/src/quantbt/core/schema.py b/src/quantbt/core/schema.py new file mode 100644 index 0000000..116ce97 --- /dev/null +++ b/src/quantbt/core/schema.py @@ -0,0 +1,211 @@ +""" +quantbt.core.schema +------------------- +Domain configuration objects shared by native and optional adapter backends. + +These dataclasses are intentionally lightweight and dependency-free beyond the +standard library. Hot loops should receive ndarray views derived from these +objects, not the objects themselves. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional + + +class AssetType(str, Enum): + CRYPTO = "crypto" + STOCK = "stock" + FUTURE = "future" + FX = "fx" + OPTION = "option" + + +class MarginMode(str, Enum): + CASH = "cash" + ISOLATED = "isolated" + CROSS = "cross" + PORTFOLIO = "portfolio" + + +class OmsMode(str, Enum): + NETTING = "netting" + HEDGING = "hedging" + + +class OrderSide(str, Enum): + BUY = "buy" + SELL = "sell" + + @property + def sign(self) -> float: + return 1.0 if self is OrderSide.BUY else -1.0 + + +class OrderType(str, Enum): + MARKET = "market" + LIMIT = "limit" + STOP_MARKET = "stop_market" + STOP_LIMIT = "stop_limit" + + +class TimeInForce(str, Enum): + GTC = "gtc" + IOC = "ioc" + FOK = "fok" + GTD = "gtd" + + +class LiquiditySide(str, Enum): + MAKER = "maker" + TAKER = "taker" + + +class FillPricePolicy(str, Enum): + CLOSE = "close" + OPEN = "open" + TOUCH = "touch" + NEXT_OPEN = "next_open" + + +class SameBarPolicy(str, Enum): + CONSERVATIVE = "conservative" + ENTRY_FIRST = "entry_first" + EXIT_FIRST = "exit_first" + + +class BasketExecutionPolicy(str, Enum): + BEST_EFFORT = "best_effort" + ALL_OR_NONE = "all_or_none" + + +@dataclass(frozen=True) +class FeeModel: + maker: float = 0.0 + taker: float = 0.0 + + def __post_init__(self) -> None: + if self.maker < 0.0 or self.taker < 0.0: + raise ValueError("fee rates must be >= 0") + + def rate_for(self, liquidity: LiquiditySide) -> float: + return self.maker if liquidity is LiquiditySide.MAKER else self.taker + + +@dataclass(frozen=True) +class InstrumentSpec: + symbol: str + asset_type: AssetType = AssetType.CRYPTO + contract_size: float = 1.0 + tick_size: float = 0.0 + lot_size: float = 0.0 + min_qty: float = 0.0 + min_notional: float = 0.0 + price_precision: Optional[int] = None + qty_precision: Optional[int] = None + fee_model: FeeModel = field(default_factory=FeeModel) + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.symbol: + raise ValueError("symbol is required") + if self.contract_size <= 0.0: + raise ValueError("contract_size must be > 0") + if self.tick_size < 0.0 or self.lot_size < 0.0: + raise ValueError("tick_size and lot_size must be >= 0") + if self.min_qty < 0.0 or self.min_notional < 0.0: + raise ValueError("min_qty and min_notional must be >= 0") + if self.price_precision is not None and self.price_precision < 0: + raise ValueError("price_precision must be >= 0") + if self.qty_precision is not None and self.qty_precision < 0: + raise ValueError("qty_precision must be >= 0") + + +@dataclass(frozen=True) +class AccountConfig: + initial_capital: float + base_currency: str = "USD" + leverage: float = 1.0 + maintenance_ratio: float = 0.005 + margin_mode: MarginMode = MarginMode.CROSS + oms_mode: OmsMode = OmsMode.NETTING + margin_buffer: float = 0.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.initial_capital <= 0.0: + raise ValueError("initial_capital must be > 0") + if self.leverage <= 0.0: + raise ValueError("leverage must be > 0") + if self.maintenance_ratio < 0.0: + raise ValueError("maintenance_ratio must be >= 0") + if self.margin_buffer < 0.0: + raise ValueError("margin_buffer must be >= 0") + + @property + def initial_buying_power(self) -> float: + return self.initial_capital * self.leverage + + +@dataclass(frozen=True) +class ExecutionConfig: + fill_price_policy: FillPricePolicy = FillPricePolicy.CLOSE + same_bar_policy: SameBarPolicy = SameBarPolicy.CONSERVATIVE + slippage_bps: float = 0.0 + allow_partial_fill: bool = False + reject_on_insufficient_margin: bool = True + min_order_notional: float = 0.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.slippage_bps < 0.0: + raise ValueError("slippage_bps must be >= 0") + if self.min_order_notional < 0.0: + raise ValueError("min_order_notional must be >= 0") + + @property + def slippage_rate(self) -> float: + return self.slippage_bps / 10_000.0 + + +@dataclass(frozen=True) +class SignalSpec: + timestamp: object + symbol: str + value: float + kind: str = "weight" + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class BasketLegSpec: + symbol: str + ratio: float + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.symbol: + raise ValueError("symbol is required") + + +@dataclass(frozen=True) +class BasketSpec: + basket_id: str + legs: tuple[BasketLegSpec, ...] + gross_notional: float + freeze_hedge: bool = True + hedged_margin_offset: float = 0.0 + execution_policy: BasketExecutionPolicy = BasketExecutionPolicy.BEST_EFFORT + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.basket_id: + raise ValueError("basket_id is required") + if len(self.legs) == 0: + raise ValueError("basket must contain at least one leg") + if self.gross_notional < 0.0: + raise ValueError("gross_notional must be >= 0") + if not 0.0 <= self.hedged_margin_offset <= 1.0: + raise ValueError("hedged_margin_offset must be in [0, 1]") diff --git a/src/quantbt/core/scopes.py b/src/quantbt/core/scopes.py new file mode 100644 index 0000000..5fc618d --- /dev/null +++ b/src/quantbt/core/scopes.py @@ -0,0 +1,96 @@ +""" +Reporting scope helpers. + +Walk-forward and train/test split runs store a full stitched timeline, but the +natural performance report is the OOS/test portion only. These helpers keep +endpoint-level and result-level metrics/plots consistent. +""" + +from __future__ import annotations + +import pandas as pd + +from .results import BacktestResultV2 +from .types import BacktestResult + + +def scoped_result(result, scope: str = "auto"): + """ + Return `result` or an OOS/test-sliced copy for reporting. + + `auto` means OOS/test for walk-forward artifacts and full result for normal + backtests. Use `full` to audit the complete stitched timeline. + """ + normalized = str(scope or "auto").lower().strip() + if normalized == "auto": + normalized = "oos" if "walk_forward" in result.metadata else "full" + if normalized == "full": + return result + if normalized in {"test", "oos"}: + return _slice_result_to_walk_forward_oos(result, scope=normalized) + raise ValueError("scope must be auto, full, test, or oos") + + +def _slice_result_to_walk_forward_oos(result, scope: str): + wf_meta = result.metadata.get("walk_forward") + if not wf_meta: + raise ValueError(f"scope={scope!r} is only available for walk_forward/train_test_split results") + fold_table = wf_meta.get("fold_table") + if fold_table is None or len(fold_table) == 0: + raise ValueError("walk-forward result does not contain a fold_table") + + idx = result.equity.index + mask = pd.Series(False, index=idx) + for _, row in fold_table.iterrows(): + start = pd.Timestamp(row["test_start"]) + end = pd.Timestamp(row["test_end"]) + mask |= (idx >= start) & (idx <= end) + if not bool(mask.any()): + raise ValueError("walk-forward OOS/test scope contains no bars in result index") + + sliced_metadata = dict(result.metadata) + sliced_wf_meta = dict(wf_meta) + sliced_wf_meta["report_scope"] = scope + sliced_metadata["walk_forward"] = sliced_wf_meta + + if isinstance(result, BacktestResultV2): + return BacktestResultV2( + equity=result.equity.loc[mask].copy(), + returns=result.returns.loc[mask].copy(), + positions=result.positions.loc[mask].copy(), + closes=result.closes.loc[mask].copy(), + symbols=list(result.symbols), + initial_capital=float(result.initial_capital), + leverage=float(result.leverage), + liquidated=bool(result.liquidated), + liquidation_bar=int(result.liquidation_bar), + orders=getattr(result, "orders", ()), + fills=getattr(result, "fills", ()), + trades=getattr(result, "trades", ()), + fees=_slice_indexed_like(result.fees, mask), + funding=_slice_indexed_like(result.funding, mask), + margin=_slice_indexed_like(result.margin, mask), + diagnostics=_slice_indexed_like(result.diagnostics, mask), + metadata=sliced_metadata, + ) + + return BacktestResult( + equity=result.equity.loc[mask].copy(), + returns=result.returns.loc[mask].copy(), + positions=result.positions.loc[mask].copy(), + closes=result.closes.loc[mask].copy(), + symbols=list(result.symbols), + initial_capital=float(result.initial_capital), + leverage=float(result.leverage), + liquidated=bool(result.liquidated), + liquidation_bar=int(result.liquidation_bar), + metadata=sliced_metadata, + ) + + +def _slice_indexed_like(obj, mask: pd.Series): + if obj is None: + return obj + if isinstance(obj, (pd.Series, pd.DataFrame)) and obj.index.equals(mask.index): + return obj.loc[mask].copy() + return obj diff --git a/src/quantbt/core/structured_orders.py b/src/quantbt/core/structured_orders.py new file mode 100644 index 0000000..0a9dfa3 --- /dev/null +++ b/src/quantbt/core/structured_orders.py @@ -0,0 +1,378 @@ +""" +Structured order package compilers. + +These helpers convert transparent strategy-package specs into explicit +``OrderIntent`` objects. They intentionally do not contain alpha logic; the +generated orders are passed to event backends such as Nautilus for execution +simulation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Sequence + +import pandas as pd + +from .orders import OrderIntent +from .schema import OrderSide, OrderType, TimeInForce + + +@dataclass(frozen=True) +class StructuredOrderPlan: + package_id: str + package_type: str + orders: tuple[OrderIntent, ...] + order_table: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class BracketOrderSpec: + """ + Entry plus linked take-profit/stop-loss exits. + + ``exit_timestamp`` defaults to the entry timestamp. In bar-based validation, + callers may set it to the next bar to model contingent exits becoming + active only after the entry fill is known. + """ + + symbol: str + entry_timestamp: object + side: OrderSide + qty: float + package_id: str = "BRACKET-001" + entry_order_type: OrderType = OrderType.MARKET + entry_price: Optional[float] = None + entry_trigger_price: Optional[float] = None + take_profit_price: Optional[float] = None + stop_loss_price: Optional[float] = None + exit_timestamp: Optional[object] = None + entry_tif: TimeInForce = TimeInForce.IOC + exit_tif: TimeInForce = TimeInForce.GTC + reduce_only_exits: bool = True + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "side", _coerce_enum(OrderSide, self.side)) + object.__setattr__(self, "entry_order_type", _coerce_enum(OrderType, self.entry_order_type)) + object.__setattr__(self, "entry_tif", _coerce_enum(TimeInForce, self.entry_tif)) + object.__setattr__(self, "exit_tif", _coerce_enum(TimeInForce, self.exit_tif)) + if not self.symbol: + raise ValueError("BracketOrderSpec.symbol is required") + if self.qty <= 0.0: + raise ValueError("BracketOrderSpec.qty must be > 0") + if self.take_profit_price is None and self.stop_loss_price is None: + raise ValueError("BracketOrderSpec requires take_profit_price or stop_loss_price") + + +@dataclass(frozen=True) +class DcaGridSpec: + """ + Deterministic DCA/grid order package. + + Base entry is a market order. Safety orders are GTC limits at grid prices. + Optional TP/SL exits are reduce-only OCO siblings sized to the maximum + planned ladder quantity, which is conservative for validation and auditable + in ``metadata``. + """ + + symbol: str + entry_timestamp: object + side: OrderSide + package_id: str = "DCA-GRID-001" + base_qty: Optional[float] = None + base_notional: Optional[float] = None + entry_price: Optional[float] = None + safety_order_count: int = 0 + safety_qty: Optional[float] = None + safety_notional: Optional[float] = None + step_pct: float = 0.01 + step_scale: float = 1.0 + volume_scale: float = 1.0 + take_profit_pct: Optional[float] = None + stop_loss_pct: Optional[float] = None + take_profit_price: Optional[float] = None + stop_loss_price: Optional[float] = None + exit_timestamp: Optional[object] = None + entry_tif: TimeInForce = TimeInForce.IOC + safety_tif: TimeInForce = TimeInForce.GTC + exit_tif: TimeInForce = TimeInForce.GTC + reduce_only_exits: bool = True + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "side", _coerce_enum(OrderSide, self.side)) + object.__setattr__(self, "entry_tif", _coerce_enum(TimeInForce, self.entry_tif)) + object.__setattr__(self, "safety_tif", _coerce_enum(TimeInForce, self.safety_tif)) + object.__setattr__(self, "exit_tif", _coerce_enum(TimeInForce, self.exit_tif)) + if not self.symbol: + raise ValueError("DcaGridSpec.symbol is required") + if self.base_qty is None and self.base_notional is None: + raise ValueError("DcaGridSpec requires base_qty or base_notional") + if self.base_qty is not None and self.base_qty <= 0.0: + raise ValueError("DcaGridSpec.base_qty must be > 0") + if self.base_notional is not None and self.base_notional <= 0.0: + raise ValueError("DcaGridSpec.base_notional must be > 0") + if self.safety_order_count < 0: + raise ValueError("DcaGridSpec.safety_order_count must be >= 0") + if self.safety_order_count and self.safety_qty is None and self.safety_notional is None: + raise ValueError("DcaGridSpec safety orders require safety_qty or safety_notional") + if self.step_pct <= 0.0 or self.step_scale <= 0.0 or self.volume_scale <= 0.0: + raise ValueError("DCA step_pct, step_scale, and volume_scale must be > 0") + + +def build_bracket_order_plan(spec: BracketOrderSpec) -> StructuredOrderPlan: + ts_entry = _utc_timestamp(spec.entry_timestamp) + ts_exit = _utc_timestamp(spec.exit_timestamp or spec.entry_timestamp) + package_id = spec.package_id + oco_group_id = f"{package_id}:oco" + tag_prefix = spec.tag or package_id + + common = { + "package_id": package_id, + "package_type": "bracket_oco", + "structured_type": "bracket_oco", + "oco_group_id": oco_group_id, + "oco_policy": "cancel_sibling_on_first_exit_fill", + } + entry = OrderIntent( + timestamp=ts_entry, + symbol=spec.symbol, + side=spec.side, + order_type=spec.entry_order_type, + qty=float(spec.qty), + price=spec.entry_price, + trigger_price=spec.entry_trigger_price, + tif=spec.entry_tif, + tag=f"{tag_prefix}:entry", + metadata={**spec.metadata, **common, "leg_role": "entry"}, + ) + orders = [entry] + exit_side = _opposite_side(spec.side) + if spec.take_profit_price is not None: + orders.append( + OrderIntent( + timestamp=ts_exit, + symbol=spec.symbol, + side=exit_side, + order_type=OrderType.LIMIT, + qty=float(spec.qty), + price=float(spec.take_profit_price), + tif=spec.exit_tif, + reduce_only=spec.reduce_only_exits, + tag=f"{tag_prefix}:take-profit", + metadata={**spec.metadata, **common, "leg_role": "take_profit", "parent_tag": entry.tag}, + ) + ) + if spec.stop_loss_price is not None: + orders.append( + OrderIntent( + timestamp=ts_exit, + symbol=spec.symbol, + side=exit_side, + order_type=OrderType.STOP_MARKET, + qty=float(spec.qty), + trigger_price=float(spec.stop_loss_price), + tif=spec.exit_tif, + reduce_only=spec.reduce_only_exits, + tag=f"{tag_prefix}:stop-loss", + metadata={**spec.metadata, **common, "leg_role": "stop_loss", "parent_tag": entry.tag}, + ) + ) + return _structured_plan(package_id, "bracket_oco", orders, metadata={**spec.metadata, **common}) + + +def build_dca_grid_order_plan(spec: DcaGridSpec, close: pd.Series) -> StructuredOrderPlan: + close = _prepare_close(close) + ts_entry = _utc_timestamp(spec.entry_timestamp) + if ts_entry not in close.index: + raise ValueError("DCA entry_timestamp must exist in close index") + entry_price = float(spec.entry_price if spec.entry_price is not None else close.loc[ts_entry]) + if entry_price <= 0.0: + raise ValueError("DCA entry_price must be > 0") + + package_id = spec.package_id + oco_group_id = f"{package_id}:exit-oco" + tag_prefix = spec.tag or package_id + base_qty = float(spec.base_qty if spec.base_qty is not None else float(spec.base_notional) / entry_price) + side_sign = spec.side.sign + common = { + "package_id": package_id, + "package_type": "dca_grid", + "structured_type": "dca_grid", + "oco_group_id": oco_group_id, + "oco_policy": "cancel_sibling_on_first_exit_fill", + "entry_price_reference": entry_price, + } + + orders = [ + OrderIntent( + timestamp=ts_entry, + symbol=spec.symbol, + side=spec.side, + order_type=OrderType.MARKET, + qty=base_qty, + tif=spec.entry_tif, + tag=f"{tag_prefix}:base", + metadata={ + **spec.metadata, + **common, + "leg_role": "base", + "ladder_level": 1, + "target_units": side_sign * base_qty, + }, + ) + ] + + total_qty = base_qty + weighted_cost = entry_price * base_qty + for safety_index in range(int(spec.safety_order_count)): + level = safety_index + 2 + deviation = _cumulative_grid_deviation(spec.step_pct, spec.step_scale, safety_index) + trigger = entry_price * (1.0 - deviation if spec.side is OrderSide.BUY else 1.0 + deviation) + if trigger <= 0.0: + raise ValueError("DCA grid trigger price must be > 0") + qty_base = float(spec.safety_qty if spec.safety_qty is not None else float(spec.safety_notional) / trigger) + qty = qty_base * (float(spec.volume_scale) ** safety_index) + total_qty += qty + weighted_cost += trigger * qty + orders.append( + OrderIntent( + timestamp=ts_entry, + symbol=spec.symbol, + side=spec.side, + order_type=OrderType.LIMIT, + qty=qty, + price=trigger, + tif=spec.safety_tif, + tag=f"{tag_prefix}:safety-{safety_index + 1}", + metadata={ + **spec.metadata, + **common, + "leg_role": "safety", + "ladder_level": level, + "grid_deviation": deviation, + "target_units": side_sign * total_qty, + }, + ) + ) + + avg_full_ladder = weighted_cost / total_qty + ts_exit = _utc_timestamp(spec.exit_timestamp or spec.entry_timestamp) + exit_side = _opposite_side(spec.side) + tp_price = spec.take_profit_price + if tp_price is None and spec.take_profit_pct is not None: + tp_price = avg_full_ladder * (1.0 + spec.take_profit_pct if spec.side is OrderSide.BUY else 1.0 - spec.take_profit_pct) + sl_price = spec.stop_loss_price + if sl_price is None and spec.stop_loss_pct is not None: + sl_price = entry_price * (1.0 - spec.stop_loss_pct if spec.side is OrderSide.BUY else 1.0 + spec.stop_loss_pct) + + exit_meta = { + **spec.metadata, + **common, + "exit_quantity_policy": "max_planned_ladder_qty", + "max_planned_ladder_qty": total_qty, + "full_ladder_avg_entry": avg_full_ladder, + } + if tp_price is not None: + orders.append( + OrderIntent( + timestamp=ts_exit, + symbol=spec.symbol, + side=exit_side, + order_type=OrderType.LIMIT, + qty=total_qty, + price=float(tp_price), + tif=spec.exit_tif, + reduce_only=spec.reduce_only_exits, + tag=f"{tag_prefix}:take-profit", + metadata={**exit_meta, "leg_role": "take_profit"}, + ) + ) + if sl_price is not None: + orders.append( + OrderIntent( + timestamp=ts_exit, + symbol=spec.symbol, + side=exit_side, + order_type=OrderType.STOP_MARKET, + qty=total_qty, + trigger_price=float(sl_price), + tif=spec.exit_tif, + reduce_only=spec.reduce_only_exits, + tag=f"{tag_prefix}:stop-loss", + metadata={**exit_meta, "leg_role": "stop_loss"}, + ) + ) + + return _structured_plan( + package_id, + "dca_grid", + orders, + metadata={ + **spec.metadata, + **common, + "max_planned_ladder_qty": total_qty, + "full_ladder_avg_entry": avg_full_ladder, + "safety_order_count": int(spec.safety_order_count), + }, + ) + + +def _structured_plan(package_id: str, package_type: str, orders: Sequence[OrderIntent], metadata: Dict) -> StructuredOrderPlan: + table = pd.DataFrame( + [ + { + "timestamp": _utc_timestamp(order.timestamp), + "symbol": order.symbol, + "side": order.side.value, + "qty": float(order.qty), + "order_type": order.order_type.value, + "price": order.price, + "trigger_price": order.trigger_price, + "tif": order.tif.value, + "reduce_only": bool(order.reduce_only), + "tag": order.tag, + "leg_role": order.metadata.get("leg_role"), + "package_id": order.metadata.get("package_id"), + "oco_group_id": order.metadata.get("oco_group_id"), + "ladder_level": order.metadata.get("ladder_level"), + } + for order in orders + ] + ) + return StructuredOrderPlan(package_id=package_id, package_type=package_type, orders=tuple(orders), order_table=table, metadata=metadata) + + +def _cumulative_grid_deviation(step_pct: float, step_scale: float, safety_index: int) -> float: + deviation = 0.0 + step = float(step_pct) + for _ in range(safety_index + 1): + deviation += step + step *= float(step_scale) + return deviation + + +def _prepare_close(close: pd.Series) -> pd.Series: + out = close.copy() + out.index = pd.DatetimeIndex(out.index) + out.index = out.index.tz_localize("UTC") if out.index.tz is None else out.index.tz_convert("UTC") + return out.sort_index() + + +def _utc_timestamp(value) -> pd.Timestamp: + ts = pd.Timestamp(value) + return ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC") + + +def _opposite_side(side: OrderSide) -> OrderSide: + return OrderSide.SELL if side is OrderSide.BUY else OrderSide.BUY + + +def _coerce_enum(enum_cls, value): + if isinstance(value, enum_cls): + return value + return enum_cls(str(value).lower().strip()) diff --git a/src/quantbt/core/types.py b/src/quantbt/core/types.py new file mode 100644 index 0000000..fe5d961 --- /dev/null +++ b/src/quantbt/core/types.py @@ -0,0 +1,87 @@ +""" +quantbt.core.types +------------------ +Shared dataclasses. BacktestResult is the single output contract used by +metrics, viz, and optimizer modules — nothing downstream imports BacktestEngine. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd + + +@dataclass +class BacktestResult: + """ + Immutable output of a single backtest run. + + Attributes + ---------- + equity Equity curve indexed by DatetimeIndex (UTC). + returns Bar-frequency net return series. + positions DataFrame, one column per symbol, target units per bar. + closes DataFrame, one column per symbol, close prices. + symbols Ordered list of symbol names. + initial_capital + leverage + liquidated True if the account was margin-called. + liquidation_bar Integer bar index of liquidation, -1 if none. + metadata Arbitrary dict for storing run parameters. + """ + + equity: pd.Series + returns: pd.Series + positions: pd.DataFrame + closes: pd.DataFrame + symbols: List[str] + initial_capital: float + leverage: float + liquidated: bool = False + liquidation_bar: int = -1 + metadata: Dict = field(default_factory=dict) + + # ── computed on first access ────────────────────────────────────────── + @property + def drawdown(self) -> pd.Series: + """Drawdown series as a positive fraction (0 = at peak, 1 = 100% loss).""" + peak = self.equity.cummax() + return (peak - self.equity) / peak.replace(0, np.nan) + + @property + def daily_equity(self) -> pd.Series: + return self.equity.resample("1D").last().ffill().dropna() + + @property + def daily_returns(self) -> pd.Series: + return self.daily_equity.pct_change().dropna() + + def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """Return the standard QuantBT metrics dictionary for this result.""" + from .scopes import scoped_result + from ..metrics.performance import full_report + + return full_report(scoped_result(self, scope=scope), trading_days=trading_days) + + def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """Print a legacy-style metrics report and return the metrics dict.""" + from ..endpoint import format_metrics_report + + report = self.full_report(trading_days=trading_days, scope=scope) + print(format_metrics_report(report)) + return report + + def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = "auto"): + """Plot cumulative return and drawdown for this result.""" + from ..viz import quick_plot + + return quick_plot(self, theme=theme, figsize=figsize, scope=scope) + + def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"): + """Render the QuantBT tearsheet for this result.""" + from ..viz import tearsheet + + return tearsheet(self, theme=theme, benchmark=benchmark, scope=scope) diff --git a/src/quantbt/core/vectorized.py b/src/quantbt/core/vectorized.py new file mode 100644 index 0000000..d6912f1 --- /dev/null +++ b/src/quantbt/core/vectorized.py @@ -0,0 +1,203 @@ +""" +quantbt.core.vectorized +----------------------- +Numba kernels for the V2 native vectorized backend. +""" + +from __future__ import annotations + +import numpy as np +from numba import njit + + +REJECT_NONE = 0 +REJECT_INSUFFICIENT_MARGIN = 1 + +LIQ_NONE = 0 +LIQ_INTRABAR = 1 +LIQ_AFTER_FUNDING = 2 +LIQ_AFTER_REBALANCE = 3 + + +@njit(cache=True) +def _engine_units_v2( + n_bars: int, + n_syms: int, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + target_units: np.ndarray, + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverages: np.ndarray, + maint_ratio: float, + fee_rates: np.ndarray, + contract_sizes: np.ndarray, + slippage: float, + use_funding: bool, +): + equity_curve = np.zeros(n_bars, dtype=np.float64) + pos_out = np.zeros((n_bars, n_syms), dtype=np.float64) + fee_arr = np.zeros(n_bars, dtype=np.float64) + turnover_arr = np.zeros(n_bars, dtype=np.float64) + funding_arr = np.zeros(n_bars, dtype=np.float64) + init_margin = np.zeros(n_bars, dtype=np.float64) + maint_margin = np.zeros(n_bars, dtype=np.float64) + rejected = np.zeros(n_bars, dtype=np.int64) + reject_code = np.zeros(n_bars, dtype=np.int64) + + current_pos = np.zeros(n_syms, dtype=np.float64) + equity = init_capital + liq_flag = False + liq_idx = -1 + liq_reason = LIQ_NONE + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + for s in range(n_syms): + pos_out[i, s] = 0.0 + continue + + # Mark carried positions close-to-close. + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity += p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + + # Intrabar liquidation before funding and new orders. + worst_equity = equity + worst_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p == 0.0: + continue + worst_p = lows[i, s] if p > 0.0 else highs[i, s] + worst_equity += p * (worst_p - closes[i, s]) * contract_sizes[s] + worst_mm += abs(p) * worst_p * contract_sizes[s] * maint_ratio + + if worst_mm > 0.0 and worst_equity <= worst_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_INTRABAR + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + # Funding on carried positions. Positive value is a cost paid. + if is_funding_bar[i] and use_funding: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + cost = p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + equity -= cost + funding_arr[i] += cost + + close_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + close_mm += abs(p) * closes[i, s] * contract_sizes[s] * maint_ratio + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_FUNDING + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + cur_im = 0.0 + for s in range(n_syms): + cur_im += abs(current_pos[s]) * closes[i, s] * contract_sizes[s] / leverages[s] + + avail = equity - cur_im + if avail < 0.0: + avail = 0.0 + + # Execute target-unit changes at close with optional slippage. + for s in range(n_syms): + target = target_units[i, s] + delta = target - current_pos[s] + if abs(delta) < 1e-12: + continue + + c = closes[i, s] + cs = contract_sizes[s] + exec_p = c * (1.0 + slippage if delta > 0.0 else 1.0 - slippage) + trade_notional = abs(delta) * exec_p * cs + fee_cost = trade_notional * fee_rates[s] + slip_cost = abs(delta) * abs(exec_p - c) * cs + + old_im = abs(current_pos[s]) * c * cs / leverages[s] + new_im = abs(target) * exec_p * cs / leverages[s] + margin_delta = new_im - old_im + required = fee_cost + slip_cost + if margin_delta > 0.0: + required += margin_delta + + if required > avail: + rejected[i] += 1 + reject_code[i] = REJECT_INSUFFICIENT_MARGIN + continue + + equity -= fee_cost + slip_cost + current_pos[s] = target + fee_arr[i] += fee_cost + turnover_arr[i] += trade_notional + avail -= fee_cost + slip_cost + margin_delta + if avail < 0.0: + avail = 0.0 + + close_im = 0.0 + close_mm = 0.0 + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + notional = abs(p) * closes[i, s] * contract_sizes[s] + close_im += notional / leverages[s] + close_mm += notional * maint_ratio + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_REBALANCE + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + init_margin[i] = 0.0 + maint_margin[i] = 0.0 + continue + + for s in range(n_syms): + pos_out[i, s] = current_pos[s] + + init_margin[i] = close_im + maint_margin[i] = close_mm + equity_curve[i] = equity + + return ( + equity_curve, + pos_out, + fee_arr, + turnover_arr, + funding_arr, + init_margin, + maint_margin, + rejected, + reject_code, + liq_flag, + liq_idx, + liq_reason, + ) diff --git a/src/quantbt/endpoint.py b/src/quantbt/endpoint.py new file mode 100644 index 0000000..548f737 --- /dev/null +++ b/src/quantbt/endpoint.py @@ -0,0 +1,4465 @@ +""" +Unified public endpoint for notebooks and services. + +`QuantBTEndpoint` is the stable integration surface above legacy and V2 +backtest engines. It stores *how* to run a backtest at construction time, while +`backtest()` / `simulate()` receive the actual data, signals, orders, or basket +objects. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field, is_dataclass, replace +from enum import Enum +import hashlib +import json +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Optional, Sequence, Union +import warnings + +import numpy as np +import pandas as pd + +from .backtester import BacktestEngine +from .backends import ( + NativeEventBackend, + NativeEventConfig, + NativeEventScoreRequirements, + NativeOptionConfig, + NativePortfolioBackend, + NativePortfolioConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, +) +from .core.arbitrage import ( + BasisArbitrageSpec, + CalendarSpreadSpec, + CrossExchangeArbSpec, + FundingArbitrageSpec, + IndexBasketArbSpec, + OptionsVolArbSpec, + SpotPerpCashCarrySpec, + StatArbPairSpec, + TriangularArbSpec, + build_arbitrage_order_plan, +) +from .core.basket import build_frozen_basket_orders +from .core.execution_depth import ( + NautilusExecutionDepthConfig, + simulate_nautilus_order_package_depth, +) +from .core.execution_contract import ExecutionContract +from .core.constraints import build_quantity_constraints +from .core.intrabar_reference import ( + IntrabarIntentTape, + IntrabarLevelMode, + IntrabarSizingMode, + run_intrabar_reference, +) +from .core.intrabar_session import IntrabarSessionTape, SessionExecutionPolicy +from .core.intrabar_kernel import FillReplayTape, run_fill_replay_kernel, run_intrabar_kernel, run_intrabar_session_kernel +from .core.market_tape import PreparedMarketTape, prepare_market_tape +from .core.orders import OrderCommand, OrderIntent, order_intents_to_lifecycle_commands +from .core.results import ( + BacktestResultV2, + NativeEventScalarScoreResult, + NativeEventScoreResult, +) +from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce +from .core.structured_orders import ( + BracketOrderSpec, + DcaGridSpec, + build_bracket_order_plan, + build_dca_grid_order_plan, +) +from .core.types import BacktestResult +from .engines import BacktestEngineV2, OptionBacktestEngine, PortfolioBacktestEngine +from .sizing.modes import compute_target_units +from .options.execution import OptionExecutionConfig +from .options.fees import OptionFeeSchedule +from .options.hedging import OptionHedgeConfig +from .options.margin import OptionMarginConfig +from .options.cache import OptionPreparedRunCache +from .options.packages import OptionPackageIntent +from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec +from .options.strategy import OptionStrategyRun + +if TYPE_CHECKING: + from .walkforward import WalkForwardConfig + + +SeriesMap = Dict[str, pd.Series] +FrameMap = Dict[str, pd.DataFrame] + + +class NativeEventProfile(str, Enum): + """Stable high-level retention/execution profile for event-driven runs.""" + + RESEARCH = "research" + OPTIMIZE = "optimize" + AUDIT = "audit" + + +_NATIVE_EVENT_PROFILE_OPTIONS = { + NativeEventProfile.RESEARCH: { + "reactive_execution_mode": "fast", + "reactive_kernel_mode": "single_pass", + "report_level": "minimal", + "audit_sink": "none", + }, + NativeEventProfile.OPTIMIZE: { + "reactive_execution_mode": "fast", + "reactive_kernel_mode": "single_pass", + "report_level": "score", + "audit_sink": "none", + }, + NativeEventProfile.AUDIT: { + "reactive_execution_mode": "audit", + "reactive_kernel_mode": "replay_certified", + "report_level": "audit", + "audit_sink": "memory", + }, +} +_NATIVE_EVENT_PUBLIC_BACKENDS = frozenset({"auto", "python", "rust"}) + + +def _normalize_native_event_profile(profile: Union[str, NativeEventProfile]) -> NativeEventProfile: + value = profile.value if isinstance(profile, NativeEventProfile) else str(profile).lower().strip() + try: + return NativeEventProfile(value) + except ValueError as exc: + valid = ", ".join(item.value for item in NativeEventProfile) + raise ValueError(f"profile must be one of: {valid}; received {profile!r}") from exc + + +def _resolve_event_driven_kwargs( + *, + input_mode: str, + profile: Union[str, NativeEventProfile], + backend: str, + kwargs: Dict, +) -> Dict: + """Resolve the small public facade into one legacy endpoint config. + + This function only resolves configuration. Matching, accounting, and + result construction remain owned by the existing endpoint constructors. + """ + + mode = str(input_mode).lower().strip() + if mode not in {"strategy", "orders"}: + raise ValueError("input_mode must be 'strategy' or 'orders'") + + profile_value = _normalize_native_event_profile(profile) + public_backend = str(backend).lower().strip() + if public_backend not in _NATIVE_EVENT_PUBLIC_BACKENDS: + raise ValueError("backend must be one of: auto, python, rust") + + resolved = dict(kwargs) + profile_options = _NATIVE_EVENT_PROFILE_OPTIONS[profile_value] + for key, value in profile_options.items(): + if key in resolved: + raise ValueError( + f"profile='{profile_value.value}' controls {key}; " + f"use the advanced native_event_{'strategy' if mode == 'strategy' else 'lifecycle'} " + "constructor for custom low-level combinations" + ) + resolved[key] = value + + advanced_backend = resolved.get("native_backend") + if advanced_backend is not None and public_backend != "auto": + raise ValueError("pass either backend=... or advanced native_backend=..., not both") + if advanced_backend is None: + resolved["native_backend"] = public_backend + + metadata = dict(resolved.pop("metadata", {}) or {}) + metadata.setdefault( + "event_driven_facade", + { + "input_mode": mode, + "profile": profile_value.value, + "backend": public_backend, + }, + ) + resolved["metadata"] = metadata + return resolved + + +@dataclass(frozen=True) +class EndpointConfig: + """ + Configuration for `QuantBTEndpoint`. + + Parameters + ---------- + mode: + Strategy integration mode. Supported values are `single_signal`, + `pct_equity`, `signal_notional`, `dca_ladder`, `orders`, `basket`, + `portfolio`, `arbitrage`, `options`, `walk_forward`, and + `nautilus_validation`. + backend: + Engine selector. Use `auto` for domain-safe defaults, or explicitly set + `legacy`, `native_vectorized`, `native_event`, or `nautilus`. + native_backend: + Native-event implementation selector: `python`, `rust`, `auto`, or + `replay_certified`. It is only consulted by native-event backends; + omitted means preserve the environment/default selection policy. + sizing: + Position sizing contract for signal modes. Examples: `%_equity`, + `signal_notional`, `notional`, `unit`, `dca_ladder`. + account: + Account/margin config used by V2 engines. Legacy runs also read + `initial_capital`, `leverage`, and `maintenance_ratio` from this object. + execution: + Execution/slippage config used by V2 engines. Legacy runs use the + endpoint `slippage` fraction. + fee: + Legacy compatibility round-trip fee. It is converted to canonical + one-way `fee_rate` at the endpoint boundary when explicit `fee_rate` + is omitted. + fee_rate: + Canonical one-way fee. If supplied, it has priority over `fee`. + alloc_per_trade: + Notional allocation for notional sizing modes, or equity fraction for + `%_equity`. + use_pyramiding: + If false, raw signals are snapped to {-1, 0, 1}. + use_funding: + Whether funding should be applied where the selected engine supports it. + funding_rate: + Scalar, series, or per-symbol mapping of funding rates. + contract_size: + Contract multiplier, scalar or per-symbol mapping. + slippage: + Legacy execution slippage fraction. Example: `0.0001` is 1 bp. + portfolio_mode: + Multi-symbol allocation mode for portfolio endpoint. + asset_type: + Asset type used by portfolio endpoint defaults. + basket: + Optional basket spec stored at construction time for basket simulations. + arbitrage_spec: + Optional arbitrage spec stored at construction time for the future + ArbitrageBacktestEngine. + structured_order_spec: + Optional DCA/grid or bracket/OCO package spec compiled into explicit + orders for Nautilus structured-order validation. + symbols: + Optional symbol list. Single-symbol endpoints use the first symbol. + dca_kwargs: + Extra DCA ladder parameters forwarded to legacy `BacktestEngine`. + nautilus_config: + Optional `NautilusBackendConfig` instance for Nautilus validation runs. + nautilus_depth_config: + Optional `NautilusExecutionDepthConfig` for package-order preflight. + Existing endpoints are unchanged when this is omitted. + report_level: + Native portfolio artifact policy. `full` preserves all audit reports; + `standard` keeps core audit tables; `minimal` keeps accounting outputs + for optimizer/service loops. Existing calls default to `full`. + option_config: + Optional `NativeOptionConfig` for native option simulations. + strategy_class: + Optional strategy callable/class for `walk_forward` mode. The strategy + must return a Series, DataFrame, or `{symbol: Series}` OOS output. + walkforward_config: + Optional `WalkForwardConfig` for split/stitch behavior. + metadata: + Free-form service metadata carried by the endpoint. + """ + + mode: str = "single_signal" + backend: str = "auto" + native_backend: Optional[str] = None + sizing: str = "signal_notional" + account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0)) + execution: ExecutionConfig = field(default_factory=ExecutionConfig) + fee: float = 0.0004 + fee_rate: Optional[float] = None + alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0 + use_pyramiding: bool = True + use_funding: bool = True + funding_rate: Union[float, pd.Series, Dict] = 0.0 + contract_size: Union[float, Dict[str, float]] = 1.0 + instruments: Optional[Union[Dict[str, InstrumentSpec], Sequence[InstrumentSpec]]] = None + qty_step: Optional[Union[float, Dict[str, float]]] = None + lot_size: Optional[Union[float, Dict[str, float]]] = None + slot_size: Optional[Union[float, Dict[str, float]]] = None + min_qty: Optional[Union[float, Dict[str, float]]] = None + min_notional: Optional[Union[float, Dict[str, float]]] = None + slippage: float = 0.0001 + portfolio_mode: str = "longshort" + betas: Union[float, Dict[str, float], None] = None + risk_lookback: int = 60 + asset_type: str = "crypto" + basket: Optional[BasketSpec] = None + arbitrage_spec: object = None + structured_order_spec: object = None + event_engine_version: str = "v1" + reactive_execution_mode: str = "fast" + reactive_kernel_mode: str = "replay_certified" + symbols: Optional[Sequence[str]] = None + dca_kwargs: Dict = field(default_factory=dict) + nautilus_config: object = None + nautilus_depth_config: Optional[NautilusExecutionDepthConfig] = None + option_config: object = None + report_level: str = "full" + audit_sink: str = "memory" + audit_sink_path: Optional[str] = None + strategy_class: object = None + walkforward_config: Optional[WalkForwardConfig] = None + walkforward_target_mode: str = "signal_notional" + metadata: Dict = field(default_factory=dict) + + @property + def v2_fee_rate(self) -> float: + return self.fee / 2.0 if self.fee_rate is None else float(self.fee_rate) + + @property + def canonical_one_way_fee_rate(self) -> float: + return self.v2_fee_rate + + +@dataclass(frozen=True) +class PreparedIntrabarRunner: + """Prepared single-symbol intrabar runner for repeated WFO/Optuna runs.""" + + endpoint: "QuantBTEndpoint" + tape: PreparedMarketTape + symbol: str + contract: ExecutionContract + profile_metadata: Dict + session_policy: Optional[SessionExecutionPolicy] = None + session_tape: Optional[IntrabarSessionTape] = None + + @property + def market(self) -> PreparedMarketTape: + return self.tape + + def run(self, intent: IntrabarIntentTape, *, report_level: Optional[str] = None) -> BacktestResultV2: + config = self.endpoint.config + level = report_level or config.report_level + kwargs = { + "tape": self.tape, + "intent": intent, + "account": config.account, + "contract": self.contract, + "fee_rate": config.v2_fee_rate, + "slippage_rate": float(config.execution.slippage_rate), + "contract_size": _scalar_for_symbol(config.contract_size, self.symbol), + **self.endpoint._intrabar_execution_kwargs(self.symbol), + "report_level": level, + } + if self.session_policy is not None: + kernel = run_intrabar_session_kernel( + **kwargs, + session_policy=self.session_policy, + session_tape=self.session_tape, + ) + else: + kernel = run_intrabar_kernel(**kwargs) + idx = kernel.equity.index + returns = kernel.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + diagnostics = pd.DataFrame( + { + "average_entry": kernel.average_entry, + "active_stop": kernel.active_stop, + "active_take_profit": kernel.active_take_profit, + "event_flags": kernel.event_flags, + "initial_margin": kernel.initial_margin, + "maintenance_margin": kernel.maintenance_margin, + "fees": kernel.fees, + "funding": kernel.funding, + }, + index=idx, + ) + metadata = { + **kernel.metadata, + "input_mode": "intrabar_intent", + "symbol": self.symbol, + "phase": "31F_prepared_intrabar_runner", + "prepared_runner": True, + "profile_metadata": dict(self.profile_metadata), + "fills_report": kernel.fills_report, + "positions_report": pd.DataFrame({f"Position_{self.symbol}": kernel.position}, index=idx), + } + result = BacktestResultV2( + equity=kernel.equity, + returns=returns, + positions=pd.DataFrame({f"Position_{self.symbol}": kernel.position.to_numpy(dtype=float)}, index=idx), + closes=pd.DataFrame({f"Close_{self.symbol}": self.tape.closes[:, 0]}, index=idx), + symbols=[self.symbol], + initial_capital=float(config.account.initial_capital), + leverage=float(config.account.leverage), + liquidated=bool(kernel.liquidated), + liquidation_bar=int(kernel.liquidation_bar), + fills=kernel.fills, + fees=kernel.fees, + funding=kernel.funding, + margin=diagnostics[["initial_margin", "maintenance_margin"]], + diagnostics=diagnostics, + metadata=metadata, + ) + self.endpoint.engine = kernel + self.endpoint._store_result(result) + return self.endpoint.result + + +@dataclass(frozen=True) +class PreparedNativeEventStrategyRunner: + """Prepared native-event reactive runner for repeated strategy scoring.""" + + endpoint: "QuantBTEndpoint" + idx: pd.DatetimeIndex + symbols: list + close_map: SeriesMap + high_map: SeriesMap + low_map: SeriesMap + opens_arr: np.ndarray + volumes_arr: np.ndarray + market_arrays: object + backend: NativeEventBackend + profile_metadata: Dict + runs: int = 0 + scores: int = 0 + + def run(self, strategy, *, report_level: Optional[str] = None) -> BacktestResultV2: + """Run the prepared strategy and return the public BacktestResultV2.""" + if strategy is None: + raise ValueError("prepared native-event runner requires strategy=...") + config = self.endpoint.config + level = report_level or config.report_level + result = self.backend.run_strategy( + datetime_index=self.idx, + strategy=strategy, + closes=self.close_map, + highs=self.high_map, + lows=self.low_map, + opens=None, + volumes=None, + funding_rate=config.funding_rate, + contract_size=config.contract_size, + leverage=config.account.leverage, + fee_rate=config.v2_fee_rate, + symbols=self.symbols, + instruments=config.instruments, + qty_step=config.qty_step, + lot_size=config.lot_size, + slot_size=config.slot_size, + min_qty=config.min_qty, + min_notional=config.min_notional, + execution_mode=config.reactive_execution_mode, + reactive_kernel_mode=config.reactive_kernel_mode, + report_level=level, + audit_sink=config.audit_sink, + audit_sink_path=config.audit_sink_path, + market_arrays=self.market_arrays, + opens_arr=self.opens_arr, + volumes_arr=self.volumes_arr, + ) + result.metadata.setdefault("prepared_native_event_strategy", self.metadata) + object.__setattr__(self, "runs", self.runs + 1) + self.endpoint._store_result(result) + return self.endpoint.result + + simulate = run + + def score( + self, + strategy, + *, + trading_days: int = 365, + score_requirements: Optional[NativeEventScoreRequirements] = None, + ) -> Union[NativeEventScoreResult, NativeEventScalarScoreResult]: + """ + Run the prepared strategy through the direct score path. + + The default compatibility contract stores ndarray accounting arrays and + scalar metrics. Passing ``NativeEventScoreRequirements.scalar_score_contract()`` + returns the low-retention scalar result instead. Neither form updates + ``endpoint.result``. + """ + if strategy is None: + raise ValueError("prepared native-event score requires strategy=...") + config = self.endpoint.config + score = self.backend.run_strategy_score( + datetime_index=self.idx, + strategy=strategy, + closes=self.close_map, + highs=self.high_map, + lows=self.low_map, + opens=None, + volumes=None, + funding_rate=config.funding_rate, + contract_size=config.contract_size, + leverage=config.account.leverage, + fee_rate=config.v2_fee_rate, + symbols=self.symbols, + instruments=config.instruments, + qty_step=config.qty_step, + lot_size=config.lot_size, + slot_size=config.slot_size, + min_qty=config.min_qty, + min_notional=config.min_notional, + execution_mode=config.reactive_execution_mode, + market_arrays=self.market_arrays, + opens_arr=self.opens_arr, + volumes_arr=self.volumes_arr, + trading_days=trading_days, + score_requirements=score_requirements, + ) + object.__setattr__(self, "scores", self.scores + 1) + return replace( + score, + metadata={ + **dict(score.metadata), + "prepared_native_event_strategy": self.metadata, + }, + ) + + @property + def metadata(self) -> Dict[str, object]: + return { + **self.profile_metadata, + "runs": int(self.runs), + "scores": int(self.scores), + "market_signature": self.market_arrays.signature, + } + + +class QuantBTEndpoint: + """ + Stable notebook/service facade for all QuantBT backtest modes. + + Create the endpoint with a factory constructor such as + `QuantBTEndpoint.pct_equity(...)`, then pass market data and signals to + `backtest()`. The instance stores the latest `result` and exposes report and + visualization helpers. + """ + + def __init__(self, config: Optional[EndpointConfig] = None, **kwargs): + """ + Build an endpoint from an `EndpointConfig` or keyword arguments. + + Examples + -------- + >>> endpoint = QuantBTEndpoint(mode="single_signal", sizing="signal_notional") + >>> result = endpoint.backtest(data=df, signal_col="position") + """ + self.config = config or _config_from_kwargs(**kwargs) + self.result: Optional[Union[BacktestResult, BacktestResultV2]] = None + self.engine = None + + def prepare_service_context( + self, + *, + data=None, + closes=None, + highs=None, + lows=None, + datetime_index=None, + symbols=None, + ) -> "QuantBTPreparedContext": + """ + Normalize market data once for repeated service/WFO-style replays. + + This is an opt-in performance helper. It does not change normal + `backtest(...)` behavior and only supports routes whose prepared-array + parity is locked by tests: single-symbol `signal_notional` with + `native_vectorized`, and `portfolio` with `native_portfolio`. + """ + return QuantBTPreparedContext.from_endpoint( + self, + data=data, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols, + ) + + def prepare_intrabar( + self, + *, + data, + datetime_index=None, + symbols: Optional[Sequence[str]] = None, + session_tape: Optional[IntrabarSessionTape] = None, + funding_event_timestamps=None, + funding_event_rates=None, + ) -> PreparedIntrabarRunner: + """ + Prepare strict intrabar market tape once and reuse it for many intents. + + This is an opt-in service/WFO helper. Normal `.backtest(...)` remains + backward-compatible, while optimizer loops can avoid rebuilding OHLCV, + funding, validation certificate, data signature, and quantity profiles + on every trial. + """ + symbol_list = list(symbols or self.config.symbols or ["DEFAULT"]) + if len(symbol_list) != 1: + raise ValueError("prepare_intrabar currently supports exactly one symbol") + tape = prepare_market_tape( + data=data, + datetime_index=datetime_index, + symbols=symbol_list, + funding_rate=self.config.funding_rate, + funding_event_timestamps=funding_event_timestamps, + funding_event_rates=funding_event_rates, + use_funding=self.config.use_funding, + validation_mode="strict", + missing_funding_policy=str(self.config.metadata.get("missing_funding_policy", "raise")), + source_timezone=self.config.metadata.get("source_timezone"), + bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")), + ) + contract = _execution_contract_from_config(self.config) + session_policy = _session_policy_from_config(self.config) + if session_policy is not None and session_tape is None: + raise ValueError("session_tape is required when session_policy is configured") + if session_tape is not None and len(session_tape.session_id) != tape.n_bars: + raise ValueError("session_tape length must match prepared market tape length") + symbol = symbol_list[0] + profile = { + "mode": self.config.mode, + "backend": self.config.backend, + "account": asdict(self.config.account), + "execution": asdict(self.config.execution), + "fee_rate": self.config.v2_fee_rate, + "contract_size": _scalar_for_symbol(self.config.contract_size, symbol), + "intrabar": self._intrabar_execution_kwargs(symbol), + "data_signature": tape.signature, + "session_policy": None if session_policy is None else session_policy.to_metadata(), + "session_tape_signature": None if session_tape is None else session_tape.signature, + } + profile["prepared_signature"] = _prepared_profile_signature(tape.signature, profile) + return PreparedIntrabarRunner( + endpoint=self, + tape=tape, + symbol=symbol, + contract=contract, + profile_metadata=profile, + session_policy=session_policy, + session_tape=session_tape, + ) + + def prepare_native_event_strategy( + self, + *, + data=None, + closes=None, + highs=None, + lows=None, + datetime_index=None, + symbols: Optional[Sequence[str]] = None, + ) -> PreparedNativeEventStrategyRunner: + """ + Prepare native-event reactive market state once for repeated scoring. + + Normal `native_event_strategy(...).simulate(...)` remains unchanged. + This helper is for WFO/Optuna/service loops where the same market tape + is replayed many times with different strategy parameters. + """ + config = self.config + if str(config.backend).lower().strip() not in {"native_event", "auto"}: + raise ValueError("prepare_native_event_strategy requires backend='native_event' or auto") + symbol_list = list(symbols or config.symbols or (closes.keys() if closes is not None else [])) + if data is not None and not isinstance(data, dict) and not symbol_list: + symbol_list = ["asset"] + if not symbol_list: + raise ValueError("prepare_native_event_strategy requires symbols") + if data is not None and not isinstance(data, dict): + if len(symbol_list) != 1: + raise ValueError("single DataFrame native-event preparation requires exactly one symbol") + frame = _standardize_frame(data, datetime_index=datetime_index) + symbol = symbol_list[0] + idx = frame.index + close_map = {symbol: frame["close"]} + high_map = {symbol: frame.get("high", frame["close"])} + low_map = {symbol: frame.get("low", frame["close"])} + opens_arr = np.ascontiguousarray(frame[["open"]].to_numpy(dtype=np.float64)) + volumes_arr = np.ascontiguousarray(frame[["volume"]].to_numpy(dtype=np.float64)) + else: + close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data( + data=data, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbol_list, + ) + opens_arr, volumes_arr = _prepared_native_event_open_volume_arrays(data, idx, symbol_list, close_map) + backend = NativeEventBackend( + NativeEventConfig( + account=config.account, + execution=config.execution, + fee_rate=config.v2_fee_rate, + use_funding=bool(config.use_funding), + report_level=config.report_level, + audit_sink=config.audit_sink, + audit_sink_path=config.audit_sink_path, + reactive_kernel_mode=config.reactive_kernel_mode, + native_backend=config.native_backend, + ) + ) + market = backend.prepare_market_arrays( + datetime_index=idx, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=config.funding_rate, + symbols=symbol_list, + ) + profile = { + "mode": config.mode, + "backend": "native_event", + "event_engine_version": "v2", + "reactive_execution_mode": config.reactive_execution_mode, + "reactive_kernel_mode": config.reactive_kernel_mode, + "account": asdict(config.account), + "execution": asdict(config.execution), + "fee_rate": config.v2_fee_rate, + "report_level": config.report_level, + "symbols": tuple(symbol_list), + "bars": int(len(idx)), + "data_signature": market.signature, + } + return PreparedNativeEventStrategyRunner( + endpoint=self, + idx=idx, + symbols=list(symbol_list), + close_map=close_map, + high_map=high_map, + low_map=low_map, + opens_arr=opens_arr, + volumes_arr=volumes_arr, + market_arrays=market, + backend=backend, + profile_metadata=profile, + ) + + @classmethod + def pct_equity(cls, **kwargs) -> "QuantBTEndpoint": + """ + Create a legacy `%_equity` endpoint. + + Use this for strategies whose signal is a direction/weight and whose + order notional should be recomputed from live equity on signal changes. + `alloc_per_trade` is interpreted as an equity fraction when <= 1.0 + (`0.5` means 50% of current equity), or as a percent when > 1.0. + + Data requirement for `backtest()`: + a single OHLCV DataFrame with a DatetimeIndex and `close`; `high` and + `low` are strongly recommended for liquidation checks. + """ + return cls(_config_from_kwargs(mode="pct_equity", sizing="%_equity", backend="legacy", **kwargs)) + + @classmethod + def signal_notional(cls, backend: str = "native_vectorized", **kwargs) -> "QuantBTEndpoint": + """ + Create a signal-notional endpoint. + + Signal changes anchor target units at the current price. Between signal + changes, units are frozen, avoiding price-drift micro-rebalancing. This + is the recommended default for systematic single-symbol alpha research. + + `backend` can be `native_vectorized` for speed or `native_event` when + you want generated market rebalance orders and fill records. + """ + return cls(_config_from_kwargs(mode="signal_notional", sizing="signal_notional", backend=backend, **kwargs)) + + @classmethod + def intrabar_bracket_reference( + cls, + *, + level_mode: Union[str, IntrabarLevelMode] = IntrabarLevelMode.PERCENT_DISTANCE, + intrabar_sizing_mode: Union[str, IntrabarSizingMode] = IntrabarSizingMode.UNITS, + close_on_last_bar: bool = True, + execution_contract: Optional[ExecutionContract] = None, + session_policy: Optional[SessionExecutionPolicy] = None, + **kwargs, + ) -> "QuantBTEndpoint": + """ + Create the Phase 31B readable intrabar reference endpoint. + + This endpoint is the causal Python oracle for `intrabar_bracket_v1`. + Strategy output can stay compact: pass a signed `signal`/`signal_col` + where positive means long entry size, negative means short entry size, + and zero means no new entry. Optional stop, take-profit, trailing, and + technical-exit arrays are supplied through `intent_cols` at run time. + + It is intentionally not the future Numba production kernel. Use it to + verify SL/TP/trailing/reversal semantics and audit fill timing before + promoting an alpha to the fast intrabar backend. + """ + metadata = dict(kwargs.pop("metadata", {})) + mode_value = level_mode.value if hasattr(level_mode, "value") else str(level_mode) + metadata.setdefault("intrabar_level_mode", mode_value) + metadata.setdefault("intrabar_sizing_mode", IntrabarSizingMode(intrabar_sizing_mode).value) + metadata.setdefault("execution_contract_id", "intrabar_bracket_v1") + contract = execution_contract or ExecutionContract.intrabar_bracket(close_on_last_bar=close_on_last_bar) + metadata.setdefault("execution_contract", contract.to_metadata()) + if session_policy is not None: + metadata["session_policy"] = session_policy.to_metadata() + return cls( + _config_from_kwargs( + mode="intrabar_bracket_reference", + backend="intrabar_reference", + sizing="intrabar_intent", + metadata=metadata, + **kwargs, + ) + ) + + @classmethod + def intrabar_bracket( + cls, + *, + level_mode: Union[str, IntrabarLevelMode] = IntrabarLevelMode.PERCENT_DISTANCE, + intrabar_sizing_mode: Union[str, IntrabarSizingMode] = IntrabarSizingMode.UNITS, + close_on_last_bar: bool = True, + execution_contract: Optional[ExecutionContract] = None, + session_policy: Optional[SessionExecutionPolicy] = None, + report_level: str = "standard", + **kwargs, + ) -> "QuantBTEndpoint": + """ + Create the Phase 31C fast Numba intrabar bracket endpoint. + + Use the same compact input contract as + `intrabar_bracket_reference(...)`. `report_level="minimal"` is meant + for optimizers, `standard` returns diagnostics, and `audit` runs a + deterministic second pass to materialize exact sparse fills. + """ + metadata = dict(kwargs.pop("metadata", {})) + mode_value = level_mode.value if hasattr(level_mode, "value") else str(level_mode) + metadata.setdefault("intrabar_level_mode", mode_value) + metadata.setdefault("intrabar_sizing_mode", IntrabarSizingMode(intrabar_sizing_mode).value) + metadata.setdefault("execution_contract_id", "intrabar_bracket_v1") + contract = execution_contract or ExecutionContract.intrabar_bracket(close_on_last_bar=close_on_last_bar) + metadata.setdefault("execution_contract", contract.to_metadata()) + if session_policy is not None: + metadata["session_policy"] = session_policy.to_metadata() + return cls( + _config_from_kwargs( + mode="intrabar_bracket", + backend="native_intrabar", + sizing="intrabar_intent", + report_level=report_level, + metadata=metadata, + **kwargs, + ) + ) + + @classmethod + def fill_replay(cls, *, report_level: str = "audit", **kwargs) -> "QuantBTEndpoint": + """ + Create a fast accounting replay endpoint for explicit fills. + + Use `backtest(data=df, fill_replay=FillReplayTape_or_DataFrame)`. This + certifies accounting from supplied fills but does not certify how those + fills were generated. + """ + metadata = dict(kwargs.pop("metadata", {})) + metadata.setdefault("execution_contract_id", "fill_replay_v1") + metadata.setdefault("execution_contract", ExecutionContract.fill_replay().to_metadata()) + return cls( + _config_from_kwargs( + mode="fill_replay", + backend="native_intrabar", + sizing="explicit_fills", + report_level=report_level, + metadata=metadata, + **kwargs, + ) + ) + + @classmethod + def dca_ladder(cls, **kwargs) -> "QuantBTEndpoint": + """ + Create a structural DCA/grid ladder endpoint. + + `signal` is a structural level series, not a target weight: + 0 is flat, +1 is base long, +2 allows the first safety order, and so on. + Negative levels model short ladders. `high` and `low` are required + because safety orders are simulated as limit fills at grid trigger + prices. + """ + return cls(_config_from_kwargs(mode="dca_ladder", sizing="dca_ladder", backend="legacy", **kwargs)) + + @classmethod + def orders(cls, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint": + """ + Create an explicit order simulation endpoint. + + Use `simulate(data=df, orders=[OrderIntent(...), ...])`. Orders are run + through the selected event backend with market/limit fill lifecycle, TIF + handling, fees, margin checks, and fills in `result.fills`. + """ + return cls(_config_from_kwargs(mode="orders", backend=backend, **kwargs)) + + @classmethod + def event_driven( + cls, + *, + input_mode: str = "strategy", + profile: Union[str, NativeEventProfile] = NativeEventProfile.RESEARCH, + backend: str = "auto", + **kwargs, + ) -> "QuantBTEndpoint": + """Create the stable public native-event facade. + + Parameters + ---------- + input_mode: + ``"strategy"`` for a stateful strategy implementing the reactive + callback protocol, or ``"orders"`` for an explicit + ``OrderCommand``/``OrderIntent`` tape. + profile: + ``"research"`` keeps a compact public result, ``"optimize"`` + selects the scalar score retention contract, and ``"audit"`` + retains replay-certified accounting and event artifacts. + backend: + ``"auto"`` follows the release policy (currently Python), + ``"python"`` selects the canonical backend, or ``"rust"`` + explicitly requests the capability-gated native wheel. + + The facade resolves profiles and delegates to + :meth:`native_event_strategy` or :meth:`native_event_lifecycle`. + It does not implement a second matcher or accounting engine. Advanced + callers may continue using those constructors directly when they need + custom ``reactive_execution_mode``, ``reactive_kernel_mode``, + ``report_level``, or ``audit_sink`` combinations. + + Examples + -------- + >>> endpoint = QuantBTEndpoint.event_driven( + ... profile="research", backend="auto", initial_capital=20_000, + ... ) + >>> result = endpoint.simulate(data=data, strategy=strategy) + + >>> endpoint = QuantBTEndpoint.event_driven( + ... input_mode="orders", profile="audit", backend="python", + ... initial_capital=20_000, + ... ) + >>> result = endpoint.simulate(data=data, order_commands=commands) + """ + + resolved = _resolve_event_driven_kwargs( + input_mode=input_mode, + profile=profile, + backend=backend, + kwargs=kwargs, + ) + if str(input_mode).lower().strip() == "strategy": + return cls.native_event_strategy(**resolved) + return cls.native_event_lifecycle(**resolved) + + @classmethod + def native_event_lifecycle(cls, **kwargs) -> "QuantBTEndpoint": + """ + Create an explicit native-event v2 lifecycle endpoint. + + Use `simulate(..., order_commands=[OrderCommand(...), ...])` for + cancel/replace/amend/OCO/parent/stop/GTD lifecycle simulations. Passing + legacy `orders=[OrderIntent(...)]` is also accepted and converted to + immediate PLACE commands. + """ + return cls( + _config_from_kwargs( + mode="orders", + backend="native_event", + event_engine_version="v2", + **kwargs, + ) + ) + + @classmethod + def native_event_strategy(cls, **kwargs) -> "QuantBTEndpoint": + """ + Create a reactive native-event v2 strategy endpoint. + + Use `simulate(data=df, strategy=obj)` where `obj` optionally implements + `initialize(context)`, `on_bar_close(context)`, and `finalize(context)`. + Commands emitted by callbacks become effective from the next bar. + """ + return cls( + _config_from_kwargs( + mode="native_event_strategy", + backend="native_event", + event_engine_version="v2", + **kwargs, + ) + ) + + @classmethod + def options( + cls, + backend: str = "native_option", + *, + option_config: Optional[NativeOptionConfig] = None, + option_execution: Optional[OptionExecutionConfig] = None, + option_margin: Optional[OptionMarginConfig] = None, + fee_schedule: Optional[OptionFeeSchedule] = None, + reporting_currency: str = "USD", + initial_balances: Optional[Dict[str, float]] = None, + conversion_rates: Optional[Dict[str, float]] = None, + settle_expired: bool = False, + max_spread_bps: Optional[float] = None, + max_source_latency_ns: Optional[int] = None, + **kwargs, + ) -> "QuantBTEndpoint": + """ + Create a native option simulation endpoint. + + Strategy/template code supplies canonical option-chain rows, + `OptionInstrumentSpec` definitions, and `OptionPackageIntent` packages + to `backtest(...)` or `simulate(...)`. The endpoint routes packages + through snapshot-level option execution, applies fills to the + multi-currency option ledger, calculates margin, and returns an + `OptionBacktestResult` with fills/packages/cash/marks/Greeks/settlement + artifacts. + + Required `backtest()` inputs: + `chain`, `instruments`, and optional `packages`. + """ + if backend.lower().strip() != "native_option": + raise ValueError("options endpoint currently supports backend='native_option' only") + metadata = dict(kwargs.pop("metadata", {})) + metadata.setdefault("mode_family", "options") + endpoint_config = _config_from_kwargs(mode="options", backend=backend, metadata=metadata, **kwargs) + if option_config is None: + option_config = NativeOptionConfig( + account=endpoint_config.account, + execution=endpoint_config.execution, + option_execution=option_execution + or OptionExecutionConfig(fee_rate=endpoint_config.v2_fee_rate, metadata={"source": "QuantBTEndpoint.options"}), + margin=option_margin or OptionMarginConfig(), + fee_schedule=fee_schedule, + reporting_currency=reporting_currency, + initial_balances=initial_balances, + conversion_rates=dict(conversion_rates or {}), + settle_expired=settle_expired, + max_spread_bps=max_spread_bps, + max_source_latency_ns=max_source_latency_ns, + metadata=metadata, + ) + endpoint_config = replace(endpoint_config, option_config=option_config) + return cls(endpoint_config) + + @classmethod + def nautilus_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint": + """ + Create a Nautilus DCA/grid structured-order validation endpoint. + + The endpoint compiles a `DcaGridSpec` into explicit orders: + base market entry, safety limit orders, and optional reduce-only + TP/SL exits. The resulting orders are replayed by Nautilus through the + same package-order adapter as `orders(backend="nautilus")`. + """ + if spec is None: + spec = DcaGridSpec(**_pop_dataclass_kwargs(kwargs, DcaGridSpec)) + return cls( + _config_from_kwargs( + mode="nautilus_dca_grid", + backend="nautilus", + structured_order_spec=spec, + symbols=[spec.symbol], + **kwargs, + ) + ) + + @classmethod + def nautilus_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwargs) -> "QuantBTEndpoint": + """ + Create a Nautilus bracket/OCO structured-order validation endpoint. + + The endpoint compiles a `BracketOrderSpec` into entry plus linked + reduce-only take-profit and/or stop-loss exits. OCO group metadata is + preserved and Nautilus cancels sibling exit orders on first exit fill. + """ + if spec is None: + spec = BracketOrderSpec(**_pop_dataclass_kwargs(kwargs, BracketOrderSpec)) + return cls( + _config_from_kwargs( + mode="nautilus_bracket_orders", + backend="nautilus", + structured_order_spec=spec, + symbols=[spec.symbol], + **kwargs, + ) + ) + + @classmethod + def native_event_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint": + """ + Create a native-event v2 DCA/grid lifecycle endpoint. + + The structured package is compiled into `OrderCommand` records so base, + safety orders, reduce-only exits, and OCO metadata are audited in + `command_report` and `order_events`. + """ + if spec is None: + spec = DcaGridSpec(**_pop_dataclass_kwargs(kwargs, DcaGridSpec)) + return cls( + _config_from_kwargs( + mode="native_event_dca_grid", + backend="native_event", + event_engine_version="v2", + structured_order_spec=spec, + symbols=[spec.symbol], + **kwargs, + ) + ) + + @classmethod + def native_event_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwargs) -> "QuantBTEndpoint": + """ + Create a native-event v2 bracket/OCO lifecycle endpoint. + + Entry, take-profit, and stop-loss legs are linked through parent/OCO + command fields and simulated by the deterministic OHLC lifecycle kernel. + """ + if spec is None: + spec = BracketOrderSpec(**_pop_dataclass_kwargs(kwargs, BracketOrderSpec)) + return cls( + _config_from_kwargs( + mode="native_event_bracket_orders", + backend="native_event", + event_engine_version="v2", + structured_order_spec=spec, + symbols=[spec.symbol], + **kwargs, + ) + ) + + @classmethod + def basket(cls, basket: Optional[BasketSpec] = None, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint": + """ + Create a basket/pair endpoint. + + Use for pair trades and frozen hedge-ratio baskets. Provide a + `BasketSpec` either here or to `simulate(..., basket=...)`, then pass a + scalar entry/exit signal and per-symbol price data. + """ + return cls(_config_from_kwargs(mode="basket", backend=backend, basket=basket, **kwargs)) + + @classmethod + def arbitrage(cls, arb_type: str, spec, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint": + """ + Create an arbitrage endpoint. + + Supported today: + + - `BasisArbitrageSpec`: native event, native vectorized, Nautilus + package-order validation. + - `StatArbPairSpec`: native event, native vectorized, Nautilus + package-order validation. + - `CalendarSpreadSpec`, `FundingArbitrageSpec`, + `SpotPerpCashCarrySpec`, and `IndexBasketArbSpec`: native event and + native vectorized package-style execution. + + `CrossExchangeArbSpec`, `TriangularArbSpec`, and `OptionsVolArbSpec` + are schema-validated but intentionally not executable through the + generic package route because they require specialized account, + sequence, latency, or Greek-aware engines. + """ + metadata = dict(kwargs.pop("metadata", {})) + metadata["arb_type"] = arb_type + return cls( + _config_from_kwargs( + mode="arbitrage", + backend=backend, + arbitrage_spec=spec, + metadata=metadata, + **kwargs, + ) + ) + + @staticmethod + def arbitrage_support_matrix() -> Dict[str, Dict[str, str]]: + """ + Return the public arbitrage endpoint support matrix. + + Services can call this helper to decide which spec/backend pair is safe + before constructing a run. A status of `supported` means the endpoint + can execute the spec. A status of `schema_only` means the dataclass and + validation exist, but execution should wait for a specialized engine. + """ + return { + "BasisArbitrageSpec": { + "status": "supported", + "backends": "native_event,native_vectorized,nautilus", + "route": "run_basis_arbitrage", + "sizing": "target_notional_to_base_qty or target_base_qty; linear contracts only", + }, + "StatArbPairSpec": { + "status": "supported", + "backends": "native_event,native_vectorized,nautilus", + "route": "run_stat_arb_pair_arbitrage", + "sizing": "target_gross_notional; optional dynamic hedge_ratios", + }, + "CalendarSpreadSpec": { + "status": "supported", + "backends": "native_event,native_vectorized", + "route": "run_package_arbitrage", + "sizing": "target_notional_to_base_qty or target_base_qty", + }, + "FundingArbitrageSpec": { + "status": "supported", + "backends": "native_event,native_vectorized", + "route": "run_package_arbitrage", + "sizing": "target_notional_to_base_qty or target_base_qty", + }, + "SpotPerpCashCarrySpec": { + "status": "supported", + "backends": "native_event,native_vectorized", + "route": "run_package_arbitrage", + "sizing": "target_notional_to_base_qty or target_base_qty", + }, + "IndexBasketArbSpec": { + "status": "supported", + "backends": "native_event,native_vectorized", + "route": "run_package_arbitrage", + "sizing": "target_gross_notional", + }, + "CrossExchangeArbSpec": { + "status": "schema_only", + "backends": "none", + "route": "needs venue/account split engine", + "sizing": "not executable yet", + }, + "TriangularArbSpec": { + "status": "schema_only", + "backends": "none", + "route": "needs sequenced path execution engine", + "sizing": "not executable yet", + }, + "OptionsVolArbSpec": { + "status": "specialized_route", + "backends": "native_option", + "route": "QuantBTEndpoint.options(...) with OptionPackageIntent and Greeks reports", + "sizing": "option package quantities; Greeks-aware risk belongs to option route", + }, + } + + @staticmethod + def options_support_matrix() -> Dict[str, Dict[str, str]]: + """ + Return the native option endpoint support matrix. + + `supported` means the Phase 7 endpoint can execute the workflow through + current native option components. `future` means the public schema is + intentionally reserved but should wait for later phases. + """ + return { + "canonical_chain_tape": { + "status": "supported", + "backend": "native_option", + "route": "prepare_option_tape", + "notes": "long-form option chain with bid/ask/mark/IV/Greeks columns", + }, + "option_packages": { + "status": "supported", + "backend": "native_option", + "route": "execute_option_package -> OptionLedger", + "notes": "atomic_all_or_none, best_effort, sequential, hedge_after_primary, rebalance_only", + }, + "multi_currency_ledger": { + "status": "supported", + "backend": "native_option", + "route": "OptionLedger", + "notes": "premium cash, fees, realized PnL, settlement cashflow and marked equity", + }, + "margin": { + "status": "supported_approx", + "backend": "native_option", + "route": "calculate_option_margin", + "notes": "venue-exact margin requires external validator or later Nautilus/venue adapter", + }, + "OptionsVolArbSpec": { + "status": "specialized_route", + "backend": "native_option", + "route": "strategy/template emits option packages; endpoint returns Greeks and attribution reports", + "notes": "not executable through generic arbitrage package route", + }, + "nautilus_options": { + "status": "experimental", + "backend": "nautilus", + "route": "quantbt.adapters.nautilus.options.validate_option_packages_with_nautilus", + "notes": "Phase 9 pins Nautilus option constructors and BBO quote semantics; full Nautilus option engine replay remains future", + }, + } + + @staticmethod + def nautilus_support_matrix() -> Dict[str, Dict[str, str]]: + """ + Return the public Nautilus adapter support matrix. + + `supported` means the route is executable through current QuantBT + endpoints. `planned` means the endpoint contract is reserved in the + roadmap but runtime execution should not be used yet. `experimental` + means the route exists for controlled validation, usually with a + narrower instrument/order scope than native engines. + """ + return { + "signal_series": { + "status": "supported", + "endpoint": "QuantBTEndpoint.nautilus_validation(...)", + "scope": "single-symbol target signal replay", + "order_types": "market delta orders generated by adapter", + "notes": "supports signal_notional, notional, unit, and %_equity sizing", + }, + "explicit_orders": { + "status": "supported", + "endpoint": "QuantBTEndpoint.orders(backend='nautilus', ...)", + "scope": "single-symbol OrderIntent replay", + "order_types": "market, limit, stop_market, stop_limit", + "notes": "preserves TIF, reduce_only, tags, price and trigger_price where Nautilus supports them", + }, + "lifecycle_commands": { + "status": "supported_native_event_adapter_aligned", + "endpoint": "QuantBTEndpoint.native_event_lifecycle(...) or QuantBTEndpoint.orders(event_engine_version='v2', ...)", + "scope": "native-event v2 command lifecycle; Nautilus package adapter accepts executable PLACE/REPLACE payloads", + "order_types": "market, limit, stop_market, stop_limit plus cancel/replace/amend/cancel_all in native-event v2", + "notes": "Nautilus command path is payload-aligned, not exchange-native cancel/amend parity yet", + }, + "reactive_strategy": { + "status": "supported_native_event_mvp", + "endpoint": "QuantBTEndpoint.native_event_strategy(...)", + "scope": "on_bar_close strategy callbacks emitting next-bar OrderCommand objects", + "order_types": "native-event v2 lifecycle commands", + "notes": "Phase 30D replay-backed MVP with captured command tape and static replay parity; incremental session is Phase 30E", + }, + "dca_grid": { + "status": "experimental", + "endpoint": "QuantBTEndpoint.nautilus_dca_grid(...)", + "scope": "base order, safety limit orders, TP/SL package", + "order_types": "market, limit, bracket/OCO exits", + "notes": "Phase 5.2C; compiles to explicit OrderIntent packages for Nautilus validation", + }, + "bracket_oco": { + "status": "experimental", + "endpoint": "QuantBTEndpoint.nautilus_bracket_orders(...)", + "scope": "entry plus linked stop-loss/take-profit exits", + "order_types": "bracket/OCO package", + "notes": "Phase 5.2C; sibling cancellation is handled by the Nautilus package strategy", + }, + "basket_pair": { + "status": "experimental", + "endpoint": "QuantBTEndpoint.basket(backend='nautilus', ...)", + "scope": "multi-leg frozen hedge-ratio packages", + "order_types": "per-leg explicit market/limit orders", + "notes": "Phase 5.2D; compiles BasketSpec signals into Nautilus package orders", + }, + "multi_symbol_portfolio": { + "status": "experimental", + "endpoint": "QuantBTEndpoint.portfolio(backend='nautilus', ...)", + "scope": "position-matrix transitions across one Nautilus venue/account", + "order_types": "per-symbol target delta orders", + "notes": "Phase 5.2D; supports pre-scalable signal_notional/notional/unit modes", + }, + "arbitrage_package_orders": { + "status": "experimental", + "endpoint": "QuantBTEndpoint.arbitrage(..., backend='nautilus')", + "scope": "basis/stat-arb package validation", + "order_types": "package market orders", + "notes": "supported for selected arbitrage specs; not a general basket endpoint yet", + }, + "parity_audit": { + "status": "supported", + "endpoint": "build_native_nautilus_parity_report(native, nautilus)", + "scope": "native-vs-Nautilus order/fill/equity comparison", + "order_types": "reporting helper", + "notes": "row-level audit exists; summary artifacts live in report bundle and tests", + }, + } + + @classmethod + def portfolio(cls, portfolio_mode: str = "longshort", backend: str = "native_portfolio", **kwargs) -> "QuantBTEndpoint": + """ + Create a multi-symbol portfolio endpoint. + + Use `backtest(positions=positions_df, data=data_dict)` where + `positions_df.columns` are symbols and `data_dict[symbol]` is an OHLCV + DataFrame. The endpoint wraps `PortfolioBacktestEngine`. + """ + return cls(_config_from_kwargs(mode="portfolio", backend=backend, portfolio_mode=portfolio_mode, **kwargs)) + + @classmethod + def nautilus_validation(cls, **kwargs) -> "QuantBTEndpoint": + """ + Create a Nautilus validation endpoint. + + This is for smaller high-fidelity validation runs. It currently supports + single-symbol signal series using the optional NautilusTrader adapter. + Nautilus must be installed in the active environment. + + `use_pyramiding` is forwarded to the Nautilus strategy adapter. When it + is false, fractional signals such as `1.4` are snapped to `1.0`; when it + is true, the raw signal scale is preserved. + """ + sizing = kwargs.pop("sizing", kwargs.pop("hedge_type", "signal_notional")) + return cls(_config_from_kwargs(mode="nautilus_validation", backend="nautilus", sizing=sizing, **kwargs)) + + @classmethod + def walk_forward( + cls, + strategy_class, + split_mode: Union[str, int, pd.Timestamp] = "walk_forward_2022", + split_frequency: str = "quarterly", + target_mode: str = "signal_notional", + window_mode: str = "expanding", + train_window: Optional[str] = None, + optimization_mode: str = "none", + optimization_config: Optional[Dict] = None, + optuna_trials: int = 0, + optuna_early_stopping: Optional[int] = None, + random_seed: int = 42, + **kwargs, + ) -> "QuantBTEndpoint": + """ + Create a walk-forward endpoint. + + The strategy callable/class is invoked once per fold and must return OOS + signal/position output indexed by timestamp. The stitched OOS output is + then routed into an existing QuantBT backtest path, so boundary trades + are charged by the normal engine instead of averaging fold equities. + Supported optimization modes are `mode_1_decay`, `mode_2_sbb`, + `mode_3_flat_minima`, `mode_4_is_only_robust`, and + `mode_5_full_robust`. + Fixed-parameter runs can leave + `optimization_mode="none"` and pass `params=...` to `backtest()`. + """ + optimization_config = dict(optimization_config or {}) + wf_config = kwargs.pop("walkforward_config", None) + scoring_backend = str( + optimization_config.get( + "scoring_backend", + _default_walkforward_scoring_backend(target_mode=target_mode, optimization_mode=optimization_mode), + ) + ) + wf_metadata = dict(optimization_config.get("metadata", {}) or {}) + wf_metadata.setdefault("use_prepared_scoring_cache", bool(optimization_config.get("use_prepared_scoring_cache", True))) + if wf_config is None: + from .walkforward import WalkForwardConfig + + wf_config = WalkForwardConfig( + split_mode=split_mode, + split_frequency=split_frequency, + window_mode=window_mode, + train_window=train_window, + target_mode=target_mode, + optimization_mode=optimization_mode, + optuna_trials=optuna_trials, + optuna_early_stopping=optuna_early_stopping, + random_seed=random_seed, + decay_lambda=float(optimization_config.get("decay_lambda", 0.5)), + decay_gamma=float(optimization_config.get("decay_gamma", 0.5)), + top_is_fraction=float(optimization_config.get("top_is_fraction", 0.10)), + top_is_k=optimization_config.get("top_is_k"), + candidate_selection_metric=str( + optimization_config.get( + "candidate_selection_metric", + ( + "is_only_robust" + if str(optimization_mode).lower().strip() == "mode_4_is_only_robust" + else ( + "full_robust" + if str(optimization_mode).lower().strip() == "mode_5_full_robust" + else "robust_decay" + ) + ), + ) + ), + candidate_decay_lambda=optimization_config.get("candidate_decay_lambda"), + candidate_decay_gamma=optimization_config.get("candidate_decay_gamma"), + sbb_samples=int(optimization_config.get("sbb_samples", 256)), + sbb_block_length=int(optimization_config.get("sbb_block_length", 20)), + sbb_decay_lambda=float(optimization_config.get("sbb_decay_lambda", 0.5)), + sbb_std_penalty=float(optimization_config.get("sbb_std_penalty", 0.1)), + sbb_simulation=str(optimization_config.get("sbb_simulation", "stationary")), + regime_count=int(optimization_config.get("regime_count", 3)), + regime_lookback=int(optimization_config.get("regime_lookback", 20)), + regime_weights=optimization_config.get("regime_weights"), + stress_vol_multiplier=float(optimization_config.get("stress_vol_multiplier", 1.0)), + garch_p=int(optimization_config.get("garch_p", 1)), + garch_q=int(optimization_config.get("garch_q", 1)), + garch_dist=str(optimization_config.get("garch_dist", "t")), + garch_vol_multiplier=float(optimization_config.get("garch_vol_multiplier", 1.0)), + flat_top_fraction=float(optimization_config.get("flat_top_fraction", 0.1)), + flat_eps=float(optimization_config.get("flat_eps", 0.15)), + flat_min_samples=int(optimization_config.get("flat_min_samples", 3)), + flat_selector=str(optimization_config.get("flat_selector", "medoid")), + plateau_quantile=float(optimization_config.get("plateau_quantile", 0.25)), + plateau_median_weight=float(optimization_config.get("plateau_median_weight", 0.25)), + plateau_std_penalty=float(optimization_config.get("plateau_std_penalty", 0.50)), + plateau_size_bonus=float(optimization_config.get("plateau_size_bonus", 0.01)), + is_subperiods=int(optimization_config.get("is_subperiods", 6)), + q25_weight=float(optimization_config.get("q25_weight", 0.30)), + dispersion_penalty=float(optimization_config.get("dispersion_penalty", 0.50)), + temporal_weight=float(optimization_config.get("temporal_weight", 0.65)), + plateau_weight=float(optimization_config.get("plateau_weight", 0.35)), + use_bootstrap_penalty=bool(optimization_config.get("use_bootstrap_penalty", False)), + use_complexity_penalty=bool(optimization_config.get("use_complexity_penalty", False)), + scoring_backend=scoring_backend, + scoring_trading_days=int(optimization_config.get("scoring_trading_days", 365)), + min_trades_per_year=optimization_config.get("min_trades_per_year"), + trade_penalty_factor=optimization_config.get("trade_penalty_factor"), + use_numba=bool(optimization_config.get("use_numba", True)), + metadata=wf_metadata, + ) + default_sizing = "signal_notional" if target_mode in {"portfolio", "basket", "arbitrage"} else target_mode + sizing = kwargs.pop("sizing", kwargs.pop("hedge_type", default_sizing)) + backend = kwargs.pop("backend", "auto") + return cls( + _config_from_kwargs( + mode="walk_forward", + backend=backend, + sizing=sizing, + strategy_class=strategy_class, + walkforward_config=wf_config, + walkforward_target_mode=target_mode, + **kwargs, + ) + ) + + @classmethod + def train_test_split( + cls, + strategy_class, + test_start: Union[str, int, pd.Timestamp], + target_mode: str = "signal_notional", + window_mode: str = "expanding", + train_window: Optional[str] = None, + optimization_mode: str = "none", + optimization_config: Optional[Dict] = None, + optuna_trials: int = 0, + optuna_early_stopping: Optional[int] = None, + random_seed: int = 42, + **kwargs, + ) -> "QuantBTEndpoint": + """ + Create a single holdout train/test endpoint. + + This is a convenience wrapper around `walk_forward(...)` with + `split_frequency="single"`. The strategy is optimized on the train + segment before `test_start`, emits OOS output on the holdout segment, + then the stitched holdout signal is routed into the selected QuantBT + target mode. `optimization_mode` accepts the same values as + walk-forward: `none`, `mode_1_decay`, `mode_2_sbb`, + `mode_3_flat_minima`, `mode_4_is_only_robust`, and + `mode_5_full_robust`. + """ + return cls.walk_forward( + strategy_class=strategy_class, + split_mode=test_start, + split_frequency="single", + target_mode=target_mode, + window_mode=window_mode, + train_window=train_window, + optimization_mode=optimization_mode, + optimization_config=optimization_config, + optuna_trials=optuna_trials, + optuna_early_stopping=optuna_early_stopping, + random_seed=random_seed, + **kwargs, + ) + + def backtest( + self, + data=None, + signal: Optional[pd.Series] = None, + signal_col: Optional[str] = None, + positions: Optional[Union[pd.DataFrame, SeriesMap]] = None, + orders: Optional[Sequence[OrderIntent]] = None, + order_commands: Optional[Sequence[OrderCommand]] = None, + strategy=None, + basket: Optional[BasketSpec] = None, + closes: Optional[SeriesMap] = None, + highs: Optional[SeriesMap] = None, + lows: Optional[SeriesMap] = None, + hedge_ratios: Optional[SeriesMap] = None, + datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None, + symbols: Optional[Sequence[str]] = None, + params: Optional[Dict] = None, + param_ranges: Optional[Dict] = None, + chain: Optional[pd.DataFrame] = None, + instruments: Optional[Union[OptionInstrumentRegistry, Sequence[OptionInstrumentSpec], Dict[str, OptionInstrumentSpec]]] = None, + packages: Optional[Sequence[OptionPackageIntent]] = None, + strategy_run: Optional[OptionStrategyRun] = None, + intent: Optional[IntrabarIntentTape] = None, + intent_cols: Optional[Dict[str, str]] = None, + session_tape: Optional[IntrabarSessionTape] = None, + funding_event_timestamps=None, + funding_event_rates=None, + fill_replay: Optional[Union[FillReplayTape, pd.DataFrame]] = None, + underlying: Optional[Union[pd.DataFrame, pd.Series]] = None, + hedge_policy: Optional[OptionHedgeConfig] = None, + net_option_delta: Optional[pd.Series] = None, + settlement_events: Optional[Sequence] = None, + conversion_rates: Optional[Dict[str, float]] = None, + prepared_cache: Optional[OptionPreparedRunCache] = None, + ): + """ + Run the configured backtest and store the result. + + Parameters + ---------- + data: + For single-symbol modes, an OHLCV DataFrame. For portfolio/basket + modes, either a `{symbol: DataFrame}` mapping or omitted when + `closes/highs/lows` are supplied explicitly. + signal: + Single-symbol signal series, or basket entry/exit signal. + signal_col: + Column name to read from `data` when `signal` is omitted. + positions: + Portfolio positions as DataFrame or `{symbol: Series}` mapping. + orders: + Explicit `OrderIntent` sequence for order simulations. + basket: + Optional `BasketSpec` overriding the config basket for this run. + closes/highs/lows: + Explicit per-symbol price series maps. + datetime_index: + Optional common datetime index. Defaults to data/signal index. + symbols: + Optional symbol override for this run. + """ + mode = self.config.mode.lower().strip() + if mode == "options": + return self._run_options( + chain=chain if chain is not None else data, + instruments=instruments, + packages=packages, + strategy_run=strategy_run, + underlying=underlying, + hedge_policy=hedge_policy, + net_option_delta=net_option_delta, + settlement_events=settlement_events, + conversion_rates=conversion_rates, + prepared_cache=prepared_cache, + ) + if mode == "walk_forward": + return self._run_walk_forward( + data=data, + signal=signal, + signal_col=signal_col, + positions=positions, + closes=closes, + highs=highs, + lows=lows, + hedge_ratios=hedge_ratios, + datetime_index=datetime_index, + symbols=symbols, + params=params, + param_ranges=param_ranges, + ) + if mode == "arbitrage": + return self._run_arbitrage( + data=data, + signal=signal, + signal_col=signal_col, + closes=closes, + highs=highs, + lows=lows, + hedge_ratios=hedge_ratios, + datetime_index=datetime_index, + symbols=symbols, + ) + if mode == "intrabar_bracket_reference": + return self._run_intrabar_bracket_reference( + data=data, + signal=signal, + signal_col=signal_col, + datetime_index=datetime_index, + symbols=symbols, + intent=intent, + intent_cols=intent_cols, + session_tape=session_tape, + funding_event_timestamps=funding_event_timestamps, + funding_event_rates=funding_event_rates, + ) + if mode == "intrabar_bracket": + return self._run_intrabar_bracket_fast( + data=data, + signal=signal, + signal_col=signal_col, + datetime_index=datetime_index, + symbols=symbols, + intent=intent, + intent_cols=intent_cols, + session_tape=session_tape, + funding_event_timestamps=funding_event_timestamps, + funding_event_rates=funding_event_rates, + ) + if mode == "fill_replay": + return self._run_fill_replay( + data=data, + datetime_index=datetime_index, + symbols=symbols, + fill_replay=fill_replay, + ) + if mode in ("single_signal", "pct_equity", "signal_notional", "dca_ladder", "nautilus_validation"): + return self._run_single(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index, symbols=symbols) + if mode == "orders": + return self._run_orders( + data=data, + orders=orders, + order_commands=order_commands, + datetime_index=datetime_index, + symbols=symbols, + ) + if mode == "native_event_strategy": + return self._run_native_event_strategy( + data=data, + strategy=strategy, + datetime_index=datetime_index, + symbols=symbols, + ) + if mode in ("nautilus_dca_grid", "nautilus_bracket_orders", "native_event_dca_grid", "native_event_bracket_orders"): + return self._run_structured_orders(data=data, datetime_index=datetime_index, symbols=symbols) + if mode == "basket": + return self._run_basket( + data=data, + signal=signal, + signal_col=signal_col, + basket=basket, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols, + ) + if mode == "portfolio": + return self._run_portfolio( + data=data, + positions=positions, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols, + ) + raise ValueError(f"unsupported endpoint mode={self.config.mode!r}") + + def simulate( + self, + *args, + show_order_logs: bool = False, + order_log_mode: str = "fills_only", + order_log_limit: int = 500, + **kwargs, + ): + """ + Alias for `backtest()` used by order, basket, and Nautilus workflows. + + Services can call `simulate()` when the input is closer to an execution + simulation than a pure signal backtest. The routing and return contract + are identical to `backtest()`. + """ + result = self.backtest(*args, **kwargs) + if show_order_logs: + _print_order_logs(result, mode=order_log_mode, limit=order_log_limit) + return result + + def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """ + Return the full QuantBT metrics dictionary for the latest result. + + Parameters + ---------- + trading_days: + Annualization calendar. Use 365 for crypto and 252 for equities. + scope: + `auto` uses the natural reporting scope for the endpoint. For + walk-forward and train/test split runs this means OOS/test bars + only; other endpoints use the full result. Pass `full` to audit the + complete stitched timeline, or `test`/`oos` to force OOS reporting. + + Raises + ------ + RuntimeError + If no backtest has been run yet. + """ + from .metrics import full_report as _full_report + + return _full_report(self._result_for_report_scope(scope), trading_days=trading_days) + + def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict: + """ + Print key metrics and return the full metrics dictionary. + + This intentionally mirrors the convenience style of legacy + `BacktestEngine.analyze()` without forcing a plot. + """ + rpt = self.full_report(trading_days=trading_days, scope=scope) + print(format_metrics_report(rpt)) + return rpt + + def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = "auto"): + """ + Plot cumulative return and drawdown for the latest result. + """ + from .viz import quick_plot as _quick_plot + + return _quick_plot(self._require_result(), theme=theme, figsize=figsize, scope=scope) + + def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"): + """ + Render the full QuantBT tearsheet for the latest result. + """ + from .viz import tearsheet as _tearsheet + + return _tearsheet(self._require_result(), theme=theme, benchmark=benchmark, scope=scope) + + def export_orders(self, path: Union[str, Path]) -> None: + """ + Export latest event/Nautilus order report to CSV. + + Native event runs store order diagnostics in + `result.metadata["order_report"]`; Nautilus runs store raw + `orders_report`. + """ + result = self._require_result() + report = result.metadata.get("order_report") + if report is None: + report = result.metadata.get("orders_report") + if report is None: + raise RuntimeError("latest result does not contain an order report") + report.to_csv(path) + + def export_fills(self, path: Union[str, Path]) -> None: + """ + Export latest fills to CSV. + + Native event fills are converted from `result.fills`; Nautilus fills use + the raw `fills_report` when available. + """ + result = self._require_result() + report = result.metadata.get("fills_report") + if report is None: + rows = [getattr(fill, "__dict__", dict(fill=fill)) for fill in getattr(result, "fills", ())] + report = pd.DataFrame(rows) + if report.empty: + raise RuntimeError("latest result does not contain fills") + report.to_csv(path, index=False) + + @property + def metrics(self) -> Dict: + """Return `full_report()` for the latest result.""" + return self.full_report() + + @property + def latest_orders(self): + """Return latest explicit/generated orders, or an empty tuple.""" + return getattr(self._require_result(), "orders", ()) + + @property + def fills(self): + """Return latest fills, or an empty tuple for non-event results.""" + return getattr(self._require_result(), "fills", ()) + + @property + def order_report(self) -> pd.DataFrame: + """Return latest order report, or an empty DataFrame.""" + return self._require_result().metadata.get("order_report", pd.DataFrame()) + + @property + def fills_report(self) -> pd.DataFrame: + """Return latest fills report, or an empty DataFrame.""" + return self._require_result().metadata.get("fills_report", pd.DataFrame()) + + def nautilus_pct_equity_diagnostic( + self, + *, + data, + signal=None, + signal_col: Optional[str] = None, + native_fee_round_trip: Optional[float] = None, + native_fee_one_way: Optional[float] = None, + native_use_funding: Optional[bool] = None, + native_slippage: Optional[float] = None, + ) -> Dict: + """ + Diagnose why a Nautilus `%_equity` validation run differs from native. + + This helper reports signal transition count, Nautilus order/fill count, + fee/slippage/funding semantic differences, and exchange lot-size + constraints. It is diagnostic-only and does not mutate the result. + """ + from .reporting import build_nautilus_pct_equity_diagnostic + + frame, _, sig = _normalize_single_data( + data=data, + signal=signal, + signal_col=signal_col, + datetime_index=None, + ) + return build_nautilus_pct_equity_diagnostic( + self._require_result(), + data=frame, + signal=sig, + native_fee_round_trip=native_fee_round_trip, + native_fee_one_way=native_fee_one_way, + native_use_funding=native_use_funding, + native_slippage=native_slippage, + ) + + def _run_options( + self, + chain, + instruments, + packages, + strategy_run, + underlying, + hedge_policy, + net_option_delta, + settlement_events, + conversion_rates, + prepared_cache, + ): + if chain is None: + raise ValueError("options endpoint requires chain=option_chain_dataframe or data=option_chain_dataframe") + if instruments is None: + instruments = self.config.instruments + if instruments is None: + raise ValueError("options endpoint requires instruments=OptionInstrumentRegistry/list/mapping") + config = self.config.option_config + if config is None: + config = NativeOptionConfig( + account=self.config.account, + execution=self.config.execution, + option_execution=OptionExecutionConfig(fee_rate=self.config.v2_fee_rate), + margin=OptionMarginConfig(), + metadata=dict(self.config.metadata), + ) + self.engine = OptionBacktestEngine( + chain=chain, + instruments=instruments, + packages=packages or (), + strategy_run=strategy_run, + underlying=underlying, + hedge_policy=hedge_policy, + net_option_delta=net_option_delta, + config=config, + settlement_events=settlement_events or (), + conversion_rates=conversion_rates, + prepared_cache=prepared_cache, + ) + self._store_result(self.engine.result) + return self.result + + def _run_intrabar_bracket_reference(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, session_tape=None, funding_event_timestamps=None, funding_event_rates=None): + tape, intent, symbol = self._prepare_intrabar_run(data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps, funding_event_rates) + contract = _execution_contract_from_config(self.config) + session_policy = _session_policy_from_config(self.config) + if session_policy is not None and session_tape is None: + raise ValueError("session_tape is required when session_policy is configured") + oracle = run_intrabar_reference( + tape=tape, + intent=intent, + account=self.config.account, + contract=contract, + fee_rate=self.config.v2_fee_rate, + slippage_rate=float(self.config.execution.slippage_rate), + contract_size=_scalar_for_symbol(self.config.contract_size, symbol), + session_policy=session_policy, + session_tape=session_tape, + **self._intrabar_execution_kwargs(symbol), + ) + idx = oracle.equity.index + returns = oracle.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + diagnostics = pd.DataFrame( + { + "average_entry": oracle.average_entry, + "active_stop": oracle.active_stop, + "active_take_profit": oracle.active_take_profit, + "event_flags": oracle.event_flags, + "fees": oracle.fees, + "funding": oracle.funding, + }, + index=idx, + ) + metadata = { + **oracle.metadata, + "backend": "intrabar_reference", + "backend_alias": "intrabar_bracket_reference", + "engine_id": "intrabar_reference_v1", + "input_mode": "intrabar_intent", + "symbol": symbol, + "validation_certificate": asdict(tape.validation_certificate), + "strict_market_tape": True, + "phase": "31B_python_reference_oracle", + "fills_report": _intrabar_fills_to_frame(oracle.fills), + "positions_report": pd.DataFrame({f"Position_{symbol}": oracle.position}, index=idx), + } + result = BacktestResultV2( + equity=oracle.equity, + returns=returns, + positions=pd.DataFrame({f"Position_{symbol}": oracle.position.to_numpy(dtype=float)}, index=idx), + closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx), + symbols=[symbol], + initial_capital=float(self.config.account.initial_capital), + leverage=float(self.config.account.leverage), + fills=oracle.fills, + fees=oracle.fees, + funding=oracle.funding, + diagnostics=diagnostics, + metadata=metadata, + ) + self.engine = oracle + self._store_result(result) + return self.result + + def _run_intrabar_bracket_fast(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, session_tape=None, funding_event_timestamps=None, funding_event_rates=None): + tape, intent, symbol = self._prepare_intrabar_run(data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps, funding_event_rates) + contract = _execution_contract_from_config(self.config) + session_policy = _session_policy_from_config(self.config) + if session_policy is not None and session_tape is None: + raise ValueError("session_tape is required when session_policy is configured") + if session_policy is None and session_tape is not None: + raise ValueError("session_policy is required when session_tape is supplied") + kwargs = { + "tape": tape, + "intent": intent, + "account": self.config.account, + "contract": contract, + "fee_rate": self.config.v2_fee_rate, + "slippage_rate": float(self.config.execution.slippage_rate), + "contract_size": _scalar_for_symbol(self.config.contract_size, symbol), + **self._intrabar_execution_kwargs(symbol), + "report_level": self.config.report_level, + } + if session_policy is not None: + kernel = run_intrabar_session_kernel( + **kwargs, + session_policy=session_policy, + session_tape=session_tape, + ) + else: + kernel = run_intrabar_kernel(**kwargs) + idx = kernel.equity.index + returns = kernel.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + diagnostics = pd.DataFrame( + { + "average_entry": kernel.average_entry, + "active_stop": kernel.active_stop, + "active_take_profit": kernel.active_take_profit, + "event_flags": kernel.event_flags, + "initial_margin": kernel.initial_margin, + "maintenance_margin": kernel.maintenance_margin, + "fees": kernel.fees, + "funding": kernel.funding, + }, + index=idx, + ) + metadata = { + **kernel.metadata, + "input_mode": "intrabar_intent", + "symbol": symbol, + "phase": "31C_numba_intrabar_kernel", + "fills_report": kernel.fills_report, + "positions_report": pd.DataFrame({f"Position_{symbol}": kernel.position}, index=idx), + } + result = BacktestResultV2( + equity=kernel.equity, + returns=returns, + positions=pd.DataFrame({f"Position_{symbol}": kernel.position.to_numpy(dtype=float)}, index=idx), + closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx), + symbols=[symbol], + initial_capital=float(self.config.account.initial_capital), + leverage=float(self.config.account.leverage), + liquidated=bool(kernel.liquidated), + liquidation_bar=int(kernel.liquidation_bar), + fills=kernel.fills, + fees=kernel.fees, + funding=kernel.funding, + margin=diagnostics[["initial_margin", "maintenance_margin"]], + diagnostics=diagnostics, + metadata=metadata, + ) + self.engine = kernel + self._store_result(result) + return self.result + + def _run_fill_replay(self, data, datetime_index, symbols, fill_replay): + if fill_replay is None: + raise ValueError("fill_replay endpoint requires fill_replay=FillReplayTape or DataFrame") + symbol_list = list(symbols or self.config.symbols or ["DEFAULT"]) + if len(symbol_list) != 1: + raise ValueError("fill_replay currently supports exactly one symbol") + symbol = symbol_list[0] + tape = prepare_market_tape( + data=data, + datetime_index=datetime_index, + symbols=symbol_list, + funding_rate=self.config.funding_rate, + use_funding=False, + validation_mode="strict", + source_timezone=self.config.metadata.get("source_timezone"), + bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")), + ) + if isinstance(fill_replay, FillReplayTape): + fill_tape = fill_replay + elif isinstance(fill_replay, pd.DataFrame): + fill_tape = FillReplayTape.from_frame( + fill_replay, + fee_rate=self.config.v2_fee_rate, + contract_size=_scalar_for_symbol(self.config.contract_size, symbol), + ) + else: + raise TypeError("fill_replay must be a FillReplayTape or pandas DataFrame") + replay = run_fill_replay_kernel( + tape=tape, + fill_tape=fill_tape, + account=self.config.account, + contract_size=_scalar_for_symbol(self.config.contract_size, symbol), + ) + idx = replay.equity.index + returns = replay.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + metadata = { + **replay.metadata, + "symbol": symbol, + "phase": "31C_fill_replay_kernel", + "fills_report": fill_replay.copy() if isinstance(fill_replay, pd.DataFrame) else pd.DataFrame(), + } + result = BacktestResultV2( + equity=replay.equity, + returns=returns, + positions=pd.DataFrame({f"Position_{symbol}": replay.position.to_numpy(dtype=float)}, index=idx), + closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx), + symbols=[symbol], + initial_capital=float(self.config.account.initial_capital), + leverage=float(self.config.account.leverage), + fees=replay.fees, + diagnostics=pd.DataFrame({"event_flags": replay.event_flags, "fees": replay.fees}, index=idx), + metadata=metadata, + ) + self.engine = replay + self._store_result(result) + return self.result + + def _prepare_intrabar_run(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps=None, funding_event_rates=None): + symbol_list = list(symbols or self.config.symbols or ["DEFAULT"]) + if len(symbol_list) != 1: + raise ValueError(f"{self.config.mode} currently supports exactly one symbol") + symbol = symbol_list[0] + tape = prepare_market_tape( + data=data, + datetime_index=datetime_index, + symbols=symbol_list, + funding_rate=self.config.funding_rate, + funding_event_timestamps=funding_event_timestamps, + funding_event_rates=funding_event_rates, + use_funding=self.config.use_funding, + validation_mode="strict", + missing_funding_policy=str(self.config.metadata.get("missing_funding_policy", "raise")), + source_timezone=self.config.metadata.get("source_timezone"), + bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")), + ) + lookup_frame = None if isinstance(data, PreparedMarketTape) else _strict_lookup_frame(data, datetime_index, source_timezone=self.config.metadata.get("source_timezone")) + if intent is None: + level_mode = IntrabarLevelMode(str(self.config.metadata.get("intrabar_level_mode", IntrabarLevelMode.PERCENT_DISTANCE.value))) + intent = _intrabar_intent_from_endpoint_input( + frame=lookup_frame, + index=pd.DatetimeIndex(pd.to_datetime(tape.timestamps_ns, utc=True)), + signal=signal, + signal_col=signal_col, + intent_cols=intent_cols or {}, + level_mode=level_mode, + ) + return tape, intent, symbol + + def _intrabar_execution_kwargs(self, symbol: str) -> Dict: + constraints = build_quantity_constraints( + [symbol], + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + ) + sizing_mode = IntrabarSizingMode(str(self.config.metadata.get("intrabar_sizing_mode", IntrabarSizingMode.UNITS.value))) + fixed_notional = float(self.config.metadata.get("fixed_notional", self.config.alloc_per_trade if not isinstance(self.config.alloc_per_trade, dict) else self.config.alloc_per_trade.get(symbol, 0.0))) + equity_fraction = float(self.config.metadata.get("equity_fraction", self.config.alloc_per_trade if not isinstance(self.config.alloc_per_trade, dict) else self.config.alloc_per_trade.get(symbol, 0.0))) + risk_fraction = float(self.config.metadata.get("risk_fraction", 0.0)) + return { + "sizing_mode": sizing_mode, + "fixed_notional": fixed_notional, + "equity_fraction": equity_fraction, + "risk_fraction": risk_fraction, + "qty_step": float(constraints.qty_step[0]), + "min_qty": float(constraints.min_qty[0]), + "min_notional": float(constraints.min_notional[0]), + "tick_size": _tick_size_for_symbol(self.config.instruments, symbol, self.config.metadata.get("tick_size", 0.0)), + } + + def _run_single(self, data, signal, signal_col, datetime_index, symbols): + frame, idx, sig = _normalize_single_data(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index) + backend = _resolve_backend(self.config) + symbol_list = list(symbols or self.config.symbols or ["DEFAULT"]) + if backend == "legacy": + self.engine = BacktestEngine( + Datetime=idx, + Position=sig, + Close=frame["close"], + High=frame.get("high"), + Low=frame.get("low"), + fee=self.config.fee, + use_pyramiding=self.config.use_pyramiding, + initial_capital=self.config.account.initial_capital, + leverage=self.config.account.leverage, + maintenance_ratio=self.config.account.maintenance_ratio, + contract_size=self.config.contract_size, + use_funding_rate=self.config.use_funding, + funding_rate=self.config.funding_rate, + alloc_per_trade=self.config.alloc_per_trade, + hedge_type=self.config.sizing, + slippage=self.config.slippage, + symbols=None, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + **self.config.dca_kwargs, + ) + self._store_result(self.engine.result) + return self.result + + self.engine = BacktestEngineV2( + data=frame, + signals=sig, + symbols=symbol_list, + backend=backend, + native_backend=self.config.native_backend, + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + alloc_per_trade=self.config.alloc_per_trade, + hedge_type=self.config.sizing, + use_pyramiding=self.config.use_pyramiding, + contract_size=self.config.contract_size, + nautilus_config=self.config.nautilus_config, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + report_level=self.config.report_level, + audit_sink=self.config.audit_sink, + audit_sink_path=self.config.audit_sink_path, + reactive_kernel_mode=self.config.reactive_kernel_mode, + ) + markers = _intrabar_marker_columns(frame) + if backend == "native_vectorized" and markers: + warnings.warn( + "native_vectorized is close_target_v2 and does not certify intrabar SL/TP/trailing columns " + f"{markers}; use a future intrabar/fill-replay/event backend for those semantics.", + RuntimeWarning, + stacklevel=2, + ) + self.engine.result.metadata["intrabar_misuse_markers"] = markers + self.engine.result.metadata["certification_status"] = "uncertified_intrabar_columns_on_close_target" + self._store_result(self.engine.result) + return self.result + + def _run_orders(self, data, orders, order_commands, datetime_index, symbols): + if not orders and not order_commands: + raise ValueError("orders endpoint requires orders=[OrderIntent(...)] or order_commands=[OrderCommand(...)]") + frame, idx, _ = _normalize_single_data(data=data, signal=pd.Series(0.0, index=_infer_index(data, datetime_index)), signal_col=None, datetime_index=datetime_index) + backend = _resolve_backend(self.config) + event_version = str(self.config.event_engine_version).lower().strip() + if order_commands is not None: + event_version = "v2" + self.engine = BacktestEngineV2( + data=frame, + symbols=list(symbols or self.config.symbols or ["asset"]), + backend=backend, + native_backend=self.config.native_backend, + orders=orders, + order_commands=order_commands, + event_engine_version=event_version, + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + report_level=self.config.report_level, + audit_sink=self.config.audit_sink, + audit_sink_path=self.config.audit_sink_path, + reactive_kernel_mode=self.config.reactive_kernel_mode, + ) + self._store_result(self.engine.result) + return self.result + + def _run_native_event_strategy(self, data, strategy, datetime_index, symbols): + if strategy is None: + raise ValueError("native_event_strategy endpoint requires strategy=...") + frame, idx, _ = _normalize_single_data( + data=data, + signal=pd.Series(0.0, index=_infer_index(data, datetime_index)), + signal_col=None, + datetime_index=datetime_index, + ) + symbol_list = list(symbols or self.config.symbols or ["asset"]) + self.engine = BacktestEngineV2( + data=frame, + symbols=symbol_list, + backend="native_event", + native_backend=self.config.native_backend, + strategy=strategy, + event_engine_version="v2", + reactive_execution_mode=self.config.reactive_execution_mode, + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + report_level=self.config.report_level, + audit_sink=self.config.audit_sink, + audit_sink_path=self.config.audit_sink_path, + reactive_kernel_mode=self.config.reactive_kernel_mode, + ) + self._store_result(self.engine.result) + return self.result + + def _run_structured_orders(self, data, datetime_index, symbols): + spec = self.config.structured_order_spec + if spec is None: + raise ValueError(f"{self.config.mode} endpoint requires a structured order spec") + frame = _standardize_frame(data, datetime_index=datetime_index) + symbol_list = list(symbols or self.config.symbols or [spec.symbol]) + if spec.symbol not in symbol_list: + symbol_list = [spec.symbol] + if isinstance(spec, DcaGridSpec): + plan = build_dca_grid_order_plan(spec, close=frame["close"]) + elif isinstance(spec, BracketOrderSpec): + plan = build_bracket_order_plan(spec) + else: + raise TypeError(f"unsupported structured_order_spec={type(spec).__name__}") + + params = { + "input_mode": plan.package_type, + "structured_order_plan": plan, + "structured_order_table": plan.order_table, + "package_id": plan.package_id, + "package_type": plan.package_type, + "package_metadata": plan.metadata, + "order_count_input": len(plan.orders), + } + backend = _resolve_backend(self.config) + if backend == "native_event": + commands = order_intents_to_lifecycle_commands(plan.orders) + self.engine = BacktestEngineV2( + data=frame, + symbols=[spec.symbol], + backend="native_event", + native_backend=self.config.native_backend, + order_commands=commands, + event_engine_version="v2", + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + report_level=self.config.report_level, + audit_sink=self.config.audit_sink, + audit_sink_path=self.config.audit_sink_path, + ) + result = self.engine.result + result.metadata.update( + { + **params, + "engine": f"event_v2_{plan.package_type}", + "lifecycle_command_count": len(commands), + "lifecycle_commands": commands, + } + ) + elif backend == "nautilus": + result = self._run_nautilus_package_orders( + data={spec.symbol: frame}, + orders=plan.orders, + symbols=[spec.symbol], + params=params, + ) + result.metadata["engine"] = f"nautilus_{plan.package_type}" + else: + raise ValueError(f"structured order endpoints require backend='native_event' or 'nautilus', got {backend!r}") + self._store_result(result) + return self.result + + def _run_basket(self, data, signal, signal_col, basket, closes, highs, lows, datetime_index, symbols): + spec = basket or self.config.basket + if spec is None: + raise ValueError("basket endpoint requires a BasketSpec") + sig = signal if signal is not None else _signal_from_data(data, signal_col) + if sig is None: + raise ValueError("basket endpoint requires signal or signal_col") + close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data( + data=data, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols, + ) + backend = _resolve_backend(self.config) + if backend == "nautilus": + plan = build_frozen_basket_orders( + datetime_index=idx, + basket=spec, + signal=sig, + closes=close_map, + order_type=OrderType.MARKET, + tif=TimeInForce.IOC, + ) + result = self._run_nautilus_package_orders( + data=_frames_from_symbol_maps(close_map, high_map, low_map, symbol_list), + orders=plan.orders, + symbols=symbol_list, + params={ + "input_mode": "basket_package", + "basket_id": spec.basket_id, + "basket_plan": plan, + "basket_target_units": plan.target_units, + "basket_execution_policy": spec.execution_policy.value, + "package_target_units": plan.target_units, + "order_count_input": len(plan.orders), + }, + ) + result.metadata["engine"] = "nautilus_basket_package" + self._store_result(result) + return self.result + + self.engine = BacktestEngineV2( + backend="native_event", + native_backend=self.config.native_backend, + basket=spec, + signal=sig, + closes=close_map, + highs=high_map, + lows=low_map, + datetime_index=idx, + symbols=symbol_list, + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + ) + self._store_result(self.engine.result) + return self.result + + def _run_arbitrage(self, data, signal, signal_col, closes, highs, lows, hedge_ratios, datetime_index, symbols): + spec = self.config.arbitrage_spec + if spec is None: + raise ValueError("arbitrage endpoint requires an arbitrage spec") + phase_g_package_specs = (CalendarSpreadSpec, FundingArbitrageSpec, SpotPerpCashCarrySpec, IndexBasketArbSpec) + schema_only_specs = (CrossExchangeArbSpec, TriangularArbSpec, OptionsVolArbSpec) + if isinstance(spec, schema_only_specs): + if isinstance(spec, OptionsVolArbSpec): + raise NotImplementedError( + "OptionsVolArbSpec must route through QuantBTEndpoint.options(...), not generic arbitrage execution. " + "The option route preserves package fills, multi-currency ledger, Greeks, settlement, and margin reports." + ) + raise NotImplementedError( + f"{type(spec).__name__} is schema-validated but requires a specialized arbitrage engine; " + "do not route it through generic package execution" + ) + if not isinstance(spec, (BasisArbitrageSpec, StatArbPairSpec, *phase_g_package_specs)): + raise NotImplementedError( + "Arbitrage endpoint supports BasisArbitrageSpec, StatArbPairSpec, and package-style Phase G specs; " + f"got {type(spec).__name__}" + ) + backend = _resolve_backend(self.config) + if backend not in ("native_event", "native_vectorized", "nautilus"): + raise NotImplementedError("Phase F arbitrage endpoint supports backend='native_event', 'native_vectorized', or 'nautilus'") + sig = signal if signal is not None else _signal_from_data(data, signal_col) + if sig is None: + raise ValueError("arbitrage endpoint requires signal or signal_col") + spec_symbols = [leg.symbol for leg in spec.legs] + close_map, high_map, low_map, idx, _ = _normalize_symbol_data( + data=data, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols or spec_symbols, + ) + if backend == "nautilus": + if not isinstance(spec, (BasisArbitrageSpec, StatArbPairSpec)): + raise NotImplementedError("Phase F Nautilus arbitrage supports BasisArbitrageSpec and StatArbPairSpec only") + result = self._run_nautilus_arbitrage( + spec=spec, + signal=sig, + close_map=close_map, + high_map=high_map, + low_map=low_map, + idx=idx, + hedge_ratios=hedge_ratios, + ) + self._store_result(result) + return self.result + + if backend == "native_event": + self.engine = NativeEventBackend( + NativeEventConfig( + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + report_level=self.config.report_level, + audit_sink=self.config.audit_sink, + audit_sink_path=self.config.audit_sink_path, + native_backend=self.config.native_backend, + ) + ) + else: + self.engine = NativeVectorizedBackend( + NativeVectorizedConfig( + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + ) + ) + if isinstance(spec, BasisArbitrageSpec): + result = self.engine.run_basis_arbitrage( + datetime_index=idx, + spec=spec, + signal=sig, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + leverage=self.config.account.leverage, + hedge_ratios=hedge_ratios, + ) + elif isinstance(spec, StatArbPairSpec): + result = self.engine.run_stat_arb_pair_arbitrage( + datetime_index=idx, + spec=spec, + signal=sig, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + leverage=self.config.account.leverage, + hedge_ratios=hedge_ratios, + ) + else: + result = self.engine.run_package_arbitrage( + datetime_index=idx, + spec=spec, + signal=sig, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + leverage=self.config.account.leverage, + hedge_ratios=hedge_ratios, + ) + self._store_result(result) + return self.result + + def _run_walk_forward( + self, + data, + signal, + signal_col, + positions, + closes, + highs, + lows, + hedge_ratios, + datetime_index, + symbols, + params, + param_ranges, + ): + if self.config.strategy_class is None: + raise ValueError("walk_forward endpoint requires strategy_class") + from .walkforward import WalkForwardConfig, WalkForwardEngine + + wf_config = self.config.walkforward_config or WalkForwardConfig(target_mode=self.config.walkforward_target_mode) + target_mode = self.config.walkforward_target_mode.lower().strip() + scorer = ( + _make_walkforward_endpoint_scorer( + self.config, + target_mode=target_mode, + symbols=symbols, + wf_config=wf_config, + market_data=data, + market_closes=closes, + market_highs=highs, + market_lows=lows, + market_datetime_index=datetime_index, + ) + if wf_config.scoring_backend == "endpoint" + else None + ) + engine = WalkForwardEngine(strategy=self.config.strategy_class, config=wf_config, scorer=scorer) + wf_result = engine.run( + data=data if data is not None else closes, + params=params, + param_ranges=param_ranges, + datetime_index=datetime_index, + ) + stitched = wf_result.oos_output + if stitched is None: + raise ValueError("walk-forward strategy produced no OOS output") + + if target_mode == "portfolio": + if isinstance(stitched, pd.Series): + raise TypeError("portfolio walk_forward target_mode requires DataFrame or {symbol: Series} output") + result = self._run_portfolio( + data=data, + positions=stitched, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols, + ) + elif target_mode == "arbitrage": + if not isinstance(stitched, pd.Series): + raise TypeError("arbitrage walk_forward target_mode requires a scalar signal Series output") + result = self._run_arbitrage( + data=data, + signal=stitched, + signal_col=None, + closes=closes, + highs=highs, + lows=lows, + hedge_ratios=hedge_ratios, + datetime_index=datetime_index, + symbols=symbols, + ) + elif target_mode == "basket": + if not isinstance(stitched, pd.Series): + raise TypeError("basket walk_forward target_mode requires a scalar signal Series output") + result = self._run_basket( + data=data, + signal=stitched, + signal_col=None, + basket=self.config.basket, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols, + ) + else: + if not isinstance(stitched, pd.Series): + raise TypeError(f"{target_mode} walk_forward target_mode requires a scalar signal Series output") + result = self._run_single( + data=data, + signal=stitched, + signal_col=None, + datetime_index=datetime_index, + symbols=symbols, + ) + + wf_result.backtest_result = result + result.metadata["walk_forward"] = { + "engine": wf_result.metadata["engine"], + "target_mode": target_mode, + "n_folds": wf_result.metadata["n_folds"], + "report_scope": "test" if wf_result.metadata.get("split_frequency") == "single" else "oos", + "split_frequency": wf_result.metadata.get("split_frequency"), + "window_mode": wf_result.metadata.get("window_mode"), + "params": wf_result.params, + "fold_table": wf_result.fold_table, + "trial_table": wf_result.trial_table, + "candidate_table": wf_result.candidate_table, + "best_trial": wf_result.best_trial, + "optimization_mode": wf_result.metadata.get("optimization_mode"), + "validation_claim": wf_result.metadata.get("validation_claim"), + "full_sample_used_for_selection": wf_result.metadata.get("full_sample_used_for_selection"), + "oos_used_for_selection": wf_result.metadata.get("oos_used_for_selection"), + "data_hash": wf_result.metadata.get("data_hash"), + "config_hash": wf_result.metadata.get("config_hash"), + "random_seed": wf_result.metadata.get("random_seed"), + "top_is_fraction": wf_result.metadata.get("top_is_fraction"), + "top_is_k": wf_result.metadata.get("top_is_k"), + "candidate_selection_metric": wf_result.metadata.get("candidate_selection_metric"), + "scoring_trading_days": wf_result.metadata.get("scoring_trading_days"), + "min_trades_per_year": wf_result.metadata.get("min_trades_per_year"), + "trade_penalty_factor": wf_result.metadata.get("trade_penalty_factor"), + "sbb_simulation": wf_result.metadata.get("sbb_simulation"), + "sbb_samples": wf_result.metadata.get("sbb_samples"), + "sbb_block_length": wf_result.metadata.get("sbb_block_length"), + "regime_count": wf_result.metadata.get("regime_count"), + "regime_lookback": wf_result.metadata.get("regime_lookback"), + "regime_weights": wf_result.metadata.get("regime_weights"), + "stress_vol_multiplier": wf_result.metadata.get("stress_vol_multiplier"), + "garch_p": wf_result.metadata.get("garch_p"), + "garch_q": wf_result.metadata.get("garch_q"), + "garch_dist": wf_result.metadata.get("garch_dist"), + "garch_vol_multiplier": wf_result.metadata.get("garch_vol_multiplier"), + "plateau_quantile": wf_result.metadata.get("plateau_quantile"), + "plateau_median_weight": wf_result.metadata.get("plateau_median_weight"), + "plateau_std_penalty": wf_result.metadata.get("plateau_std_penalty"), + "plateau_size_bonus": wf_result.metadata.get("plateau_size_bonus"), + "is_subperiods": wf_result.metadata.get("is_subperiods"), + "q25_weight": wf_result.metadata.get("q25_weight"), + "dispersion_penalty": wf_result.metadata.get("dispersion_penalty"), + "temporal_weight": wf_result.metadata.get("temporal_weight"), + "plateau_weight": wf_result.metadata.get("plateau_weight"), + "use_bootstrap_penalty": wf_result.metadata.get("use_bootstrap_penalty"), + "use_complexity_penalty": wf_result.metadata.get("use_complexity_penalty"), + "scoring_backend": wf_result.metadata.get("scoring_backend"), + "numba_enabled": wf_result.metadata.get("numba_enabled"), + } + if scorer is not None and hasattr(scorer, "prepared_cache_metadata"): + result.metadata["walk_forward"]["prepared_scoring_cache"] = scorer.prepared_cache_metadata() + result.metadata["walk_forward_result"] = wf_result + self.engine = engine + self.result = result + return result + + def _run_nautilus_arbitrage(self, spec, signal, close_map, high_map, low_map, idx, hedge_ratios): + from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine + + symbols = [leg.symbol for leg in spec.legs] + if isinstance(spec, BasisArbitrageSpec): + plan = build_arbitrage_order_plan( + datetime_index=idx, + spec=spec, + signal=signal, + closes=close_map, + hedge_ratios=hedge_ratios, + ) + extra_metadata = { + "spread_report": NativeVectorizedBackend( + NativeVectorizedConfig(account=self.config.account, execution=self.config.execution) + )._basis_spread_report(idx, spec, close_map, plan.target_units), + "package_rejection_report": plan.rejection_report, + } + elif isinstance(spec, StatArbPairSpec): + basket = BasketSpec( + basket_id=spec.arb_id, + legs=tuple(BasketLegSpec(symbol=leg.symbol, ratio=float(leg.ratio)) for leg in spec.legs), + gross_notional=float(spec.sizing_policy.notional), + freeze_hedge=bool(spec.hedge_policy.freeze_on_entry), + hedged_margin_offset=float(spec.margin_model.hedged_margin_offset), + ) + rebalance_threshold = spec.hedge_policy.rebalance_threshold + if not spec.hedge_policy.freeze_on_entry and rebalance_threshold is None: + rebalance_threshold = 0.0 + plan = build_frozen_basket_orders( + datetime_index=idx, + basket=basket, + signal=signal, + closes=close_map, + hedge_ratios=hedge_ratios, + order_type=OrderType.MARKET, + tif=TimeInForce.IOC, + rebalance_threshold=rebalance_threshold, + ) + extra_metadata = { + "beta_drift_report": NativeVectorizedBackend( + NativeVectorizedConfig(account=self.config.account, execution=self.config.execution) + )._stat_arb_beta_drift_report(idx, spec, plan, rebalance_threshold), + "rebalance_threshold": rebalance_threshold, + } + else: + raise NotImplementedError(f"Nautilus arbitrage does not support {type(spec).__name__}") + + data = _frames_from_symbol_maps(close_map, high_map, low_map, symbols) + config = self.config.nautilus_config + if config is None: + config = NautilusBackendConfig( + timeframe=str(self.config.metadata.get("timeframe", "1h")), + starting_balance=self.config.account.initial_capital, + trade_notional=0.0, + sizing_mode="notional", + ) + else: + config = replace( + config, + starting_balance=self.config.account.initial_capital, + trade_notional=0.0, + sizing_mode="notional", + ) + self.engine = NautilusBacktestEngine(config) + result = self.engine.run_order_packages( + data=data, + orders=plan.orders, + symbols=symbols, + params={ + "arb_id": spec.arb_id, + "arb_type": spec.arb_type.value, + "arbitrage_plan": plan, + "package_target_units": plan.target_units, + **extra_metadata, + }, + ) + result.metadata["engine"] = "nautilus_arbitrage_package" + return result + + def _run_portfolio(self, data, positions, closes, highs, lows, datetime_index, symbols): + pos_map = _positions_to_map(positions) + if not pos_map: + raise ValueError("portfolio endpoint requires positions DataFrame or mapping") + close_map, high_map, low_map, idx, _ = _normalize_symbol_data( + data=data, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols or list(pos_map.keys()), + ) + if ( + self.config.mode.lower().strip() == "walk_forward" + and self.config.walkforward_target_mode.lower().strip() == "portfolio" + and self.config.backend.lower().strip() not in {"legacy_portfolio", "nautilus"} + ): + backend = "native_portfolio" + else: + backend = _resolve_backend(self.config) + if backend == "nautilus": + symbol_list = list(symbols or pos_map.keys()) + native_reference = PortfolioBacktestEngine( + positions=pos_map, + closes=close_map, + highs=high_map, + lows=low_map, + datetime_index=idx, + mode=self.config.portfolio_mode, + backend="native_portfolio", + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.canonical_one_way_fee_rate, + alloc_per_trade=self.config.alloc_per_trade, + contract_size=self.config.contract_size, + hedge_type=self.config.sizing if self.config.sizing else "signal_notional", + asset_type=self.config.asset_type, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + leverage=self.config.account.leverage, + maintenance_ratio=self.config.account.maintenance_ratio, + use_pyramiding=self.config.use_pyramiding, + betas=self.config.betas, + risk_lookback=self.config.risk_lookback, + report_level=self.config.report_level, + ).result + target_units = native_reference.metadata["target_units_report"].reindex(columns=symbol_list) + orders = _build_portfolio_orders_from_target_units_for_nautilus( + target_units=target_units, + symbols=symbol_list, + tag=f"portfolio:{self.config.sizing if self.config.sizing else 'signal_notional'}", + ) + result = self._run_nautilus_package_orders( + data=_frames_from_symbol_maps(close_map, high_map, low_map, symbol_list), + orders=orders, + symbols=symbol_list, + params={ + "input_mode": "portfolio_matrix", + "portfolio_mode": self.config.portfolio_mode, + "portfolio_target_units": target_units, + "package_target_units": target_units, + "order_count_input": len(orders), + }, + ) + result.metadata["engine"] = "nautilus_portfolio_matrix" + result.metadata["native_portfolio_reference_final_equity"] = float(native_reference.equity.iloc[-1]) + from .reporting import build_portfolio_nautilus_validation_report + + result.metadata["portfolio_nautilus_validation_report"] = build_portfolio_nautilus_validation_report( + native_reference, + result, + equity_tolerance=float(self.config.metadata.get("portfolio_nautilus_equity_tolerance", 1e-6)), + position_tolerance=float(self.config.metadata.get("portfolio_nautilus_position_tolerance", 1e-6)), + ) + self._store_result(result) + return self.result + + self.engine = PortfolioBacktestEngine( + positions=pos_map, + closes=close_map, + highs=high_map, + lows=low_map, + datetime_index=idx, + mode=self.config.portfolio_mode, + backend=backend, + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.canonical_one_way_fee_rate, + alloc_per_trade=self.config.alloc_per_trade, + contract_size=self.config.contract_size, + hedge_type=self.config.sizing if self.config.sizing else "notional", + asset_type=self.config.asset_type, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + leverage=self.config.account.leverage, + maintenance_ratio=self.config.account.maintenance_ratio, + use_pyramiding=self.config.use_pyramiding, + betas=self.config.betas, + risk_lookback=self.config.risk_lookback, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + report_level=self.config.report_level, + ) + self._store_result(self.engine.result) + return self.result + + def _run_nautilus_package_orders(self, data, orders, symbols, params): + from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine + + run_params = dict(params or {}) + run_orders = _annotate_orders_for_depth(orders, run_params) + depth_result = None + if self.config.nautilus_depth_config is not None: + depth_result = simulate_nautilus_order_package_depth( + orders=run_orders, + data=data, + config=self.config.nautilus_depth_config, + ) + run_orders = depth_result.orders + run_params.update( + { + "nautilus_depth_enabled": True, + "nautilus_depth_order_report": depth_result.order_report, + "nautilus_depth_package_report": depth_result.package_report, + "nautilus_depth_metadata": depth_result.metadata, + "order_count_before_depth": len(orders), + "order_count_after_depth": len(run_orders), + } + ) + else: + run_params.setdefault("nautilus_depth_enabled", False) + + config = self.config.nautilus_config + if config is None: + config = NautilusBackendConfig( + instrument_id=symbols[0], + timeframe=str(self.config.metadata.get("timeframe", "1h")), + starting_balance=self.config.account.initial_capital, + trade_notional=0.0, + sizing_mode="notional", + ) + else: + config = replace( + config, + starting_balance=self.config.account.initial_capital, + trade_notional=0.0, + sizing_mode="notional", + ) + self.engine = NautilusBacktestEngine(config) + if not run_orders: + return _empty_nautilus_preflight_result( + data=data, + symbols=symbols, + account=self.config.account, + metadata={ + "backend": "nautilus", + "engine": "nautilus_package_orders_preflight_rejected", + "input_mode": run_params.get("input_mode", "order_packages"), + "orders_count": 0, + "fills_count": 0, + "positions_count": 0, + **run_params, + }, + ) + return self.engine.run_order_packages( + data=data, + orders=run_orders, + symbols=symbols, + params=run_params, + ) + + def _store_result(self, result): + _normalize_result_contract(result) + _attach_endpoint_run_config(result, self.config) + self.result = result + return result + + def _require_result(self): + if self.result is None: + raise RuntimeError("run backtest() or simulate() before requesting results") + return self.result + + def _result_for_report_scope(self, scope: str): + from .core.scopes import scoped_result + + return scoped_result(self._require_result(), scope=scope) + + +def _normalize_result_contract(result) -> None: + """ + Make common result artifacts safe to access across all endpoint backends. + + Legacy and vectorized results do not naturally have fills/orders. Notebook + integrations still benefit from stable empty artifacts instead of + AttributeError/KeyError. + """ + metadata = result.metadata + if not hasattr(result, "orders"): + setattr(result, "orders", ()) + if not hasattr(result, "fills"): + setattr(result, "fills", ()) + + order_report = metadata.get("order_report") + if order_report is None: + order_report = metadata.get("orders_report") + if order_report is None: + order_report = pd.DataFrame() + metadata["order_report"] = order_report + if metadata.get("orders_report") is None: + metadata["orders_report"] = order_report + + if metadata.get("fills_report") is None: + metadata["fills_report"] = _fills_to_frame(getattr(result, "fills", ())) + if metadata.get("positions_report") is None: + metadata["positions_report"] = pd.DataFrame() + if "orders_count" not in metadata: + metadata["orders_count"] = len(getattr(result, "orders", ())) + if "fills_count" not in metadata: + metadata["fills_count"] = len(getattr(result, "fills", ())) + engine = str(metadata.get("engine", "unknown")) + backend = str(metadata.get("backend", metadata.get("backend_alias", "unknown"))) + metadata.setdefault("backend_alias", backend) + metadata.setdefault("engine_id", engine) + metadata.setdefault("kernel_version", engine) + metadata.setdefault( + "execution_contract", + { + "engine_id": metadata["engine_id"], + "signal_phase": metadata.get("signal_phase", "unspecified"), + "fill_phase": metadata.get("fill_phase", "unspecified"), + "intrabar_exit_model": metadata.get("intrabar_exit_model", "unspecified"), + }, + ) + + +def _attach_endpoint_run_config(result, config: EndpointConfig) -> None: + metadata = result.metadata + payload = _endpoint_run_config_payload(config) + _sync_applied_nautilus_config(payload, metadata) + metadata["run_config"] = payload + metadata.setdefault("initial_capital", payload["account"]["initial_capital"]) + metadata.setdefault("leverage", payload["account"]["leverage"]) + metadata.setdefault("maintenance_ratio", payload["account"]["maintenance_ratio"]) + metadata.setdefault("fee_rate", payload["fees"]["one_way_fee_rate"]) + metadata.setdefault("canonical_one_way_fee_rate", payload["fees"]["canonical_one_way_fee_rate"]) + metadata.setdefault("fee_round_trip", payload["fees"]["round_trip_fee"]) + metadata.setdefault("alloc_per_trade", payload["sizing"]["alloc_per_trade"]) + metadata.setdefault("slippage", payload["execution"]["legacy_slippage_rate"]) + metadata.setdefault("slippage_bps", payload["execution"]["slippage_bps"]) + metadata.setdefault("use_funding", payload["funding"]["use_funding"]) + + +def _sync_applied_nautilus_config(payload: Dict, metadata: Dict) -> None: + """Keep report run_config aligned with the adapter config that actually ran.""" + if payload.get("backend") != "nautilus": + return + nautilus = dict(payload.get("nautilus") or {}) + if not nautilus: + return + + for source_key, target_key in ( + ("instrument_id", "instrument_id"), + ("sizing_mode", "sizing_mode"), + ("trade_notional", "trade_notional"), + ("use_pyramiding", "use_pyramiding"), + ("close_positions_on_stop", "close_positions_on_stop"), + ): + if metadata.get(source_key) is not None: + nautilus[target_key] = _jsonable(metadata[source_key]) + + if metadata.get("timeframe") is not None: + nautilus["timeframe"] = _jsonable(metadata["timeframe"]) + if metadata.get("initial_capital") is not None: + nautilus["starting_balance"] = _jsonable(metadata["initial_capital"]) + + payload["nautilus"] = nautilus + + +def _endpoint_run_config_payload(config: EndpointConfig) -> Dict: + intrabar_mode = str(config.mode).lower().strip() in {"intrabar_bracket", "intrabar_bracket_reference", "fill_replay"} + payload = { + "mode": config.mode, + "backend": config.backend, + "portfolio_mode": config.portfolio_mode, + "asset_type": config.asset_type, + "account": _jsonable(asdict(config.account)), + "execution": { + **_jsonable(asdict(config.execution)), + "legacy_slippage_rate": None if intrabar_mode else float(config.slippage), + "slippage_bps": float(config.execution.slippage_bps), + }, + "fees": { + "round_trip_fee": float(config.fee), + "one_way_fee_rate": float(config.canonical_one_way_fee_rate), + "canonical_one_way_fee_rate": float(config.canonical_one_way_fee_rate), + "explicit_fee_rate": None if config.fee_rate is None else float(config.fee_rate), + "legacy_fee_converted": config.fee_rate is None, + "applied_fee_source": "fee_rate" if config.fee_rate is not None else "legacy_fee", + }, + "sizing": { + "hedge_type": config.sizing, + "alloc_per_trade": _jsonable(config.alloc_per_trade), + "use_pyramiding": bool(config.use_pyramiding), + "contract_size": _jsonable(config.contract_size), + }, + "funding": { + "use_funding": bool(config.use_funding), + "funding_rate": _jsonable(config.funding_rate), + }, + "dca_kwargs": _jsonable(config.dca_kwargs), + "structured_order_spec": _jsonable(config.structured_order_spec), + "symbols": _jsonable(config.symbols), + "metadata": _jsonable(config.metadata), + "report_level": config.report_level, + "audit_sink": config.audit_sink, + "audit_sink_path": config.audit_sink_path, + } + if config.nautilus_config is not None: + payload["nautilus"] = _jsonable( + asdict(config.nautilus_config) if is_dataclass(config.nautilus_config) else vars(config.nautilus_config) + ) + return payload + + +def _jsonable(value): + if is_dataclass(value): + return _jsonable(asdict(value)) + if isinstance(value, dict): + return {str(k): _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + if isinstance(value, pd.Series): + return { + "type": "Series", + "name": value.name, + "rows": int(len(value)), + "start": str(value.index[0]) if len(value) else None, + "end": str(value.index[-1]) if len(value) else None, + } + if isinstance(value, pd.DataFrame): + return { + "type": "DataFrame", + "rows": int(len(value)), + "columns": list(value.columns), + "start": str(value.index[0]) if len(value) else None, + "end": str(value.index[-1]) if len(value) else None, + } + if hasattr(value, "value"): + return value.value + return value + + +def _fills_to_frame(fills) -> pd.DataFrame: + rows = [] + for fill in fills: + row = getattr(fill, "__dict__", None) + rows.append(dict(row) if row is not None else {"fill": fill}) + return pd.DataFrame(rows) + + +def format_metrics_report(report: Dict) -> str: + """ + Format a metrics dictionary as a legacy-style text report. + + The returned string is intentionally plain monospaced text so notebooks, + terminals, logs, and services all render the same high-signal report. + """ + lines = [ + ("Initial Capital", _fmt_money(report.get("initial_capital"), decimals=0)), + ("Final Equity", _fmt_money(report.get("final_equity"), decimals=2)), + ("Total Return", _fmt_pct(report.get("total_return_pct"), signed=True, decimals=2)), + ("CAGR", _fmt_pct(report.get("cagr_pct"), signed=True, decimals=2)), + ("Sharpe Ratio", _fmt_float(report.get("sharpe"), decimals=3)), + ("Sortino Ratio", _fmt_float(report.get("sortino"), decimals=3)), + ("Calmar Ratio", _fmt_float(report.get("calmar"), decimals=3)), + ("Omega Ratio", _fmt_float(report.get("omega"), decimals=3)), + ("Max Drawdown", _fmt_pct(report.get("max_drawdown_pct"), signed=False, decimals=2)), + ("Avg Drawdown", _fmt_pct(report.get("avg_drawdown_pct"), signed=False, decimals=2)), + ("Max DD Duration", _fmt_days(report.get("max_dd_duration_days"))), + ("Profit Factor", _fmt_float(report.get("profit_factor"), decimals=3)), + ("Long Hit Rate", _fmt_pct(report.get("long_hitrate_pct"), signed=False, decimals=2)), + ("Short Hit Rate", _fmt_pct(report.get("short_hitrate_pct"), signed=False, decimals=2)), + ("Avg Win", _fmt_pct(report.get("avg_win_pct"), signed=True, decimals=3)), + ("Avg Loss", _fmt_pct(report.get("avg_loss_pct"), signed=True, decimals=3)), + ("Expectancy", _fmt_pct(report.get("expectancy_pct"), signed=True, decimals=3)), + ("Number of Trades", _fmt_int(report.get("num_trades"))), + ("Liquidated", f"{'Yes' if report.get('liquidated') else 'No':>14}"), + ] + col_width = max(len(key) for key, _ in lines) + body = "\n".join(f" {key:<{col_width}} {value}" for key, value in lines) + return f"\n{body}\n" + + +def _fmt_money(value, decimals: int) -> str: + if value is None or pd.isna(value): + return f"{'n/a':>15}" + return f"$ {float(value):>13,.{decimals}f}" + + +def _fmt_pct(value, signed: bool, decimals: int) -> str: + if value is None or pd.isna(value): + return f"{'n/a':>15}" + sign = "+" if signed else "" + return f"{float(value):>{sign}13.{decimals}f}%" + + +def _fmt_float(value, decimals: int) -> str: + if value is None or pd.isna(value): + return f"{'n/a':>14}" + return f"{float(value):>14.{decimals}f}" + + +def _fmt_days(value) -> str: + if value is None or pd.isna(value): + return f"{'n/a':>16}" + return f"{int(value):>11d} days" + + +def _fmt_int(value) -> str: + if value is None or pd.isna(value): + return f"{'n/a':>14}" + return f"{int(value):>14,d}" + + +def _config_from_kwargs(**kwargs) -> EndpointConfig: + mode_name = str(kwargs.get("mode", "")).lower().strip() + metadata = dict(kwargs.pop("metadata", {}) or {}) + if "tick_size" in kwargs: + metadata.setdefault("tick_size", kwargs.pop("tick_size")) + if "source_timezone" in kwargs: + metadata.setdefault("source_timezone", kwargs.pop("source_timezone")) + if "missing_funding_policy" in kwargs: + metadata.setdefault("missing_funding_policy", kwargs.pop("missing_funding_policy")) + if "bar_timestamp_semantics" in kwargs: + metadata.setdefault("bar_timestamp_semantics", kwargs.pop("bar_timestamp_semantics")) + hedge_type_alias = kwargs.pop("hedge_type", None) + if hedge_type_alias is not None and "sizing" not in kwargs: + kwargs["sizing"] = hedge_type_alias + + initial_capital = kwargs.pop("initial_capital", None) + leverage = kwargs.pop("leverage", None) + maintenance_ratio = kwargs.pop("maintenance_ratio", None) + account = kwargs.pop("account", None) + if account is None: + account = AccountConfig( + initial_capital=100_000.0 if initial_capital is None else float(initial_capital), + leverage=1.0 if leverage is None else float(leverage), + maintenance_ratio=0.005 if maintenance_ratio is None else float(maintenance_ratio), + ) + + legacy_slippage_supplied = "slippage" in kwargs + legacy_slippage_value = kwargs.get("slippage") + slippage_bps = kwargs.pop("slippage_bps", None) + execution = kwargs.pop("execution", None) + if slippage_bps is not None and execution is not None: + raise ValueError("pass either execution=ExecutionConfig(...) or slippage_bps=..., not both") + if slippage_bps is not None and legacy_slippage_supplied: + raise ValueError("pass either slippage_bps or legacy slippage, not both") + if execution is None: + if slippage_bps is not None: + execution = ExecutionConfig(slippage_bps=float(slippage_bps)) + elif mode_name in {"intrabar_bracket", "intrabar_bracket_reference", "portfolio"} and legacy_slippage_supplied: + warnings.warn( + "QuantBT native endpoints use slippage_bps as the source of truth; " + "legacy slippage was converted to slippage_bps for compatibility.", + DeprecationWarning, + stacklevel=3, + ) + execution = ExecutionConfig(slippage_bps=float(legacy_slippage_value) * 10_000.0) + else: + execution = ExecutionConfig(slippage_bps=0.0) + + dca_kwargs = kwargs.pop("dca_kwargs", {}) + for key in ( + "dca_base_notional", + "dca_safety_notional", + "dca_step_pct", + "dca_step_scale", + "dca_volume_scale", + "dca_max_safety_orders", + "dca_take_profit_pct", + "dca_allow_same_bar_exit", + ): + if key in kwargs: + dca_kwargs[key] = kwargs.pop(key) + + return EndpointConfig(account=account, execution=execution, dca_kwargs=dca_kwargs, metadata=metadata, **kwargs) + + +def _pop_dataclass_kwargs(kwargs: Dict, dataclass_type) -> Dict: + fields = getattr(dataclass_type, "__dataclass_fields__", {}) + out = {} + for key in list(fields): + if key == "metadata": + continue + if key in kwargs: + out[key] = kwargs.pop(key) + return out + + +def _resolve_backend(config: EndpointConfig) -> str: + backend = config.backend.lower().strip() + if backend != "auto": + if backend == "legacy_portfolio": + return backend + if backend not in {"legacy", "native_vectorized", "native_event", "native_portfolio", "native_option", "nautilus"}: + raise ValueError(f"unsupported backend={config.backend!r}") + return backend + mode = config.mode.lower().strip() + sizing = config.sizing.lower().strip() + if mode == "portfolio": + return "native_portfolio" + if mode == "options": + return "native_option" + if mode in ("pct_equity", "dca_ladder") or sizing in ("%_equity", "pct_equity", "dca_ladder", "dca"): + return "legacy" + if mode == "nautilus_validation": + return "nautilus" + if mode in ("orders", "basket", "arbitrage"): + return "native_event" + return "native_vectorized" + + +def _default_walkforward_scoring_backend(target_mode: str, optimization_mode: str) -> str: + mode = str(target_mode).lower().strip() + opt_mode = str(optimization_mode).lower().strip() + if opt_mode == "mode_2_sbb": + return "proxy" + if mode in {"pct_equity", "%_equity", "signal_notional", "single_signal", "dca_ladder"}: + return "endpoint" + return "proxy" + + +def _make_walkforward_endpoint_scorer( + config: EndpointConfig, + target_mode: str, + symbols=None, + wf_config: Optional[WalkForwardConfig] = None, + market_data=None, + market_closes=None, + market_highs=None, + market_lows=None, + market_datetime_index=None, +): + return _WalkForwardEndpointScorer( + config=config, + target_mode=target_mode, + symbols=symbols, + wf_config=wf_config, + market_data=market_data, + market_closes=market_closes, + market_highs=market_highs, + market_lows=market_lows, + market_datetime_index=market_datetime_index, + ) + + +@dataclass +class QuantBTPreparedContext: + """ + Run-local prepared market context for repeated endpoint replays. + + The context stores copied prepared market arrays and validates datetime / + symbol signatures inside the backend on every replay. It is intentionally + caller-owned and never a mutable global cache. + """ + + endpoint: QuantBTEndpoint + mode: str + idx: pd.DatetimeIndex + symbols: list + close_map: SeriesMap + high_map: SeriesMap + low_map: SeriesMap + market_arrays: object + backend: object + frame: Optional[pd.DataFrame] = None + runs: int = 0 + + @classmethod + def from_endpoint( + cls, + endpoint: QuantBTEndpoint, + *, + data=None, + closes=None, + highs=None, + lows=None, + datetime_index=None, + symbols=None, + ) -> "QuantBTPreparedContext": + config = endpoint.config + backend_name = _resolve_backend(config) + mode = config.mode.lower().strip() + sizing = config.sizing.lower().strip() + + if mode in {"single_signal", "signal_notional"} and backend_name == "native_vectorized" and sizing in {"signal_notional", "signal"}: + frame = _standardize_frame(data, datetime_index=datetime_index) + symbol_list = list(symbols or config.symbols or ["DEFAULT"]) + if len(symbol_list) != 1: + raise ValueError("single-symbol prepared context requires exactly one symbol") + symbol = symbol_list[0] + close_map = {symbol: frame["close"]} + high_map = {symbol: frame.get("high", frame["close"])} + low_map = {symbol: frame.get("low", frame["close"])} + backend = NativeVectorizedBackend( + NativeVectorizedConfig( + account=config.account, + execution=config.execution, + fee_rate=config.v2_fee_rate, + use_funding=bool(config.use_funding), + ) + ) + market = backend.prepare_market_arrays( + datetime_index=frame.index, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=config.funding_rate, + symbols=symbol_list, + ) + return cls( + endpoint=endpoint, + mode="single_signal_notional", + idx=frame.index, + symbols=symbol_list, + close_map=close_map, + high_map=high_map, + low_map=low_map, + market_arrays=market, + backend=backend, + frame=frame, + ) + + if mode == "portfolio" and backend_name == "native_portfolio": + close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data( + data=data, + closes=closes, + highs=highs, + lows=lows, + datetime_index=datetime_index, + symbols=symbols or config.symbols, + ) + fee_oneway = config.canonical_one_way_fee_rate + backend = NativePortfolioBackend( + NativePortfolioConfig( + account=config.account, + execution=config.execution, + fee_rate=fee_oneway, + use_funding=bool(config.use_funding), + report_level=config.report_level, + ) + ) + market = backend.prepare_market_arrays( + datetime_index=idx, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=config.funding_rate, + symbols=symbol_list, + ) + return cls( + endpoint=endpoint, + mode="portfolio", + idx=idx, + symbols=list(symbol_list), + close_map=close_map, + high_map=high_map, + low_map=low_map, + market_arrays=market, + backend=backend, + ) + + raise NotImplementedError( + "prepared service context currently supports native_vectorized signal_notional " + "and native_portfolio only; use normal backtest(...) for this endpoint" + ) + + @property + def metadata(self) -> Dict[str, object]: + return { + "mode": self.mode, + "symbols": tuple(self.symbols), + "bars": int(len(self.idx)), + "runs": int(self.runs), + "market_signature": self.market_arrays.signature, + } + + def backtest(self, *, signal=None, signal_col: Optional[str] = None, positions=None): + """Replay a new signal or position matrix on the prepared market tape.""" + if self.mode == "single_signal_notional": + result = self._run_single(signal=signal, signal_col=signal_col) + elif self.mode == "portfolio": + result = self._run_portfolio(positions=positions) + else: # pragma: no cover - guarded by constructor + raise NotImplementedError(f"unsupported prepared context mode={self.mode!r}") + self.runs += 1 + result.metadata.setdefault("prepared_service_context", self.metadata) + self.endpoint._store_result(result) + return result + + simulate = backtest + + def _run_single(self, *, signal=None, signal_col: Optional[str] = None): + config = self.endpoint.config + if signal is None: + signal = _signal_from_data(self.frame, signal_col) + if signal is None: + raise ValueError("prepared single-symbol context requires signal or signal_col") + raw = _series_to_raw_matrix(signal, self.idx) + symbol = self.symbols[0] + return self.backend.run_signals( + datetime_index=self.idx, + positions={symbol: pd.Series(0.0, index=self.idx)}, + closes=self.close_map, + highs=self.high_map, + lows=self.low_map, + funding_rate=config.funding_rate, + contract_size=config.contract_size, + leverage=config.account.leverage, + alloc_per_trade=config.alloc_per_trade, + hedge_type=config.sizing, + use_pyramiding=config.use_pyramiding, + symbols=self.symbols, + market_arrays=self.market_arrays, + raw_signal_matrix=raw, + instruments=config.instruments, + qty_step=config.qty_step, + lot_size=config.lot_size, + slot_size=config.slot_size, + min_qty=config.min_qty, + min_notional=config.min_notional, + ) + + def _run_portfolio(self, *, positions=None): + if positions is None: + raise ValueError("prepared portfolio context requires positions") + config = self.endpoint.config + raw = _positions_to_raw_matrix(positions, self.idx, self.symbols) + return self.backend.run_signals( + positions=None, + closes=self.close_map, + highs=self.high_map, + lows=self.low_map, + datetime_index=self.idx, + mode=config.portfolio_mode, + alloc_per_trade=config.alloc_per_trade, + contract_size=config.contract_size, + hedge_type=config.sizing if config.sizing else "notional", + funding_rate=config.funding_rate, + leverage=config.account.leverage, + maintenance_ratio=config.account.maintenance_ratio, + asset_type=config.asset_type, + use_pyramiding=config.use_pyramiding, + betas=config.betas, + risk_lookback=config.risk_lookback, + market_arrays=self.market_arrays, + raw_signal_matrix=raw, + instruments=config.instruments, + qty_step=config.qty_step, + lot_size=config.lot_size, + slot_size=config.slot_size, + min_qty=config.min_qty, + min_notional=config.min_notional, + report_level=config.report_level, + ) + + +class _WalkForwardEndpointScorer: + """ + Endpoint-backed WFO scorer with run-local prepared market array reuse. + + The cache is intentionally scoped to one scorer instance, which is created + for one `QuantBTEndpoint.backtest(...)` call. It never caches by pandas + object identity and every prepared reuse is validated by backend signatures. + """ + + def __init__( + self, + config: EndpointConfig, + target_mode: str, + symbols=None, + wf_config: Optional[WalkForwardConfig] = None, + market_data=None, + market_closes=None, + market_highs=None, + market_lows=None, + market_datetime_index=None, + ): + self.config = config + self.target_mode = str(target_mode).lower().strip() + self.score_config = _walkforward_scoring_config(config, self.target_mode) + self.symbols = None if symbols is None and config.symbols is None else list(symbols or config.symbols or []) + self.wf_config = wf_config + self.market_data = market_data + self.market_closes = market_closes + self.market_highs = market_highs + self.market_lows = market_lows + self.market_datetime_index = market_datetime_index + self.use_prepared_cache = bool((wf_config.metadata if wf_config is not None else {}).get("use_prepared_scoring_cache", True)) + self.prepared_scoring_report_level = str( + (wf_config.metadata if wf_config is not None else {}).get("prepared_scoring_report_level", "minimal") + ) + self._single_backend = None + self._single_market_maps = {} + self._single_market_cache = {} + self._portfolio_backend = None + self._portfolio_market_maps = {} + self._portfolio_market_cache = {} + self._stats = { + "enabled": bool(self.use_prepared_cache), + "target_mode": self.target_mode, + "backend": self.score_config.backend, + "market_cache_hits": 0, + "market_cache_misses": 0, + "market_cache_entries": 0, + "prepared_runs": 0, + "fallback_runs": 0, + } + + def __call__(self, data, output, index, fold, params, context: str, trading_days: int) -> Dict[str, float]: + try: + if self._can_score_single_vectorized_prepared(output): + result = self._score_single_vectorized_prepared(output=output, index=index) + elif self._can_score_portfolio_prepared(output): + result = self._score_portfolio_prepared(output=output, index=index) + else: + result = self._score_fallback(data=data, output=output, index=index) + report = result.full_report(trading_days=trading_days, scope="full") + except Exception as exc: + raise RuntimeError( + "walk-forward endpoint scoring failed during " + f"{context} for fold_id={fold.fold_id}; target_mode={self.target_mode!r}; params={params}" + ) from exc + return { + "sharpe": float(report.get("sharpe", 0.0)), + "turnover": float(report.get("num_trades", 0.0)), + "trade_count": float(report.get("num_trades", 0.0)), + "mean_return": float(report.get("total_return_pct", 0.0)) / 100.0, + "volatility": 0.0, + "max_drawdown_pct": float(report.get("max_drawdown_pct", 0.0)), + "profit_factor": float(report.get("profit_factor", 0.0)), + } + + def prepared_cache_metadata(self) -> Dict[str, object]: + meta = dict(self._stats) + meta["market_cache_entries"] = len(self._portfolio_market_cache) + len(self._single_market_cache) + meta["prepared_scoring_report_level"] = self.prepared_scoring_report_level + meta["available"] = ( + self._prepared_single_available() + or (self.target_mode == "portfolio" and self.score_config.backend == "native_portfolio") + ) + return meta + + def _prepared_single_available(self) -> bool: + return ( + self.score_config.mode == "signal_notional" + and _resolve_backend(self.score_config) == "native_vectorized" + ) + + def _can_score_single_vectorized_prepared(self, output) -> bool: + return ( + self.use_prepared_cache + and self._prepared_single_available() + and isinstance(output, pd.Series) + ) + + def _can_score_portfolio_prepared(self, output) -> bool: + return ( + self.use_prepared_cache + and self.score_config.mode == "portfolio" + and self.score_config.backend == "native_portfolio" + and isinstance(output, (pd.DataFrame, dict)) + ) + + def _score_fallback(self, data, output, index): + self._stats["fallback_runs"] += 1 + temp = QuantBTEndpoint(self.score_config) + sliced_data = _slice_wf_data_to_index(data, index) + symbol_list = self._symbol_list(output) + if self.score_config.mode == "portfolio": + return temp.backtest(data=sliced_data, positions=output, symbols=symbol_list) + return temp.backtest(data=sliced_data, signal=output, symbols=symbol_list) + + def _score_single_vectorized_prepared(self, output: pd.Series, index): + idx = _ensure_utc_index(index) + symbol_list = self._symbol_list(output) + close_map, high_map, low_map = self._single_maps(symbol_list) + backend = self._single_backend_instance() + cache_key = self._market_cache_key(idx, symbol_list) + market = self._single_market_cache.get(cache_key) + if market is None: + market = backend.prepare_market_arrays( + datetime_index=idx, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=self.score_config.funding_rate, + symbols=symbol_list, + ) + self._single_market_cache[cache_key] = market + self._stats["market_cache_misses"] += 1 + else: + self._stats["market_cache_hits"] += 1 + + self._stats["prepared_runs"] += 1 + return backend.run_signals( + positions={symbol_list[0]: output}, + closes=close_map, + highs=high_map, + lows=low_map, + datetime_index=idx, + funding_rate=self.score_config.funding_rate, + contract_size=self.score_config.contract_size, + leverage=self.score_config.account.leverage, + alloc_per_trade=self.score_config.alloc_per_trade, + hedge_type=self.score_config.sizing, + use_pyramiding=self.score_config.use_pyramiding, + symbols=symbol_list, + market_arrays=market, + instruments=self.score_config.instruments, + qty_step=self.score_config.qty_step, + lot_size=self.score_config.lot_size, + slot_size=self.score_config.slot_size, + min_qty=self.score_config.min_qty, + min_notional=self.score_config.min_notional, + ) + + def _score_portfolio_prepared(self, output, index): + idx = _ensure_utc_index(index) + symbol_list = self._symbol_list(output) + close_map, high_map, low_map = self._portfolio_maps(symbol_list) + backend = self._portfolio_backend_instance() + cache_key = self._market_cache_key(idx, symbol_list) + market = self._portfolio_market_cache.get(cache_key) + if market is None: + market = backend.prepare_market_arrays( + datetime_index=idx, + closes=close_map, + highs=high_map, + lows=low_map, + funding_rate=self.score_config.funding_rate, + symbols=symbol_list, + ) + self._portfolio_market_cache[cache_key] = market + self._stats["market_cache_misses"] += 1 + else: + self._stats["market_cache_hits"] += 1 + + pos_map = _positions_to_map(output) + raw_signals = NativePortfolioBackend.prepare_signal_matrix(pos_map, idx, symbol_list) + self._stats["prepared_runs"] += 1 + return backend.run_signals( + positions=None, + closes=close_map, + highs=high_map, + lows=low_map, + datetime_index=idx, + mode=self.score_config.portfolio_mode, + alloc_per_trade=self.score_config.alloc_per_trade, + contract_size=self.score_config.contract_size, + hedge_type=self.score_config.sizing if self.score_config.sizing else "notional", + funding_rate=self.score_config.funding_rate, + leverage=self.score_config.account.leverage, + maintenance_ratio=self.score_config.account.maintenance_ratio, + asset_type=self.score_config.asset_type, + use_pyramiding=self.score_config.use_pyramiding, + betas=self.score_config.betas, + risk_lookback=self.score_config.risk_lookback, + market_arrays=market, + raw_signal_matrix=raw_signals, + instruments=self.score_config.instruments, + qty_step=self.score_config.qty_step, + lot_size=self.score_config.lot_size, + slot_size=self.score_config.slot_size, + min_qty=self.score_config.min_qty, + min_notional=self.score_config.min_notional, + report_level=self.prepared_scoring_report_level, + ) + + def _single_backend_instance(self) -> NativeVectorizedBackend: + if self._single_backend is None: + self._single_backend = NativeVectorizedBackend( + NativeVectorizedConfig( + account=self.score_config.account, + execution=self.score_config.execution, + fee_rate=self.score_config.v2_fee_rate, + use_funding=bool(self.score_config.use_funding), + ) + ) + return self._single_backend + + def _portfolio_backend_instance(self) -> NativePortfolioBackend: + if self._portfolio_backend is None: + fee_oneway = self.score_config.canonical_one_way_fee_rate + self._portfolio_backend = NativePortfolioBackend( + NativePortfolioConfig( + account=self.score_config.account, + execution=self.score_config.execution, + fee_rate=fee_oneway, + use_funding=bool(self.score_config.use_funding), + report_level=self.prepared_scoring_report_level, + ) + ) + return self._portfolio_backend + + def _single_maps(self, symbol_list): + key = tuple(symbol_list) + if key not in self._single_market_maps: + if self.market_closes is not None or isinstance(self.market_data, dict): + close_map, high_map, low_map, _idx, _symbols = _normalize_symbol_data( + data=self.market_data, + closes=self.market_closes, + highs=self.market_highs, + lows=self.market_lows, + datetime_index=self.market_datetime_index, + symbols=symbol_list, + ) + else: + frame = _standardize_frame(self.market_data, datetime_index=self.market_datetime_index) + symbol = symbol_list[0] + close_map = {symbol: frame["close"]} + high_map = {symbol: frame.get("high", frame["close"])} + low_map = {symbol: frame.get("low", frame["close"])} + self._single_market_maps[key] = (close_map, high_map, low_map) + return self._single_market_maps[key] + + def _portfolio_maps(self, symbol_list): + key = tuple(symbol_list) + if key not in self._portfolio_market_maps: + close_map, high_map, low_map, _idx, _symbols = _normalize_symbol_data( + data=self.market_data, + closes=self.market_closes, + highs=self.market_highs, + lows=self.market_lows, + datetime_index=self.market_datetime_index, + symbols=symbol_list, + ) + self._portfolio_market_maps[key] = (close_map, high_map, low_map) + return self._portfolio_market_maps[key] + + def _symbol_list(self, output) -> list: + if self.symbols: + return list(self.symbols) + if isinstance(output, pd.DataFrame): + return list(output.columns) + if isinstance(output, dict): + return list(output.keys()) + return ["DEFAULT"] + + @staticmethod + def _market_cache_key(index: pd.DatetimeIndex, symbols: Sequence[str]): + idx = _ensure_utc_index(index) + first = None if len(idx) == 0 else int(idx.asi8[0]) + last = None if len(idx) == 0 else int(idx.asi8[-1]) + return (tuple(symbols), int(len(idx)), first, last) + + +def _walkforward_scoring_config(config: EndpointConfig, target_mode: str) -> EndpointConfig: + mode = str(target_mode).lower().strip() + if mode in {"pct_equity", "%_equity"}: + return replace(config, mode="pct_equity", backend="legacy", sizing="%_equity") + if mode == "dca_ladder": + return replace(config, mode="dca_ladder", backend="legacy", sizing="dca_ladder") + if mode in {"signal_notional", "single_signal"}: + return replace(config, mode="signal_notional", backend=config.backend, sizing="signal_notional") + if mode == "portfolio": + return replace(config, mode="portfolio", backend="native_portfolio") + raise NotImplementedError(f"endpoint scoring is not implemented for walk-forward target_mode={target_mode!r}") + + +def _strict_lookup_frame(data, datetime_index=None, *, source_timezone: Optional[str] = None) -> pd.DataFrame: + if not isinstance(data, pd.DataFrame): + raise ValueError("intrabar endpoint requires a DataFrame when intent is not supplied explicitly") + frame = data.copy().rename( + columns={ + "Datetime": "timestamp", + "Date": "timestamp", + "Timestamp": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Volume": "volume", + } + ) + if datetime_index is not None: + frame.index = _endpoint_strict_index(datetime_index, source_timezone=source_timezone) + elif "timestamp" in frame.columns: + frame = frame.set_index(_endpoint_strict_index(frame["timestamp"], source_timezone=source_timezone)) + else: + frame.index = _endpoint_strict_index(frame.index, source_timezone=source_timezone) + return frame + + +def _endpoint_strict_index(value, *, source_timezone: Optional[str] = None) -> pd.DatetimeIndex: + raw = pd.DatetimeIndex(pd.to_datetime(value, errors="raise")) + if raw.tz is None: + if source_timezone is None: + raise ValueError("intrabar endpoint received timezone-naive data; pass metadata={'source_timezone': ...}") + raw = raw.tz_localize(source_timezone) + return raw.tz_convert("UTC") + + +def _execution_contract_from_config(config: EndpointConfig) -> ExecutionContract: + meta = config.metadata.get("execution_contract") + if meta: + return ExecutionContract.from_metadata(meta) + contract_id = str(config.metadata.get("execution_contract_id", "intrabar_bracket_v1")) + if contract_id == "intrabar_bracket_v1": + return ExecutionContract.intrabar_bracket() + return ExecutionContract.from_metadata({"engine_id": contract_id}) + + +def _session_policy_from_config(config: EndpointConfig) -> Optional[SessionExecutionPolicy]: + return SessionExecutionPolicy.from_metadata(config.metadata.get("session_policy")) + + +def _tick_size_for_symbol(instruments, symbol: str, default: float = 0.0) -> float: + if isinstance(default, dict): + fallback = float(default.get(symbol, 0.0)) + else: + fallback = float(default or 0.0) + if instruments is None: + return fallback + if isinstance(instruments, dict): + inst = instruments.get(symbol) + return fallback if inst is None else float(getattr(inst, "tick_size", fallback)) + for inst in instruments: + if getattr(inst, "symbol", None) == symbol: + return float(getattr(inst, "tick_size", fallback)) + return fallback + + +def _prepared_profile_signature(data_signature: str, profile: Dict) -> str: + payload = dict(profile) + payload["data_signature"] = data_signature + raw = json.dumps(_jsonable(payload), sort_keys=True, default=str).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def _intrabar_intent_from_endpoint_input( + *, + frame: Optional[pd.DataFrame], + index: pd.DatetimeIndex, + signal, + signal_col: Optional[str], + intent_cols: Dict[str, str], + level_mode: IntrabarLevelMode, +) -> IntrabarIntentTape: + signed_signal = None + if signal is not None: + signed_signal = _strict_series_values(signal, index, name="signal") + elif signal_col is not None: + signed_signal = _strict_frame_col_values(frame, index, signal_col, dtype=float) + elif "entry_signal" in intent_cols: + signed_signal = _strict_frame_col_values(frame, index, intent_cols["entry_signal"], dtype=float) + elif "signal" in intent_cols: + signed_signal = _strict_frame_col_values(frame, index, intent_cols["signal"], dtype=float) + + if "entry_side" in intent_cols: + entry_side = np.sign(_strict_frame_col_values(frame, index, intent_cols["entry_side"], dtype=float)).astype(np.int8) + elif signed_signal is not None: + entry_side = np.sign(signed_signal).astype(np.int8) + else: + raise ValueError("intrabar endpoint requires signal/signal_col or intent_cols['entry_side']") + + if "entry_size" in intent_cols: + entry_size = np.abs(_strict_frame_col_values(frame, index, intent_cols["entry_size"], dtype=float)) + elif signed_signal is not None: + entry_size = np.abs(signed_signal) + else: + raise ValueError("intrabar endpoint requires intent_cols['entry_size'] when no signed signal is supplied") + + return IntrabarIntentTape.from_arrays( + entry_side=entry_side, + entry_size=entry_size, + stop_value=_optional_intent_col(frame, index, intent_cols, "stop_value"), + take_profit_value=_optional_intent_col(frame, index, intent_cols, "take_profit_value"), + trailing_value=_optional_intent_col(frame, index, intent_cols, "trailing_value"), + technical_exit=_optional_intent_col(frame, index, intent_cols, "technical_exit", dtype=bool), + exit_long=_optional_intent_col(frame, index, intent_cols, "exit_long", dtype=bool), + exit_short=_optional_intent_col(frame, index, intent_cols, "exit_short", dtype=bool), + level_mode=level_mode, + ) + + +def _strict_series_values(series, index: pd.DatetimeIndex, *, name: str) -> np.ndarray: + if not isinstance(series, pd.Series): + series = pd.Series(series, index=index) + s = series.copy() + s.index = pd.DatetimeIndex(pd.to_datetime(s.index, errors="raise", utc=True)) + if not s.index.equals(index): + raise ValueError(f"{name} index must exactly match the strict market tape index") + return pd.to_numeric(s, errors="raise").to_numpy(dtype=np.float64) + + +def _strict_frame_col_values(frame: Optional[pd.DataFrame], index: pd.DatetimeIndex, col: str, *, dtype=float) -> np.ndarray: + if frame is None: + raise ValueError(f"intent column {col!r} requires DataFrame data") + if col not in frame.columns: + raise ValueError(f"intent column {col!r} not found in data") + if not pd.DatetimeIndex(frame.index).equals(index): + raise ValueError(f"intent column {col!r} index must exactly match the strict market tape index") + if dtype is bool: + return frame[col].fillna(False).astype(bool).to_numpy(dtype=np.bool_) + return pd.to_numeric(frame[col], errors="raise").to_numpy(dtype=np.float64) + + +def _optional_intent_col(frame: Optional[pd.DataFrame], index: pd.DatetimeIndex, cols: Dict[str, str], key: str, *, dtype=float): + col = cols.get(key) + if col is None: + return None + return _strict_frame_col_values(frame, index, col, dtype=dtype) + + +def _scalar_for_symbol(value, symbol: str, default: float = 1.0) -> float: + if isinstance(value, dict): + return float(value.get(symbol, default)) + return float(default if value is None else value) + + +def _intrabar_fills_to_frame(fills) -> pd.DataFrame: + rows = [] + for fill in fills: + rows.append( + { + "bar_index": int(fill.bar_index), + "sequence": int(fill.sequence), + "timestamp": pd.Timestamp(fill.timestamp), + "side": int(fill.side), + "qty": float(fill.qty), + "price": float(fill.price), + "fee": float(fill.fee), + "reason": fill.reason.value if hasattr(fill.reason, "value") else str(fill.reason), + } + ) + return pd.DataFrame(rows) + + +def _slice_wf_data_to_index(data, index: pd.DatetimeIndex): + if isinstance(data, pd.DataFrame): + return data.reindex(index).copy() + if isinstance(data, pd.Series): + return data.reindex(index).copy() + if isinstance(data, dict): + out = {} + for key, value in data.items(): + if isinstance(value, (pd.DataFrame, pd.Series)): + out[key] = value.reindex(index).copy() + else: + out[key] = value + return out + return data + + +def _normalize_single_data(data, signal, signal_col, datetime_index): + if data is None: + raise ValueError("single-symbol endpoint requires data DataFrame") + frame = _standardize_frame(data, datetime_index) + sig = signal if signal is not None else _signal_from_data(frame, signal_col) + if sig is None: + raise ValueError("single-symbol endpoint requires signal or signal_col") + sig = sig.copy() + if isinstance(sig.index, pd.DatetimeIndex): + sig.index = sig.index.tz_localize("UTC") if sig.index.tz is None else sig.index.tz_convert("UTC") + sig = sig[~sig.index.duplicated(keep="first")].reindex(frame.index, method="ffill").fillna(0.0) + return frame, frame.index, sig + + +def _intrabar_marker_columns(frame: pd.DataFrame) -> list[str]: + markers = { + "exit_price", + "exit_type", + "stop_loss", + "stoploss", + "sl", + "take_profit", + "takeprofit", + "tp", + "trailing", + "trailing_stop", + "use_sl", + "use_tp", + "slpercent", + "tppercent", + } + found = [] + for col in frame.columns: + key = str(col).lower() + if key in markers or "trailing" in key or "stop_loss" in key or "take_profit" in key: + found.append(str(col)) + return found + + +def _normalize_symbol_data(data, closes, highs, lows, datetime_index, symbols): + if closes is not None: + symbol_list = list(symbols or closes.keys()) + idx = pd.DatetimeIndex(datetime_index if datetime_index is not None else closes[symbol_list[0]].index) + idx = _ensure_utc_index(idx) + close_map = {s: _align_series(closes[s], idx) for s in symbol_list} + high_map = {s: _align_series((highs or closes)[s], idx) for s in symbol_list} + low_map = {s: _align_series((lows or closes)[s], idx) for s in symbol_list} + return close_map, high_map, low_map, idx, symbol_list + if not isinstance(data, dict): + raise ValueError("multi-symbol endpoint requires data dict or explicit closes") + symbol_list = list(symbols or data.keys()) + frames = {s: _standardize_frame(data[s], datetime_index=None) for s in symbol_list} + idx = _ensure_utc_index(datetime_index if datetime_index is not None else frames[symbol_list[0]].index) + close_map = {s: _align_series(frames[s]["close"], idx) for s in symbol_list} + high_map = {s: _align_series(frames[s].get("high", frames[s]["close"]), idx) for s in symbol_list} + low_map = {s: _align_series(frames[s].get("low", frames[s]["close"]), idx) for s in symbol_list} + return close_map, high_map, low_map, idx, symbol_list + + +def _frames_from_symbol_maps(close_map, high_map, low_map, symbols) -> FrameMap: + frames = {} + for symbol in symbols: + close = close_map[symbol] + frames[symbol] = pd.DataFrame( + { + "open": close, + "high": high_map.get(symbol, close), + "low": low_map.get(symbol, close), + "close": close, + "volume": 0.0, + }, + index=close.index, + ) + return frames + + +def _prepared_native_event_open_volume_arrays(data, idx: pd.DatetimeIndex, symbols, close_map) -> tuple[np.ndarray, np.ndarray]: + open_cols = [] + volume_cols = [] + for symbol in symbols: + close = close_map[symbol] + if isinstance(data, dict) and symbol in data and isinstance(data[symbol], pd.DataFrame): + frame = _standardize_frame(data[symbol], datetime_index=None) + open_cols.append(_align_series(frame.get("open", frame["close"]), idx).to_numpy(dtype=np.float64)) + volume_cols.append(_align_series(frame.get("volume", pd.Series(0.0, index=frame.index)), idx).to_numpy(dtype=np.float64)) + else: + open_cols.append(close.to_numpy(dtype=np.float64)) + volume_cols.append(np.zeros(len(idx), dtype=np.float64)) + return ( + np.ascontiguousarray(np.column_stack(open_cols), dtype=np.float64), + np.ascontiguousarray(np.column_stack(volume_cols), dtype=np.float64), + ) + + +def _empty_nautilus_preflight_result(data, symbols, account: AccountConfig, metadata: Dict) -> BacktestResultV2: + symbol_list = list(symbols) + if not symbol_list: + raise ValueError("symbols are required for empty Nautilus preflight result") + first = data[symbol_list[0]] + idx = _ensure_utc_index(first.index) + equity = pd.Series(float(account.initial_capital), index=idx, name="equity") + returns = pd.Series(0.0, index=idx, name="returns") + positions = pd.DataFrame({f"Position_{symbol}": 0.0 for symbol in symbol_list}, index=idx) + closes = {} + for symbol in symbol_list: + frame = data[symbol] + close = frame["close"] if "close" in frame else frame["Close"] + close = close.copy() + close.index = _ensure_utc_index(close.index) + closes[f"Close_{symbol}"] = close.reindex(idx).ffill().bfill().astype(float) + return BacktestResultV2( + equity=equity, + returns=returns, + positions=positions, + closes=pd.DataFrame(closes, index=idx), + symbols=symbol_list, + initial_capital=float(account.initial_capital), + leverage=float(account.leverage), + metadata=dict(metadata), + ) + + +def _annotate_orders_for_depth(orders: Sequence[OrderIntent], params: Dict) -> tuple[OrderIntent, ...]: + package_type = params.get("package_type") or params.get("input_mode") + package_id = params.get("package_id") or params.get("basket_id") or params.get("arb_id") + if package_type is None and package_id is None: + return tuple(orders) + out = [] + for order in orders: + metadata = dict(order.metadata) + if package_type is not None: + metadata.setdefault("package_type", str(package_type)) + if package_id is not None: + metadata.setdefault("package_id", str(package_id)) + out.append(replace(order, metadata=metadata)) + return tuple(out) + + +def _standardize_frame(data, datetime_index=None) -> pd.DataFrame: + if not isinstance(data, pd.DataFrame): + raise ValueError("data must be a pandas DataFrame") + frame = data.copy() + frame = frame.rename( + columns={ + "Datetime": "timestamp", + "Date": "timestamp", + "Timestamp": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Volume": "volume", + } + ) + if datetime_index is not None: + frame.index = _ensure_utc_index(datetime_index) + elif "timestamp" in frame.columns: + frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True, errors="coerce") + frame = frame.dropna(subset=["timestamp"]).set_index("timestamp") + else: + frame.index = _ensure_utc_index(frame.index) + frame = frame[~frame.index.duplicated(keep="first")].sort_index() + if "close" not in frame.columns: + raise ValueError("data must contain close/Close") + for col in ("high", "low"): + if col not in frame.columns: + frame[col] = frame["close"] + if "open" not in frame.columns: + frame["open"] = frame["close"] + if "volume" not in frame.columns: + frame["volume"] = 0.0 + return frame + + +def _signal_from_data(data, signal_col): + if signal_col is None: + return None + if data is None or signal_col not in data: + raise ValueError(f"signal_col={signal_col!r} not found in data") + return data[signal_col] + + +def _positions_to_map(positions) -> Dict[str, pd.Series]: + if positions is None: + return {} + if isinstance(positions, pd.DataFrame): + return {str(col): positions[col] for col in positions.columns} + return dict(positions) + + +def _series_to_raw_matrix(signal, idx: pd.DatetimeIndex) -> np.ndarray: + if isinstance(signal, pd.Series): + ser = signal + else: + ser = pd.Series(signal, index=idx) + if _series_index_matches(ser, idx): + values = ser.to_numpy(dtype=np.float64, copy=True) + else: + values = _align_series(ser, idx).fillna(0.0).to_numpy(dtype=np.float64, copy=True) + return np.ascontiguousarray(values.reshape(-1, 1), dtype=np.float64) + + +def _positions_to_raw_matrix(positions, idx: pd.DatetimeIndex, symbols: Sequence[str]) -> np.ndarray: + symbol_list = list(symbols) + if isinstance(positions, pd.DataFrame) and all(symbol in positions.columns for symbol in symbol_list): + frame = positions.loc[:, symbol_list] + if _frame_index_matches(frame, idx): + return np.ascontiguousarray(frame.to_numpy(dtype=np.float64, copy=True), dtype=np.float64) + elif isinstance(positions, dict): + exact = True + cols = [] + for symbol in symbol_list: + series = positions.get(symbol) + if not isinstance(series, pd.Series) or not _series_index_matches(series, idx): + exact = False + break + cols.append(series.to_numpy(dtype=np.float64, copy=True)) + if exact: + return np.ascontiguousarray(np.column_stack(cols), dtype=np.float64) + + pos_map = _positions_to_map(positions) + return NativePortfolioBackend.prepare_signal_matrix(pos_map, idx, symbol_list) + + +def _series_index_matches(series: pd.Series, idx: pd.DatetimeIndex) -> bool: + if not isinstance(series.index, pd.DatetimeIndex) or len(series.index) != len(idx): + return False + return bool(np.array_equal(_ensure_utc_index(series.index).asi8, idx.asi8)) + + +def _frame_index_matches(frame: pd.DataFrame, idx: pd.DatetimeIndex) -> bool: + if not isinstance(frame.index, pd.DatetimeIndex) or len(frame.index) != len(idx): + return False + return bool(np.array_equal(_ensure_utc_index(frame.index).asi8, idx.asi8)) + + +def _build_portfolio_orders_for_nautilus( + datetime_index, + positions: Dict[str, pd.Series], + closes: Dict[str, pd.Series], + alloc_per_trade, + hedge_type: str, + use_pyramiding: bool, + symbols, +) -> tuple[tuple[OrderIntent, ...], pd.DataFrame]: + ht = str(hedge_type).lower().strip() + if ht in {"%_equity", "pct_equity", "dca_ladder", "dca"}: + raise NotImplementedError( + "Nautilus portfolio validation currently supports pre-scalable modes " + "('signal_notional', 'notional', 'unit'). Use native portfolio for " + f"hedge_type={hedge_type!r}." + ) + idx = _ensure_utc_index(datetime_index) + alloc = _alloc_map(alloc_per_trade, symbols) + orders = [] + target_cols = {} + for symbol in symbols: + signal = _align_series(positions[symbol], idx) + close = _align_series(closes[symbol], idx) + target = compute_target_units( + hedge_type=hedge_type, + signal=signal, + close=close, + alloc=alloc[symbol], + use_pyramiding=use_pyramiding, + ).fillna(0.0) + target_cols[symbol] = target + prev = 0.0 + for ts, value in target.items(): + current = float(value) + delta = current - prev + if abs(delta) > 1e-12: + orders.append( + OrderIntent( + timestamp=ts, + symbol=symbol, + side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=abs(delta), + tif=TimeInForce.IOC, + tag=f"portfolio:{ht}", + metadata={ + "portfolio_mode": "matrix", + "target_units": current, + "previous_units": prev, + }, + ) + ) + prev = current + out = pd.DataFrame({symbol: target_cols[symbol] for symbol in symbols}, index=idx) + return tuple(sorted(orders, key=lambda order: pd.Timestamp(order.timestamp).value)), out + + +def _build_portfolio_orders_from_target_units_for_nautilus( + target_units: pd.DataFrame, + symbols, + tag: str, +) -> tuple[OrderIntent, ...]: + idx = _ensure_utc_index(target_units.index) + target = target_units.copy() + target.index = idx + orders = [] + for symbol in symbols: + if symbol not in target: + raise ValueError(f"target_units missing symbol {symbol!r}") + prev = 0.0 + for ts, value in target[symbol].fillna(0.0).items(): + current = float(value) + delta = current - prev + if abs(delta) > 1e-12: + orders.append( + OrderIntent( + timestamp=ts, + symbol=symbol, + side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=abs(delta), + tif=TimeInForce.IOC, + tag=tag, + metadata={ + "portfolio_mode": "matrix", + "target_units": current, + "previous_units": prev, + }, + ) + ) + prev = current + return tuple(sorted(orders, key=lambda order: (pd.Timestamp(order.timestamp).value, str(order.symbol)))) + + +def _alloc_map(value, symbols) -> Dict[str, float]: + if isinstance(value, dict): + return {symbol: float(value.get(symbol, 100_000.0)) for symbol in symbols} + return {symbol: float(value) for symbol in symbols} + + +def _print_order_logs(result, mode: str = "fills_only", limit: int = 500) -> None: + try: + from .reporting.nautilus_bundle import format_nautilus_event_log + + orders_report = result.metadata.get("orders_report") + if orders_report is None: + orders_report = result.metadata.get("order_report") + lines = format_nautilus_event_log( + fills_report=result.metadata.get("fills_report"), + orders_report=orders_report, + positions=getattr(result, "positions", None), + mode=mode, + limit=int(limit), + ) + except Exception as exc: + print(f"Order log unavailable: {type(exc).__name__}: {exc}") + return + for line in lines: + print(line) + + +def _infer_index(data, datetime_index): + if datetime_index is not None: + return _ensure_utc_index(datetime_index) + if isinstance(data, pd.DataFrame): + return _standardize_frame(data).index + raise ValueError("could not infer datetime index") + + +def _ensure_utc_index(index) -> pd.DatetimeIndex: + return pd.DatetimeIndex(pd.to_datetime(index, utc=True)) + + +def _align_series(series: pd.Series, idx: pd.DatetimeIndex) -> pd.Series: + ser = series.copy() + if isinstance(ser.index, pd.DatetimeIndex): + ser.index = ser.index.tz_localize("UTC") if ser.index.tz is None else ser.index.tz_convert("UTC") + return ser[~ser.index.duplicated(keep="first")].reindex(idx, method="ffill") diff --git a/src/quantbt/engines.py b/src/quantbt/engines.py new file mode 100644 index 0000000..4f20b46 --- /dev/null +++ b/src/quantbt/engines.py @@ -0,0 +1,1014 @@ +""" +Public V2 engine facades. + +These classes keep the old public API untouched while giving new notebooks a +single backend selector for native vectorized, native event-driven, and optional +Nautilus validation runs. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Dict, List, Optional, Sequence, Tuple, Union + +import pandas as pd + +from .backends import ( + NativeEventBackend, + NativeEventConfig, + NativeOptionBackend, + NativeOptionConfig, + NativePortfolioBackend, + NativePortfolioConfig, + NativeVectorizedBackend, + NativeVectorizedConfig, +) +from .core.orders import OrderAction, OrderCommand, OrderIntent, order_intents_to_lifecycle_commands +from .core.preprocessor import validate_datetime +from .core.results import BacktestResultV2, OptionBacktestResult +from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce +from .options.cache import OptionPreparedRunCache +from .options.hedging import OptionHedgeConfig +from .options.packages import OptionPackageIntent +from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec +from .options.strategy import OptionStrategyRun +from .portfolio import MultiSymbolPortfolio +from .sizing.modes import compute_target_units + + +SeriesMap = Dict[str, pd.Series] + + +class BacktestEngineV2: + """ + Backend-selecting facade for upgraded quantbt engines. + + Parameters can be supplied in a dataframe-oriented style (`data` and + `signals`) or an explicit dictionary style (`closes`, `highs`, `lows`, + `positions`, `target_units`, `orders`). + """ + + VALID_BACKENDS = {"native_vectorized", "native_event", "nautilus"} + + def __init__( + self, + data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]] = None, + signals: Optional[Union[pd.Series, SeriesMap]] = None, + backend: str = "native_vectorized", + native_backend: Optional[str] = None, + account: Optional[AccountConfig] = None, + execution: Optional[ExecutionConfig] = None, + fee_rate: float = 0.0, + use_funding: bool = True, + alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0, + hedge_type: str = "signal_notional", + use_pyramiding: bool = True, + positions: Optional[Union[pd.Series, SeriesMap]] = None, + target_units: Optional[Union[pd.Series, SeriesMap]] = None, + orders: Optional[Sequence[OrderIntent]] = None, + order_commands: Optional[Sequence[OrderCommand]] = None, + strategy=None, + event_engine_version: str = "v1", + reactive_execution_mode: str = "fast", + reactive_kernel_mode: str = "replay_certified", + report_level: str = "audit", + audit_sink: str = "memory", + audit_sink_path: Optional[str] = None, + datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None, + closes: Optional[SeriesMap] = None, + highs: Optional[SeriesMap] = None, + lows: Optional[SeriesMap] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + symbols: Optional[List[str]] = None, + basket: Optional[BasketSpec] = None, + signal: Optional[pd.Series] = None, + hedge_ratios: Optional[SeriesMap] = None, + nautilus_config=None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + auto_run: bool = True, + ): + self.backend = backend.lower().strip() + if self.backend not in self.VALID_BACKENDS: + raise ValueError(f"backend must be one of {sorted(self.VALID_BACKENDS)}") + + self.data = data + self.native_backend = native_backend + self.signals = signals + self.account = account or AccountConfig(initial_capital=100_000.0) + self.execution = execution or ExecutionConfig() + self.fee_rate = float(fee_rate) + self.use_funding = bool(use_funding) + self.alloc_per_trade = alloc_per_trade + self.hedge_type = hedge_type + self.use_pyramiding = use_pyramiding + self.positions = positions + self.target_units = target_units + self.orders = tuple(orders or ()) + self.order_commands = tuple(order_commands or ()) + self.strategy = strategy + self.event_engine_version = str(event_engine_version).lower().strip() + self.reactive_execution_mode = str(reactive_execution_mode).lower().strip() + self.reactive_kernel_mode = str(reactive_kernel_mode).lower().strip() + self.report_level = str(report_level) + self.audit_sink = str(audit_sink) + self.audit_sink_path = audit_sink_path + self.datetime_index = datetime_index + self.closes = closes + self.highs = highs + self.lows = lows + self.funding_rate = funding_rate + self.contract_size = contract_size + self.leverage = leverage + self.symbols = symbols + self.basket = basket + self.signal = signal + self.hedge_ratios = hedge_ratios + self.nautilus_config = nautilus_config + self.instruments = instruments + self.qty_step = qty_step + self.lot_size = lot_size + self.slot_size = slot_size + self.min_qty = min_qty + self.min_notional = min_notional + self.result: Optional[BacktestResultV2] = None + + if auto_run: + self.run() + + def run(self) -> BacktestResultV2: + if self.backend == "native_vectorized": + self.result = self._run_native_vectorized() + elif self.backend == "native_event": + self.result = self._run_native_event() + else: + self.result = self._run_nautilus() + return self.result + + def _run_native_vectorized(self) -> BacktestResultV2: + idx, closes, highs, lows, symbols = self._market_data() + backend = NativeVectorizedBackend( + NativeVectorizedConfig( + account=self.account, + execution=self.execution, + fee_rate=self.fee_rate, + use_funding=self.use_funding, + ) + ) + + if self.target_units is not None: + target_units = _as_series_map(self.target_units, symbols) + return backend.run_target_units( + datetime_index=idx, + target_units=target_units, + closes=closes, + highs=highs, + lows=lows, + funding_rate=self.funding_rate, + contract_size=self.contract_size, + leverage=self.leverage, + symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + ) + + raw_positions = self.positions if self.positions is not None else self.signals + if raw_positions is None: + raise ValueError("native_vectorized requires signals, positions, or target_units") + + return backend.run_signals( + datetime_index=idx, + positions=_as_series_map(raw_positions, symbols), + closes=closes, + highs=highs, + lows=lows, + funding_rate=self.funding_rate, + contract_size=self.contract_size, + leverage=self.leverage, + alloc_per_trade=self.alloc_per_trade, + hedge_type=self.hedge_type, + use_pyramiding=self.use_pyramiding, + symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + ) + + def _run_native_event(self) -> BacktestResultV2: + idx, closes, highs, lows, symbols = self._market_data() + backend = NativeEventBackend( + NativeEventConfig( + account=self.account, + execution=self.execution, + fee_rate=self.fee_rate, + use_funding=self.use_funding, + report_level=self.report_level, + audit_sink=self.audit_sink, + audit_sink_path=self.audit_sink_path, + reactive_kernel_mode=self.reactive_kernel_mode, + native_backend=self.native_backend, + ) + ) + + if self.strategy is not None: + opens, volumes = _market_open_volume( + data=self.data, + datetime_index=idx, + closes=closes, + symbols=symbols, + ) + return backend.run_strategy( + datetime_index=idx, + strategy=self.strategy, + closes=closes, + highs=highs, + lows=lows, + opens=opens, + volumes=volumes, + funding_rate=self.funding_rate, + contract_size=self.contract_size, + leverage=self.leverage, + fee_rate=self.fee_rate, + symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + execution_mode=self.reactive_execution_mode, + reactive_kernel_mode=self.reactive_kernel_mode, + report_level=self.report_level, + audit_sink=self.audit_sink, + audit_sink_path=self.audit_sink_path, + ) + + if self.basket is not None: + basket_signal = self.signal if self.signal is not None else _first_signal(self.signals) + if basket_signal is None: + raise ValueError("basket event backtest requires signal or signals") + return backend.run_basket( + datetime_index=idx, + basket=self.basket, + signal=basket_signal, + closes=closes, + highs=highs, + lows=lows, + hedge_ratios=self.hedge_ratios, + funding_rate=self.funding_rate, + contract_size=self.contract_size, + leverage=self.leverage, + symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + ) + + if self.order_commands or self.event_engine_version in {"v2", "event_v2", "lifecycle", "lifecycle_v2"}: + commands = self.order_commands + if not commands and self.orders: + commands = order_intents_to_lifecycle_commands(self.orders) + if not commands: + raw_positions = self.positions if self.positions is not None else self.signals + if raw_positions is None: + raise ValueError("native_event v2 requires order_commands, orders, signals, positions, or a basket") + generated_orders = tuple( + _build_market_rebalance_orders( + datetime_index=idx, + positions=_as_series_map(raw_positions, symbols), + closes=closes, + alloc_per_trade=self.alloc_per_trade, + hedge_type=self.hedge_type, + use_pyramiding=self.use_pyramiding, + symbols=symbols, + ) + ) + commands = order_intents_to_lifecycle_commands(generated_orders) + return backend.run_order_commands( + datetime_index=idx, + commands=commands, + closes=closes, + highs=highs, + lows=lows, + funding_rate=self.funding_rate, + contract_size=self.contract_size, + leverage=self.leverage, + symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + report_level=self.report_level, + audit_sink=self.audit_sink, + audit_sink_path=self.audit_sink_path, + ) + + orders = self.orders + if not orders: + raw_positions = self.positions if self.positions is not None else self.signals + if raw_positions is None: + raise ValueError("native_event requires explicit orders, signals, positions, or a basket") + orders = tuple( + _build_market_rebalance_orders( + datetime_index=idx, + positions=_as_series_map(raw_positions, symbols), + closes=closes, + alloc_per_trade=self.alloc_per_trade, + hedge_type=self.hedge_type, + use_pyramiding=self.use_pyramiding, + symbols=symbols, + ) + ) + return backend.run_orders( + datetime_index=idx, + orders=orders, + closes=closes, + highs=highs, + lows=lows, + funding_rate=self.funding_rate, + contract_size=self.contract_size, + leverage=self.leverage, + symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + ) + + def _run_nautilus(self) -> BacktestResultV2: + from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine + + symbol_override = self.symbols[0] if self.symbols else None + trade_notional = self.alloc_per_trade if not isinstance(self.alloc_per_trade, dict) else next( + iter(self.alloc_per_trade.values()) + ) + if self.orders or self.order_commands: + idx, closes, highs, lows, symbols = self._market_data() + package_orders = self.orders + input_mode = "explicit_orders" + if self.order_commands: + package_orders = _commands_to_package_order_intents(self.order_commands) + input_mode = "lifecycle_commands" + data = _frames_for_nautilus( + data=self.data, + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + symbols=symbols, + ) + config = self.nautilus_config + updates = { + "starting_balance": self.account.initial_capital, + "trade_notional": 0.0, + "sizing_mode": "notional", + } + if symbol_override: + updates["instrument_id"] = symbol_override + if config is None: + config = NautilusBackendConfig( + instrument_id=symbol_override or symbols[0], + starting_balance=self.account.initial_capital, + trade_notional=0.0, + sizing_mode="notional", + ) + else: + config = replace(config, **updates) + package_params = { + "input_mode": input_mode, + "order_count_input": int(len(package_orders)), + } + if self.order_commands: + package_params["command_count_input"] = int(len(self.order_commands)) + return NautilusBacktestEngine(config).run_order_packages( + data=data, + orders=package_orders, + symbols=symbols, + params=package_params, + ) + + data = _single_frame(self.data) + if data is None: + idx, closes, highs, lows, symbols = self._market_data() + symbol = symbols[0] + data = pd.DataFrame( + { + "open": closes[symbol], + "high": highs[symbol], + "low": lows[symbol], + "close": closes[symbol], + "volume": 0.0, + }, + index=idx, + ) + + signal = _first_signal(self.positions if self.positions is not None else self.signals) + if signal is None: + raise ValueError("nautilus backend requires a single signal series") + + config = self.nautilus_config + if config is None: + config = NautilusBackendConfig( + instrument_id=symbol_override or "BTCUSDT-PERP.BINANCE", + starting_balance=self.account.initial_capital, + trade_notional=float(trade_notional), + sizing_mode=self.hedge_type, + use_pyramiding=self.use_pyramiding, + ) + else: + updates = { + "starting_balance": self.account.initial_capital, + "trade_notional": float(trade_notional), + "sizing_mode": self.hedge_type, + "use_pyramiding": self.use_pyramiding, + } + if symbol_override and config.instrument_id == "BTCUSDT-PERP.BINANCE": + updates["instrument_id"] = symbol_override + config = replace(config, **updates) + return NautilusBacktestEngine(config).run_signal_series(data=data, signal=signal) + + def _market_data(self) -> Tuple[pd.DatetimeIndex, SeriesMap, SeriesMap, SeriesMap, List[str]]: + return _market_data( + data=self.data, + datetime_index=self.datetime_index, + closes=self.closes, + highs=self.highs, + lows=self.lows, + symbols=self.symbols, + ) + + +class EventDrivenBacktestEngine(BacktestEngineV2): + """Convenience facade pinned to the native event-driven backend.""" + + def __init__(self, *args, **kwargs): + kwargs["backend"] = "native_event" + super().__init__(*args, **kwargs) + + +class OptionBacktestEngine: + """ + Native option facade returning `OptionBacktestResult`. + + Parameters + ---------- + chain: + Canonical long-form option chain rows. + instruments: + Option instrument registry, sequence, or mapping. + packages: + Option package intents generated by a strategy/template layer. + config: + Native option backend configuration. + """ + + def __init__( + self, + *, + chain: Optional[pd.DataFrame] = None, + instruments: Optional[OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Dict[str, OptionInstrumentSpec]] = None, + packages: Sequence[OptionPackageIntent] = (), + strategy_run: Optional[OptionStrategyRun] = None, + underlying: Optional[Union[pd.DataFrame, pd.Series]] = None, + hedge_policy: Optional[OptionHedgeConfig] = None, + net_option_delta: Optional[pd.Series] = None, + config: Optional[NativeOptionConfig] = None, + settlement_events: Optional[Sequence] = None, + conversion_rates: Optional[Dict[str, float]] = None, + prepared_cache: Optional[OptionPreparedRunCache] = None, + auto_run: bool = True, + ): + self.chain = chain + self.instruments = instruments + self.strategy_run = strategy_run + self.packages = tuple(packages or (strategy_run.packages if strategy_run is not None else ())) + self.underlying = underlying + self.hedge_policy = hedge_policy or (strategy_run.hedge_policy if strategy_run is not None else None) + self.net_option_delta = net_option_delta + self.config = config or NativeOptionConfig() + self.settlement_events = tuple(settlement_events or ()) + self.conversion_rates = conversion_rates + self.prepared_cache = prepared_cache + self.backend = NativeOptionBackend(self.config) + self.result: Optional[OptionBacktestResult] = None + + if auto_run: + self.run() + + def run(self) -> OptionBacktestResult: + if self.chain is None: + raise ValueError("OptionBacktestEngine requires chain") + if self.instruments is None: + raise ValueError("OptionBacktestEngine requires instruments") + self.result = self.backend.run( + chain=self.chain, + instruments=self.instruments, + packages=self.packages, + settlement_events=self.settlement_events, + conversion_rates=self.conversion_rates, + prepared_cache=self.prepared_cache, + underlying=self.underlying, + hedge_policy=self.hedge_policy, + net_option_delta=self.net_option_delta, + ) + if self.strategy_run is not None: + self.result.metadata["strategy_run"] = self.strategy_run.metadata + self.result.metadata["selected_contracts"] = self.strategy_run.selected_contracts + self.result.run_manifest["strategy_run"] = self.strategy_run.metadata + self.result.metadata["run_manifest"] = self.result.run_manifest + return self.result + + +class PortfolioBacktestEngine: + """ + V2-compatible multi-symbol portfolio facade. + + The default backend intentionally wraps the existing `MultiSymbolPortfolio` + so old portfolio mode semantics stay unchanged while returning + `BacktestResultV2` to new metrics and migration code. + """ + + def __init__( + self, + positions: Dict[str, pd.Series], + closes: Dict[str, pd.Series], + datetime_index: Union[pd.DatetimeIndex, pd.Series], + mode: str = "longshort", + backend: str = "native_portfolio", + account: Optional[AccountConfig] = None, + execution: Optional[ExecutionConfig] = None, + fee_rate: Optional[float] = None, + alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0, + contract_size: Union[float, Dict[str, float], None] = None, + hedge_type: str = "notional", + asset_type: str = "crypto", + use_funding: Optional[bool] = None, + funding_rate: Union[float, Dict[str, float], None] = None, + leverage: Optional[Union[float, Dict[str, float]]] = None, + maintenance_ratio: Optional[float] = None, + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + report_level: str = "full", + auto_run: bool = True, + **kwargs, + ): + legacy_slippage = kwargs.pop("slippage", None) + if execution is None and legacy_slippage is not None: + execution = ExecutionConfig(slippage_bps=float(legacy_slippage) * 10_000.0) + self.positions = positions + self.closes = closes + self.datetime_index = datetime_index + self.mode = mode + self.backend = backend.lower().strip() + self.account = account or AccountConfig(initial_capital=100_000.0) + self.execution = execution or ExecutionConfig() + self.fee_rate = fee_rate + self.alloc_per_trade = alloc_per_trade + self.contract_size = contract_size + self.hedge_type = hedge_type + self.asset_type = asset_type + self.use_funding = use_funding if use_funding is not None else asset_type.lower() == "crypto" + self.funding_rate = funding_rate + self.leverage = leverage if leverage is not None else self.account.leverage + self.maintenance_ratio = ( + maintenance_ratio if maintenance_ratio is not None else self.account.maintenance_ratio + ) + self.highs = highs + self.lows = lows + self.instruments = instruments + self.qty_step = qty_step + self.lot_size = lot_size + self.slot_size = slot_size + self.min_qty = min_qty + self.min_notional = min_notional + self.report_level = report_level + self.kwargs = kwargs + self.portfolio: Optional[MultiSymbolPortfolio] = None + self.result: Optional[BacktestResultV2] = None + + if auto_run: + self.run() + + def run(self) -> BacktestResultV2: + if self.backend in {"legacy", "legacy_portfolio", "portfolio"}: + asset_type = self.asset_type.lower() + default_fee = 0.0004 if asset_type == "crypto" else 0.0001 + fee_oneway = self.fee_rate if self.fee_rate is not None else default_fee / 2.0 + legacy_kwargs = { + key: value + for key, value in self.kwargs.items() + if key not in {"use_pyramiding", "betas", "risk_lookback"} + } + self.portfolio = MultiSymbolPortfolio( + positions=self.positions, + closes=self.closes, + datetime_index=self.datetime_index, + mode=self.mode, + fee_rate=fee_oneway, + alloc_per_trade=self.alloc_per_trade, + contract_size=self.contract_size, + hedge_type=self.hedge_type, + initial_capital=self.account.initial_capital, + asset_type=self.asset_type, + use_funding=self.use_funding, + funding_rate=self.funding_rate, + leverage=self.leverage, + maintenance_ratio=self.maintenance_ratio, + highs=self.highs, + lows=self.lows, + **legacy_kwargs, + ) + self.result = BacktestResultV2.from_legacy(self.portfolio.result) + self.result.metadata["backend"] = "legacy_portfolio" + return self.result + + if self.backend == "native_vectorized": + engine = BacktestEngineV2( + positions=self.positions, + closes=self.closes, + highs=self.highs, + lows=self.lows, + datetime_index=self.datetime_index, + backend="native_vectorized", + account=self.account, + execution=self.execution, + fee_rate=self.fee_rate or 0.0, + use_funding=bool(self.use_funding), + alloc_per_trade=self.alloc_per_trade, + hedge_type=self.hedge_type, + contract_size=self.contract_size or 1.0, + leverage=self.leverage, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + ) + self.result = engine.result + return self.result + + if self.backend == "native_portfolio": + asset_type = self.asset_type.lower() + default_fee = 0.0004 if asset_type == "crypto" else 0.0001 + fee_oneway = self.fee_rate if self.fee_rate is not None else default_fee / 2.0 + default_contract = 1.0 if asset_type == "crypto" else 100.0 + backend = NativePortfolioBackend( + NativePortfolioConfig( + account=self.account, + execution=self.execution, + fee_rate=fee_oneway, + use_funding=bool(self.use_funding), + report_level=self.report_level, + ) + ) + self.result = backend.run_signals( + positions=self.positions, + closes=self.closes, + highs=self.highs, + lows=self.lows, + datetime_index=self.datetime_index, + mode=self.mode, + alloc_per_trade=self.alloc_per_trade, + contract_size=self.contract_size if self.contract_size is not None else default_contract, + hedge_type=self.hedge_type, + funding_rate=self.funding_rate if self.funding_rate is not None else 0.0001, + leverage=self.leverage, + maintenance_ratio=self.maintenance_ratio, + asset_type=self.asset_type, + use_pyramiding=bool(self.kwargs.get("use_pyramiding", True)), + betas=self.kwargs.get("betas"), + risk_lookback=int(self.kwargs.get("risk_lookback", 60)), + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + report_level=self.report_level, + ) + return self.result + + raise ValueError("PortfolioBacktestEngine backend must be legacy_portfolio, native_vectorized, or native_portfolio") + + +def _market_data( + data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]], + datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]], + closes: Optional[SeriesMap], + highs: Optional[SeriesMap], + lows: Optional[SeriesMap], + symbols: Optional[List[str]], +) -> Tuple[pd.DatetimeIndex, SeriesMap, SeriesMap, SeriesMap, List[str]]: + if closes is not None: + symbol_list = symbols or list(closes.keys()) + idx = validate_datetime(datetime_index if datetime_index is not None else closes[symbol_list[0]].index) + close_map = {s: closes[s] for s in symbol_list} + high_map = {s: highs[s] for s in symbol_list} if highs is not None else close_map + low_map = {s: lows[s] for s in symbol_list} if lows is not None else close_map + return idx, close_map, high_map, low_map, symbol_list + + if data is None: + raise ValueError("market data is required") + + if isinstance(data, pd.DataFrame): + symbol = symbols[0] if symbols else "asset" + idx, close, high, low = _extract_frame_ohlc(data, datetime_index) + return idx, {symbol: close}, {symbol: high}, {symbol: low}, [symbol] + + symbol_list = symbols or list(data.keys()) + close_map: SeriesMap = {} + high_map: SeriesMap = {} + low_map: SeriesMap = {} + idx = None + for symbol in symbol_list: + value = data[symbol] + if isinstance(value, pd.Series): + close = value + high = value + low = value + local_idx = validate_datetime(datetime_index if datetime_index is not None else value.index) + else: + local_idx, close, high, low = _extract_frame_ohlc(value, datetime_index) + idx = local_idx if idx is None else idx + close_map[symbol] = close + high_map[symbol] = high + low_map[symbol] = low + return idx, close_map, high_map, low_map, symbol_list + + +def _market_open_volume( + data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]], + datetime_index: pd.DatetimeIndex, + closes: SeriesMap, + symbols: List[str], +) -> Tuple[SeriesMap, SeriesMap]: + opens: SeriesMap = {} + volumes: SeriesMap = {} + if isinstance(data, pd.DataFrame): + if len(symbols) != 1: + raise ValueError("single DataFrame reactive run requires one symbol") + frame = _extract_frame_ohlcv(data, datetime_index) + opens[symbols[0]] = frame["open"] + volumes[symbols[0]] = frame["volume"] + return opens, volumes + if isinstance(data, dict): + for symbol in symbols: + value = data[symbol] + if isinstance(value, pd.DataFrame): + frame = _extract_frame_ohlcv(value, datetime_index) + opens[symbol] = frame["open"] + volumes[symbol] = frame["volume"] + else: + close = closes[symbol] + opens[symbol] = close + volumes[symbol] = pd.Series(0.0, index=close.index, name="volume") + return opens, volumes + for symbol in symbols: + close = closes[symbol] + opens[symbol] = close + volumes[symbol] = pd.Series(0.0, index=close.index, name="volume") + return opens, volumes + + +def _extract_frame_ohlc( + data: pd.DataFrame, + datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]], +) -> Tuple[pd.DatetimeIndex, pd.Series, pd.Series, pd.Series]: + frame = data.copy() + rename = { + "Datetime": "timestamp", + "Date": "timestamp", + "Timestamp": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Volume": "volume", + } + frame = frame.rename(columns=rename) + if datetime_index is not None: + frame.index = validate_datetime(datetime_index) + elif "timestamp" in frame.columns: + frame["timestamp"] = pd.to_datetime(frame["timestamp"], errors="coerce", utc=True) + frame = frame.dropna(subset=["timestamp"]).set_index("timestamp") + else: + frame.index = validate_datetime(frame.index) + frame = frame[~frame.index.duplicated(keep="first")].sort_index() + idx = validate_datetime(frame.index) + if "close" not in frame.columns: + raise ValueError("data frame must contain close/Close") + close = pd.Series(frame["close"].to_numpy(), index=idx, name="close") + high = pd.Series(frame["high"].to_numpy(), index=idx, name="high") if "high" in frame.columns else close + low = pd.Series(frame["low"].to_numpy(), index=idx, name="low") if "low" in frame.columns else close + return idx, close, high, low + + +def _frames_for_nautilus( + data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]], + datetime_index: pd.DatetimeIndex, + closes: SeriesMap, + highs: SeriesMap, + lows: SeriesMap, + symbols: List[str], +) -> Dict[str, pd.DataFrame]: + if isinstance(data, pd.DataFrame): + if len(symbols) != 1: + raise ValueError("single DataFrame Nautilus order replay requires one symbol") + return {symbols[0]: _extract_frame_ohlcv(data, datetime_index)} + if isinstance(data, dict): + frames = {} + for symbol in symbols: + value = data[symbol] + if isinstance(value, pd.DataFrame): + frames[symbol] = _extract_frame_ohlcv(value, datetime_index) + else: + close = pd.Series(value.to_numpy(), index=datetime_index, name="close") + frames[symbol] = _frame_from_ohlc(close, close, close) + return frames + return {symbol: _frame_from_ohlc(closes[symbol], highs[symbol], lows[symbol]) for symbol in symbols} + + +def _extract_frame_ohlcv(data: pd.DataFrame, datetime_index: pd.DatetimeIndex) -> pd.DataFrame: + frame = data.copy() + frame = frame.rename( + columns={ + "Datetime": "timestamp", + "Date": "timestamp", + "Timestamp": "timestamp", + "Open": "open", + "High": "high", + "Low": "low", + "Close": "close", + "Volume": "volume", + } + ) + frame.index = datetime_index + if "close" not in frame.columns: + raise ValueError("data frame must contain close/Close") + for col in ("open", "high", "low"): + if col not in frame.columns: + frame[col] = frame["close"] + if "volume" not in frame.columns: + frame["volume"] = 0.0 + return frame[["open", "high", "low", "close", "volume"]].copy() + + +def _frame_from_ohlc(close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame: + return pd.DataFrame( + { + "open": close, + "high": high, + "low": low, + "close": close, + "volume": 0.0, + }, + index=close.index, + ) + + +def _as_series_map(value: Union[pd.Series, SeriesMap], symbols: List[str]) -> SeriesMap: + if isinstance(value, pd.Series): + if len(symbols) != 1: + raise ValueError("single series input requires exactly one symbol") + return {symbols[0]: value} + return {s: value[s] for s in symbols} + + +def _build_market_rebalance_orders( + datetime_index: pd.DatetimeIndex, + positions: SeriesMap, + closes: SeriesMap, + alloc_per_trade: Union[float, Dict[str, float]], + hedge_type: str, + use_pyramiding: bool, + symbols: List[str], +) -> List[OrderIntent]: + ht = hedge_type.lower().strip() + if ht in ("%_equity", "pct_equity", "dca_ladder", "dca"): + raise NotImplementedError( + "native_event signal adapter supports pre-scalable target-unit modes " + "('signal_notional', 'notional', 'unit'). Use explicit orders for " + f"hedge_type={hedge_type!r}." + ) + + alloc = _per_symbol_mapping(alloc_per_trade, symbols, default=100_000.0) + orders: List[OrderIntent] = [] + for symbol in symbols: + signal = positions[symbol].copy() + close = closes[symbol].copy() + if isinstance(signal.index, pd.DatetimeIndex): + signal.index = signal.index.tz_localize("UTC") if signal.index.tz is None else signal.index.tz_convert("UTC") + if isinstance(close.index, pd.DatetimeIndex): + close.index = close.index.tz_localize("UTC") if close.index.tz is None else close.index.tz_convert("UTC") + signal = signal[~signal.index.duplicated(keep="first")].reindex(datetime_index, method="ffill").fillna(0.0) + close = close[~close.index.duplicated(keep="first")].reindex(datetime_index, method="ffill") + target_units = compute_target_units( + hedge_type=hedge_type, + signal=signal, + close=close, + alloc=alloc[symbol], + use_pyramiding=use_pyramiding, + ).fillna(0.0) + prev = 0.0 + for ts, target in target_units.items(): + target = float(target) + delta = target - prev + if abs(delta) > 1e-12: + orders.append( + OrderIntent( + timestamp=ts, + symbol=symbol, + side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=abs(delta), + tif=TimeInForce.IOC, + tag=f"signal_rebalance:{hedge_type}", + ) + ) + prev = target + return sorted(orders, key=lambda order: pd.Timestamp(order.timestamp).value) + + +def _per_symbol_mapping(value, symbols: List[str], default: float) -> Dict[str, float]: + if isinstance(value, dict): + return {s: float(value.get(s, default)) for s in symbols} + return {s: float(value) for s in symbols} + + +def _commands_to_package_order_intents(commands: Sequence[OrderCommand]) -> Tuple[OrderIntent, ...]: + orders: List[OrderIntent] = [] + for command in commands: + if command.action not in (OrderAction.PLACE, OrderAction.REPLACE): + continue + if command.symbol is None or command.side is None or command.order_type is None or command.qty is None: + continue + metadata = { + **dict(command.metadata), + "command_action": command.action.value, + "target_order_id": command.target_order_id, + "parent_order_id": command.parent_order_id, + "group_id": command.group_id, + "oco_group_id": command.oco_group_id, + "activation_policy": command.activation_policy.value, + } + orders.append( + OrderIntent( + timestamp=command.timestamp, + symbol=command.symbol, + side=command.side, + order_type=command.order_type, + qty=float(command.qty), + price=command.price, + trigger_price=command.trigger_price, + tif=command.tif, + reduce_only=command.reduce_only, + order_id=command.order_id, + tag=command.tag, + metadata=metadata, + ) + ) + return tuple(orders) + + +def _first_signal(value: Optional[Union[pd.Series, SeriesMap]]) -> Optional[pd.Series]: + if value is None: + return None + if isinstance(value, pd.Series): + return value + return next(iter(value.values())) + + +def _single_frame(data) -> Optional[pd.DataFrame]: + if isinstance(data, pd.DataFrame): + return data + if isinstance(data, dict) and data: + first = next(iter(data.values())) + return first if isinstance(first, pd.DataFrame) else None + return None diff --git a/src/quantbt/metrics/__init__.py b/src/quantbt/metrics/__init__.py new file mode 100644 index 0000000..d1ca338 --- /dev/null +++ b/src/quantbt/metrics/__init__.py @@ -0,0 +1,45 @@ +from .performance import ( + full_report, + total_return, + cagr, + sharpe, + sortino, + calmar, + omega, + max_drawdown, + max_drawdown_pct, + avg_drawdown, + drawdown_duration, + hitrate, + number_of_trades, + profit_factor, + avg_win_loss, + expectancy, + rolling_sharpe, + rolling_drawdown, +) +from .options_analytics import option_attribution_report, option_report_bundle, option_run_manifest + +__all__ = [ + "full_report", + "total_return", + "cagr", + "sharpe", + "sortino", + "calmar", + "omega", + "max_drawdown", + "max_drawdown_pct", + "avg_drawdown", + "drawdown_duration", + "hitrate", + "number_of_trades", + "profit_factor", + "avg_win_loss", + "expectancy", + "rolling_sharpe", + "rolling_drawdown", + "option_attribution_report", + "option_report_bundle", + "option_run_manifest", +] diff --git a/src/quantbt/metrics/options_analytics.py b/src/quantbt/metrics/options_analytics.py new file mode 100644 index 0000000..78dcaa9 --- /dev/null +++ b/src/quantbt/metrics/options_analytics.py @@ -0,0 +1,46 @@ +""" +Option-domain report helpers. + +These functions summarize `OptionBacktestResult` artifacts without recomputing +ledger accounting or execution PnL. +""" + +from __future__ import annotations + +from typing import Dict + +import pandas as pd + + +def option_run_manifest(result) -> Dict: + """Return the option run manifest stored by `NativeOptionBackend`.""" + return dict(getattr(result, "run_manifest", None) or result.metadata.get("run_manifest", {})) + + +def option_attribution_report(result) -> pd.DataFrame: + """Return the option attribution table, or an empty DataFrame.""" + report = getattr(result, "attribution_report", None) + if report is None: + report = result.metadata.get("attribution_report") + return report.copy() if isinstance(report, pd.DataFrame) else pd.DataFrame() + + +def option_report_bundle(result) -> Dict[str, pd.DataFrame]: + """Return all standard option audit tables as a dictionary.""" + names = ( + "fills_report", + "packages_report", + "cash_report", + "marks_report", + "greeks_report", + "settlements_report", + "margin_report", + "attribution_report", + ) + out = {} + for name in names: + report = getattr(result, name, None) + if report is None: + report = result.metadata.get(name) + out[name] = report.copy() if isinstance(report, pd.DataFrame) else pd.DataFrame() + return out diff --git a/src/quantbt/metrics/performance.py b/src/quantbt/metrics/performance.py new file mode 100644 index 0000000..da7c377 --- /dev/null +++ b/src/quantbt/metrics/performance.py @@ -0,0 +1,541 @@ +""" +quantbt.metrics.performance +---------------------------- +Pure functions. All accept a BacktestResult (or bare pd.Series of returns) +and return scalars or DataFrames. No side-effects, no plotting. + +All return-based statistics default to daily frequency with 365-day Sharpe +scaling (crypto); pass trading_days=252 for equities. +""" + +from __future__ import annotations + +from typing import Dict, Sequence, Tuple + +import numpy as np +import pandas as pd + +from ..core.types import BacktestResult + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _daily(result: BacktestResult) -> pd.Series: + return result.daily_returns + + +def _equity_daily(result: BacktestResult) -> pd.Series: + return result.daily_equity + + +def _finite_returns(series: pd.Series) -> pd.Series: + r = pd.to_numeric(series, errors="coerce").replace([np.inf, -np.inf], np.nan).dropna() + return r.astype(float) + + +def _returns_for_stats(result: BacktestResult) -> pd.Series: + """ + Return sample used by distribution metrics. + + Daily returns are preferred for stable multi-day reports. Very short + intraday/scoped runs can collapse to one daily equity point, producing an + empty daily return sample; in that case we fall back to bar returns so + Sharpe, Omega, PF, and avg win/loss do not become artificial 0/inf values. + """ + daily = _finite_returns(_daily(result)) + if len(daily) > 0: + return daily + bar = _finite_returns(result.returns) + if len(bar) > 0: + return bar + return _finite_returns(result.equity.pct_change().fillna(0.0)) + + +def _annualization_periods(result: BacktestResult, trading_days: int) -> float: + daily = _finite_returns(_daily(result)) + if len(daily) > 0: + return float(trading_days) + idx = result.equity.index + if len(idx) >= 2 and isinstance(idx, pd.DatetimeIndex): + deltas = idx.to_series().diff().dropna().dt.total_seconds() + deltas = deltas[deltas > 0.0] + if len(deltas) > 0: + median_seconds = float(deltas.median()) + if median_seconds > 0.0: + return float(365.25 * 24 * 60 * 60 / median_seconds) + return float(trading_days) + + +def _elapsed_years(result: BacktestResult, trading_days: int) -> float: + eq = result.equity.dropna() + if len(eq) < 2: + return 0.0 + idx = eq.index + if isinstance(idx, pd.DatetimeIndex): + elapsed_days = (idx[-1] - idx[0]).total_seconds() / 86_400.0 + if elapsed_days > 0.0: + return elapsed_days / 365.25 + daily = _equity_daily(result) + if len(daily) >= 2: + return len(daily) / float(trading_days) + return len(eq) / float(trading_days) + + +# ── return metrics ─────────────────────────────────────────────────────────── + +def total_return(result: BacktestResult) -> float: + """Total return as a decimal (0.25 = 25%).""" + eq = result.equity + return (eq.iloc[-1] - result.initial_capital) / result.initial_capital + + +def cagr(result: BacktestResult, trading_days: int = 365) -> float: + """Compound annual growth rate.""" + eq = result.equity.dropna() + if len(eq) >= 2 and isinstance(eq.index, pd.DatetimeIndex): + elapsed_days = (eq.index[-1] - eq.index[0]).total_seconds() / 86_400.0 + if 0.0 < elapsed_days < 1.0: + return total_return(result) + years = _elapsed_years(result, trading_days) + if years <= 0: + return 0.0 + growth = eq.iloc[-1] / eq.iloc[0] + if growth <= 0.0: + return -1.0 + annual_log = np.log(growth) / years + if annual_log > 50.0: + return float(np.expm1(50.0)) + if annual_log < -50.0: + return float(np.expm1(-50.0)) + return float(np.expm1(annual_log)) + + +def sharpe(result: BacktestResult, trading_days: int = 365, risk_free: float = 0.0) -> float: + periods = _annualization_periods(result, trading_days) + r = _returns_for_stats(result) - risk_free / periods + sd = r.std(ddof=1) + return (r.mean() / sd) * np.sqrt(periods) if sd > 0 else 0.0 + + +def sortino(result: BacktestResult, trading_days: int = 365, mar: float = 0.0) -> float: + periods = _annualization_periods(result, trading_days) + r = _returns_for_stats(result) + d = r[r < mar] - mar + dd = np.sqrt((d ** 2).mean()) if len(d) > 0 else 0.0 + if dd == 0.0 and r.mean() > mar: + return np.inf + return (r.mean() / dd) * np.sqrt(periods) if dd > 0 else 0.0 + + +def calmar(result: BacktestResult, trading_days: int = 365) -> float: + c = cagr(result, trading_days) + mdd = max_drawdown(result) + return c / mdd if mdd > 0 else 0.0 + + +def omega(result: BacktestResult, threshold: float = 0.0) -> float: + """Omega ratio (Keating & Shadwick).""" + r = _returns_for_stats(result) + gain = (r[r > threshold] - threshold).sum() + loss = (threshold - r[r < threshold]).sum() + return gain / loss if loss > 0 else np.inf + + +# ── drawdown metrics ───────────────────────────────────────────────────────── + +def max_drawdown(result: BacktestResult) -> float: + """Maximum drawdown as a positive fraction.""" + return float(result.drawdown.max()) + + +def max_drawdown_pct(result: BacktestResult) -> float: + return max_drawdown(result) * 100.0 + + +def avg_drawdown(result: BacktestResult) -> float: + """Mean of all drawdown troughs (fraction).""" + dd = result.drawdown + return float(dd[dd > 0].mean()) if (dd > 0).any() else 0.0 + + +def drawdown_duration(result: BacktestResult) -> Tuple[int, int]: + """ + Returns (max_duration_bars, avg_duration_bars). + Duration counted in calendar days on daily equity. + """ + eq = _equity_daily(result) + peak = eq.cummax() + in_dd = (peak != eq) + + durations = [] + run = 0 + for v in in_dd: + if v: + run += 1 + else: + if run > 0: + durations.append(run) + run = 0 + if run > 0: + durations.append(run) + + if not durations: + return 0, 0 + return int(max(durations)), int(np.mean(durations)) + + +# ── trade statistics ───────────────────────────────────────────────────────── + +def hitrate(result: BacktestResult) -> Tuple[float, float]: + """ + Returns (long_hitrate_pct, short_hitrate_pct). + A bar is a 'win' if the daily return > 0 while the position is active. + """ + eq_ret = result.returns + long_hr = [] + short_hr = [] + + for sym in result.symbols: + pos = result.positions[f"Position_{sym}"] + long_mask = pos > 0 + short_mask = pos < 0 + + long_wins = ((eq_ret > 0) & long_mask).sum() + long_total = long_mask.sum() + + short_wins = ((eq_ret > 0) & short_mask).sum() + short_total = short_mask.sum() + + long_hr.append(long_wins / long_total * 100 if long_total > 0 else 0.0) + short_hr.append(short_wins / short_total * 100 if short_total > 0 else 0.0) + + return float(np.mean(long_hr)), float(np.mean(short_hr)) + + +def number_of_trades(result: BacktestResult) -> int: + """Count signal transitions (any symbol).""" + count = 0 + for sym in result.symbols: + pos = result.positions[f"Position_{sym}"] + count += int((pos.diff() != 0).sum()) + return count + + +def profit_factor(result: BacktestResult) -> float: + r = _returns_for_stats(result) + gains = r[r > 0].sum() + loss = abs(r[r < 0].sum()) + return gains / loss if loss > 0 else np.inf + + +def avg_win_loss(result: BacktestResult) -> Tuple[float, float]: + """(avg_win_pct, avg_loss_pct) in percent.""" + r = _returns_for_stats(result) + w = r[r > 0].mean() * 100 if (r > 0).any() else 0.0 + l = r[r < 0].mean() * 100 if (r < 0).any() else 0.0 + return float(w), float(l) + + +def expectancy(result: BacktestResult) -> float: + """ + Expectancy = HR × avg_win + (1 − HR) × avg_loss + (uses combined long/short hitrate average) + """ + lh, sh = hitrate(result) + hr = (lh + sh) / 200.0 # convert to decimal average + aw, al = avg_win_loss(result) + return hr * aw + (1 - hr) * al + + +# ── rolling metrics ────────────────────────────────────────────────────────── + +def rolling_sharpe( + result: BacktestResult, + window: int = 30, + trading_days: int = 365, +) -> pd.Series: + r = _daily(result) + mu = r.rolling(window).mean() + sd = r.rolling(window).std(ddof=1) + return (mu / sd) * np.sqrt(trading_days) + + +def rolling_drawdown(result: BacktestResult) -> pd.Series: + """Rolling drawdown fraction from trailing peak.""" + eq = _equity_daily(result) + peak = eq.cummax() + return (peak - eq) / peak + + +# ── full report dict ───────────────────────────────────────────────────────── + +def compute_performance_metrics( + *, + timestamps: Sequence, + equity: Sequence[float], + returns: Sequence[float], + positions, + symbols: Sequence[str], + initial_capital: float, + liquidated: bool = False, + trading_days: int = 365, +) -> Dict: + """ + Shared array-first metric contract. + + This intentionally mirrors `full_report()` semantics so lightweight + prepared/native-event score paths and public `BacktestResultV2` reports use + one metric implementation. + """ + idx = pd.DatetimeIndex(timestamps) + equity_arr = np.asarray(equity, dtype=np.float64) + returns_arr = np.asarray(returns, dtype=np.float64) + pos_arr = np.asarray(positions, dtype=np.float64) + if pos_arr.ndim == 1: + pos_arr = pos_arr.reshape(-1, 1) + if len(equity_arr) == 0: + raise ValueError("equity path cannot be empty") + if len(returns_arr) != len(equity_arr): + raise ValueError("returns must have the same length as equity") + if pos_arr.shape[0] != len(equity_arr): + raise ValueError("positions must have the same number of rows as equity") + + stats_returns = _array_returns_for_stats(idx, equity_arr, returns_arr) + annual_periods = _array_annualization_periods(idx, stats_returns, trading_days) + elapsed_years = _array_elapsed_years(idx, equity_arr, trading_days) + drawdown = _array_drawdown(equity_arr) + max_dd = float(np.nanmax(drawdown)) if len(drawdown) else 0.0 + avg_dd = float(np.nanmean(drawdown[drawdown > 0.0])) if np.any(drawdown > 0.0) else 0.0 + max_dd_duration, avg_dd_duration = _array_drawdown_duration_days(idx, equity_arr) + + final_equity = float(equity_arr[-1]) + total_ret = (final_equity - float(initial_capital)) / float(initial_capital) + cagr_value = _array_cagr(equity_arr, total_ret, elapsed_years) + sharpe_value = _array_sharpe(stats_returns, annual_periods) + sortino_value = _array_sortino(stats_returns, annual_periods) + omega_value = _array_omega(stats_returns) + pf_value = _array_profit_factor(stats_returns) + long_hr, short_hr = _array_hitrate(returns_arr, pos_arr) + avg_win, avg_loss = _array_avg_win_loss(stats_returns) + hr = (long_hr + short_hr) / 200.0 + expectancy_value = hr * avg_win + (1.0 - hr) * avg_loss + + return { + "initial_capital": float(initial_capital), + "final_equity": final_equity, + "total_return_pct": float(total_ret * 100.0), + "cagr_pct": float(cagr_value * 100.0), + "sharpe": float(sharpe_value), + "sortino": float(sortino_value), + "calmar": float(cagr_value / max_dd) if max_dd > 0.0 else 0.0, + "omega": float(omega_value), + "max_drawdown_pct": float(max_dd * 100.0), + "avg_drawdown_pct": float(avg_dd * 100.0), + "max_dd_duration_days": int(max_dd_duration), + "avg_dd_duration_days": int(avg_dd_duration), + "profit_factor": float(pf_value), + "long_hitrate_pct": float(long_hr), + "short_hitrate_pct": float(short_hr), + "avg_win_pct": float(avg_win), + "avg_loss_pct": float(avg_loss), + "expectancy_pct": float(expectancy_value), + "num_trades": int(_array_number_of_trades(pos_arr)), + "liquidated": bool(liquidated), + } + + +def _array_finite_returns(values: np.ndarray) -> np.ndarray: + arr = np.asarray(values, dtype=np.float64) + return arr[np.isfinite(arr)] + + +def _array_daily_equity(idx: pd.DatetimeIndex, equity: np.ndarray) -> np.ndarray: + if len(equity) == 0: + return np.empty(0, dtype=np.float64) + if len(idx) != len(equity): + return np.asarray(equity, dtype=np.float64) + day_ns = 86_400_000_000_000 + days = idx.view("int64") // day_ns + if len(days) == 0: + return np.empty(0, dtype=np.float64) + change = np.flatnonzero(days[1:] != days[:-1]) + last_idx = np.concatenate((change, np.array([len(days) - 1], dtype=np.int64))) + return np.asarray(equity, dtype=np.float64)[last_idx] + + +def _array_returns_for_stats(idx: pd.DatetimeIndex, equity: np.ndarray, returns: np.ndarray) -> np.ndarray: + daily_equity = _array_daily_equity(idx, equity) + if len(daily_equity) >= 2: + base = daily_equity[:-1] + daily_returns = np.divide( + daily_equity[1:] - base, + base, + out=np.zeros(len(base), dtype=np.float64), + where=base != 0.0, + ) + daily_returns = _array_finite_returns(daily_returns) + if len(daily_returns) > 0: + return daily_returns + bar = _array_finite_returns(returns) + if len(bar) > 0: + return bar + if len(equity) < 2: + return np.zeros(1, dtype=np.float64) + base = equity[:-1] + out = np.divide(equity[1:] - base, base, out=np.zeros(len(base), dtype=np.float64), where=base != 0.0) + return _array_finite_returns(out) + + +def _array_annualization_periods(idx: pd.DatetimeIndex, stats_returns: np.ndarray, trading_days: int) -> float: + daily_equity_returns = len(stats_returns) > 0 + if daily_equity_returns and len(idx) >= 2: + day_ns = 86_400_000_000_000 + if len(np.unique(idx.view("int64") // day_ns)) >= 2: + return float(trading_days) + if len(idx) >= 2: + ns = idx.view("int64") + deltas = np.diff(ns).astype(np.float64) / 1_000_000_000.0 + deltas = deltas[deltas > 0.0] + if len(deltas) > 0: + median_seconds = float(np.median(deltas)) + if median_seconds > 0.0: + return float(365.25 * 24 * 60 * 60 / median_seconds) + return float(trading_days) + + +def _array_elapsed_years(idx: pd.DatetimeIndex, equity: np.ndarray, trading_days: int) -> float: + if len(equity) < 2: + return 0.0 + if len(idx) >= 2: + elapsed_days = (idx[-1] - idx[0]).total_seconds() / 86_400.0 + if elapsed_days > 0.0: + return float(elapsed_days / 365.25) + daily_equity = _array_daily_equity(idx, equity) + if len(daily_equity) >= 2: + return float(len(daily_equity) / float(trading_days)) + return float(len(equity) / float(trading_days)) + + +def _array_cagr(equity: np.ndarray, total_ret: float, years: float) -> float: + if len(equity) >= 2 and years > 0.0: + elapsed_days = years * 365.25 + if 0.0 < elapsed_days < 1.0: + return float(total_ret) + if years <= 0.0: + return 0.0 + growth = float(equity[-1] / equity[0]) + if growth <= 0.0: + return -1.0 + annual_log = np.log(growth) / years + if annual_log > 50.0: + return float(np.expm1(50.0)) + if annual_log < -50.0: + return float(np.expm1(-50.0)) + return float(np.expm1(annual_log)) + + +def _array_sharpe(r: np.ndarray, periods: float) -> float: + if len(r) < 2: + return 0.0 + sd = float(np.std(r, ddof=1)) + return float((np.mean(r) / sd) * np.sqrt(periods)) if sd > 0.0 else 0.0 + + +def _array_sortino(r: np.ndarray, periods: float, mar: float = 0.0) -> float: + downside = r[r < mar] - mar + dd = float(np.sqrt(np.mean(downside ** 2))) if len(downside) > 0 else 0.0 + mean = float(np.mean(r)) if len(r) > 0 else 0.0 + if dd == 0.0 and mean > mar: + return np.inf + return float((mean / dd) * np.sqrt(periods)) if dd > 0.0 else 0.0 + + +def _array_omega(r: np.ndarray, threshold: float = 0.0) -> float: + gain = float(np.sum(r[r > threshold] - threshold)) + loss = float(np.sum(threshold - r[r < threshold])) + return gain / loss if loss > 0.0 else np.inf + + +def _array_drawdown(equity: np.ndarray) -> np.ndarray: + peak = np.maximum.accumulate(equity) + return np.divide(peak - equity, peak, out=np.zeros_like(equity, dtype=np.float64), where=peak != 0.0) + + +def _array_drawdown_duration_days(idx: pd.DatetimeIndex, equity: np.ndarray) -> Tuple[int, int]: + daily_equity = _array_daily_equity(idx, equity) + if len(daily_equity) == 0: + return 0, 0 + peak = np.maximum.accumulate(daily_equity) + in_dd = peak != daily_equity + durations = [] + run = 0 + for value in in_dd: + if value: + run += 1 + elif run > 0: + durations.append(run) + run = 0 + if run > 0: + durations.append(run) + if not durations: + return 0, 0 + return int(max(durations)), int(np.mean(durations)) + + +def _array_hitrate(returns: np.ndarray, positions: np.ndarray) -> Tuple[float, float]: + long_hr = [] + short_hr = [] + for col in range(positions.shape[1]): + pos = positions[:, col] + long_mask = pos > 0.0 + short_mask = pos < 0.0 + long_total = int(np.sum(long_mask)) + short_total = int(np.sum(short_mask)) + long_wins = int(np.sum((returns > 0.0) & long_mask)) + short_wins = int(np.sum((returns > 0.0) & short_mask)) + long_hr.append(long_wins / long_total * 100.0 if long_total > 0 else 0.0) + short_hr.append(short_wins / short_total * 100.0 if short_total > 0 else 0.0) + return float(np.mean(long_hr)), float(np.mean(short_hr)) + + +def _array_number_of_trades(positions: np.ndarray) -> int: + if positions.size == 0: + return 0 + total = 0 + for col in range(positions.shape[1]): + pos = positions[:, col] + total += 1 + if len(pos) > 1: + total += int(np.sum(np.diff(pos) != 0.0)) + return int(total) + + +def _array_profit_factor(r: np.ndarray) -> float: + gains = float(np.sum(r[r > 0.0])) + loss = float(abs(np.sum(r[r < 0.0]))) + return gains / loss if loss > 0.0 else np.inf + + +def _array_avg_win_loss(r: np.ndarray) -> Tuple[float, float]: + wins = r[r > 0.0] + losses = r[r < 0.0] + win = float(np.mean(wins) * 100.0) if len(wins) > 0 else 0.0 + loss = float(np.mean(losses) * 100.0) if len(losses) > 0 else 0.0 + return win, loss + +def full_report(result: BacktestResult, trading_days: int = 365) -> Dict: + """ + Returns an ordered dict of all key metrics. + Suitable for programmatic use; viz/tearsheet renders it. + """ + positions = result.positions[[f"Position_{sym}" for sym in result.symbols]].to_numpy(dtype=np.float64) + return compute_performance_metrics( + timestamps=result.equity.index, + equity=result.equity.to_numpy(dtype=np.float64), + returns=result.returns.to_numpy(dtype=np.float64), + positions=positions, + symbols=result.symbols, + initial_capital=float(result.initial_capital), + liquidated=bool(result.liquidated), + trading_days=trading_days, + ) diff --git a/src/quantbt/optimization/__init__.py b/src/quantbt/optimization/__init__.py new file mode 100644 index 0000000..1184068 --- /dev/null +++ b/src/quantbt/optimization/__init__.py @@ -0,0 +1,94 @@ +"""Domain-agnostic optimization API for QuantBT.""" + +from .callbacks import JsonlOptimizationLogger, SingleObjectiveEarlyStopping +from .candidate_selection import CandidateSelector, RobustSelectionConfig, SelectedCandidate, constraints_feasible +from .config import OptimizationConfig, SamplerConfig +from .constraints import CONSTRAINTS_USER_ATTR, constraints_from_trial, set_trial_constraints +from .evaluator import TrialEvaluator +from .evaluators import ( + ArbitrageGenericEvaluator, + ArbitrageTrialOutput, + GenericEndpointEvaluator, + GridDCAGenericEvaluator, + GridDCATrialOutput, + OptionPackageGenericEvaluator, + OptionTrialOutput, + PreparedIntrabarEvaluator, + PreparedNativeEventStrategyEvaluator, + PreparedPortfolioEvaluator, + PreparedSignalEvaluator, +) +from .objectives import ( + MissingOptimizationMetricError, + ReportMetricObjective, + SharpeObjective, + max_drawdown_constraint, + max_margin_utilization_constraint, + max_rejection_rate_constraint, + max_turnover_constraint, + metric_from_result, + metrics_from_result, + min_trades_constraint, + result_full_report, +) +from .optimizer import OptunaOptimizer +from .multiseed import MultiSeedOptimization +from .result import ObjectiveResult, OptimizationResult, OptimizationTrialRecord +from .samplers import build_sampler +from .space import ( + SearchSpaceInfo, + build_grid_search_space, + search_space_info, + stable_params_key, + suggest_parameter, + suggest_params, +) + +__all__ = [ + "CONSTRAINTS_USER_ATTR", + "ArbitrageGenericEvaluator", + "ArbitrageTrialOutput", + "CandidateSelector", + "GenericEndpointEvaluator", + "GridDCAGenericEvaluator", + "GridDCATrialOutput", + "JsonlOptimizationLogger", + "MissingOptimizationMetricError", + "MultiSeedOptimization", + "ObjectiveResult", + "OptionPackageGenericEvaluator", + "OptionTrialOutput", + "OptimizationConfig", + "OptimizationResult", + "OptimizationTrialRecord", + "OptunaOptimizer", + "PreparedIntrabarEvaluator", + "PreparedNativeEventStrategyEvaluator", + "PreparedPortfolioEvaluator", + "PreparedSignalEvaluator", + "ReportMetricObjective", + "RobustSelectionConfig", + "SamplerConfig", + "SearchSpaceInfo", + "SelectedCandidate", + "SharpeObjective", + "SingleObjectiveEarlyStopping", + "TrialEvaluator", + "build_grid_search_space", + "build_sampler", + "constraints_feasible", + "constraints_from_trial", + "max_drawdown_constraint", + "max_margin_utilization_constraint", + "max_rejection_rate_constraint", + "max_turnover_constraint", + "metric_from_result", + "metrics_from_result", + "min_trades_constraint", + "search_space_info", + "set_trial_constraints", + "stable_params_key", + "suggest_parameter", + "suggest_params", + "result_full_report", +] diff --git a/src/quantbt/optimization/callbacks.py b/src/quantbt/optimization/callbacks.py new file mode 100644 index 0000000..bed2372 --- /dev/null +++ b/src/quantbt/optimization/callbacks.py @@ -0,0 +1,116 @@ +"""Callbacks shared by QuantBT optimization workflows.""" + +from __future__ import annotations + +import json +from pathlib import Path +import time +from typing import Optional + + +class SingleObjectiveEarlyStopping: + """Stop a single-objective Optuna study after best-value stagnation.""" + + def __init__(self, patience: int, direction: str, min_delta: float = 1e-4, min_trials: int = 0): + if patience <= 0: + raise ValueError("patience must be positive") + direction = str(direction).lower().strip() + if direction not in {"maximize", "minimize"}: + raise ValueError("direction must be maximize or minimize") + if min_delta < 0.0: + raise ValueError("min_delta must be >= 0") + if min_trials < 0: + raise ValueError("min_trials must be >= 0") + self.patience = int(patience) + self.direction = direction + self.min_delta = float(min_delta) + self.min_trials = int(min_trials) + self._best: Optional[float] = None + self._stale = 0 + self._completed = 0 + + def __call__(self, study, trial) -> None: + try: + import optuna + except Exception: # pragma: no cover - optuna import guard + optuna = None + if optuna is not None and trial.state is not optuna.trial.TrialState.COMPLETE: + return + try: + current = float(study.best_value) + except Exception: + return + self._completed += 1 + if self._is_improved(current): + self._best = current + self._stale = 0 + else: + self._stale += 1 + if self._completed >= self.min_trials and self._stale >= self.patience: + study.stop() + + def _is_improved(self, current: float) -> bool: + if self._best is None: + return True + if self.direction == "maximize": + return current > self._best + self.min_delta + return current < self._best - self.min_delta + + +class JsonlOptimizationLogger: + """Append parseable JSONL trial records. + + Single-objective studies log when the best trial changes. Multi-objective + studies log every completed trial because there is no scalar best value. + """ + + def __init__(self, path, *, objective_count: int): + self.path = Path(path) + self.objective_count = int(objective_count) + self._previous_best_number: Optional[int] = None + self.path.parent.mkdir(parents=True, exist_ok=True) + + def __call__(self, study, frozen_trial) -> None: + try: + import optuna + except Exception: # pragma: no cover - optuna import guard + optuna = None + if optuna is not None and frozen_trial.state is not optuna.trial.TrialState.COMPLETE: + return + if self.objective_count == 1: + try: + best_number = int(study.best_trial.number) + except Exception: + return + if best_number == self._previous_best_number: + return + self._previous_best_number = best_number + row = { + "trial": int(frozen_trial.number), + "state": str(frozen_trial.state.name), + "values": _trial_values(frozen_trial), + "params": dict(frozen_trial.user_attrs.get("quantbt_full_params", frozen_trial.params)), + "metrics": dict(frozen_trial.user_attrs.get("quantbt_metrics", {})), + "constraints": list(frozen_trial.user_attrs.get("quantbt_constraints", ())), + "metadata": dict(frozen_trial.user_attrs.get("quantbt_metadata", {})), + "duration_seconds": _duration_seconds(frozen_trial), + "logged_at_unix": time.time(), + } + with self.path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(row, sort_keys=True, default=str) + "\n") + + +def _trial_values(frozen_trial) -> list[float]: + if getattr(frozen_trial, "values", None) is not None: + return [float(value) for value in frozen_trial.values] + if getattr(frozen_trial, "value", None) is not None: + return [float(frozen_trial.value)] + return [] + + +def _duration_seconds(frozen_trial) -> Optional[float]: + start = getattr(frozen_trial, "datetime_start", None) + complete = getattr(frozen_trial, "datetime_complete", None) + if start is None or complete is None: + return None + return float((complete - start).total_seconds()) diff --git a/src/quantbt/optimization/candidate_selection.py b/src/quantbt/optimization/candidate_selection.py new file mode 100644 index 0000000..2583941 --- /dev/null +++ b/src/quantbt/optimization/candidate_selection.py @@ -0,0 +1,397 @@ +"""Candidate selection helpers for optimization results.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from statistics import median +from typing import Any, Iterable, Optional + +from .result import OptimizationResult, OptimizationTrialRecord + + +def constraints_feasible(constraints: tuple[float, ...]) -> bool: + """Return True when all Optuna formal constraints are feasible.""" + + return all(float(value) <= 0.0 for value in constraints) + + +@dataclass(frozen=True) +class SelectedCandidate: + """Selected production candidate after feasibility/robustness filtering.""" + + params: dict[str, Any] + values: tuple[float, ...] = () + metrics: dict[str, float] = field(default_factory=dict) + constraints: tuple[float, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RobustSelectionConfig: + """Configuration for plateau-based production candidate selection. + + The selector is deliberately post-optimization: the sampler still learns + from the raw objective surface, while the final production params are + selected from a stable feasible neighborhood instead of a single spike. + """ + + top_quantile: float = 0.10 + min_trades: Optional[float] = None + max_drawdown_pct: Optional[float] = None + neighborhood_radius: float = 0.10 + min_neighbor_count: int = 3 + seed_consensus: int = 1 + instability_penalty: float = 0.25 + worst_weight: float = 0.25 + drawdown_penalty: float = 0.0 + size_bonus: float = 0.01 + ignore_params: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not (0.0 < float(self.top_quantile) <= 1.0): + raise ValueError("top_quantile must be in (0, 1]") + if float(self.neighborhood_radius) < 0.0: + raise ValueError("neighborhood_radius must be non-negative") + if int(self.min_neighbor_count) <= 0: + raise ValueError("min_neighbor_count must be positive") + if int(self.seed_consensus) <= 0: + raise ValueError("seed_consensus must be positive") + object.__setattr__(self, "ignore_params", tuple(str(name) for name in self.ignore_params)) + + +@dataclass(frozen=True) +class CandidateSelector: + """Small public selector interface. + + This is intentionally conservative. Robust WFO plateau selectors can plug + into this interface later; Phase 32B provides best/feasible/Pareto policies + so Optuna's best trial is not silently treated as production params. + """ + + mode: str = "feasible_best" + objective_index: int = 0 + config: Optional[RobustSelectionConfig] = None + + def select(self, result: OptimizationResult) -> SelectedCandidate: + mode = str(self.mode).lower().strip() + if mode in {"best", "single_best"}: + return self._single_best(result, require_feasible=False) + if mode in {"feasible_best", "best_feasible"}: + return self._single_best(result, require_feasible=True) + if mode in {"pareto_first", "first_pareto"}: + return self._pareto_first(result) + if mode in {"robust_plateau", "plateau_robust"}: + return self._robust_plateau(result) + raise ValueError(f"unsupported candidate selector mode={self.mode!r}") + + def _single_best(self, result: OptimizationResult, *, require_feasible: bool) -> SelectedCandidate: + direction = _direction(result, int(self.objective_index)) + completed = [record for record in result.trials if record.state == "COMPLETE" and len(record.values) > int(self.objective_index)] + if require_feasible: + completed = [record for record in completed if constraints_feasible(record.constraints)] + if not completed: + raise ValueError("no completed feasible optimization trials") + reverse = direction == "maximize" + best = sorted(completed, key=lambda record: record.values[int(self.objective_index)], reverse=reverse)[0] + return _selected_from_record( + best, + metadata={ + "selector": self.mode, + "objective_index": int(self.objective_index), + "feasibility_filter": bool(require_feasible), + }, + ) + + def _pareto_first(self, result: OptimizationResult) -> SelectedCandidate: + pareto = [trial for trial in result.pareto_trials if constraints_feasible(tuple(float(value) for value in trial.user_attrs.get("quantbt_constraints", ())))] + if not pareto: + raise ValueError("optimization result has no Pareto trials") + trial = pareto[0] + params = dict(trial.user_attrs.get("quantbt_full_params", trial.params)) + return SelectedCandidate( + params=params, + values=tuple(float(value) for value in (trial.values or ())), + metrics=dict(trial.user_attrs.get("quantbt_metrics", {})), + constraints=tuple(float(value) for value in trial.user_attrs.get("quantbt_constraints", ())), + metadata={ + "selector": self.mode, + "trial_number": int(trial.number), + "pareto_count": int(len(result.pareto_trials)), + "feasible_pareto_count": int(len(pareto)), + }, + ) + + def _robust_plateau(self, result: OptimizationResult) -> SelectedCandidate: + config = self.config or RobustSelectionConfig() + objective_index = int(self.objective_index) + direction = _direction(result, objective_index) + feasible = [ + record + for record in result.trials + if _record_feasible_for_robust(record, objective_index=objective_index, config=config) + ] + if not feasible: + raise ValueError("no completed feasible optimization trials for robust plateau selection") + + ranked = sorted( + feasible, + key=lambda record: _signed_objective(record, objective_index, direction), + reverse=True, + ) + top_n = max( + 1, + int(math.ceil(len(ranked) * float(config.top_quantile))), + min(int(config.min_neighbor_count), len(ranked)), + ) + top_n = min(top_n, len(ranked)) + top = ranked[:top_n] + param_names = _param_names(feasible, ignore=config.ignore_params) + fallback_reasons: list[str] = [] + scored: list[dict[str, Any]] = [] + for record in top: + neighbors = [ + neighbor + for neighbor in top + if _param_distance(record.params, neighbor.params, feasible, param_names) <= float(config.neighborhood_radius) + ] + if not neighbors: + neighbors = [record] + seed_count = _seed_consensus_count(neighbors) + meets_count = len(neighbors) >= int(config.min_neighbor_count) + meets_seed = seed_count >= int(config.seed_consensus) + if meets_count and meets_seed: + scored.append(_score_neighborhood(record, neighbors, objective_index, direction, config, feasible, param_names)) + + if not scored: + fallback_reasons.append("no_candidate_met_neighbor_or_seed_consensus") + for record in top: + neighbors = [ + neighbor + for neighbor in top + if _param_distance(record.params, neighbor.params, feasible, param_names) <= float(config.neighborhood_radius) + ] or [record] + scored.append(_score_neighborhood(record, neighbors, objective_index, direction, config, feasible, param_names)) + + best_cluster = sorted(scored, key=lambda row: row["plateau_score"], reverse=True)[0] + selected_record = _medoid_record(best_cluster["neighbors"], feasible, param_names, objective_index, direction) + result.robust_candidates = [ + { + "trial_number": int(row["center"].number), + "plateau_score": float(row["plateau_score"]), + "neighbor_count": int(len(row["neighbors"])), + "seed_consensus_count": int(row["seed_consensus_count"]), + "median_objective": float(row["median_objective"]), + "worst_objective": float(row["worst_objective"]), + "objective_std": float(row["objective_std"]), + "params": dict(row["center"].params), + } + for row in sorted(scored, key=lambda item: item["plateau_score"], reverse=True) + ] + metadata = { + "selector": self.mode, + "selected_by": "robust_plateau", + "objective_index": objective_index, + "top_quantile": float(config.top_quantile), + "top_trials": int(top_n), + "feasible_trials": int(len(feasible)), + "neighborhood_radius": float(config.neighborhood_radius), + "min_neighbor_count": int(config.min_neighbor_count), + "seed_consensus": int(config.seed_consensus), + "seed_consensus_count": int(best_cluster["seed_consensus_count"]), + "neighbor_count": int(len(best_cluster["neighbors"])), + "plateau_score": float(best_cluster["plateau_score"]), + "median_objective": float(best_cluster["median_objective"]), + "worst_objective": float(best_cluster["worst_objective"]), + "objective_std": float(best_cluster["objective_std"]), + "cluster_center_trial": int(best_cluster["center"].number), + "medoid_trial_number": int(selected_record.number), + "fallback_reasons": fallback_reasons, + "param_names": param_names, + } + return _selected_from_record(selected_record, metadata=metadata) + + +def _selected_from_record(record: OptimizationTrialRecord, *, metadata: Optional[dict[str, Any]] = None) -> SelectedCandidate: + merged_metadata = dict(record.metadata) + merged_metadata.update(metadata or {}) + merged_metadata["trial_number"] = int(record.number) + return SelectedCandidate( + params=dict(record.params), + values=tuple(record.values), + metrics=dict(record.metrics), + constraints=tuple(record.constraints), + metadata=merged_metadata, + ) + + +def _direction(result: OptimizationResult, objective_index: int) -> str: + try: + directions = tuple(str(direction.name).lower() for direction in result.study.directions) + except Exception: + directions = ("maximize",) + if objective_index < 0 or objective_index >= len(directions): + raise ValueError("objective_index out of range for optimization directions") + return directions[objective_index] + + +def _record_feasible_for_robust( + record: OptimizationTrialRecord, + *, + objective_index: int, + config: RobustSelectionConfig, +) -> bool: + if record.state != "COMPLETE" or len(record.values) <= int(objective_index): + return False + if not constraints_feasible(record.constraints): + return False + if config.min_trades is not None: + trades = _metric(record, ("num_trades", "trades", "trade_count")) + if trades is None or float(trades) < float(config.min_trades): + return False + if config.max_drawdown_pct is not None: + mdd = _metric(record, ("max_drawdown_pct", "mdd_pct", "max_dd_pct")) + if mdd is None or float(mdd) > float(config.max_drawdown_pct): + return False + return True + + +def _metric(record: OptimizationTrialRecord, names: Iterable[str]) -> Optional[float]: + for name in names: + if name in record.metrics: + return float(record.metrics[name]) + return None + + +def _signed_objective(record: OptimizationTrialRecord, objective_index: int, direction: str) -> float: + value = float(record.values[int(objective_index)]) + if direction == "minimize": + return -value + return value + + +def _param_names(records: Iterable[OptimizationTrialRecord], *, ignore: tuple[str, ...]) -> list[str]: + ignored = set(ignore) + names: set[str] = set() + for record in records: + names.update(str(name) for name in record.params if str(name) not in ignored) + return sorted(names) + + +def _param_distance( + left: dict[str, Any], + right: dict[str, Any], + records: Iterable[OptimizationTrialRecord], + param_names: list[str], +) -> float: + if not param_names: + return 0.0 + total = 0.0 + for name in param_names: + lv = left.get(name) + rv = right.get(name) + if _is_numeric(lv) and _is_numeric(rv): + span = _numeric_span(records, name) + diff = 0.0 if span <= 0.0 else abs(float(lv) - float(rv)) / span + else: + diff = 0.0 if lv == rv else 1.0 + total += diff * diff + return math.sqrt(total / len(param_names)) + + +def _numeric_span(records: Iterable[OptimizationTrialRecord], name: str) -> float: + values = [float(record.params[name]) for record in records if name in record.params and _is_numeric(record.params[name])] + if not values: + return 0.0 + return float(max(values) - min(values)) + + +def _is_numeric(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value)) + + +def _seed_labels(records: Iterable[OptimizationTrialRecord]) -> set[str]: + labels = set() + for record in records: + for key in ("quantbt_seed", "seed"): + if key in record.metadata: + labels.add(str(record.metadata[key])) + break + return labels + + +def _seed_consensus_count(records: Iterable[OptimizationTrialRecord]) -> int: + records = list(records) + labels = _seed_labels(records) + if labels: + return len(labels) + return 1 if records else 0 + + +def _score_neighborhood( + center: OptimizationTrialRecord, + neighbors: list[OptimizationTrialRecord], + objective_index: int, + direction: str, + config: RobustSelectionConfig, + all_records: list[OptimizationTrialRecord], + param_names: list[str], +) -> dict[str, Any]: + signed = [_signed_objective(record, objective_index, direction) for record in neighbors] + med = float(median(signed)) + worst = float(min(signed)) + std = _std(signed) + mdds = [_metric(record, ("max_drawdown_pct", "mdd_pct", "max_dd_pct")) for record in neighbors] + mdd_penalty = float(median([float(value) for value in mdds if value is not None])) if any(value is not None for value in mdds) else 0.0 + score = ( + med + + float(config.worst_weight) * worst + - float(config.instability_penalty) * std + - float(config.drawdown_penalty) * mdd_penalty + + float(config.size_bonus) * math.log1p(len(neighbors)) + ) + return { + "center": center, + "neighbors": neighbors, + "plateau_score": float(score), + "median_objective": med, + "worst_objective": worst, + "objective_std": std, + "seed_consensus_count": _seed_consensus_count(neighbors), + "mean_distance": _mean_distance(center, neighbors, all_records, param_names), + } + + +def _std(values: list[float]) -> float: + if len(values) <= 1: + return 0.0 + mean = sum(values) / len(values) + return math.sqrt(sum((value - mean) ** 2 for value in values) / len(values)) + + +def _mean_distance( + center: OptimizationTrialRecord, + neighbors: list[OptimizationTrialRecord], + records: list[OptimizationTrialRecord], + param_names: list[str], +) -> float: + if not neighbors: + return 0.0 + return sum(_param_distance(center.params, record.params, records, param_names) for record in neighbors) / len(neighbors) + + +def _medoid_record( + neighbors: list[OptimizationTrialRecord], + all_records: list[OptimizationTrialRecord], + param_names: list[str], + objective_index: int, + direction: str, +) -> OptimizationTrialRecord: + return sorted( + neighbors, + key=lambda record: ( + _mean_distance(record, neighbors, all_records, param_names), + -_signed_objective(record, objective_index, direction), + int(record.number), + ), + )[0] diff --git a/src/quantbt/optimization/config.py b/src/quantbt/optimization/config.py new file mode 100644 index 0000000..ab61560 --- /dev/null +++ b/src/quantbt/optimization/config.py @@ -0,0 +1,84 @@ +"""Configuration objects for QuantBT domain-agnostic optimization.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional, Tuple, Union + + +Direction = str + + +@dataclass(frozen=True) +class OptimizationConfig: + """Runtime configuration for :class:`OptunaOptimizer`. + + The config intentionally avoids strategy/domain fields. Domain-specific + data, endpoints, prepared runners, and metric extraction belong in + evaluator adapters. + """ + + study_name: str + n_trials: int = 300 + directions: Tuple[Direction, ...] = ("maximize",) + seed: Optional[int] = 42 + n_jobs: int = 1 + early_stopping_rounds: Optional[int] = None + early_stopping_min_trials: int = 0 + early_stopping_min_delta: float = 1e-4 + show_progress_bar: bool = True + storage: Optional[str] = None + load_if_exists: bool = True + log_path: Optional[Union[str, Path]] = None + duplicate_policy: str = "prune" + exception_policy: str = "raise" + + def __post_init__(self) -> None: + if not str(self.study_name).strip(): + raise ValueError("study_name must be non-empty") + if self.n_trials <= 0: + raise ValueError("n_trials must be positive") + if not self.directions: + raise ValueError("at least one direction is required") + directions = tuple(str(direction).lower().strip() for direction in self.directions) + invalid = set(directions) - {"maximize", "minimize"} + if invalid: + raise ValueError(f"invalid directions: {invalid}") + object.__setattr__(self, "directions", directions) + if self.n_jobs <= 0: + raise ValueError("n_jobs must be positive") + if self.early_stopping_rounds is not None and self.early_stopping_rounds <= 0: + raise ValueError("early_stopping_rounds must be positive when provided") + if self.early_stopping_min_trials < 0: + raise ValueError("early_stopping_min_trials must be >= 0") + if self.early_stopping_min_delta < 0.0: + raise ValueError("early_stopping_min_delta must be >= 0") + duplicate_policy = str(self.duplicate_policy).lower().strip() + if duplicate_policy not in {"allow", "prune", "raise"}: + raise ValueError("duplicate_policy must be allow, prune, or raise") + object.__setattr__(self, "duplicate_policy", duplicate_policy) + exception_policy = str(self.exception_policy).lower().strip() + if exception_policy not in {"raise", "fail_trial", "prune"}: + raise ValueError("exception_policy must be raise, fail_trial, or prune") + object.__setattr__(self, "exception_policy", exception_policy) + + +@dataclass(frozen=True) +class SamplerConfig: + """Optuna sampler selection and sampler-specific kwargs.""" + + name: str = "tpe" + kwargs: dict[str, Any] = field(default_factory=dict) + constraint_mode: str = "sampler" + + def __post_init__(self) -> None: + name = str(self.name).lower().strip() + if not name: + raise ValueError("sampler name must be non-empty") + object.__setattr__(self, "name", name) + object.__setattr__(self, "kwargs", dict(self.kwargs or {})) + constraint_mode = str(self.constraint_mode).lower().strip() + if constraint_mode not in {"sampler", "post_filter"}: + raise ValueError("constraint_mode must be sampler or post_filter") + object.__setattr__(self, "constraint_mode", constraint_mode) diff --git a/src/quantbt/optimization/constraints.py b/src/quantbt/optimization/constraints.py new file mode 100644 index 0000000..b0ee19a --- /dev/null +++ b/src/quantbt/optimization/constraints.py @@ -0,0 +1,22 @@ +"""Formal constraint helpers for Optuna-backed optimization.""" + +from __future__ import annotations + +from typing import Sequence + + +CONSTRAINTS_USER_ATTR = "quantbt_constraints" + + +def set_trial_constraints(trial, constraints: Sequence[float]) -> tuple[float, ...]: + """Store constraints on an Optuna trial using QuantBT's canonical key.""" + + values = tuple(float(value) for value in constraints) + trial.set_user_attr(CONSTRAINTS_USER_ATTR, values) + return values + + +def constraints_from_trial(frozen_trial) -> tuple[float, ...]: + """Optuna sampler callback returning trial constraints.""" + + return tuple(float(value) for value in frozen_trial.user_attrs.get(CONSTRAINTS_USER_ATTR, ())) diff --git a/src/quantbt/optimization/evaluator.py b/src/quantbt/optimization/evaluator.py new file mode 100644 index 0000000..8d1b776 --- /dev/null +++ b/src/quantbt/optimization/evaluator.py @@ -0,0 +1,21 @@ +"""Evaluator protocol for domain-specific optimization adapters.""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol + +from .result import ObjectiveResult + + +class TrialEvaluator(Protocol): + """Protocol implemented by domain adapters. + + The optimizer only sees parameters and an ObjectiveResult. Signal, + intrabar, portfolio, arbitrage, grid/DCA, and options details must remain + inside evaluator implementations. + """ + + def evaluate(self, params: Mapping[str, Any]) -> ObjectiveResult: + """Evaluate one parameter set and return objective values.""" + + ... diff --git a/src/quantbt/optimization/evaluators/__init__.py b/src/quantbt/optimization/evaluators/__init__.py new file mode 100644 index 0000000..b3aff2c --- /dev/null +++ b/src/quantbt/optimization/evaluators/__init__.py @@ -0,0 +1,32 @@ +"""Domain-specific optimization evaluators. + +Phase 32A intentionally keeps this namespace empty except for package +discovery. Prepared signal/intrabar/portfolio and generic endpoint evaluators +are implemented in Phase 32B. +""" + +__all__: list[str] = [] +"""Domain evaluator adapters for QuantBT optimization.""" + +from .arbitrage import ArbitrageGenericEvaluator, ArbitrageTrialOutput +from .generic import GenericEndpointEvaluator +from .grid_dca import GridDCAGenericEvaluator, GridDCATrialOutput +from .intrabar import PreparedIntrabarEvaluator +from .native_event import PreparedNativeEventStrategyEvaluator +from .options import OptionPackageGenericEvaluator, OptionTrialOutput +from .portfolio import PreparedPortfolioEvaluator +from .signal import PreparedSignalEvaluator + +__all__ = [ + "ArbitrageGenericEvaluator", + "ArbitrageTrialOutput", + "GenericEndpointEvaluator", + "GridDCAGenericEvaluator", + "GridDCATrialOutput", + "OptionPackageGenericEvaluator", + "OptionTrialOutput", + "PreparedIntrabarEvaluator", + "PreparedNativeEventStrategyEvaluator", + "PreparedPortfolioEvaluator", + "PreparedSignalEvaluator", +] diff --git a/src/quantbt/optimization/evaluators/arbitrage.py b/src/quantbt/optimization/evaluators/arbitrage.py new file mode 100644 index 0000000..4a5e99b --- /dev/null +++ b/src/quantbt/optimization/evaluators/arbitrage.py @@ -0,0 +1,22 @@ +"""Generic arbitrage optimization adapter contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .generic import GenericEndpointEvaluator + + +@dataclass(frozen=True) +class ArbitrageTrialOutput: + """Domain output contract for arbitrage trial builders.""" + + signal: Any + hedge_ratios: Any = None + run_overrides: dict[str, Any] = field(default_factory=dict) + + +class ArbitrageGenericEvaluator(GenericEndpointEvaluator): + """Generic fallback for arbitrage endpoints until specialized evaluators exist.""" + diff --git a/src/quantbt/optimization/evaluators/generic.py b/src/quantbt/optimization/evaluators/generic.py new file mode 100644 index 0000000..4bc2a5a --- /dev/null +++ b/src/quantbt/optimization/evaluators/generic.py @@ -0,0 +1,34 @@ +"""Generic QuantBT endpoint evaluator fallback.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping + +from ..result import ObjectiveResult + + +ObjectiveBuilder = Callable[[Any, Mapping[str, Any]], ObjectiveResult] + + +@dataclass +class GenericEndpointEvaluator: + """Evaluate params by building endpoint inputs and calling a run function.""" + + build_run_inputs: Callable[[Mapping[str, Any]], Mapping[str, Any]] + run_func: Callable[..., Any] + objective_builder: ObjectiveBuilder + metadata: dict[str, Any] = field(default_factory=dict) + + last_result: Any = field(default=None, init=False) + last_run_inputs: dict[str, Any] = field(default_factory=dict, init=False) + + def evaluate(self, params: Mapping[str, Any]) -> ObjectiveResult: + run_inputs = dict(self.build_run_inputs(params)) + result = self.run_func(**run_inputs) + objective = self.objective_builder(result, params) + if not isinstance(objective, ObjectiveResult): + raise TypeError("objective_builder must return ObjectiveResult") + self.last_run_inputs = run_inputs + self.last_result = result + return objective diff --git a/src/quantbt/optimization/evaluators/grid_dca.py b/src/quantbt/optimization/evaluators/grid_dca.py new file mode 100644 index 0000000..a725333 --- /dev/null +++ b/src/quantbt/optimization/evaluators/grid_dca.py @@ -0,0 +1,22 @@ +"""Generic grid/DCA optimization adapter contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .generic import GenericEndpointEvaluator + + +@dataclass(frozen=True) +class GridDCATrialOutput: + """Domain output contract for structural grid/DCA trial builders.""" + + levels: Any = None + order_plan: Any = None + run_overrides: dict[str, Any] = field(default_factory=dict) + + +class GridDCAGenericEvaluator(GenericEndpointEvaluator): + """Generic fallback for grid/DCA endpoints until prepared adapters exist.""" + diff --git a/src/quantbt/optimization/evaluators/intrabar.py b/src/quantbt/optimization/evaluators/intrabar.py new file mode 100644 index 0000000..1f8e484 --- /dev/null +++ b/src/quantbt/optimization/evaluators/intrabar.py @@ -0,0 +1,54 @@ +"""Prepared intrabar evaluator.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping, Optional + +import pandas as pd + +from ..result import ObjectiveResult +from .generic import ObjectiveBuilder + + +@dataclass +class PreparedIntrabarEvaluator: + """Replay intrabar strategy intents through a prepared intrabar runner.""" + + runner: Any + strategy_func: Callable[..., Any] + objective_builder: ObjectiveBuilder + intent_builder: Optional[Callable[[Any, Mapping[str, Any]], Any]] = None + report_level: str = "minimal" + pass_runner: bool = False + pass_market: bool = False + + last_result: Any = field(default=None, init=False) + last_intent: Any = field(default=None, init=False) + + def evaluate(self, params: Mapping[str, Any]) -> ObjectiveResult: + if self.pass_runner: + output = self.strategy_func(self.runner, params) + elif self.pass_market: + output = self.strategy_func(self.runner.market, params) + else: + output = self.strategy_func(params) + intent = self._to_intent(output, params) + result = self.runner.run(intent, report_level=self.report_level) + objective = self.objective_builder(result, params) + if not isinstance(objective, ObjectiveResult): + raise TypeError("objective_builder must return ObjectiveResult") + self.last_intent = intent + self.last_result = result + return objective + + def _to_intent(self, output: Any, params: Mapping[str, Any]) -> Any: + from ...core.intrabar_reference import IntrabarIntentTape + + if self.intent_builder is not None: + return self.intent_builder(output, params) + if isinstance(output, IntrabarIntentTape): + return output + if isinstance(output, pd.DataFrame): + return IntrabarIntentTape.from_frame(output) + raise TypeError("intrabar strategy must return IntrabarIntentTape or DataFrame, or provide intent_builder") diff --git a/src/quantbt/optimization/evaluators/native_event.py b/src/quantbt/optimization/evaluators/native_event.py new file mode 100644 index 0000000..0902e36 --- /dev/null +++ b/src/quantbt/optimization/evaluators/native_event.py @@ -0,0 +1,51 @@ +"""Prepared native-event strategy evaluator.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping + +from ..result import ObjectiveResult +from ...backends.native_event import NativeEventScoreRequirements +from .generic import ObjectiveBuilder + + +@dataclass +class PreparedNativeEventStrategyEvaluator: + """Evaluate reactive native-event strategies through a prepared runner.""" + + runner: Any + strategy_factory: Callable[[Mapping[str, Any]], Any] + objective_builder: ObjectiveBuilder + trading_days: int = 365 + retain_last: bool = False + score_requirements: NativeEventScoreRequirements = field( + default_factory=NativeEventScoreRequirements.scalar_score_contract + ) + + last_result: Any = field(default=None, init=False) + last_strategy: Any = field(default=None, init=False) + + def evaluate(self, params: Mapping[str, Any]) -> ObjectiveResult: + strategy = self.strategy_factory(params) + requirements = NativeEventScoreRequirements.from_strategy( + strategy, + base=self.score_requirements, + ) + result = self.runner.score( + strategy, + trading_days=self.trading_days, + score_requirements=requirements, + ) + objective = self.objective_builder(result, params) + if not isinstance(objective, ObjectiveResult): + raise TypeError("objective_builder must return ObjectiveResult") + if self.retain_last: + self.last_strategy = strategy + self.last_result = result + else: + # Optimization can run thousands of trials. Retaining a strategy + # and score result pins their arrays until the evaluator dies. + self.last_strategy = None + self.last_result = None + return objective diff --git a/src/quantbt/optimization/evaluators/options.py b/src/quantbt/optimization/evaluators/options.py new file mode 100644 index 0000000..ad84a7c --- /dev/null +++ b/src/quantbt/optimization/evaluators/options.py @@ -0,0 +1,22 @@ +"""Generic option-package optimization adapter contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .generic import GenericEndpointEvaluator + + +@dataclass(frozen=True) +class OptionTrialOutput: + """Domain output contract for option package trial builders.""" + + package: Any = None + hedge_plan: Any = None + run_overrides: dict[str, Any] = field(default_factory=dict) + + +class OptionPackageGenericEvaluator(GenericEndpointEvaluator): + """Generic fallback for option package endpoints until prepared adapters exist.""" + diff --git a/src/quantbt/optimization/evaluators/portfolio.py b/src/quantbt/optimization/evaluators/portfolio.py new file mode 100644 index 0000000..75d864b --- /dev/null +++ b/src/quantbt/optimization/evaluators/portfolio.py @@ -0,0 +1,42 @@ +"""Prepared native portfolio evaluator.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping, Optional + +from ..result import ObjectiveResult +from .generic import ObjectiveBuilder + + +@dataclass +class PreparedPortfolioEvaluator: + """Replay strategy position matrices through a prepared portfolio context.""" + + prepared_context: Any + strategy_func: Callable[..., Any] + objective_builder: ObjectiveBuilder + pass_context: bool = False + positions_key: Optional[str] = None + + last_result: Any = field(default=None, init=False) + last_positions: Any = field(default=None, init=False) + + def evaluate(self, params: Mapping[str, Any]) -> ObjectiveResult: + output = self.strategy_func(self.prepared_context, params) if self.pass_context else self.strategy_func(params) + positions = _extract_positions(output, positions_key=self.positions_key) + result = self.prepared_context.backtest(positions=positions) + objective = self.objective_builder(result, params) + if not isinstance(objective, ObjectiveResult): + raise TypeError("objective_builder must return ObjectiveResult") + self.last_positions = positions + self.last_result = result + return objective + + +def _extract_positions(output: Any, *, positions_key: Optional[str]) -> Any: + if positions_key is None: + return output + if isinstance(output, Mapping): + return output[positions_key] + return getattr(output, positions_key) diff --git a/src/quantbt/optimization/evaluators/signal.py b/src/quantbt/optimization/evaluators/signal.py new file mode 100644 index 0000000..7a5cd0e --- /dev/null +++ b/src/quantbt/optimization/evaluators/signal.py @@ -0,0 +1,43 @@ +"""Prepared single-symbol signal evaluator.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping, Optional + +from ..result import ObjectiveResult +from .generic import ObjectiveBuilder + + +@dataclass +class PreparedSignalEvaluator: + """Replay strategy signals through a prepared single-symbol context.""" + + prepared_context: Any + strategy_func: Callable[..., Any] + objective_builder: ObjectiveBuilder + pass_context: bool = False + signal_key: Optional[str] = None + signal_col: Optional[str] = None + + last_result: Any = field(default=None, init=False) + last_signal: Any = field(default=None, init=False) + + def evaluate(self, params: Mapping[str, Any]) -> ObjectiveResult: + output = self.strategy_func(self.prepared_context, params) if self.pass_context else self.strategy_func(params) + signal = _extract_signal(output, signal_key=self.signal_key) + result = self.prepared_context.backtest(signal=signal, signal_col=self.signal_col) + objective = self.objective_builder(result, params) + if not isinstance(objective, ObjectiveResult): + raise TypeError("objective_builder must return ObjectiveResult") + self.last_signal = signal + self.last_result = result + return objective + + +def _extract_signal(output: Any, *, signal_key: Optional[str]) -> Any: + if signal_key is None: + return output + if isinstance(output, Mapping): + return output[signal_key] + return getattr(output, signal_key) diff --git a/src/quantbt/optimization/multiseed.py b/src/quantbt/optimization/multiseed.py new file mode 100644 index 0000000..e3e42f4 --- /dev/null +++ b/src/quantbt/optimization/multiseed.py @@ -0,0 +1,176 @@ +"""Multi-seed optimization orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any, Callable, Mapping, Optional, Sequence + +from .candidate_selection import CandidateSelector, RobustSelectionConfig +from .config import OptimizationConfig, SamplerConfig +from .evaluator import TrialEvaluator +from .optimizer import OptunaOptimizer, _apply_baseline_floor, _is_better +from .result import OptimizationResult, OptimizationTrialRecord + + +@dataclass(frozen=True) +class MultiSeedOptimization: + """Run the same search across several sampler seeds and aggregate trials. + + This is a search-quality tool, not a different objective. Each seed still + optimizes the same evaluator; the aggregate result then selects production + params from regions that survive multiple random trajectories. + """ + + evaluator: TrialEvaluator + config: OptimizationConfig + sampler_config: SamplerConfig = field(default_factory=SamplerConfig) + seeds: Sequence[Optional[int]] = (None, 41, 42, 43, 44) + trials_per_seed: Optional[int] = None + + def optimize( + self, + *, + param_ranges: Mapping[str, Any], + fixed_params: Optional[Mapping[str, Any]] = None, + initial_trials: Optional[Sequence[Mapping[str, Any]]] = None, + effective_params_builder: Optional[Callable[[Mapping[str, Any]], Mapping[str, Any]]] = None, + candidate_selector: Optional[CandidateSelector] = None, + ) -> OptimizationResult: + if not self.seeds: + raise ValueError("MultiSeedOptimization.seeds must be non-empty") + + seed_results: list[OptimizationResult] = [] + combined_trials: list[OptimizationTrialRecord] = [] + seed_summaries: list[dict[str, Any]] = [] + global_number = 0 + for seed_index, seed in enumerate(self.seeds): + seed_label = "none" if seed is None else str(seed) + config = replace( + self.config, + seed=seed, + n_trials=int(self.trials_per_seed or self.config.n_trials), + study_name=f"{self.config.study_name}_seed_{seed_label}", + ) + result = OptunaOptimizer( + evaluator=self.evaluator, + config=config, + sampler_config=self.sampler_config, + ).optimize( + param_ranges=param_ranges, + fixed_params=fixed_params, + initial_trials=initial_trials, + effective_params_builder=effective_params_builder, + ) + seed_results.append(result) + seed_summaries.append(_seed_summary(result, seed=seed, seed_index=seed_index)) + for record in result.trials: + metadata = dict(record.metadata) + metadata.update( + { + "quantbt_seed": seed_label, + "quantbt_seed_index": int(seed_index), + "quantbt_original_trial_number": int(record.number), + } + ) + combined_trials.append( + OptimizationTrialRecord( + number=int(global_number), + state=str(record.state), + params=dict(record.params), + values=tuple(record.values), + metrics=dict(record.metrics), + constraints=tuple(record.constraints), + metadata=metadata, + ) + ) + global_number += 1 + + study_view = _StudyDirectionsView(seed_results[0].study.directions) + aggregate = OptimizationResult( + study=study_view, + best_params=None, + best_values=None, + pareto_trials=[], + trials=combined_trials, + trials_frame=None, + ) + aggregate.baseline_trials = [ + record + for record in combined_trials + if record.metadata.get("quantbt_source") == "warm_start" + ] + aggregate.seed_results = seed_summaries + _set_best_from_trials(aggregate) + + selector = candidate_selector or CandidateSelector( + mode="robust_plateau", + config=RobustSelectionConfig(seed_consensus=min(2, len(self.seeds))), + ) + selected = selector.select(aggregate) + aggregate.selected_params = dict(selected.params) + aggregate.selection_metadata = dict(selected.metadata) + aggregate.selection_metadata.update( + { + "selected_by_multiseed": True, + "seed_count": int(len(self.seeds)), + } + ) + aggregate.search_diagnostics = { + "seed_count": int(len(self.seeds)), + "seed_results": seed_summaries, + "completed_trials": int(sum(1 for record in combined_trials if record.state == "COMPLETE")), + "pruned_trials": int(sum(1 for record in combined_trials if record.state == "PRUNED")), + "failed_trials": int(sum(1 for record in combined_trials if record.state == "FAIL")), + "top_parameter_frequency": _top_parameter_frequency(combined_trials), + } + _apply_baseline_floor(aggregate) + return aggregate + + +def _set_best_from_trials(result: OptimizationResult) -> None: + try: + direction = str(result.study.directions[0].name).lower() + except Exception: + direction = "maximize" + completed = [record for record in result.trials if record.state == "COMPLETE" and record.values] + if not completed: + return + best = completed[0] + for record in completed[1:]: + if _is_better(record.values[0], best.values[0], direction): + best = record + result.best_params = dict(best.params) + result.best_values = tuple(best.values) + + +def _seed_summary(result: OptimizationResult, *, seed: Optional[int], seed_index: int) -> dict[str, Any]: + return { + "seed": None if seed is None else int(seed), + "seed_index": int(seed_index), + "best_params": None if result.best_params is None else dict(result.best_params), + "best_values": None if result.best_values is None else tuple(float(value) for value in result.best_values), + "selected_params": None if result.selected_params is None else dict(result.selected_params), + "search_regression": bool(result.search_regression), + "baseline_rank": list(result.search_diagnostics.get("baseline_rank", [])), + "completed_trials": int(result.search_diagnostics.get("completed_trials", 0)), + } + + +def _top_parameter_frequency(records: Sequence[OptimizationTrialRecord]) -> dict[str, dict[str, int]]: + completed = [record for record in records if record.state == "COMPLETE" and record.values] + if not completed: + return {} + ranked = sorted(completed, key=lambda record: record.values[0], reverse=True) + top_n = max(1, len(ranked) // 10) + counts: dict[str, dict[str, int]] = {} + for record in ranked[:top_n]: + for name, value in record.params.items(): + bucket = counts.setdefault(str(name), {}) + label = str(value) + bucket[label] = bucket.get(label, 0) + 1 + return counts + + +class _StudyDirectionsView: + def __init__(self, directions: Sequence[Any]): + self.directions = tuple(directions) diff --git a/src/quantbt/optimization/objectives.py b/src/quantbt/optimization/objectives.py new file mode 100644 index 0000000..80aebac --- /dev/null +++ b/src/quantbt/optimization/objectives.py @@ -0,0 +1,221 @@ +"""Common objective builders for domain-agnostic optimization.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping, Optional, Sequence + +from .result import ObjectiveResult + + +MetricMap = Mapping[str, float] +ConstraintBuilder = Callable[[MetricMap, Mapping[str, Any], Any], float] + + +class MissingOptimizationMetricError(KeyError): + """Raised when an objective/constraint metric is required but unavailable.""" + + +_METRIC_ALIASES = { + "trades": "num_trades", + "trade_count": "num_trades", + "max_drawdown": "max_drawdown_pct", + "mdd": "max_drawdown_pct", + "margin_util": "margin_utilization", + "rejections": "rejection_rate", +} + + +def normalize_metric_name(name: str) -> str: + """Return the canonical QuantBT objective metric name.""" + + key = str(name).strip() + return _METRIC_ALIASES.get(key, key) + + +def result_full_report(result: Any, *, trading_days: int = 365, scope: str = "auto") -> dict[str, Any]: + """Extract the standard metrics report from a QuantBT result-like object.""" + + if hasattr(result, "full_report") and callable(result.full_report): + return dict(result.full_report(trading_days=trading_days, scope=scope)) + metadata = dict(getattr(result, "metadata", {}) or {}) + for key in ("report", "full_report", "metrics"): + value = metadata.get(key) + if isinstance(value, Mapping): + return dict(value) + raise TypeError("result must expose full_report(...) or metadata report/metrics") + + +def metric_from_result( + result: Any, + name: str, + *, + trading_days: int = 365, + scope: str = "auto", + required: bool = True, + default: Optional[float] = None, +) -> float: + """Read a common objective metric from report, diagnostics, or metadata.""" + + canonical = normalize_metric_name(name) + report = result_full_report(result, trading_days=trading_days, scope=scope) + if canonical in report: + return float(report[canonical]) + metadata = dict(getattr(result, "metadata", {}) or {}) + if canonical in metadata: + return float(metadata[canonical]) + if canonical == "margin_utilization": + value = _margin_utilization(result) + if value is not None: + return value + if canonical == "rejection_rate": + value = _rejection_rate(result) + if value is not None: + return value + if required: + raise MissingOptimizationMetricError(f"missing required optimization metric: {canonical}") + return float(0.0 if default is None else default) + + +def metrics_from_result( + result: Any, + *, + names: Sequence[str] = ("sharpe", "max_drawdown_pct", "num_trades", "profit_factor"), + trading_days: int = 365, + scope: str = "auto", +) -> dict[str, float]: + """Extract optional display metrics from a QuantBT result. + + Missing display metrics are omitted. Metrics used as objective values or + formal constraints must be requested through `metric_from_result(..., + required=True)` or the constraint helper functions below. + """ + + metrics: dict[str, float] = {} + report = result_full_report(result, trading_days=trading_days, scope=scope) + for name in names: + canonical = normalize_metric_name(name) + if canonical in report: + metrics[canonical] = float(report[canonical]) + else: + try: + metrics[canonical] = metric_from_result(result, canonical, trading_days=trading_days, scope=scope, required=True) + except MissingOptimizationMetricError: + pass + return metrics + + +def max_drawdown_constraint(max_drawdown_pct: float) -> ConstraintBuilder: + """Constraint: realized max drawdown must be <= `max_drawdown_pct`.""" + + limit = float(max_drawdown_pct) + return lambda metrics, params, result: _required_metric(metrics, "max_drawdown_pct") - limit + + +def min_trades_constraint(min_trades: float) -> ConstraintBuilder: + """Constraint: realized number of trades must be >= `min_trades`.""" + + required = float(min_trades) + return lambda metrics, params, result: required - _required_metric(metrics, "num_trades") + + +def max_turnover_constraint(max_turnover: float) -> ConstraintBuilder: + """Constraint: realized turnover must be <= `max_turnover`.""" + + limit = float(max_turnover) + return lambda metrics, params, result: _required_metric(metrics, "turnover") - limit + + +def max_margin_utilization_constraint(max_margin_utilization: float) -> ConstraintBuilder: + """Constraint: maximum margin utilization must be <= limit.""" + + limit = float(max_margin_utilization) + return lambda metrics, params, result: _required_metric(metrics, "margin_utilization") - limit + + +def max_rejection_rate_constraint(max_rejection_rate: float) -> ConstraintBuilder: + """Constraint: package/order rejection rate must be <= limit.""" + + limit = float(max_rejection_rate) + return lambda metrics, params, result: _required_metric(metrics, "rejection_rate") - limit + + +@dataclass(frozen=True) +class ReportMetricObjective: + """Build an ObjectiveResult from QuantBT full-report metrics. + + Formal constraints keep Optuna's convention: values `<= 0` are feasible. + The score itself is not polluted by arbitrary penalties when a constraint + can express the domain rule explicitly. + """ + + value_metrics: Sequence[str] = ("sharpe",) + metric_names: Sequence[str] = ( + "sharpe", + "max_drawdown_pct", + "num_trades", + "turnover", + "profit_factor", + "margin_utilization", + "rejection_rate", + ) + trading_days: int = 365 + scope: str = "auto" + constraints: Sequence[ConstraintBuilder] = field(default_factory=tuple) + metadata_builder: Optional[Callable[[Any, Mapping[str, Any], MetricMap], Mapping[str, Any]]] = None + + def __call__(self, result: Any, params: Mapping[str, Any]) -> ObjectiveResult: + metrics = metrics_from_result(result, names=self.metric_names, trading_days=self.trading_days, scope=self.scope) + values = tuple(metric_from_result(result, name, trading_days=self.trading_days, scope=self.scope, required=True) for name in self.value_metrics) + constraints = tuple(float(builder(metrics, params, result)) for builder in self.constraints) + metadata = {} if self.metadata_builder is None else dict(self.metadata_builder(result, params, metrics)) + return ObjectiveResult(values=values, metrics=metrics, constraints=constraints, metadata=metadata) + + +@dataclass(frozen=True) +class SharpeObjective(ReportMetricObjective): + """Single-objective Sharpe score with optional formal constraints.""" + + value_metrics: Sequence[str] = ("sharpe",) + + +def _required_metric(metrics: MetricMap, name: str) -> float: + canonical = normalize_metric_name(name) + if canonical not in metrics: + raise MissingOptimizationMetricError(f"missing required optimization metric: {canonical}") + return float(metrics[canonical]) + + +def _margin_utilization(result: Any) -> Optional[float]: + margin = getattr(result, "margin", None) + equity = getattr(result, "equity", None) + try: + if margin is not None and equity is not None and len(margin) and len(equity): + initial = margin["initial_margin"] if "initial_margin" in margin else margin.iloc[:, 0] + util = (initial.astype(float) / equity.astype(float).replace(0.0, float("nan"))).max() + return float(0.0 if util != util else util) + except Exception: + pass + return None + + +def _rejection_rate(result: Any) -> Optional[float]: + metadata = dict(getattr(result, "metadata", {}) or {}) + for key in ("rejection_rate", "package_rejection_rate"): + if key in metadata: + return float(metadata[key]) + rejected = metadata.get("rejected_count", metadata.get("rejections")) + fills = metadata.get("fill_count", metadata.get("fills_count")) + if rejected is not None and fills is not None: + denom = float(rejected) + float(fills) + return 0.0 if denom <= 0.0 else float(rejected) / denom + fills_obj = getattr(result, "fills", ()) + try: + fill_count = len(fills_obj) + if "rejected_count" not in metadata: + return None + rejected_count = int(metadata["rejected_count"]) + denom = fill_count + rejected_count + return 0.0 if denom <= 0 else float(rejected_count) / float(denom) + except Exception: + return None diff --git a/src/quantbt/optimization/optimizer.py b/src/quantbt/optimization/optimizer.py new file mode 100644 index 0000000..b420320 --- /dev/null +++ b/src/quantbt/optimization/optimizer.py @@ -0,0 +1,500 @@ +"""Domain-agnostic Optuna optimizer core.""" + +from __future__ import annotations + +import math +from typing import Any, Callable, Mapping, Optional, Sequence + +from .callbacks import JsonlOptimizationLogger, SingleObjectiveEarlyStopping +from .candidate_selection import constraints_feasible +from .config import OptimizationConfig, SamplerConfig +from .constraints import constraints_from_trial, set_trial_constraints +from .evaluator import TrialEvaluator +from .result import ObjectiveResult, OptimizationResult, OptimizationTrialRecord +from .samplers import build_sampler +from .space import search_space_info, stable_params_key, suggest_params + + +class OptunaOptimizer: + """Generic Optuna orchestration over a domain-specific evaluator.""" + + def __init__( + self, + *, + evaluator: TrialEvaluator, + config: OptimizationConfig, + sampler_config: Optional[SamplerConfig] = None, + ): + self.evaluator = evaluator + self.config = config + self.sampler_config = sampler_config or SamplerConfig() + self._seen_params: set[str] = set() + + def optimize( + self, + *, + param_ranges: Mapping[str, Any], + fixed_params: Optional[Mapping[str, Any]] = None, + initial_trials: Optional[Sequence[Mapping[str, Any]]] = None, + effective_params_builder: Optional[Callable[[Mapping[str, Any]], Mapping[str, Any]]] = None, + candidate_selector=None, + ) -> OptimizationResult: + """Run an Optuna study and return a QuantBT result schema.""" + + try: + import optuna + except Exception as exc: # pragma: no cover - dependency guard + raise ImportError("QuantBT optimization requires optuna") from exc + if int(self.config.n_jobs) != 1: + raise NotImplementedError("parallel optimization is not certified") + + objective_count = len(self.config.directions) + self._seen_params = set() + constraints_callback = ( + constraints_from_trial + if self.sampler_config.name in {"tpe", "nsgaii"} and self.sampler_config.constraint_mode == "sampler" + else None + ) + sampler = build_sampler( + self.sampler_config, + seed=self.config.seed, + search_space=param_ranges, + objective_count=objective_count, + constraints_func=constraints_callback, + ) + study = optuna.create_study( + study_name=self.config.study_name, + directions=list(self.config.directions), + sampler=sampler, + storage=self.config.storage, + load_if_exists=bool(self.config.load_if_exists), + pruner=optuna.pruners.NopPruner(), + ) + self._preload_seen_params(study) + self._enqueue_initial_trials( + study, + param_ranges=param_ranges, + fixed_params=fixed_params, + initial_trials=initial_trials, + ) + callbacks = [] + if self.config.early_stopping_rounds is not None: + if objective_count != 1: + raise ValueError("early stopping is supported for single-objective optimization only") + callbacks.append( + SingleObjectiveEarlyStopping( + self.config.early_stopping_rounds, + self.config.directions[0], + min_delta=float(self.config.early_stopping_min_delta), + min_trials=int(self.config.early_stopping_min_trials), + ) + ) + if self.config.log_path is not None: + callbacks.append(JsonlOptimizationLogger(self.config.log_path, objective_count=objective_count)) + + catch = (Exception,) if self.config.exception_policy == "fail_trial" else () + study.optimize( + lambda trial: self._objective( + trial, + param_ranges, + fixed_params, + objective_count, + effective_params_builder=effective_params_builder, + ), + n_trials=int(self.config.n_trials), + n_jobs=int(self.config.n_jobs), + callbacks=callbacks, + show_progress_bar=bool(self.config.show_progress_bar), + catch=catch, + ) + result = _build_result(study, objective_count) + result.baseline_trials = [ + record + for record in result.trials + if record.metadata.get("quantbt_source") == "warm_start" + ] + result.search_diagnostics = _search_diagnostics( + param_ranges=param_ranges, + fixed_params=fixed_params, + result=result, + objective_index=0, + ) + if candidate_selector is not None: + selected = candidate_selector.select(result) + result.selected_params = dict(getattr(selected, "params", selected)) + result.selection_metadata = dict(getattr(selected, "metadata", {})) + elif objective_count == 1: + if _result_has_constraints(result): + result.selected_params = None + result.selection_metadata = {"selected_by": None, "reason": "constraints_require_explicit_candidate_selector"} + else: + result.selected_params = dict(result.best_params or {}) + _apply_baseline_floor(result) + return result + + def _enqueue_initial_trials(self, study, *, param_ranges, fixed_params, initial_trials) -> None: + if not initial_trials: + return + fixed = dict(fixed_params or {}) + for idx, payload in enumerate(initial_trials): + full_params = dict(payload or {}) + full_params.update(fixed) + trial_params = _trial_params_for_enqueue(full_params, param_ranges, fixed) + study.enqueue_trial( + trial_params, + user_attrs={ + "quantbt_source": "warm_start", + "quantbt_initial_trial_id": int(idx), + "quantbt_initial_full_params": dict(full_params), + }, + skip_if_exists=True, + ) + + def _preload_seen_params(self, study) -> None: + if not self.config.load_if_exists: + return + for trial in getattr(study, "trials", ()): + key = trial.user_attrs.get("quantbt_params_key") + if key is None: + params = trial.user_attrs.get("quantbt_full_params", trial.params) + if params: + key = stable_params_key(params) + if key: + self._seen_params.add(str(key)) + + def _objective(self, trial, param_ranges, fixed_params, objective_count: int, *, effective_params_builder=None): + try: + import optuna + except Exception as exc: # pragma: no cover + raise ImportError("QuantBT optimization requires optuna") from exc + params = suggest_params(trial, param_ranges, fixed_params=fixed_params) + source = str(trial.user_attrs.get("quantbt_source", "sampled")) + raw_params_key = stable_params_key(params) + effective_params = dict(effective_params_builder(params)) if effective_params_builder is not None else dict(params) + params_key = stable_params_key(effective_params) + trial.set_user_attr("quantbt_full_params", dict(params)) + trial.set_user_attr("quantbt_source", source) + trial.set_user_attr("quantbt_params_key", params_key) + trial.set_user_attr("quantbt_raw_params_key", raw_params_key) + trial.set_user_attr("quantbt_effective_params", dict(effective_params)) + if params_key in self._seen_params: + if self.config.duplicate_policy == "prune": + raise optuna.TrialPruned("duplicate parameter set") + if self.config.duplicate_policy == "raise": + raise ValueError(f"duplicate parameter set: {params_key}") + self._seen_params.add(params_key) + + try: + objective = self.evaluator.evaluate(params) + except optuna.TrialPruned: + raise + except Exception as exc: + if self.config.exception_policy == "prune": + raise optuna.TrialPruned(str(exc)) from exc + raise + if not isinstance(objective, ObjectiveResult): + raise TypeError("TrialEvaluator.evaluate must return ObjectiveResult") + if objective.constraints and self.sampler_config.name not in {"tpe", "nsgaii"} and self.sampler_config.constraint_mode != "post_filter": + raise ValueError( + f"sampler {self.sampler_config.name!r} does not support formal constraints; " + "set SamplerConfig(..., constraint_mode='post_filter') to filter candidates after optimization" + ) + if len(objective.values) != objective_count: + raise ValueError(f"objective returned {len(objective.values)} values but config has {objective_count} directions") + if not all(math.isfinite(float(value)) for value in objective.values): + raise optuna.TrialPruned("non-finite objective value") + + trial.set_user_attr("quantbt_metrics", dict(objective.metrics)) + metadata = dict(objective.metadata) + metadata.setdefault("quantbt_source", source) + metadata.setdefault("quantbt_params_key", params_key) + metadata.setdefault("quantbt_raw_params_key", raw_params_key) + trial.set_user_attr("quantbt_metadata", metadata) + set_trial_constraints(trial, objective.constraints) + + if objective_count == 1: + return float(objective.values[0]) + return tuple(float(value) for value in objective.values) + + +def _build_result(study, objective_count: int) -> OptimizationResult: + trials = [_trial_record(trial) for trial in study.trials] + trials_frame = None + try: + trials_frame = study.trials_dataframe() + except Exception: + trials_frame = None + if objective_count == 1: + try: + best_params = dict(study.best_trial.user_attrs.get("quantbt_full_params", study.best_params)) + best_values = (float(study.best_value),) + except Exception: + best_params = None + best_values = None + pareto_trials = [] + else: + best_params = None + best_values = None + pareto_trials = list(study.best_trials) + return OptimizationResult( + study=study, + best_params=best_params, + best_values=best_values, + pareto_trials=pareto_trials, + trials=trials, + trials_frame=trials_frame, + ) + + +def _result_has_constraints(result: OptimizationResult) -> bool: + return any(len(record.constraints) > 0 for record in result.trials if record.state == "COMPLETE") + + +def _trial_record(trial) -> OptimizationTrialRecord: + values = tuple(float(value) for value in (trial.values or ())) + metadata = dict(trial.user_attrs.get("quantbt_metadata", {})) + for key in ( + "quantbt_source", + "quantbt_params_key", + "quantbt_raw_params_key", + "quantbt_initial_trial_id", + ): + if key in trial.user_attrs: + metadata.setdefault(key, trial.user_attrs[key]) + return OptimizationTrialRecord( + number=int(trial.number), + state=str(trial.state.name), + params=dict(trial.user_attrs.get("quantbt_full_params", trial.params)), + values=values, + metrics=dict(trial.user_attrs.get("quantbt_metrics", {})), + constraints=tuple(float(value) for value in trial.user_attrs.get("quantbt_constraints", ())), + metadata=metadata, + ) + + +def _trial_params_for_enqueue(params: Mapping[str, Any], param_ranges: Mapping[str, Any], fixed_params: Mapping[str, Any]) -> dict[str, Any]: + """Return only Optuna-suggested params for `study.enqueue_trial`. + + Scalar constants and fixed params are merged inside `suggest_params`, so + enqueuing them would create confusing Optuna distributions. Missing active + search params are rejected because a warm-start baseline must be evaluated + exactly, not partially sampled. + """ + + queued: dict[str, Any] = {} + missing: list[str] = [] + fixed = set(dict(fixed_params or {})) + for name, spec in dict(param_ranges or {}).items(): + if name in fixed or not _is_suggested_spec(spec): + continue + if name not in params: + missing.append(str(name)) + else: + queued[str(name)] = params[name] + if missing: + joined = ", ".join(missing[:10]) + raise ValueError(f"initial trial is missing search params: {joined}") + return queued + + +def _is_suggested_spec(spec: Any) -> bool: + if isinstance(spec, range): + return True + if isinstance(spec, tuple) and len(spec) in (2, 3): + return True + if isinstance(spec, list): + return True + return False + + +def _apply_baseline_floor(result: OptimizationResult) -> None: + """Keep the best feasible warm-start when selected candidate regresses.""" + + try: + directions = tuple(str(direction.name).lower() for direction in result.study.directions) + except Exception: + directions = ("maximize",) + if len(directions) != 1: + return + baselines = [ + record + for record in result.baseline_trials + if record.state == "COMPLETE" and record.values and constraints_feasible(record.constraints) + ] + if not baselines: + result.search_regression = False + result.selection_metadata.setdefault("best_baseline_trial", None) + return + best_baseline = sorted( + baselines, + key=lambda record: record.values[0], + reverse=directions[0] == "maximize", + )[0] + selected = _selected_record(result) + if selected is None: + selected = best_baseline + selected_value = selected.values[0] if selected.values else float("-inf") + baseline_better = _is_better(best_baseline.values[0], selected_value, directions[0]) + result.selection_metadata.setdefault( + "best_baseline_trial", + { + "trial_number": int(best_baseline.number), + "value": float(best_baseline.values[0]), + "params": dict(best_baseline.params), + }, + ) + if not baseline_better: + result.search_regression = False + result.selection_metadata.setdefault("search_regression", False) + return + result.selected_params = dict(best_baseline.params) + result.search_regression = True + result.selection_metadata.update( + { + "selected_by": "warm_start_baseline_floor", + "search_regression": True, + "previous_selected_trial": None if selected is None else int(selected.number), + "previous_selected_value": None if selected is None or not selected.values else float(selected.values[0]), + "trial_number": int(best_baseline.number), + "value": float(best_baseline.values[0]), + } + ) + + +def _selected_record(result: OptimizationResult) -> Optional[OptimizationTrialRecord]: + trial_number = result.selection_metadata.get("trial_number") + if trial_number is not None: + for record in result.trials: + if int(record.number) == int(trial_number): + return record + if result.selected_params is not None: + selected_key = stable_params_key(result.selected_params) + for record in result.trials: + if stable_params_key(record.params) == selected_key and record.state == "COMPLETE": + return record + if result.best_params is not None: + best_key = stable_params_key(result.best_params) + for record in result.trials: + if stable_params_key(record.params) == best_key and record.state == "COMPLETE": + return record + return None + + +def _is_better(candidate: float, incumbent: float, direction: str) -> bool: + if direction == "minimize": + return float(candidate) < float(incumbent) + return float(candidate) > float(incumbent) + + +def _search_diagnostics( + *, + param_ranges: Mapping[str, Any], + fixed_params: Optional[Mapping[str, Any]], + result: OptimizationResult, + objective_index: int, +) -> dict[str, Any]: + info = search_space_info(param_ranges, fixed_params=fixed_params) + variable_names = list(info.variable_names) + completed = [ + record + for record in result.trials + if record.state == "COMPLETE" and len(record.values) > int(objective_index) + ] + try: + direction = str(result.study.directions[int(objective_index)].name).lower() + except Exception: + direction = "maximize" + ranked = sorted( + completed, + key=lambda record: record.values[int(objective_index)], + reverse=direction == "maximize", + ) + top_n = max(1, int(math.ceil(len(ranked) * 0.10))) if ranked else 0 + top = ranked[:top_n] + source_counts: dict[str, int] = {} + effective_keys: list[str] = [] + for record in result.trials: + source = str(record.metadata.get("quantbt_source", "sampled")) + source_counts[source] = source_counts.get(source, 0) + 1 + key = record.metadata.get("quantbt_params_key") + if key is not None: + effective_keys.append(str(key)) + coverage = { + name: len({record.params.get(name) for record in completed if name in record.params}) + for name in variable_names + } + return { + "nominal_dimension": int(len(variable_names)), + "variable_names": variable_names, + "grid_size_estimate": info.grid_size, + "has_categorical": bool(info.has_categorical), + "has_continuous": bool(info.has_continuous), + "has_dynamic_float": bool(info.has_dynamic_float), + "param_kind_counts": _param_kind_counts(param_ranges, fixed_params), + "completed_trials": int(len(completed)), + "pruned_trials": int(sum(1 for record in result.trials if record.state == "PRUNED")), + "failed_trials": int(sum(1 for record in result.trials if record.state == "FAIL")), + "source_counts": source_counts, + "effective_duplicate_count": int(len(effective_keys) - len(set(effective_keys))), + "param_coverage": coverage, + "top_decile_size": int(top_n), + "top_decile_distributions": _top_distributions(top, variable_names), + "baseline_rank": _baseline_rank(ranked), + } + + +def _param_kind_counts(param_ranges: Mapping[str, Any], fixed_params: Optional[Mapping[str, Any]]) -> dict[str, int]: + fixed = set(dict(fixed_params or {})) + counts = {"fixed": 0, "categorical": 0, "int": 0, "float": 0, "constant": 0} + for name, spec in dict(param_ranges or {}).items(): + if name in fixed: + counts["fixed"] += 1 + continue + if isinstance(spec, range) or isinstance(spec, list): + counts["categorical"] += 1 + elif isinstance(spec, tuple) and len(spec) in (2, 3): + numeric = all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in spec) + looks_int = numeric and all(isinstance(value, int) and not isinstance(value, bool) for value in spec) + counts["int" if looks_int else "float"] += 1 + else: + counts["constant"] += 1 + return counts + + +def _top_distributions(records: Sequence[OptimizationTrialRecord], variable_names: Sequence[str]) -> dict[str, dict[str, Any]]: + distributions: dict[str, dict[str, Any]] = {} + for name in variable_names: + values = [record.params.get(name) for record in records if name in record.params] + counts: dict[str, int] = {} + numeric: list[float] = [] + for value in values: + counts[str(value)] = counts.get(str(value), 0) + 1 + if isinstance(value, (int, float)) and not isinstance(value, bool): + numeric.append(float(value)) + payload: dict[str, Any] = {"counts": counts} + if numeric: + payload.update( + { + "min": float(min(numeric)), + "max": float(max(numeric)), + "mean": float(sum(numeric) / len(numeric)), + } + ) + distributions[str(name)] = payload + return distributions + + +def _baseline_rank(ranked: Sequence[OptimizationTrialRecord]) -> list[dict[str, Any]]: + rows = [] + for rank, record in enumerate(ranked, start=1): + if record.metadata.get("quantbt_source") != "warm_start": + continue + rows.append( + { + "rank": int(rank), + "trial_number": int(record.number), + "value": None if not record.values else float(record.values[0]), + "params": dict(record.params), + } + ) + return rows diff --git a/src/quantbt/optimization/result.py b/src/quantbt/optimization/result.py new file mode 100644 index 0000000..d1ecdd1 --- /dev/null +++ b/src/quantbt/optimization/result.py @@ -0,0 +1,80 @@ +"""Result schemas for QuantBT optimization.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional, Sequence, Tuple + + +@dataclass(frozen=True) +class ObjectiveResult: + """Evaluator output consumed by the domain-agnostic optimizer. + + `values` follows Optuna conventions: one value for single-objective + optimization and one value per configured direction for multi-objective + optimization. Formal constraints use Optuna's sign convention: + `<= 0` means feasible and `> 0` means violated. + """ + + values: Tuple[float, ...] + metrics: dict[str, float] = field(default_factory=dict) + constraints: Tuple[float, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + values = tuple(float(value) for value in self.values) + if not values: + raise ValueError("ObjectiveResult.values must be non-empty") + constraints = tuple(float(value) for value in self.constraints) + metrics = {str(key): float(value) for key, value in dict(self.metrics or {}).items()} + object.__setattr__(self, "values", values) + object.__setattr__(self, "constraints", constraints) + object.__setattr__(self, "metrics", metrics) + object.__setattr__(self, "metadata", dict(self.metadata or {})) + + @classmethod + def scalar( + cls, + value: float, + *, + metrics: Optional[dict[str, float]] = None, + constraints: Sequence[float] = (), + metadata: Optional[dict[str, Any]] = None, + ) -> "ObjectiveResult": + """Build a single-objective result.""" + + return cls(values=(float(value),), metrics=dict(metrics or {}), constraints=tuple(constraints), metadata=dict(metadata or {})) + + +@dataclass(frozen=True) +class OptimizationTrialRecord: + """Compact, serializable record of one completed/pruned/failed trial.""" + + number: int + state: str + params: dict[str, Any] + values: Tuple[float, ...] = () + metrics: dict[str, float] = field(default_factory=dict) + constraints: Tuple[float, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class OptimizationResult: + """Public result returned by :class:`OptunaOptimizer`.""" + + study: Any + best_params: Optional[dict[str, Any]] + best_values: Optional[Tuple[float, ...]] + pareto_trials: list[Any] + trials: list[OptimizationTrialRecord] + trials_frame: Any + selected_params: Optional[dict[str, Any]] = None + selection_metadata: dict[str, Any] = field(default_factory=dict) + baseline_trials: list[OptimizationTrialRecord] = field(default_factory=list) + phase_results: list[Any] = field(default_factory=list) + seed_results: list[Any] = field(default_factory=list) + robust_candidates: list[Any] = field(default_factory=list) + selected_validation: dict[str, Any] = field(default_factory=dict) + search_regression: bool = False + search_diagnostics: dict[str, Any] = field(default_factory=dict) diff --git a/src/quantbt/optimization/samplers.py b/src/quantbt/optimization/samplers.py new file mode 100644 index 0000000..ec125cb --- /dev/null +++ b/src/quantbt/optimization/samplers.py @@ -0,0 +1,84 @@ +"""Optuna sampler factory with QuantBT compatibility checks.""" + +from __future__ import annotations + +import inspect +from typing import Any, Callable, Mapping, Optional + +from .config import SamplerConfig +from .space import build_grid_search_space, search_space_info + + +def build_sampler( + sampler_config: SamplerConfig, + *, + seed: Optional[int], + search_space: Mapping[str, Any], + objective_count: int, + constraints_func: Optional[Callable] = None, +): + """Build an Optuna sampler and validate domain-agnostic compatibility.""" + + try: + import optuna + except Exception as exc: # pragma: no cover - dependency guard + raise ImportError("QuantBT optimization requires optuna") from exc + + cfg = sampler_config if isinstance(sampler_config, SamplerConfig) else SamplerConfig(**dict(sampler_config)) + name = cfg.name + kwargs = dict(cfg.kwargs) + info = search_space_info(search_space) + + if name == "tpe": + payload = {**kwargs} + if seed is not None: + payload.setdefault("seed", int(seed)) + if constraints_func is not None and _accepts(optuna.samplers.TPESampler, "constraints_func"): + payload.setdefault("constraints_func", constraints_func) + return optuna.samplers.TPESampler(**payload) + + if name == "random": + if constraints_func is not None: + raise ValueError("RandomSampler does not support formal constraints") + payload = {**kwargs} + if seed is not None: + payload.setdefault("seed", int(seed)) + return optuna.samplers.RandomSampler(**payload) + + if name == "grid": + if constraints_func is not None: + raise ValueError("GridSampler does not support formal constraints") + max_grid_size = int(kwargs.pop("max_grid_size", 100_000)) + grid = build_grid_search_space(search_space, max_grid_size=max_grid_size) + payload = {**kwargs} + if seed is not None: + payload.setdefault("seed", int(seed)) + return optuna.samplers.GridSampler(grid, **payload) + + if name == "cmaes": + if constraints_func is not None: + raise ValueError("CmaEsSampler does not support formal constraints") + if info.has_categorical: + raise ValueError("CMA-ES requires a numeric continuous/int search space; categorical params are not supported") + if info.has_dynamic_float is False and not info.variable_names: + raise ValueError("CMA-ES requires at least one variable numeric parameter") + payload = {**kwargs} + if seed is not None: + payload.setdefault("seed", int(seed)) + return optuna.samplers.CmaEsSampler(**payload) + + if name == "nsgaii": + payload = {**kwargs} + if seed is not None: + payload.setdefault("seed", int(seed)) + if constraints_func is not None and _accepts(optuna.samplers.NSGAIISampler, "constraints_func"): + payload.setdefault("constraints_func", constraints_func) + if objective_count < 1: + raise ValueError("objective_count must be positive") + return optuna.samplers.NSGAIISampler(**payload) + + raise ValueError("sampler name must be one of: tpe, random, grid, cmaes, nsgaii") + + +def _accepts(callable_obj, parameter: str) -> bool: + return parameter in inspect.signature(callable_obj).parameters diff --git a/src/quantbt/optimization/space.py b/src/quantbt/optimization/space.py new file mode 100644 index 0000000..33ba5c6 --- /dev/null +++ b/src/quantbt/optimization/space.py @@ -0,0 +1,223 @@ +"""Search-space parsing shared by QuantBT optimization surfaces.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import math +from typing import Any, Mapping, Optional + +import numpy as np + + +@dataclass(frozen=True) +class SearchSpaceInfo: + """Static facts used by sampler compatibility checks.""" + + has_categorical: bool + has_continuous: bool + has_dynamic_float: bool + variable_names: tuple[str, ...] + grid_size: Optional[int] + + +def suggest_parameter(trial, name: str, spec: Any) -> Any: + """Suggest one parameter from a QuantBT param range spec. + + Supported specs are intentionally compatible with existing alpha notebooks: + numeric tuples, categorical lists/tuples, ranges, bool choices, and scalar + constants. + """ + + if _is_bool_choice(spec): + return trial.suggest_categorical(name, [True, False]) + if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(value) for value in spec): + low, high = spec[0], spec[1] + step = spec[2] if len(spec) == 3 else None + if _looks_int(low) and _looks_int(high) and (step is None or _looks_int(step)): + return trial.suggest_int(name, int(low), int(high), step=1 if step is None else int(step)) + if step is None: + return trial.suggest_float(name, float(low), float(high)) + return trial.suggest_float(name, float(low), float(high), step=float(step)) + if isinstance(spec, range): + values = list(spec) + if not values: + raise ValueError(f"param_ranges[{name!r}] is empty") + return trial.suggest_categorical(name, values) + if isinstance(spec, (list, tuple)): + if not spec: + raise ValueError(f"param_ranges[{name!r}] is empty") + return trial.suggest_categorical(name, list(spec)) + return spec + + +def suggest_params(trial, param_ranges: Mapping[str, Any], fixed_params: Optional[Mapping[str, Any]] = None) -> dict[str, Any]: + """Suggest params and merge fixed params. + + Fixed params override `param_ranges` entries by name. Additional fixed + params are appended to the final parameter dict. + """ + + fixed = dict(fixed_params or {}) + params: dict[str, Any] = {} + for name, spec in dict(param_ranges or {}).items(): + if name in fixed: + params[name] = fixed[name] + else: + params[name] = suggest_parameter(trial, name, spec) + for name, value in fixed.items(): + params.setdefault(name, value) + return params + + +def stable_params_key(params: Mapping[str, Any]) -> str: + """Return a deterministic key for duplicate-trial detection.""" + + return json.dumps(_jsonable(params), sort_keys=True, separators=(",", ":")) + + +def search_space_info(param_ranges: Mapping[str, Any], fixed_params: Optional[Mapping[str, Any]] = None) -> SearchSpaceInfo: + """Inspect a QuantBT search space for sampler compatibility.""" + + fixed = set(dict(fixed_params or {})) + has_categorical = False + has_continuous = False + has_dynamic_float = False + variable_names: list[str] = [] + grid_size = 1 + finite_grid = True + for name, spec in dict(param_ranges or {}).items(): + if name in fixed: + continue + kind = _spec_kind(spec) + if kind == "constant": + continue + variable_names.append(name) + if kind == "categorical": + has_categorical = True + if kind in {"float", "int"}: + has_continuous = has_continuous or kind == "float" + values = _grid_values(name, spec, allow_dynamic=True) + if values is None: + finite_grid = False + has_dynamic_float = True + else: + grid_size *= len(values) + return SearchSpaceInfo( + has_categorical=has_categorical, + has_continuous=has_continuous, + has_dynamic_float=has_dynamic_float, + variable_names=tuple(variable_names), + grid_size=grid_size if finite_grid else None, + ) + + +def build_grid_search_space( + param_ranges: Mapping[str, Any], + fixed_params: Optional[Mapping[str, Any]] = None, + *, + max_grid_size: int = 100_000, +) -> dict[str, list[Any]]: + """Build an Optuna GridSampler search space from finite specs.""" + + fixed = set(dict(fixed_params or {})) + grid: dict[str, list[Any]] = {} + size = 1 + for name, spec in dict(param_ranges or {}).items(): + if name in fixed: + continue + values = _grid_values(name, spec, allow_dynamic=False) + if values is None: + raise ValueError(f"grid sampler requires finite values for {name!r}") + if len(values) == 1 and _spec_kind(spec) == "constant": + continue + grid[name] = values + size *= len(values) + if size > int(max_grid_size): + raise ValueError(f"grid search space has {size:,} combinations, above max_grid_size={int(max_grid_size):,}") + if not grid: + raise ValueError("grid sampler requires at least one non-fixed finite parameter") + return grid + + +def _grid_values(name: str, spec: Any, *, allow_dynamic: bool) -> Optional[list[Any]]: + if _is_bool_choice(spec): + return [True, False] + if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(value) for value in spec): + low, high = spec[0], spec[1] + step = spec[2] if len(spec) == 3 else None + if _looks_int(low) and _looks_int(high) and (step is None or _looks_int(step)): + step_i = 1 if step is None else int(step) + if step_i <= 0: + raise ValueError(f"integer step for {name!r} must be positive") + return list(range(int(low), int(high) + 1, step_i)) + if step is None: + if allow_dynamic: + return None + raise ValueError(f"grid sampler requires a float step for {name!r}") + return _float_grid(float(low), float(high), float(step), name) + if isinstance(spec, range): + values = list(spec) + if not values: + raise ValueError(f"param_ranges[{name!r}] is empty") + return values + if isinstance(spec, (list, tuple)): + if not spec: + raise ValueError(f"param_ranges[{name!r}] is empty") + return list(spec) + return [spec] + + +def _float_grid(low: float, high: float, step: float, name: str) -> list[float]: + if step <= 0.0: + raise ValueError(f"float step for {name!r} must be positive") + if high < low: + raise ValueError(f"high must be >= low for {name!r}") + count = int(math.floor((high - low) / step + 1e-12)) + 1 + values = [float(low + i * step) for i in range(count)] + if values and values[-1] < high and math.isclose(values[-1] + step, high, rel_tol=1e-9, abs_tol=1e-12): + values.append(float(high)) + return values + + +def _spec_kind(spec: Any) -> str: + if _is_bool_choice(spec): + return "categorical" + if isinstance(spec, range): + return "categorical" + if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(value) for value in spec): + if _looks_int(spec[0]) and _looks_int(spec[1]) and (len(spec) == 2 or _looks_int(spec[2])): + return "int" + return "float" + if isinstance(spec, (list, tuple)): + return "categorical" + return "constant" + + +def _is_bool_choice(spec: Any) -> bool: + return ( + isinstance(spec, (list, tuple)) + and len(spec) == 2 + and all(isinstance(value, bool) for value in spec) + and set(spec) == {True, False} + ) + + +def _looks_int(value: Any) -> bool: + return isinstance(value, (int, np.integer)) and not isinstance(value, bool) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float, np.integer, np.floating)) and not isinstance(value, bool) + + +def _jsonable(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(key): _jsonable(val) for key, val in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, np.generic): + return value.item() + if isinstance(value, np.ndarray): + return [_jsonable(item) for item in value.tolist()] + return value diff --git a/src/quantbt/options/__init__.py b/src/quantbt/options/__init__.py new file mode 100644 index 0000000..488be7e --- /dev/null +++ b/src/quantbt/options/__init__.py @@ -0,0 +1,215 @@ +""" +QuantBT options domain package. + +Phase 1 exposes schema, convention, and canonical chain-data validation only. +Pricing, execution, ledger, margin, endpoint wiring, and Nautilus validation are +added in later phases. +""" + +from .conventions import ( + OptionVenueConvention, + binance_european_options_convention, + deribit_inverse_option_convention, + deribit_linear_usdc_option_convention, +) +from .cache import OptionPreparedRunCache, option_package_cache_key +from .data import CANONICAL_OPTION_CHAIN_COLUMNS, validate_option_chain_frame +from .execution import ( + OptionDepthFidelity, + OptionExecutionConfig, + OptionLimitFidelity, + OptionPackageExecutionResult, + execute_option_package, +) +from .fees import ( + OptionFeeResult, + OptionFeeSchedule, + calculate_option_fee, + deribit_inverse_fee_schedule, + deribit_linear_usdc_fee_schedule, +) +from .greeks import ( + OptionGreeks, + inverse_black76_greeks_base, + inverse_black76_greeks_quote, + linear_black76_greeks, + scale_greeks_to_reporting_currency, +) +from .hedging import ( + HedgeDecision, + HedgePathResult, + OptionHedgeConfig, + OptionHedgePolicyType, + compute_net_option_delta, + hedge_decision, + run_delta_hedge_path, +) +from .iv import IVStatus, ImpliedVolResult, implied_vol_black76, implied_vol_inverse_black76_base +from .ledger import OptionLedger, OptionPosition +from .lifecycle import ( + OptionSettlementRepresentation, + OptionSettlementResult, + option_expiry_payoff_per_unit, + settle_option_expiry, +) +from .margin import ( + ExternalOptionMarginValidator, + OptionLiquidationAudit, + OptionMarginConfig, + OptionMarginModel, + OptionMarginRequirement, + calculate_option_margin, + liquidate_option_positions, +) +from .packages import ( + OptionPackageExecutionPolicy, + OptionPackageIntent, + OptionPackageLeg, + compile_option_package_orders, +) +from .pricing import ( + black76_intrinsic, + black76_parity_residual, + black76_parity_value, + black76_price, + inverse_black76_intrinsic_base, + inverse_black76_parity_residual_base, + inverse_black76_parity_value_base, + inverse_black76_price_base, +) +from .schema import ( + ExerciseStyle, + InstrumentRegistrySignature, + OptionDecisionFillPolicy, + OptionInstrumentRegistry, + OptionInstrumentSpec, + OptionKind, + PremiumConvention, + SettlementStyle, +) +from .selectors import ( + OptionSelection, + OptionSelectionFilters, + available_option_rows, + select_atm_option, + select_target_delta_option, + select_target_dte_option, + select_target_moneyness_option, +) +from .surface import SurfaceDiagnostics, TotalVarianceSurface +from .tape import YEAR_NS, OptionTapeSignature, PreparedOptionTape, prepare_option_tape +from .strategy import GammaScalpingConfig, OptionStrategyRun, build_gamma_scalping_strategy_run +from .templates import ( + butterfly, + calendar, + collar, + condor, + covered_call, + long_call, + long_put, + risk_reversal, + short_call, + short_put, + straddle, + strangle, + vertical, +) + +__all__ = [ + "CANONICAL_OPTION_CHAIN_COLUMNS", + "ExerciseStyle", + "ExternalOptionMarginValidator", + "HedgeDecision", + "HedgePathResult", + "GammaScalpingConfig", + "InstrumentRegistrySignature", + "OptionDecisionFillPolicy", + "OptionDepthFidelity", + "OptionExecutionConfig", + "OptionFeeResult", + "OptionFeeSchedule", + "OptionHedgeConfig", + "OptionHedgePolicyType", + "OptionInstrumentRegistry", + "OptionInstrumentSpec", + "OptionKind", + "OptionGreeks", + "OptionLimitFidelity", + "OptionLiquidationAudit", + "OptionMarginConfig", + "OptionMarginModel", + "OptionMarginRequirement", + "OptionPackageExecutionPolicy", + "OptionPackageExecutionResult", + "OptionPackageIntent", + "OptionPackageLeg", + "OptionLedger", + "OptionVenueConvention", + "OptionSelection", + "OptionSelectionFilters", + "OptionSettlementRepresentation", + "OptionSettlementResult", + "OptionStrategyRun", + "OptionTapeSignature", + "OptionPosition", + "OptionPreparedRunCache", + "PremiumConvention", + "PreparedOptionTape", + "SettlementStyle", + "SurfaceDiagnostics", + "TotalVarianceSurface", + "YEAR_NS", + "binance_european_options_convention", + "black76_intrinsic", + "black76_parity_residual", + "black76_parity_value", + "black76_price", + "build_gamma_scalping_strategy_run", + "calculate_option_fee", + "calculate_option_margin", + "compile_option_package_orders", + "deribit_inverse_option_convention", + "deribit_inverse_fee_schedule", + "deribit_linear_usdc_option_convention", + "deribit_linear_usdc_fee_schedule", + "implied_vol_black76", + "implied_vol_inverse_black76_base", + "inverse_black76_greeks_base", + "inverse_black76_greeks_quote", + "inverse_black76_intrinsic_base", + "inverse_black76_parity_residual_base", + "inverse_black76_parity_value_base", + "inverse_black76_price_base", + "IVStatus", + "ImpliedVolResult", + "linear_black76_greeks", + "available_option_rows", + "compute_net_option_delta", + "execute_option_package", + "hedge_decision", + "liquidate_option_positions", + "option_expiry_payoff_per_unit", + "option_package_cache_key", + "prepare_option_tape", + "run_delta_hedge_path", + "scale_greeks_to_reporting_currency", + "select_atm_option", + "select_target_delta_option", + "select_target_dte_option", + "select_target_moneyness_option", + "settle_option_expiry", + "butterfly", + "calendar", + "collar", + "condor", + "covered_call", + "long_call", + "long_put", + "risk_reversal", + "short_call", + "short_put", + "straddle", + "strangle", + "vertical", + "validate_option_chain_frame", +] diff --git a/src/quantbt/options/cache.py b/src/quantbt/options/cache.py new file mode 100644 index 0000000..5c0ece6 --- /dev/null +++ b/src/quantbt/options/cache.py @@ -0,0 +1,116 @@ +""" +Prepared option run cache. + +The cache is explicit and signature-checked. It is designed for service/WFO +loops where the same option chain tape is replayed with many package choices. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Tuple + +import pandas as pd + +from ..core.orders import OrderIntent +from .packages import OptionPackageIntent, compile_option_package_orders +from .schema import OptionInstrumentRegistry +from .tape import PreparedOptionTape, prepare_option_tape + + +@dataclass +class OptionPreparedRunCache: + tape: PreparedOptionTape + package_orders: Dict[Tuple, Tuple[OrderIntent, ...]] = field(default_factory=dict) + metadata: Dict = field(default_factory=dict) + + @classmethod + def from_chain( + cls, + chain: pd.DataFrame, + registry: OptionInstrumentRegistry, + *, + max_spread_bps: Optional[float] = None, + max_source_latency_ns: Optional[int] = None, + convention_signature: Optional[Tuple] = None, + ) -> "OptionPreparedRunCache": + tape = prepare_option_tape( + chain, + registry, + max_spread_bps=max_spread_bps, + max_source_latency_ns=max_source_latency_ns, + convention_signature=convention_signature, + ) + return cls( + tape=tape, + metadata={ + "cache_type": "OptionPreparedRunCache", + "snapshot_count": int(tape.snapshot_count), + "row_count": int(tape.row_count), + "registry_symbols": tuple(registry.symbols), + "convention_signature": tape.signature.convention_signature, + }, + ) + + def validate( + self, + registry: OptionInstrumentRegistry, + *, + timestamps_ns=None, + convention_signature: Optional[Tuple] = None, + ) -> None: + self.tape.validate_compatible( + registry_signature=registry.signature, + timestamps_ns=timestamps_ns, + convention_signature=convention_signature, + ) + + def compile_package(self, package: OptionPackageIntent) -> Tuple[OrderIntent, ...]: + key = option_package_cache_key(package) + cached = self.package_orders.get(key) + if cached is None: + cached = compile_option_package_orders(package) + self.package_orders[key] = cached + return cached + + @property + def package_cache_size(self) -> int: + return len(self.package_orders) + + +def option_package_cache_key(package: OptionPackageIntent) -> Tuple: + """Return a deterministic key for compiled option package order leaves.""" + return ( + int(package.timestamp_ns), + str(package.package_id), + float(package.quantity), + package.execution_policy.value, + None if package.max_debit is None else float(package.max_debit), + None if package.min_credit is None else float(package.min_credit), + tuple( + ( + leg.instrument_id, + leg.side.value, + float(leg.ratio), + leg.order_type.value, + None if leg.limit_price is None else float(leg.limit_price), + leg.tif.value, + leg.role, + leg.tag, + tuple(sorted((str(k), _stable_value(v)) for k, v in leg.metadata.items())), + ) + for leg in package.legs + ), + package.tag, + tuple(sorted((str(k), _stable_value(v)) for k, v in package.metadata.items())), + ) + + +def _stable_value(value): + if isinstance(value, (str, int, float, bool, type(None))): + return value + if isinstance(value, dict): + return tuple(sorted((str(k), _stable_value(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return tuple(_stable_value(item) for item in value) + return repr(value) diff --git a/src/quantbt/options/conventions.py b/src/quantbt/options/conventions.py new file mode 100644 index 0000000..d8cb957 --- /dev/null +++ b/src/quantbt/options/conventions.py @@ -0,0 +1,158 @@ +""" +Versioned option venue conventions. + +These conventions are descriptive configuration, not a pricing engine and not a +claim that venue portfolio margin is exactly replicated. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Tuple + +from .schema import ExerciseStyle, PremiumConvention, SettlementStyle + + +@dataclass(frozen=True) +class OptionVenueConvention: + venue: str + convention_id: str + premium_convention: PremiumConvention + exercise_style: ExerciseStyle + settlement_style: SettlementStyle + premium_currency: str + settlement_currency: str + quote_currency: str + supported_underlyings: Tuple[str, ...] = () + fee_schedule_id: str = "" + margin_schedule_id: str = "" + exact_venue_margin: bool = False + notes: str = "" + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "venue", str(self.venue).lower().strip()) + object.__setattr__(self, "premium_convention", _coerce(PremiumConvention, self.premium_convention, "premium_convention")) + object.__setattr__(self, "exercise_style", _coerce(ExerciseStyle, self.exercise_style, "exercise_style")) + object.__setattr__(self, "settlement_style", _coerce(SettlementStyle, self.settlement_style, "settlement_style")) + if not self.venue or not self.convention_id: + raise ValueError("venue and convention_id are required") + for field_name in ("premium_currency", "settlement_currency", "quote_currency"): + value = getattr(self, field_name) + if not value: + raise ValueError(f"{field_name} is required") + object.__setattr__(self, field_name, str(value).upper()) + object.__setattr__(self, "supported_underlyings", tuple(str(value).upper() for value in self.supported_underlyings)) + _validate_convention(self) + + @property + def signature(self) -> Tuple: + return ( + self.venue, + self.convention_id, + self.premium_convention.value, + self.exercise_style.value, + self.settlement_style.value, + self.premium_currency, + self.settlement_currency, + self.quote_currency, + self.supported_underlyings, + self.fee_schedule_id, + self.margin_schedule_id, + bool(self.exact_venue_margin), + ) + + +def deribit_inverse_option_convention( + *, + underlying: str = "BTC", + version: str = "deribit_inverse_v1", +) -> OptionVenueConvention: + base = str(underlying).upper() + if base not in {"BTC", "ETH"}: + raise ValueError("Deribit inverse convention currently supports BTC or ETH") + return OptionVenueConvention( + venue="deribit", + convention_id=version, + premium_convention=PremiumConvention.INVERSE_BASE, + exercise_style=ExerciseStyle.EUROPEAN, + settlement_style=SettlementStyle.CASH, + premium_currency=base, + settlement_currency=base, + quote_currency="USD", + supported_underlyings=(base,), + fee_schedule_id=f"deribit_{base.lower()}_inverse_options", + margin_schedule_id="deribit_pm_external_or_scenario_approximation", + exact_venue_margin=False, + notes="Inverse premium and settlement are in base currency; native margin is approximation unless validated externally.", + ) + + +def deribit_linear_usdc_option_convention( + *, + underlying: str = "BTC", + version: str = "deribit_linear_usdc_v1", + settlement_style: SettlementStyle = SettlementStyle.FUTURE_THEN_CASH, +) -> OptionVenueConvention: + base = str(underlying).upper() + return OptionVenueConvention( + venue="deribit", + convention_id=version, + premium_convention=PremiumConvention.LINEAR_QUOTE, + exercise_style=ExerciseStyle.EUROPEAN, + settlement_style=settlement_style, + premium_currency="USDC", + settlement_currency="USDC", + quote_currency="USDC", + supported_underlyings=(base,), + fee_schedule_id="deribit_linear_usdc_options", + margin_schedule_id="deribit_pm_external_or_scenario_approximation", + exact_venue_margin=False, + notes="Linear USDC option convention supports economic cash or future-then-cash settlement representation.", + ) + + +def binance_european_options_convention( + *, + underlying: str = "BTC", + version: str = "binance_european_options_v1", +) -> OptionVenueConvention: + base = str(underlying).upper() + return OptionVenueConvention( + venue="binance", + convention_id=version, + premium_convention=PremiumConvention.LINEAR_QUOTE, + exercise_style=ExerciseStyle.EUROPEAN, + settlement_style=SettlementStyle.CASH, + premium_currency="USDT", + settlement_currency="USDT", + quote_currency="USDT", + supported_underlyings=(base,), + fee_schedule_id="binance_options_versioned_external", + margin_schedule_id="binance_options_external_or_scenario_approximation", + exact_venue_margin=False, + notes="Binance config is schema/convention metadata only until official fee/margin parity tests are added.", + ) + + +def _coerce(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc + + +def _validate_convention(convention: OptionVenueConvention) -> None: + if convention.premium_convention is PremiumConvention.INVERSE_BASE: + if convention.premium_currency != convention.settlement_currency: + raise ValueError("inverse convention requires premium_currency == settlement_currency") + if convention.quote_currency == convention.premium_currency: + raise ValueError("inverse convention requires quote_currency distinct from base premium currency") + elif convention.premium_convention is PremiumConvention.LINEAR_QUOTE: + if convention.premium_currency != convention.quote_currency: + raise ValueError("linear convention requires premium_currency == quote_currency") + elif convention.premium_convention is PremiumConvention.QUANTO: + if convention.premium_currency == convention.settlement_currency == convention.quote_currency: + raise ValueError("quanto convention requires at least one distinct currency") diff --git a/src/quantbt/options/data.py b/src/quantbt/options/data.py new file mode 100644 index 0000000..35b3d4f --- /dev/null +++ b/src/quantbt/options/data.py @@ -0,0 +1,177 @@ +""" +Canonical option chain data validation. + +The canonical chain is long-form. Phase 1 validates structure only; Phase 3 +will compile this data into a ragged/CSR option tape. +""" + +from __future__ import annotations + +from typing import Iterable, Optional, Sequence + +import numpy as np +import pandas as pd + + +CANONICAL_OPTION_CHAIN_COLUMNS = ( + "timestamp_ns", + "instrument_id", + "venue", + "underlying_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "last_price", + "index_price", + "forward_price", + "mark_iv", + "bid_iv", + "ask_iv", + "delta", + "gamma", + "vega", + "theta", + "open_interest", + "volume", + "quote_currency", + "settlement_currency", + "sequence_id", + "source_latency_ns", +) + +REQUIRED_OPTION_CHAIN_COLUMNS = ( + "timestamp_ns", + "instrument_id", + "venue", + "underlying_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "index_price", + "forward_price", + "quote_currency", + "settlement_currency", +) + + +def validate_option_chain_frame( + frame: pd.DataFrame, + *, + required_columns: Sequence[str] = REQUIRED_OPTION_CHAIN_COLUMNS, + max_spread_bps: Optional[float] = None, + reject_crossed: bool = True, +) -> pd.DataFrame: + """ + Validate and return a sorted canonical long-form option chain copy. + + This function intentionally avoids filling missing market values. Missing + fields must remain visible to later tape compilation and no-lookahead tests. + """ + if not isinstance(frame, pd.DataFrame): + raise TypeError("option chain must be a pandas DataFrame") + missing = [column for column in required_columns if column not in frame.columns] + if missing: + raise ValueError(f"option chain missing required columns: {missing}") + out = frame.copy() + _coerce_int64(out, ("timestamp_ns", "expiry_ns", "sequence_id", "source_latency_ns"), required=set(required_columns)) + _coerce_float( + out, + ( + "strike", + "bid_price", + "bid_size", + "ask_price", + "ask_size", + "mark_price", + "last_price", + "index_price", + "forward_price", + "mark_iv", + "bid_iv", + "ask_iv", + "delta", + "gamma", + "vega", + "theta", + "open_interest", + "volume", + ), + ) + _normalize_strings(out, ("instrument_id", "underlying_id", "quote_currency", "settlement_currency")) + out["venue"] = out["venue"].astype(str).str.strip().str.lower() + out["option_kind"] = out["option_kind"].astype(str).str.strip().str.lower() + _validate_positive(out, ("timestamp_ns", "expiry_ns", "strike", "index_price", "forward_price")) + _validate_non_negative(out, ("bid_price", "bid_size", "ask_price", "ask_size", "mark_price")) + if reject_crossed and bool((out["bid_price"] > out["ask_price"]).any()): + raise ValueError("option chain contains crossed quotes: bid_price > ask_price") + if bool((out["bid_price"] <= 0.0).any()): + raise ValueError("option chain requires bid_price > 0 in Phase 1 canonical validation") + if bool((out["ask_price"] <= 0.0).any()): + raise ValueError("option chain requires ask_price > 0 in Phase 1 canonical validation") + if max_spread_bps is not None: + if max_spread_bps < 0.0: + raise ValueError("max_spread_bps must be >= 0") + mid = 0.5 * (out["bid_price"].to_numpy() + out["ask_price"].to_numpy()) + spread_bps = np.divide( + out["ask_price"].to_numpy() - out["bid_price"].to_numpy(), + mid, + out=np.full(len(out), np.inf, dtype=np.float64), + where=mid > 0.0, + ) * 10_000.0 + if bool((spread_bps > float(max_spread_bps)).any()): + raise ValueError("option chain contains quotes wider than max_spread_bps") + if bool((out["expiry_ns"] <= out["timestamp_ns"]).any()): + raise ValueError("option chain contains expired quotes") + if not set(out["option_kind"].unique()).issubset({"call", "put"}): + raise ValueError("option_kind must be call or put") + out = out.sort_values(["timestamp_ns", "sequence_id", "instrument_id"] if "sequence_id" in out else ["timestamp_ns", "instrument_id"]) + out = out.reset_index(drop=True) + if bool(out.duplicated(subset=[column for column in ("timestamp_ns", "instrument_id", "sequence_id") if column in out]).any()): + raise ValueError("option chain contains duplicate timestamp/instrument/sequence rows") + return out + + +def _coerce_int64(frame: pd.DataFrame, columns: Iterable[str], *, required: set[str]) -> None: + for column in columns: + if column not in frame: + if column in required: + raise ValueError(f"missing required integer column {column!r}") + continue + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("int64") + + +def _coerce_float(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame: + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("float64") + + +def _normalize_strings(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame: + frame[column] = frame[column].astype(str).str.strip() + for column in ("quote_currency", "settlement_currency"): + if column in frame: + frame[column] = frame[column].str.upper() + + +def _validate_positive(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame and bool((frame[column] <= 0).any()): + raise ValueError(f"{column} must be > 0") + + +def _validate_non_negative(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame and bool((frame[column] < 0).any()): + raise ValueError(f"{column} must be >= 0") diff --git a/src/quantbt/options/execution.py b/src/quantbt/options/execution.py new file mode 100644 index 0000000..d516b3e --- /dev/null +++ b/src/quantbt/options/execution.py @@ -0,0 +1,600 @@ +""" +Snapshot-level option package execution. + +Phase 4 is an execution simulator on a prepared option tape. It is intentionally +not the final multi-currency ledger, margin, expiry, or Nautilus adapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple + +import pandas as pd + +from ..core.orders import Fill, OrderIntent +from ..core.schema import LiquiditySide, OrderSide, OrderType, TimeInForce +from .packages import OptionPackageExecutionPolicy, OptionPackageIntent, compile_option_package_orders +from .tape import PreparedOptionTape + + +class OptionLimitFidelity(str, Enum): + CROSS_ONLY = "cross_only" + MAKER_TOUCH = "maker_touch" + + +class OptionDepthFidelity(str, Enum): + TOP_OF_BOOK = "top_of_book" + + +@dataclass(frozen=True) +class OptionExecutionConfig: + initial_cash: float = 0.0 + fee_rate: float = 0.0 + allow_partial_fill: bool = True + max_quote_age_ns: Optional[int] = None + limit_fidelity: OptionLimitFidelity = OptionLimitFidelity.CROSS_ONLY + depth_fidelity: OptionDepthFidelity = OptionDepthFidelity.TOP_OF_BOOK + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "limit_fidelity", _coerce_enum(OptionLimitFidelity, self.limit_fidelity, "limit_fidelity")) + object.__setattr__(self, "depth_fidelity", _coerce_enum(OptionDepthFidelity, self.depth_fidelity, "depth_fidelity")) + if self.fee_rate < 0.0: + raise ValueError("fee_rate must be >= 0") + if self.max_quote_age_ns is not None and self.max_quote_age_ns < 0: + raise ValueError("max_quote_age_ns must be >= 0") + + +@dataclass(frozen=True) +class OptionPackageExecutionResult: + fills: Tuple[Fill, ...] + order_report: pd.DataFrame + package_report: pd.DataFrame + cash: float + positions: Dict[str, float] + margin_report: Dict + metadata: Dict = field(default_factory=dict) + + +@dataclass +class _ExecutionState: + cash: float + positions: Dict[str, float] + + def copy(self) -> "_ExecutionState": + return _ExecutionState(cash=float(self.cash), positions=dict(self.positions)) + + +@dataclass(frozen=True) +class _OrderEvaluation: + fill: Optional[Fill] + row: Dict + cash_delta: float + position_delta: float + + +_ORDER_REPORT_COLUMNS = [ + "package_id", + "order_id", + "symbol", + "side", + "order_type", + "tif", + "requested_qty", + "filled_qty", + "residual_qty", + "fill_price", + "fee", + "cash_delta", + "status", + "reject_reason", + "liquidity", + "snapshot_timestamp_ns", + "decision_timestamp_ns", + "row_index", + "depth_fidelity", + "limit_fidelity", + "residual_risk", + "atomicity", +] + +_PACKAGE_REPORT_COLUMNS = [ + "package_id", + "execution_policy", + "status", + "reject_reason", + "requested_orders", + "filled_orders", + "partial_orders", + "cash_before", + "cash_after", + "net_cash_delta", + "gross_premium", + "debit", + "credit", + "max_debit", + "min_credit", + "atomicity", + "exchange_combo", + "block_trade_style", + "depth_fidelity", +] + + +def execute_option_package( + package: OptionPackageIntent, + tape: PreparedOptionTape, + *, + config: Optional[OptionExecutionConfig] = None, + positions: Optional[Dict[str, float]] = None, + compiled_orders: Optional[Tuple[OrderIntent, ...]] = None, +) -> OptionPackageExecutionResult: + """Execute one option package against the latest observable tape snapshot.""" + cfg = config or OptionExecutionConfig() + state = _ExecutionState(cash=float(cfg.initial_cash), positions=dict(positions or {})) + orders = tuple(compiled_orders) if compiled_orders is not None else compile_option_package_orders(package) + policy = package.execution_policy + if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE: + return _execute_atomic_all_or_none(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.BEST_EFFORT: + return _execute_best_effort(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.SEQUENTIAL: + return _execute_sequential(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY: + return _execute_hedge_after_primary(package, orders, tape, cfg, state) + if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY: + return _execute_rebalance_only(package, orders, tape, cfg, state) + raise ValueError(f"unsupported option execution policy: {policy}") + + +def _execute_atomic_all_or_none( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + evaluations = [_evaluate_order(order, tape, cfg, trial, package.package_id) for order in orders] + all_full = all(row.row["status"] == "filled" for row in evaluations) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not all_full or not guard_ok: + reason = guard_reason or "atomic_all_or_none_unfilled_leg" + rows = [_rejected_row(ev.row, reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", reason) + fills = [] + for ev in evaluations: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], "filled", "") + + +def _execute_best_effort( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + for order in orders: + ev = _evaluate_order(order, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _execute_sequential( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + stopped = False + for order in orders: + if stopped: + row = _base_skipped_row(package.package_id, order, "sequential_previous_leg_failed", cfg) + evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0)) + continue + ev = _evaluate_order(order, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + if ev.row["status"] not in {"filled", "partial"}: + stopped = True + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _execute_hedge_after_primary( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + primary = next((order for order in orders if order.metadata.get("option_leg_role") == "primary"), orders[0]) + hedge_orders = tuple(order for order in orders if order is not primary) + primary_ev = _evaluate_order(primary, tape, cfg, trial, package.package_id) + evaluations.append(primary_ev) + if primary_ev.row["status"] != "filled": + rows = [primary_ev.row] + [_base_skipped_row(package.package_id, order, "primary_not_filled", cfg) for order in hedge_orders] + return _final_result(package, cfg, state, state, [], rows, "rejected", "primary_not_filled") + _apply_evaluation(trial, primary_ev) + fills.append(primary_ev.fill) + for order in hedge_orders: + ev = _evaluate_order(order, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _execute_rebalance_only( + package: OptionPackageIntent, + orders: Tuple[OrderIntent, ...], + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, +) -> OptionPackageExecutionResult: + trial = state.copy() + fills: List[Fill] = [] + evaluations = [] + for order in orders: + target_signed = float(order.side.sign) * float(order.qty) + current = float(trial.positions.get(order.symbol, 0.0)) + delta = target_signed - current + if abs(delta) <= 1e-12: + row = _base_skipped_row(package.package_id, order, "already_at_target", cfg) + row["status"] = "no_op" + evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0)) + continue + adjusted = OrderIntent( + timestamp=order.timestamp, + symbol=order.symbol, + side=OrderSide.BUY if delta > 0 else OrderSide.SELL, + order_type=order.order_type, + qty=abs(delta), + price=order.price, + tif=order.tif, + tag=order.tag, + metadata={**order.metadata, "rebalance_target_signed_qty": target_signed, "rebalance_current_qty": current}, + ) + ev = _evaluate_order(adjusted, tape, cfg, trial, package.package_id) + evaluations.append(ev) + if ev.fill is not None: + _apply_evaluation(trial, ev) + fills.append(ev.fill) + guard_ok, guard_reason = _package_cash_guard(package, evaluations) + if not guard_ok: + rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations] + return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason) + status = _package_status(evaluations) + return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "") + + +def _evaluate_order( + order: OrderIntent, + tape: PreparedOptionTape, + cfg: OptionExecutionConfig, + state: _ExecutionState, + package_id: str, +) -> _OrderEvaluation: + snapshot_index = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns) + rows = tape.snapshot_slice(snapshot_index) + row_index = _find_row_index(tape, rows, order.symbol) + if row_index is None: + return _OrderEvaluation(None, _base_rejected_row(package_id, order, "instrument_not_listed_at_snapshot", cfg), 0.0, 0.0) + fill_price, liquidity, fillable, reason = _fill_price(order, tape, row_index, cfg) + if not fillable: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, 0.0, "open", reason, liquidity), 0.0, 0.0) + available = _available_qty(order, tape, row_index) + fill_qty = min(float(order.qty), available) + residual = float(order.qty) - fill_qty + if fill_qty <= 0.0: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "no_top_of_book_size", liquidity), 0.0, 0.0) + if residual > 1e-12 and order.tif is TimeInForce.FOK: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "fok_insufficient_size", liquidity), 0.0, 0.0) + if residual > 1e-12 and order.tif is TimeInForce.IOC: + if not cfg.allow_partial_fill: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "ioc_partial_not_allowed", liquidity), 0.0, 0.0) + status = "partial" + reason = "ioc_residual_canceled" + elif residual > 1e-12 and order.tif is TimeInForce.GTC: + if not cfg.allow_partial_fill: + return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "gtc_waiting_for_size", liquidity), 0.0, 0.0) + status = "partial" + reason = "gtc_residual_open" + else: + status = "filled" + reason = "" + fee = fill_qty * fill_price * cfg.fee_rate + cash_delta = fill_qty * fill_price - fee if order.side is OrderSide.SELL else -(fill_qty * fill_price + fee) + position_delta = order.side.sign * fill_qty + fill = Fill( + timestamp=tape.timestamp_ns[snapshot_index], + symbol=order.symbol, + side=order.side, + qty=fill_qty, + price=fill_price, + fee=fee, + liquidity=liquidity, + order_id=order.order_id, + metadata={**order.metadata, "option_row_index": int(row_index), "package_id": package_id}, + ) + row = _row_from_order(package_id, order, tape, row_index, cfg, fill_qty, fill_price, status, reason, liquidity, fee=fee, cash_delta=cash_delta) + return _OrderEvaluation(fill, row, cash_delta, position_delta) + + +def _fill_price( + order: OrderIntent, + tape: PreparedOptionTape, + row_index: int, + cfg: OptionExecutionConfig, +) -> tuple[float, LiquiditySide, bool, str]: + bid = float(tape.bid_price[row_index]) + ask = float(tape.ask_price[row_index]) + if order.order_type is OrderType.MARKET: + return (ask if order.side is OrderSide.BUY else bid), LiquiditySide.TAKER, True, "" + if order.order_type is not OrderType.LIMIT: + return float("nan"), LiquiditySide.TAKER, False, "unsupported_option_order_type" + limit = float(order.price) + if cfg.limit_fidelity is OptionLimitFidelity.CROSS_ONLY: + if order.side is OrderSide.BUY and limit >= ask: + return ask, LiquiditySide.TAKER, True, "" + if order.side is OrderSide.SELL and limit <= bid: + return bid, LiquiditySide.TAKER, True, "" + return limit, LiquiditySide.MAKER, False, "limit_not_crossed" + if order.side is OrderSide.BUY and limit >= bid: + return min(limit, ask), LiquiditySide.MAKER if limit < ask else LiquiditySide.TAKER, True, "maker_touch_simulated" + if order.side is OrderSide.SELL and limit <= ask: + return max(limit, bid), LiquiditySide.MAKER if limit > bid else LiquiditySide.TAKER, True, "maker_touch_simulated" + return limit, LiquiditySide.MAKER, False, "limit_not_touched" + + +def _available_qty(order: OrderIntent, tape: PreparedOptionTape, row_index: int) -> float: + return float(tape.ask_size[row_index] if order.side is OrderSide.BUY else tape.bid_size[row_index]) + + +def _find_row_index(tape: PreparedOptionTape, rows: slice, symbol: str) -> Optional[int]: + for idx in range(rows.start, rows.stop): + if tape.instrument_id[idx] == symbol: + return idx + return None + + +def _apply_evaluation(state: _ExecutionState, evaluation: _OrderEvaluation) -> None: + if evaluation.fill is None: + return + state.cash += float(evaluation.cash_delta) + state.positions[evaluation.fill.symbol] = state.positions.get(evaluation.fill.symbol, 0.0) + evaluation.position_delta + + +def _package_cash_guard(package: OptionPackageIntent, evaluations: List[_OrderEvaluation]) -> tuple[bool, str]: + net_cash_delta = sum(ev.cash_delta for ev in evaluations if ev.fill is not None) + debit = max(-net_cash_delta, 0.0) + credit = max(net_cash_delta, 0.0) + if package.max_debit is not None and debit > float(package.max_debit) + 1e-12: + return False, "max_debit_exceeded" + if package.min_credit is not None and credit + 1e-12 < float(package.min_credit): + return False, "min_credit_not_met" + return True, "" + + +def _final_result( + package: OptionPackageIntent, + cfg: OptionExecutionConfig, + initial_state: _ExecutionState, + final_state: _ExecutionState, + fills: List[Optional[Fill]], + rows: List[Dict], + package_status: str, + reject_reason: str, +) -> OptionPackageExecutionResult: + concrete_fills = tuple(fill for fill in fills if fill is not None) + order_report = pd.DataFrame(rows, columns=_ORDER_REPORT_COLUMNS) + filled_orders = int((order_report["status"] == "filled").sum()) if not order_report.empty else 0 + partial_orders = int((order_report["status"] == "partial").sum()) if not order_report.empty else 0 + net_cash_delta = float(final_state.cash - initial_state.cash) + gross_premium = float(order_report["filled_qty"].mul(order_report["fill_price"]).sum()) if not order_report.empty else 0.0 + package_report = pd.DataFrame( + [ + { + "package_id": package.package_id, + "execution_policy": package.execution_policy.value, + "status": package_status, + "reject_reason": reject_reason, + "requested_orders": len(package.legs), + "filled_orders": filled_orders, + "partial_orders": partial_orders, + "cash_before": initial_state.cash, + "cash_after": final_state.cash, + "net_cash_delta": net_cash_delta, + "gross_premium": gross_premium, + "debit": max(-net_cash_delta, 0.0), + "credit": max(net_cash_delta, 0.0), + "max_debit": package.max_debit, + "min_credit": package.min_credit, + "atomicity": _atomicity_for_report(package.execution_policy), + "exchange_combo": False, + "block_trade_style": False, + "depth_fidelity": cfg.depth_fidelity.value, + } + ], + columns=_PACKAGE_REPORT_COLUMNS, + ) + positions = {symbol: qty for symbol, qty in final_state.positions.items() if abs(qty) > 1e-12} + return OptionPackageExecutionResult( + fills=concrete_fills, + order_report=order_report, + package_report=package_report, + cash=float(final_state.cash), + positions=positions, + margin_report={ + "phase": "phase4_snapshot_execution", + "margin_model": "not_implemented_until_phase5", + "gross_premium": gross_premium, + "position_count": len(positions), + }, + metadata={ + "backend": "native_option_phase4", + "execution_scope": "snapshot_package_execution", + "depth_fidelity": cfg.depth_fidelity.value, + "limit_fidelity": cfg.limit_fidelity.value, + "atomicity": _atomicity_for_report(package.execution_policy), + **cfg.metadata, + }, + ) + + +def _package_status(evaluations: List[_OrderEvaluation]) -> str: + statuses = [ev.row["status"] for ev in evaluations] + if statuses and all(status == "filled" for status in statuses): + return "filled" + if any(status == "partial" for status in statuses): + return "partial" + if any(status == "filled" for status in statuses): + return "partial" + if any(status == "open" for status in statuses): + return "open" + return "rejected" + + +def _row_from_order( + package_id: str, + order: OrderIntent, + tape: PreparedOptionTape, + row_index: int, + cfg: OptionExecutionConfig, + filled_qty: float, + fill_price: float, + status: str, + reject_reason: str, + liquidity: LiquiditySide, + *, + fee: float = 0.0, + cash_delta: float = 0.0, +) -> Dict: + snapshot_idx = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns) + return { + "package_id": package_id, + "order_id": order.order_id, + "symbol": order.symbol, + "side": order.side.value, + "order_type": order.order_type.value, + "tif": order.tif.value, + "requested_qty": float(order.qty), + "filled_qty": float(filled_qty), + "residual_qty": max(float(order.qty) - float(filled_qty), 0.0), + "fill_price": float(fill_price), + "fee": float(fee), + "cash_delta": float(cash_delta), + "status": status, + "reject_reason": reject_reason, + "liquidity": liquidity.value, + "snapshot_timestamp_ns": int(tape.timestamp_ns[snapshot_idx]), + "decision_timestamp_ns": int(order.timestamp), + "row_index": int(row_index), + "depth_fidelity": cfg.depth_fidelity.value, + "limit_fidelity": cfg.limit_fidelity.value, + "residual_risk": bool(status == "partial"), + "atomicity": order.metadata.get("atomicity", ""), + } + + +def _base_rejected_row(package_id: str, order: OrderIntent, reason: str, cfg: OptionExecutionConfig) -> Dict: + return _base_skipped_row(package_id, order, reason, cfg, status="rejected") + + +def _base_skipped_row( + package_id: str, + order: OrderIntent, + reason: str, + cfg: OptionExecutionConfig, + *, + status: str = "skipped", +) -> Dict: + return { + "package_id": package_id, + "order_id": order.order_id, + "symbol": order.symbol, + "side": order.side.value, + "order_type": order.order_type.value, + "tif": order.tif.value, + "requested_qty": float(order.qty), + "filled_qty": 0.0, + "residual_qty": float(order.qty), + "fill_price": float("nan"), + "fee": 0.0, + "cash_delta": 0.0, + "status": status, + "reject_reason": reason, + "liquidity": "", + "snapshot_timestamp_ns": 0, + "decision_timestamp_ns": int(order.timestamp), + "row_index": -1, + "depth_fidelity": cfg.depth_fidelity.value, + "limit_fidelity": cfg.limit_fidelity.value, + "residual_risk": False, + "atomicity": order.metadata.get("atomicity", ""), + } + + +def _rejected_row(row: Dict, reason: str) -> Dict: + rejected = dict(row) + rejected["status"] = "rejected" + rejected["reject_reason"] = reason + rejected["filled_qty"] = 0.0 + rejected["residual_qty"] = rejected["requested_qty"] + rejected["fee"] = 0.0 + rejected["cash_delta"] = 0.0 + rejected["residual_risk"] = False + return rejected + + +def _atomicity_for_report(policy: OptionPackageExecutionPolicy) -> str: + if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE: + return "simulated_atomic_all_or_none" + if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY: + return "simulated_primary_then_hedge" + if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY: + return "simulated_rebalance_only" + return f"simulated_{policy.value}" + + +def _coerce_enum(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc diff --git a/src/quantbt/options/fees.py b/src/quantbt/options/fees.py new file mode 100644 index 0000000..d358e93 --- /dev/null +++ b/src/quantbt/options/fees.py @@ -0,0 +1,135 @@ +""" +Option fee schedules. + +Phase 5 implements deterministic per-leg capped fees. There is intentionally no +package-level cap because real venues cap option fees per contract/leg. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Union + +from ..core.orders import Fill +from ..core.schema import LiquiditySide +from .schema import OptionInstrumentSpec, PremiumConvention + + +@dataclass(frozen=True) +class OptionFeeResult: + fee: float + currency: str + raw_fee: float + cap: float + capped: bool + schedule_id: str + + +@dataclass(frozen=True) +class OptionFeeSchedule: + schedule_id: str + fee_currency: str + maker_rate: float = 0.0 + taker_rate: float = 0.0 + cap_premium_fraction: float = 0.125 + per_contract_fee: float = 0.0 + premium_convention: Union[PremiumConvention, str] = PremiumConvention.LINEAR_QUOTE + + def __post_init__(self) -> None: + object.__setattr__(self, "premium_convention", _coerce_premium(self.premium_convention)) + object.__setattr__(self, "fee_currency", str(self.fee_currency).upper()) + if not self.schedule_id: + raise ValueError("schedule_id is required") + if not self.fee_currency: + raise ValueError("fee_currency is required") + if self.maker_rate < 0.0 or self.taker_rate < 0.0: + raise ValueError("maker_rate and taker_rate must be >= 0") + if self.cap_premium_fraction < 0.0: + raise ValueError("cap_premium_fraction must be >= 0") + if self.per_contract_fee < 0.0: + raise ValueError("per_contract_fee must be >= 0") + + def rate_for(self, liquidity: LiquiditySide) -> float: + return self.maker_rate if liquidity is LiquiditySide.MAKER else self.taker_rate + + +def deribit_inverse_fee_schedule( + *, + base_currency: str = "BTC", + per_contract_fee: float = 0.0003, + cap_premium_fraction: float = 0.125, +) -> OptionFeeSchedule: + return OptionFeeSchedule( + schedule_id=f"deribit_{base_currency.lower()}_inverse_options_phase5", + fee_currency=base_currency, + per_contract_fee=per_contract_fee, + cap_premium_fraction=cap_premium_fraction, + premium_convention=PremiumConvention.INVERSE_BASE, + ) + + +def deribit_linear_usdc_fee_schedule( + *, + taker_rate: float = 0.0003, + maker_rate: float = 0.0003, + cap_premium_fraction: float = 0.125, +) -> OptionFeeSchedule: + return OptionFeeSchedule( + schedule_id="deribit_linear_usdc_options_phase5", + fee_currency="USDC", + maker_rate=maker_rate, + taker_rate=taker_rate, + cap_premium_fraction=cap_premium_fraction, + premium_convention=PremiumConvention.LINEAR_QUOTE, + ) + + +def calculate_option_fee( + fill: Fill, + instrument: OptionInstrumentSpec, + schedule: OptionFeeSchedule, + *, + reference_price: float, +) -> OptionFeeResult: + """ + Calculate a per-leg capped option fee. + + For inverse options the common venue-like form is a base-currency fee per + contract capped by a fraction of option premium. For linear options the raw + fee is reference notional times rate, also capped by option premium. + """ + if schedule.premium_convention != instrument.premium_convention: + raise ValueError("fee schedule premium convention does not match instrument") + if schedule.fee_currency != instrument.premium_currency: + raise ValueError("fee schedule currency must match option premium currency in Phase 5") + if reference_price <= 0.0: + raise ValueError("reference_price must be > 0") + premium_notional = float(fill.qty) * float(fill.price) * float(instrument.multiplier) + cap = premium_notional * float(schedule.cap_premium_fraction) + if instrument.premium_convention is PremiumConvention.INVERSE_BASE: + raw_fee = float(fill.qty) * float(instrument.multiplier) * float(schedule.per_contract_fee) + else: + raw_fee = ( + float(fill.qty) + * float(instrument.multiplier) + * float(reference_price) + * float(schedule.rate_for(fill.liquidity)) + ) + fee = min(raw_fee, cap) if schedule.cap_premium_fraction > 0.0 else raw_fee + return OptionFeeResult( + fee=float(fee), + currency=schedule.fee_currency, + raw_fee=float(raw_fee), + cap=float(cap), + capped=bool(fee < raw_fee), + schedule_id=schedule.schedule_id, + ) + + +def _coerce_premium(value: Union[PremiumConvention, str]) -> PremiumConvention: + if isinstance(value, PremiumConvention): + return value + try: + return PremiumConvention(str(value)) + except ValueError as exc: + raise ValueError("premium_convention is invalid") from exc diff --git a/src/quantbt/options/greeks.py b/src/quantbt/options/greeks.py new file mode 100644 index 0000000..e952c1c --- /dev/null +++ b/src/quantbt/options/greeks.py @@ -0,0 +1,173 @@ +""" +Option Greeks with explicit units. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Union + +from .pricing import ( + black76_d1_d2, + black76_price, + normal_cdf, + normal_pdf, + _coerce_kind, + _non_negative_float, + _positive_float, +) +from .schema import OptionKind + + +@dataclass(frozen=True) +class OptionGreeks: + price: float + delta: float + gamma: float + vega: float + theta: float + currency: str + unit: str + + @property + def vega_per_vol_point(self) -> float: + """Return vega for a 1 vol-point change, not a 1.0 vol change.""" + return self.vega / 100.0 + + +def linear_black76_greeks( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + currency: str = "QUOTE", +) -> OptionGreeks: + """Return Black-76 Greeks in quote currency per 1 underlying.""" + kind = _coerce_kind(option_kind) + fwd, strike_, tau, vol, df = _validated_greek_inputs(forward, strike, time_to_expiry, volatility, discount) + price = black76_price(fwd, strike_, tau, vol, kind, discount=df) + if tau <= 0.0 or vol <= 0.0: + delta = df if (kind is OptionKind.CALL and fwd > strike_) else 0.0 + if kind is OptionKind.PUT and fwd < strike_: + delta = -df + return OptionGreeks(price=price, delta=delta, gamma=0.0, vega=0.0, theta=0.0, currency=currency, unit="quote") + d1, _ = black76_d1_d2(fwd, strike_, tau, vol) + pdf = normal_pdf(d1) + if kind is OptionKind.CALL: + delta = df * normal_cdf(d1) + else: + delta = df * (normal_cdf(d1) - 1.0) + gamma = df * pdf / (fwd * vol * math.sqrt(tau)) + vega = df * fwd * pdf * math.sqrt(tau) + theta = -0.5 * df * fwd * pdf * vol / math.sqrt(tau) + return OptionGreeks( + price=price, + delta=delta, + gamma=gamma, + vega=vega, + theta=theta, + currency=str(currency).upper(), + unit="quote", + ) + + +def inverse_black76_greeks_base( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + currency: str = "BASE", +) -> OptionGreeks: + """Return inverse option Greeks in native base settlement currency.""" + fwd = _positive_float(forward, "forward") + linear = linear_black76_greeks(fwd, strike, time_to_expiry, volatility, option_kind, discount=discount) + price = linear.price / fwd + delta = linear.delta / fwd - linear.price / (fwd * fwd) + gamma = linear.gamma / fwd - 2.0 * linear.delta / (fwd * fwd) + 2.0 * linear.price / (fwd * fwd * fwd) + vega = linear.vega / fwd + theta = linear.theta / fwd + return OptionGreeks( + price=price, + delta=delta, + gamma=gamma, + vega=vega, + theta=theta, + currency=str(currency).upper(), + unit="base", + ) + + +def inverse_black76_greeks_quote( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + currency: str = "QUOTE", +) -> OptionGreeks: + """ + Return inverse option Greeks converted to quote reporting currency. + + Under the Phase 2 inverse convention, quote-reporting value equals the + corresponding linear Black-76 value, so Greeks match the linear Greeks. + """ + return linear_black76_greeks( + forward, + strike, + time_to_expiry, + volatility, + option_kind, + discount=discount, + currency=currency, + ) + + +def scale_greeks_to_reporting_currency( + greeks: OptionGreeks, + conversion_rate: float, + *, + reporting_currency: str, + vega_per_vol_point: bool = False, +) -> OptionGreeks: + """ + Statically scale Greeks into a reporting currency. + + This is a pure currency conversion helper. It does not add chain-rule delta + from a conversion rate that itself depends on the underlying. + """ + rate = _positive_float(conversion_rate, "conversion_rate") + vega_scale = 0.01 if vega_per_vol_point else 1.0 + return OptionGreeks( + price=greeks.price * rate, + delta=greeks.delta * rate, + gamma=greeks.gamma * rate, + vega=greeks.vega * rate * vega_scale, + theta=greeks.theta * rate, + currency=str(reporting_currency).upper(), + unit=f"{greeks.unit}_reported", + ) + + +def _validated_greek_inputs( + forward: float, + strike: float, + time_to_expiry: float, + volatility: float, + discount: float, +) -> tuple[float, float, float, float, float]: + return ( + _positive_float(forward, "forward"), + _positive_float(strike, "strike"), + _non_negative_float(time_to_expiry, "time_to_expiry"), + _non_negative_float(volatility, "volatility"), + _positive_float(discount, "discount"), + ) diff --git a/src/quantbt/options/hedging.py b/src/quantbt/options/hedging.py new file mode 100644 index 0000000..68f4e08 --- /dev/null +++ b/src/quantbt/options/hedging.py @@ -0,0 +1,232 @@ +""" +Option hedge policy primitives. + +Hedge accounting is intentionally explicit about ordering: hedge PnL for a +price move is earned by the hedge quantity held before that move; rebalance +decisions are evaluated after option package fills and Greek recomputation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Sequence + +import numpy as np +import pandas as pd + +from .greeks import OptionGreeks +from .ledger import OptionLedger +from .schema import OptionInstrumentSpec + + +class OptionHedgePolicyType(str, Enum): + FIXED_THRESHOLD = "fixed_threshold" + HYSTERESIS_BAND = "hysteresis_band" + TIME_BASED = "time_based" + REALIZED_VOL_SCALED_BAND = "realized_vol_scaled_band" + + +@dataclass(frozen=True) +class OptionHedgeConfig: + policy: OptionHedgePolicyType = OptionHedgePolicyType.FIXED_THRESHOLD + target_delta: float = 0.0 + threshold: float = 0.05 + enter_band: float = 0.10 + exit_band: float = 0.03 + rebalance_interval_ns: int = 0 + realized_vol_window: int = 20 + realized_vol_multiplier: float = 1.0 + min_band: float = 0.01 + hedge_contract_multiplier: float = 1.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "policy", _coerce_policy(self.policy)) + if self.threshold < 0.0 or self.enter_band < 0.0 or self.exit_band < 0.0: + raise ValueError("threshold and bands must be >= 0") + if self.exit_band > self.enter_band: + raise ValueError("exit_band must be <= enter_band") + if self.rebalance_interval_ns < 0: + raise ValueError("rebalance_interval_ns must be >= 0") + if self.realized_vol_window <= 1: + raise ValueError("realized_vol_window must be > 1") + if self.realized_vol_multiplier < 0.0 or self.min_band < 0.0: + raise ValueError("realized_vol_multiplier and min_band must be >= 0") + if self.hedge_contract_multiplier <= 0.0: + raise ValueError("hedge_contract_multiplier must be > 0") + + +@dataclass(frozen=True) +class HedgeDecision: + timestamp_ns: int + net_option_delta: float + previous_hedge_qty: float + target_hedge_qty: float + trade_qty: float + should_rebalance: bool + reason: str + band: float + + +@dataclass(frozen=True) +class HedgePathResult: + hedge_report: pd.DataFrame + final_hedge_qty: float + hedge_pnl: float + decisions: tuple[HedgeDecision, ...] + metadata: Dict + + +def compute_net_option_delta( + ledger: OptionLedger, + greeks_by_symbol: Dict[str, OptionGreeks], + instruments: Dict[str, OptionInstrumentSpec], +) -> float: + """Return portfolio option delta after package fills and Greek recompute.""" + total = 0.0 + for symbol, position in ledger.positions.items(): + if position.is_flat: + continue + greek = greeks_by_symbol.get(symbol) + instrument = instruments.get(symbol) + if greek is None or instrument is None: + raise ValueError(f"missing Greek or instrument for {symbol}") + total += float(position.qty) * float(greek.delta) * float(instrument.multiplier) + return float(total) + + +def hedge_decision( + *, + timestamp_ns: int, + net_option_delta: float, + current_hedge_qty: float, + config: OptionHedgeConfig, + last_rebalance_timestamp_ns: Optional[int] = None, + underlying_prices: Optional[Sequence[float]] = None, + currently_active: bool = False, +) -> HedgeDecision: + """Decide whether to rebalance the hedge after Greek recomputation.""" + target_qty = (float(config.target_delta) - float(net_option_delta)) / float(config.hedge_contract_multiplier) + trade_qty = target_qty - float(current_hedge_qty) + band = _active_band(config, underlying_prices) + reason = "within_band" + should = False + abs_trade = abs(trade_qty) + if config.policy is OptionHedgePolicyType.FIXED_THRESHOLD: + should = abs_trade >= config.threshold + reason = "fixed_threshold" if should else reason + elif config.policy is OptionHedgePolicyType.HYSTERESIS_BAND: + threshold = config.exit_band if currently_active else config.enter_band + should = abs_trade >= threshold + reason = "hysteresis_exit_band" if currently_active and should else ("hysteresis_enter_band" if should else reason) + band = threshold + elif config.policy is OptionHedgePolicyType.TIME_BASED: + due = last_rebalance_timestamp_ns is None or int(timestamp_ns) - int(last_rebalance_timestamp_ns) >= config.rebalance_interval_ns + should = due and abs_trade > 1e-12 + reason = "time_based_due" if should else "time_based_not_due" + elif config.policy is OptionHedgePolicyType.REALIZED_VOL_SCALED_BAND: + should = abs_trade >= band + reason = "realized_vol_scaled_band" if should else reason + return HedgeDecision( + timestamp_ns=int(timestamp_ns), + net_option_delta=float(net_option_delta), + previous_hedge_qty=float(current_hedge_qty), + target_hedge_qty=float(target_qty), + trade_qty=float(trade_qty if should else 0.0), + should_rebalance=bool(should), + reason=reason, + band=float(band), + ) + + +def run_delta_hedge_path( + timestamps_ns: Sequence[int], + underlying_prices: Sequence[float], + net_option_deltas: Sequence[float], + config: OptionHedgeConfig, + *, + initial_hedge_qty: float = 0.0, +) -> HedgePathResult: + """ + Simulate hedge PnL and rebalances over a path. + + At bar `t`, PnL from `price[t-1] -> price[t]` uses the hedge quantity held + at `t-1`. Only after that move do we evaluate the new option delta and + rebalance. + """ + ts = np.asarray(timestamps_ns, dtype=np.int64) + prices = np.asarray(underlying_prices, dtype=np.float64) + deltas = np.asarray(net_option_deltas, dtype=np.float64) + if len(ts) == 0 or len(ts) != len(prices) or len(ts) != len(deltas): + raise ValueError("timestamps, prices and deltas must be non-empty and equal length") + if bool((prices <= 0.0).any()) or bool((~np.isfinite(prices)).any()): + raise ValueError("underlying prices must be finite and > 0") + hedge_qty = float(initial_hedge_qty) + hedge_pnl = 0.0 + last_rebalance_ts: Optional[int] = None + active = abs(hedge_qty) > 1e-12 + rows = [] + decisions = [] + for i in range(len(ts)): + pnl = 0.0 + if i > 0: + pnl = hedge_qty * (prices[i] - prices[i - 1]) * config.hedge_contract_multiplier + hedge_pnl += pnl + decision = hedge_decision( + timestamp_ns=int(ts[i]), + net_option_delta=float(deltas[i]), + current_hedge_qty=hedge_qty, + config=config, + last_rebalance_timestamp_ns=last_rebalance_ts, + underlying_prices=prices[max(0, i - config.realized_vol_window + 1) : i + 1], + currently_active=active, + ) + if decision.should_rebalance: + hedge_qty += decision.trade_qty + last_rebalance_ts = int(ts[i]) + active = abs(hedge_qty) > 1e-12 + decisions.append(decision) + rows.append( + { + "timestamp_ns": int(ts[i]), + "underlying_price": float(prices[i]), + "prior_hedge_qty": decision.previous_hedge_qty, + "net_option_delta": decision.net_option_delta, + "hedge_pnl_for_prior_move": float(pnl), + "cumulative_hedge_pnl": float(hedge_pnl), + "target_hedge_qty": decision.target_hedge_qty, + "trade_qty": decision.trade_qty, + "hedge_qty_after": float(hedge_qty), + "should_rebalance": decision.should_rebalance, + "reason": decision.reason, + "band": decision.band, + } + ) + return HedgePathResult( + hedge_report=pd.DataFrame(rows), + final_hedge_qty=float(hedge_qty), + hedge_pnl=float(hedge_pnl), + decisions=tuple(decisions), + metadata={"policy": config.policy.value, "hedge_contract_multiplier": config.hedge_contract_multiplier}, + ) + + +def _active_band(config: OptionHedgeConfig, prices: Optional[Sequence[float]]) -> float: + if config.policy is not OptionHedgePolicyType.REALIZED_VOL_SCALED_BAND: + return float(config.threshold) + if prices is None or len(prices) < 2: + return float(config.min_band) + arr = np.asarray(prices, dtype=np.float64) + returns = np.diff(np.log(arr)) + realized = float(np.std(returns, ddof=1)) if len(returns) > 1 else abs(float(returns[0])) + return max(float(config.min_band), realized * float(config.realized_vol_multiplier)) + + +def _coerce_policy(value) -> OptionHedgePolicyType: + if isinstance(value, OptionHedgePolicyType): + return value + try: + return OptionHedgePolicyType(str(value)) + except ValueError as exc: + raise ValueError("invalid option hedge policy") from exc diff --git a/src/quantbt/options/iv.py b/src/quantbt/options/iv.py new file mode 100644 index 0000000..dbd60aa --- /dev/null +++ b/src/quantbt/options/iv.py @@ -0,0 +1,188 @@ +""" +Deterministic implied-volatility solvers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import math +from typing import Callable, Union + +from .pricing import ( + black76_intrinsic, + black76_price, + inverse_black76_intrinsic_base, + inverse_black76_price_base, + _coerce_kind, + _non_negative_float, + _positive_float, +) +from .schema import OptionKind + + +class IVStatus(str, Enum): + OK = "ok" + BELOW_INTRINSIC = "below_intrinsic" + ABOVE_MAX_PRICE = "above_max_price" + INVALID_INPUT = "invalid_input" + NOT_BRACKETED = "not_bracketed" + MAX_ITERATIONS = "max_iterations" + + +@dataclass(frozen=True) +class ImpliedVolResult: + implied_vol: float + status: IVStatus + iterations: int + model_price: float + lower_bound: float + upper_bound: float + residual: float + + @property + def ok(self) -> bool: + return self.status is IVStatus.OK + + +def implied_vol_black76( + price: float, + forward: float, + strike: float, + time_to_expiry: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + tolerance: float = 1e-12, + max_iterations: int = 100, + vol_lower: float = 0.0, + vol_upper: float = 5.0, + max_vol_upper: float = 20.0, +) -> ImpliedVolResult: + """Solve linear Black-76 implied volatility with bracketed bisection.""" + try: + kind = _coerce_kind(option_kind) + target = _non_negative_float(price, "price") + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + tau = _non_negative_float(time_to_expiry, "time_to_expiry") + df = _positive_float(discount, "discount") + except (TypeError, ValueError): + return _invalid_result(price) + lower_bound = black76_intrinsic(fwd, strike_, kind, discount=df) + upper_bound = _black76_upper_bound(fwd, strike_, kind, discount=df) + return _solve_bisection( + target, + lower_bound, + upper_bound, + lambda vol: black76_price(fwd, strike_, tau, vol, kind, discount=df), + tolerance=tolerance, + max_iterations=max_iterations, + vol_lower=vol_lower, + vol_upper=vol_upper, + max_vol_upper=max_vol_upper, + ) + + +def implied_vol_inverse_black76_base( + price_base: float, + forward: float, + strike: float, + time_to_expiry: float, + option_kind: Union[OptionKind, str], + *, + discount: float = 1.0, + tolerance: float = 1e-12, + max_iterations: int = 100, + vol_lower: float = 0.0, + vol_upper: float = 5.0, + max_vol_upper: float = 20.0, +) -> ImpliedVolResult: + """Solve inverse Black-76 implied volatility from base-currency price.""" + try: + kind = _coerce_kind(option_kind) + target = _non_negative_float(price_base, "price_base") + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + tau = _non_negative_float(time_to_expiry, "time_to_expiry") + df = _positive_float(discount, "discount") + except (TypeError, ValueError): + return _invalid_result(price_base) + lower_bound = inverse_black76_intrinsic_base(fwd, strike_, kind, discount=df) + upper_bound = _black76_upper_bound(fwd, strike_, kind, discount=df) / fwd + return _solve_bisection( + target, + lower_bound, + upper_bound, + lambda vol: inverse_black76_price_base(fwd, strike_, tau, vol, kind, discount=df), + tolerance=tolerance, + max_iterations=max_iterations, + vol_lower=vol_lower, + vol_upper=vol_upper, + max_vol_upper=max_vol_upper, + ) + + +def _solve_bisection( + target: float, + lower_bound: float, + upper_bound: float, + price_fn: Callable[[float], float], + *, + tolerance: float, + max_iterations: int, + vol_lower: float, + vol_upper: float, + max_vol_upper: float, +) -> ImpliedVolResult: + tol = _positive_float(tolerance, "tolerance") + if max_iterations <= 0: + return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, lower_bound, upper_bound, math.nan) + lower_vol = _non_negative_float(vol_lower, "vol_lower") + upper_vol = _positive_float(vol_upper, "vol_upper") + max_upper = _positive_float(max_vol_upper, "max_vol_upper") + if upper_vol <= lower_vol: + return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, lower_bound, upper_bound, math.nan) + if target < lower_bound - tol: + return ImpliedVolResult(math.nan, IVStatus.BELOW_INTRINSIC, 0, lower_bound, lower_bound, upper_bound, target - lower_bound) + if target > upper_bound + tol: + return ImpliedVolResult(math.nan, IVStatus.ABOVE_MAX_PRICE, 0, upper_bound, lower_bound, upper_bound, target - upper_bound) + if abs(target - lower_bound) <= tol: + return ImpliedVolResult(0.0, IVStatus.OK, 0, lower_bound, lower_bound, upper_bound, lower_bound - target) + + lower_price = price_fn(lower_vol) + upper_price = price_fn(upper_vol) + while upper_price < target and upper_vol < max_upper: + upper_vol = min(upper_vol * 2.0, max_upper) + upper_price = price_fn(upper_vol) + if target < lower_price - tol or upper_price < target - tol: + return ImpliedVolResult(math.nan, IVStatus.NOT_BRACKETED, 0, upper_price, lower_bound, upper_bound, upper_price - target) + + mid = 0.5 * (lower_vol + upper_vol) + mid_price = price_fn(mid) + for iteration in range(1, max_iterations + 1): + mid = 0.5 * (lower_vol + upper_vol) + mid_price = price_fn(mid) + residual = mid_price - target + if abs(residual) <= tol: + return ImpliedVolResult(mid, IVStatus.OK, iteration, mid_price, lower_bound, upper_bound, residual) + if mid_price < target: + lower_vol = mid + else: + upper_vol = mid + return ImpliedVolResult(mid, IVStatus.MAX_ITERATIONS, max_iterations, mid_price, lower_bound, upper_bound, mid_price - target) + + +def _black76_upper_bound(forward: float, strike: float, option_kind: OptionKind, *, discount: float) -> float: + if option_kind is OptionKind.CALL: + return discount * forward + return discount * strike + + +def _invalid_result(price: float) -> ImpliedVolResult: + try: + raw = float(price) + except (TypeError, ValueError): + raw = math.nan + target = raw if math.isfinite(raw) else math.nan + return ImpliedVolResult(math.nan, IVStatus.INVALID_INPUT, 0, math.nan, math.nan, math.nan, target) diff --git a/src/quantbt/options/ledger.py b/src/quantbt/options/ledger.py new file mode 100644 index 0000000..da0ff33 --- /dev/null +++ b/src/quantbt/options/ledger.py @@ -0,0 +1,263 @@ +""" +Multi-currency option ledger. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Iterable, Optional + +import pandas as pd + +from ..core.orders import Fill +from ..core.schema import OrderSide +from .fees import OptionFeeResult +from .schema import OptionInstrumentSpec + + +@dataclass +class OptionPosition: + symbol: str + qty: float = 0.0 + avg_entry: float = 0.0 + realized_pnl: float = 0.0 + premium_currency: str = "" + settlement_currency: str = "" + multiplier: float = 1.0 + + @property + def is_flat(self) -> bool: + return abs(self.qty) <= 1e-12 + + +@dataclass +class OptionLedger: + cash: Dict[str, float] = field(default_factory=dict) + positions: Dict[str, OptionPosition] = field(default_factory=dict) + realized_pnl: Dict[str, float] = field(default_factory=dict) + fees: Dict[str, float] = field(default_factory=dict) + settlement_cashflows: Dict[str, float] = field(default_factory=dict) + margin_locked: Dict[str, float] = field(default_factory=dict) + events: list[Dict] = field(default_factory=list) + settled_symbols: set[str] = field(default_factory=set) + + @classmethod + def from_cash(cls, balances: Dict[str, float]) -> "OptionLedger": + ledger = cls() + for currency, amount in balances.items(): + ledger.cash[str(currency).upper()] = float(amount) + return ledger + + def apply_fill( + self, + fill: Fill, + instrument: OptionInstrumentSpec, + *, + fee: Optional[OptionFeeResult] = None, + timestamp_ns: Optional[int] = None, + ) -> None: + """Apply premium cashflow, fee, position quantity, and realized PnL.""" + premium_currency = instrument.premium_currency + fee_amount = float(fee.fee) if fee is not None else float(fill.fee) + fee_currency = fee.currency if fee is not None else premium_currency + premium = float(fill.qty) * float(fill.price) * float(instrument.multiplier) + premium_cash_delta = premium if fill.side is OrderSide.SELL else -premium + self._add_cash(premium_currency, premium_cash_delta) + if fee_amount: + self._add_cash(fee_currency, -fee_amount) + self.fees[fee_currency] = self.fees.get(fee_currency, 0.0) + fee_amount + realized = self._apply_position(fill, instrument) + if realized: + self.realized_pnl[premium_currency] = self.realized_pnl.get(premium_currency, 0.0) + realized + self.events.append( + { + "timestamp_ns": int(timestamp_ns if timestamp_ns is not None else fill.timestamp), + "event_type": "fill", + "symbol": fill.symbol, + "side": fill.side.value, + "qty": float(fill.qty), + "price": float(fill.price), + "premium_currency": premium_currency, + "premium_cashflow": float(premium_cash_delta), + "fee_currency": fee_currency, + "fee": fee_amount, + "realized_pnl": float(realized), + "cash_after": dict(self.cash), + "position_after": self.positions.get(fill.symbol).qty if fill.symbol in self.positions else 0.0, + } + ) + + def apply_settlement( + self, + instrument: OptionInstrumentSpec, + *, + timestamp_ns: int, + settlement_price: float, + payoff_per_unit: float, + representation: str, + ) -> float: + """Settle and close an option position exactly once.""" + if instrument.symbol in self.settled_symbols: + raise ValueError(f"{instrument.symbol} has already been settled") + position = self.positions.get(instrument.symbol) + if position is None or position.is_flat: + self.settled_symbols.add(instrument.symbol) + self.events.append( + { + "timestamp_ns": int(timestamp_ns), + "event_type": "settlement", + "symbol": instrument.symbol, + "settlement_price": float(settlement_price), + "payoff_per_unit": float(payoff_per_unit), + "settlement_currency": instrument.settlement_currency, + "settlement_cashflow": 0.0, + "representation": representation, + "position_closed": True, + "cash_after": dict(self.cash), + } + ) + return 0.0 + cashflow = float(position.qty) * float(payoff_per_unit) * float(instrument.multiplier) + self._add_cash(instrument.settlement_currency, cashflow) + self.settlement_cashflows[instrument.settlement_currency] = ( + self.settlement_cashflows.get(instrument.settlement_currency, 0.0) + cashflow + ) + position.realized_pnl += cashflow + self.realized_pnl[instrument.settlement_currency] = self.realized_pnl.get(instrument.settlement_currency, 0.0) + cashflow + position.qty = 0.0 + position.avg_entry = 0.0 + self.settled_symbols.add(instrument.symbol) + self.events.append( + { + "timestamp_ns": int(timestamp_ns), + "event_type": "settlement", + "symbol": instrument.symbol, + "settlement_price": float(settlement_price), + "payoff_per_unit": float(payoff_per_unit), + "settlement_currency": instrument.settlement_currency, + "settlement_cashflow": float(cashflow), + "representation": representation, + "position_closed": True, + "cash_after": dict(self.cash), + } + ) + return cashflow + + def equity( + self, + *, + conversion_rates: Dict[str, float], + marks: Optional[Dict[str, float]] = None, + instruments: Optional[Dict[str, OptionInstrumentSpec]] = None, + reporting_currency: str = "USD", + ) -> float: + """Return marked equity in reporting currency.""" + total = 0.0 + for currency, amount in self.cash.items(): + total += float(amount) * _conversion_rate(currency, conversion_rates, reporting_currency) + if marks and instruments: + for symbol, mark in marks.items(): + position = self.positions.get(symbol) + instrument = instruments.get(symbol) + if position is None or instrument is None or position.is_flat: + continue + total += ( + float(position.qty) + * float(mark) + * float(instrument.multiplier) + * _conversion_rate(instrument.premium_currency, conversion_rates, reporting_currency) + ) + return float(total) + + def equity_identity_report( + self, + *, + conversion_rates: Dict[str, float], + marks: Optional[Dict[str, float]] = None, + instruments: Optional[Dict[str, OptionInstrumentSpec]] = None, + reporting_currency: str = "USD", + ) -> Dict: + equity = self.equity( + conversion_rates=conversion_rates, + marks=marks, + instruments=instruments, + reporting_currency=reporting_currency, + ) + cash_equity = sum( + float(amount) * _conversion_rate(currency, conversion_rates, reporting_currency) + for currency, amount in self.cash.items() + ) + mark_equity = equity - cash_equity + return { + "reporting_currency": reporting_currency.upper(), + "cash_equity": float(cash_equity), + "mark_equity": float(mark_equity), + "equity": float(equity), + "cash": dict(self.cash), + "fees": dict(self.fees), + "realized_pnl": dict(self.realized_pnl), + "settlement_cashflows": dict(self.settlement_cashflows), + "margin_locked": dict(self.margin_locked), + "events": len(self.events), + "reconciled": True, + } + + def event_report(self) -> pd.DataFrame: + return pd.DataFrame(self.events) + + def _apply_position(self, fill: Fill, instrument: OptionInstrumentSpec) -> float: + position = self.positions.get(fill.symbol) + if position is None: + position = OptionPosition( + symbol=fill.symbol, + premium_currency=instrument.premium_currency, + settlement_currency=instrument.settlement_currency, + multiplier=instrument.multiplier, + ) + self.positions[fill.symbol] = position + signed_qty = float(fill.signed_qty) + fill_price = float(fill.price) + prev_qty = float(position.qty) + realized = 0.0 + if abs(prev_qty) <= 1e-12 or prev_qty * signed_qty > 0.0: + new_abs = abs(prev_qty) + abs(signed_qty) + position.avg_entry = ( + (abs(prev_qty) * position.avg_entry + abs(signed_qty) * fill_price) / new_abs + if new_abs > 0.0 + else 0.0 + ) + position.qty = prev_qty + signed_qty + return 0.0 + close_qty = min(abs(prev_qty), abs(signed_qty)) + if prev_qty > 0.0: + realized = (fill_price - position.avg_entry) * close_qty * float(instrument.multiplier) + else: + realized = (position.avg_entry - fill_price) * close_qty * float(instrument.multiplier) + new_qty = prev_qty + signed_qty + position.realized_pnl += realized + if abs(new_qty) <= 1e-12: + position.qty = 0.0 + position.avg_entry = 0.0 + elif prev_qty * new_qty > 0.0: + position.qty = new_qty + else: + position.qty = new_qty + position.avg_entry = fill_price + return float(realized) + + def _add_cash(self, currency: str, amount: float) -> None: + key = str(currency).upper() + self.cash[key] = self.cash.get(key, 0.0) + float(amount) + + +def _conversion_rate(currency: str, conversion_rates: Dict[str, float], reporting_currency: str) -> float: + ccy = str(currency).upper() + report = str(reporting_currency).upper() + if ccy == report: + return 1.0 + if ccy not in conversion_rates: + raise ValueError(f"missing conversion rate for {ccy}->{report}") + rate = float(conversion_rates[ccy]) + if rate <= 0.0: + raise ValueError(f"conversion rate for {ccy}->{report} must be > 0") + return rate diff --git a/src/quantbt/options/lifecycle.py b/src/quantbt/options/lifecycle.py new file mode 100644 index 0000000..5aae15a --- /dev/null +++ b/src/quantbt/options/lifecycle.py @@ -0,0 +1,94 @@ +""" +Option lifecycle and expiry settlement. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Union + +from .ledger import OptionLedger +from .schema import OptionInstrumentSpec, OptionKind, PremiumConvention, SettlementStyle + + +class OptionSettlementRepresentation(str, Enum): + ECONOMIC_CASH = "economic_cash" + FUTURE_THEN_CASH = "future_then_cash" + + +@dataclass(frozen=True) +class OptionSettlementResult: + symbol: str + timestamp_ns: int + settlement_price: float + payoff_per_unit: float + cashflow: float + settlement_currency: str + representation: OptionSettlementRepresentation + itm: bool + position_closed: bool + + +def option_expiry_payoff_per_unit(instrument: OptionInstrumentSpec, settlement_price: float) -> float: + """Return payoff per 1 option unit in the instrument settlement currency.""" + price = float(settlement_price) + if price <= 0.0: + raise ValueError("settlement_price must be > 0") + strike = float(instrument.strike) + if instrument.option_kind is OptionKind.CALL: + intrinsic_quote = max(price - strike, 0.0) + else: + intrinsic_quote = max(strike - price, 0.0) + if instrument.premium_convention is PremiumConvention.INVERSE_BASE: + return intrinsic_quote / price + if instrument.premium_convention is PremiumConvention.LINEAR_QUOTE: + return intrinsic_quote + raise NotImplementedError("quanto option expiry payoff is not implemented in Phase 5") + + +def settle_option_expiry( + ledger: OptionLedger, + instrument: OptionInstrumentSpec, + *, + timestamp_ns: int, + settlement_price: float, + representation: Union[OptionSettlementRepresentation, str, None] = None, +) -> OptionSettlementResult: + """Settle an option position and close it exactly once.""" + rep = _resolve_representation(instrument, representation) + payoff = option_expiry_payoff_per_unit(instrument, settlement_price) + cashflow = ledger.apply_settlement( + instrument, + timestamp_ns=int(timestamp_ns), + settlement_price=float(settlement_price), + payoff_per_unit=payoff, + representation=rep.value, + ) + return OptionSettlementResult( + symbol=instrument.symbol, + timestamp_ns=int(timestamp_ns), + settlement_price=float(settlement_price), + payoff_per_unit=float(payoff), + cashflow=float(cashflow), + settlement_currency=instrument.settlement_currency, + representation=rep, + itm=bool(payoff > 0.0), + position_closed=True, + ) + + +def _resolve_representation( + instrument: OptionInstrumentSpec, + representation: Union[OptionSettlementRepresentation, str, None], +) -> OptionSettlementRepresentation: + if representation is not None: + if isinstance(representation, OptionSettlementRepresentation): + return representation + try: + return OptionSettlementRepresentation(str(representation)) + except ValueError as exc: + raise ValueError("invalid settlement representation") from exc + if instrument.settlement_style is SettlementStyle.FUTURE_THEN_CASH: + return OptionSettlementRepresentation.FUTURE_THEN_CASH + return OptionSettlementRepresentation.ECONOMIC_CASH diff --git a/src/quantbt/options/margin.py b/src/quantbt/options/margin.py new file mode 100644 index 0000000..ecf91f7 --- /dev/null +++ b/src/quantbt/options/margin.py @@ -0,0 +1,311 @@ +""" +Option margin and liquidation approximations. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Protocol, Tuple + +import pandas as pd + +from ..core.orders import Fill +from ..core.schema import LiquiditySide, OrderSide +from .ledger import OptionLedger +from .schema import OptionInstrumentSpec + + +class OptionMarginModel(str, Enum): + LONG_PREMIUM_ONLY = "long_premium_only" + STANDARD_VENUE_APPROX = "standard_venue_approx" + SCENARIO_PM_APPROX = "scenario_pm_approx" + NO_MARGIN_RESEARCH = "no_margin_research" + EXTERNAL_VALIDATOR = "external_validator" + + +class ExternalOptionMarginValidator(Protocol): + def calculate_margin( + self, + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + marks: Dict[str, float], + underlying_prices: Dict[str, float], + reporting_currency: str, + conversion_rates: Dict[str, float], + ) -> "OptionMarginRequirement": + ... + + +@dataclass(frozen=True) +class OptionMarginConfig: + model: OptionMarginModel = OptionMarginModel.STANDARD_VENUE_APPROX + maintenance_ratio: float = 0.20 + long_option_margin_rate: float = 1.0 + short_option_margin_rate: float = 0.15 + scenario_shocks: Tuple[float, ...] = (-0.20, -0.10, 0.0, 0.10, 0.20) + liquidation_fee_rate: float = 0.0 + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "model", _coerce_model(self.model)) + if self.maintenance_ratio < 0.0: + raise ValueError("maintenance_ratio must be >= 0") + if self.long_option_margin_rate < 0.0 or self.short_option_margin_rate < 0.0: + raise ValueError("margin rates must be >= 0") + if self.liquidation_fee_rate < 0.0: + raise ValueError("liquidation_fee_rate must be >= 0") + if not self.scenario_shocks: + raise ValueError("scenario_shocks cannot be empty") + + +@dataclass(frozen=True) +class OptionMarginRequirement: + initial_margin: float + maintenance_margin: float + model: OptionMarginModel + venue_exact: bool + reporting_currency: str + detail_report: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class OptionLiquidationAudit: + breached: bool + breach_reason: str + equity_before: float + maintenance_margin: float + equity_after: float + final_cash: Dict[str, float] + final_positions: Dict[str, float] + liquidation_orders: pd.DataFrame + metadata: Dict = field(default_factory=dict) + + +def calculate_option_margin( + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + marks: Dict[str, float], + underlying_prices: Dict[str, float], + *, + config: Optional[OptionMarginConfig] = None, + reporting_currency: str = "USD", + conversion_rates: Optional[Dict[str, float]] = None, + external_validator: Optional[ExternalOptionMarginValidator] = None, +) -> OptionMarginRequirement: + cfg = config or OptionMarginConfig() + rates = conversion_rates or {} + if cfg.model is OptionMarginModel.EXTERNAL_VALIDATOR: + if external_validator is None: + raise ValueError("external_validator is required for external margin model") + return external_validator.calculate_margin(ledger, instruments, marks, underlying_prices, reporting_currency, rates) + rows = [] + total_initial = 0.0 + for symbol, position in ledger.positions.items(): + if position.is_flat: + continue + instrument = instruments.get(symbol) + if instrument is None: + raise ValueError(f"missing instrument for {symbol}") + mark = _positive_map_value(marks, symbol, "mark") + conversion = _conversion_rate(instrument.premium_currency, rates, reporting_currency) + qty = float(position.qty) + abs_qty = abs(qty) + long_value = max(qty, 0.0) * mark * instrument.multiplier * conversion + short_abs_value = max(-qty, 0.0) * mark * instrument.multiplier * conversion + underlying = _underlying_price(instrument, underlying_prices) + underlying_notional = abs_qty * underlying * instrument.multiplier * _conversion_rate(instrument.quote_currency, rates, reporting_currency) + if cfg.model is OptionMarginModel.NO_MARGIN_RESEARCH: + requirement = 0.0 + reason = "research_no_margin" + elif cfg.model is OptionMarginModel.LONG_PREMIUM_ONLY: + requirement = long_value * cfg.long_option_margin_rate + reason = "long_premium_only" + elif cfg.model is OptionMarginModel.STANDARD_VENUE_APPROX: + requirement = long_value * cfg.long_option_margin_rate + max(short_abs_value, underlying_notional * cfg.short_option_margin_rate) + reason = "standard_short_notional_approx" + elif cfg.model is OptionMarginModel.SCENARIO_PM_APPROX: + requirement = _scenario_requirement(position_qty=qty, mark=mark, underlying=underlying, instrument=instrument, cfg=cfg, conversion=conversion) + reason = "scenario_pm_approx" + else: + raise ValueError(f"unsupported margin model: {cfg.model}") + total_initial += requirement + rows.append( + { + "symbol": symbol, + "qty": qty, + "mark": mark, + "underlying_price": underlying, + "premium_currency": instrument.premium_currency, + "requirement": float(requirement), + "reason": reason, + "venue_exact": False, + } + ) + maintenance = total_initial * cfg.maintenance_ratio + ledger.margin_locked[str(reporting_currency).upper()] = float(total_initial) + return OptionMarginRequirement( + initial_margin=float(total_initial), + maintenance_margin=float(maintenance), + model=cfg.model, + venue_exact=False, + reporting_currency=str(reporting_currency).upper(), + detail_report=pd.DataFrame(rows), + metadata={"venue_exact": False, **cfg.metadata}, + ) + + +def liquidate_option_positions( + ledger: OptionLedger, + instruments: Dict[str, OptionInstrumentSpec], + *, + bid_prices: Dict[str, float], + ask_prices: Dict[str, float], + margin_requirement: OptionMarginRequirement, + conversion_rates: Dict[str, float], + reporting_currency: str = "USD", + timestamp_ns: int, + fee_rate: float = 0.0, +) -> OptionLiquidationAudit: + """Liquidate all option positions with adverse bid/ask prices if breached.""" + equity_before = ledger.equity( + conversion_rates=conversion_rates, + marks=_marks_from_bbo(bid_prices, ask_prices), + instruments=instruments, + reporting_currency=reporting_currency, + ) + if equity_before >= margin_requirement.maintenance_margin: + return OptionLiquidationAudit( + breached=False, + breach_reason="equity_above_maintenance", + equity_before=float(equity_before), + maintenance_margin=float(margin_requirement.maintenance_margin), + equity_after=float(equity_before), + final_cash=dict(ledger.cash), + final_positions={symbol: pos.qty for symbol, pos in ledger.positions.items() if not pos.is_flat}, + liquidation_orders=pd.DataFrame(), + metadata={"venue_exact": margin_requirement.venue_exact}, + ) + rows = [] + for symbol, position in list(ledger.positions.items()): + if position.is_flat: + continue + instrument = instruments.get(symbol) + if instrument is None: + raise ValueError(f"missing instrument for {symbol}") + if position.qty > 0.0: + side = OrderSide.SELL + price = _positive_map_value(bid_prices, symbol, "bid") + else: + side = OrderSide.BUY + price = _positive_map_value(ask_prices, symbol, "ask") + qty = abs(float(position.qty)) + fee = qty * price * instrument.multiplier * float(fee_rate) + fill = Fill( + timestamp=int(timestamp_ns), + symbol=symbol, + side=side, + qty=qty, + price=price, + fee=fee, + liquidity=LiquiditySide.TAKER, + metadata={"liquidation": True, "adverse_bid_ask": True}, + ) + ledger.apply_fill(fill, instrument, timestamp_ns=timestamp_ns) + rows.append( + { + "timestamp_ns": int(timestamp_ns), + "symbol": symbol, + "side": side.value, + "qty": qty, + "price": price, + "fee": fee, + "reason": "maintenance_margin_breach", + "adverse_bid_ask": True, + } + ) + equity_after = ledger.equity( + conversion_rates=conversion_rates, + marks=_marks_from_bbo(bid_prices, ask_prices), + instruments=instruments, + reporting_currency=reporting_currency, + ) + return OptionLiquidationAudit( + breached=True, + breach_reason="maintenance_margin_breach", + equity_before=float(equity_before), + maintenance_margin=float(margin_requirement.maintenance_margin), + equity_after=float(equity_after), + final_cash=dict(ledger.cash), + final_positions={symbol: pos.qty for symbol, pos in ledger.positions.items() if not pos.is_flat}, + liquidation_orders=pd.DataFrame(rows), + metadata={ + "venue_exact": margin_requirement.venue_exact, + "liquidation_sequence": "all_positions_adverse_bid_ask", + "fee_rate": float(fee_rate), + }, + ) + + +def _scenario_requirement( + *, + position_qty: float, + mark: float, + underlying: float, + instrument: OptionInstrumentSpec, + cfg: OptionMarginConfig, + conversion: float, +) -> float: + if position_qty >= 0.0: + return abs(position_qty) * mark * instrument.multiplier * conversion * cfg.long_option_margin_rate + worst_loss = 0.0 + base_value = mark + for shock in cfg.scenario_shocks: + shocked_mark = max(mark * (1.0 + abs(float(shock)) * underlying / max(underlying, 1e-12)), 0.0) + pnl = float(position_qty) * (shocked_mark - base_value) * instrument.multiplier * conversion + worst_loss = max(worst_loss, -pnl) + floor = abs(position_qty) * underlying * instrument.multiplier * conversion * cfg.short_option_margin_rate + return max(worst_loss, floor) + + +def _underlying_price(instrument: OptionInstrumentSpec, underlying_prices: Dict[str, float]) -> float: + if instrument.underlying_id in underlying_prices: + return _positive_map_value(underlying_prices, instrument.underlying_id, "underlying") + return _positive_map_value(underlying_prices, instrument.symbol, "underlying") + + +def _marks_from_bbo(bid_prices: Dict[str, float], ask_prices: Dict[str, float]) -> Dict[str, float]: + symbols = set(bid_prices).union(ask_prices) + return {symbol: 0.5 * (_positive_map_value(bid_prices, symbol, "bid") + _positive_map_value(ask_prices, symbol, "ask")) for symbol in symbols} + + +def _positive_map_value(values: Dict[str, float], key: str, label: str) -> float: + if key not in values: + raise ValueError(f"missing {label} for {key}") + value = float(values[key]) + if value <= 0.0: + raise ValueError(f"{label} for {key} must be > 0") + return value + + +def _conversion_rate(currency: str, conversion_rates: Dict[str, float], reporting_currency: str) -> float: + ccy = str(currency).upper() + report = str(reporting_currency).upper() + if ccy == report: + return 1.0 + if ccy not in conversion_rates: + raise ValueError(f"missing conversion rate for {ccy}->{report}") + rate = float(conversion_rates[ccy]) + if rate <= 0.0: + raise ValueError(f"conversion rate for {ccy}->{report} must be > 0") + return rate + + +def _coerce_model(value) -> OptionMarginModel: + if isinstance(value, OptionMarginModel): + return value + try: + return OptionMarginModel(str(value)) + except ValueError as exc: + raise ValueError("invalid option margin model") from exc diff --git a/src/quantbt/options/packages.py b/src/quantbt/options/packages.py new file mode 100644 index 0000000..57f23af --- /dev/null +++ b/src/quantbt/options/packages.py @@ -0,0 +1,147 @@ +""" +Option package intents and compiler. + +This layer turns option-domain package legs into QuantBT `OrderIntent` leaves. +It does not execute orders or maintain a ledger. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Optional, Sequence, Tuple, Union + +from ..core.orders import OrderIntent +from ..core.schema import OrderSide, OrderType, TimeInForce + + +class OptionPackageExecutionPolicy(str, Enum): + ATOMIC_ALL_OR_NONE = "atomic_all_or_none" + BEST_EFFORT = "best_effort" + SEQUENTIAL = "sequential" + HEDGE_AFTER_PRIMARY = "hedge_after_primary" + REBALANCE_ONLY = "rebalance_only" + + +@dataclass(frozen=True) +class OptionPackageLeg: + """ + One option leg inside a package. + + `side` owns direction. `ratio` is always positive and scales from package + quantity, so callers cannot hide direction in a negative ratio. + """ + + instrument_id: str + side: Union[OrderSide, str] + ratio: float + order_type: Union[OrderType, str] = OrderType.MARKET + limit_price: Optional[float] = None + tif: Union[TimeInForce, str] = TimeInForce.FOK + role: str = "leg" + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "side", _coerce_enum(OrderSide, self.side, "side")) + object.__setattr__(self, "order_type", _coerce_enum(OrderType, self.order_type, "order_type")) + object.__setattr__(self, "tif", _coerce_enum(TimeInForce, self.tif, "tif")) + if not self.instrument_id: + raise ValueError("instrument_id is required") + if self.ratio <= 0.0: + raise ValueError("ratio must be > 0; side owns direction") + if self.order_type not in (OrderType.MARKET, OrderType.LIMIT): + raise ValueError("Phase 4 option package legs support market and limit orders only") + if self.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): + if self.limit_price is None or self.limit_price <= 0.0: + raise ValueError("limit option legs require limit_price > 0") + if not self.role: + raise ValueError("role is required") + + +@dataclass(frozen=True) +class OptionPackageIntent: + timestamp_ns: int + package_id: str + legs: Tuple[OptionPackageLeg, ...] + quantity: float = 1.0 + execution_policy: Union[OptionPackageExecutionPolicy, str] = OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE + max_debit: Optional[float] = None + min_credit: Optional[float] = None + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "execution_policy", + _coerce_enum(OptionPackageExecutionPolicy, self.execution_policy, "execution_policy"), + ) + object.__setattr__(self, "timestamp_ns", int(self.timestamp_ns)) + object.__setattr__(self, "legs", tuple(self.legs)) + if self.timestamp_ns <= 0: + raise ValueError("timestamp_ns must be > 0") + if not self.package_id: + raise ValueError("package_id is required") + if len(self.legs) == 0: + raise ValueError("OptionPackageIntent requires at least one leg") + if self.quantity <= 0.0: + raise ValueError("quantity must be > 0") + if self.max_debit is not None and self.max_debit < 0.0: + raise ValueError("max_debit must be >= 0") + if self.min_credit is not None and self.min_credit < 0.0: + raise ValueError("min_credit must be >= 0") + + +def compile_option_package_orders(package: OptionPackageIntent) -> Tuple[OrderIntent, ...]: + """Compile an option package to `OrderIntent` leaves with package metadata.""" + orders = [] + atomicity = _atomicity_label(package.execution_policy) + for leg_index, leg in enumerate(package.legs): + metadata = { + **leg.metadata, + "package_id": package.package_id, + "package_type": "option_package", + "option_package_id": package.package_id, + "option_leg_index": int(leg_index), + "option_leg_ratio": float(leg.ratio), + "option_leg_role": leg.role, + "option_execution_policy": package.execution_policy.value, + "atomicity": atomicity, + "exchange_combo": False, + "block_trade_style": False, + "simulated_atomicity": package.execution_policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE, + } + qty = float(package.quantity) * float(leg.ratio) + order = OrderIntent( + timestamp=package.timestamp_ns, + symbol=leg.instrument_id, + side=leg.side, + order_type=leg.order_type, + qty=qty, + price=leg.limit_price, + tif=leg.tif, + tag=leg.tag or package.tag, + metadata=metadata, + ) + orders.append(order) + return tuple(orders) + + +def _atomicity_label(policy: OptionPackageExecutionPolicy) -> str: + if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE: + return "simulated_all_or_none" + if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY: + return "simulated_primary_then_hedge" + if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY: + return "simulated_rebalance_only" + return f"simulated_{policy.value}" + + +def _coerce_enum(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc diff --git a/src/quantbt/options/pricing.py b/src/quantbt/options/pricing.py new file mode 100644 index 0000000..05e48b3 --- /dev/null +++ b/src/quantbt/options/pricing.py @@ -0,0 +1,191 @@ +""" +Option pricing primitives. + +Phase 2 intentionally keeps pricing deterministic and scalar. Execution, +margin, expiry, and ledger accounting are added in later phases. +""" + +from __future__ import annotations + +import math +from typing import Union + +from .schema import OptionKind + + +Number = Union[int, float] + + +def black76_price( + forward: Number, + strike: Number, + time_to_expiry: Number, + volatility: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """Return linear Black-76 option value in quote currency per 1 underlying.""" + kind = _coerce_kind(option_kind) + fwd, strike_, tau, vol, df = _validate_inputs(forward, strike, time_to_expiry, volatility, discount) + intrinsic = black76_intrinsic(fwd, strike_, kind, discount=df) + if tau <= 0.0 or vol <= 0.0: + return intrinsic + d1, d2 = black76_d1_d2(fwd, strike_, tau, vol) + if kind is OptionKind.CALL: + return df * (fwd * normal_cdf(d1) - strike_ * normal_cdf(d2)) + return df * (strike_ * normal_cdf(-d2) - fwd * normal_cdf(-d1)) + + +def black76_intrinsic( + forward: Number, + strike: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """Return discounted intrinsic value in quote currency.""" + kind = _coerce_kind(option_kind) + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + df = _positive_float(discount, "discount") + if kind is OptionKind.CALL: + return df * max(fwd - strike_, 0.0) + return df * max(strike_ - fwd, 0.0) + + +def black76_parity_value(forward: Number, strike: Number, *, discount: Number = 1.0) -> float: + """Return theoretical linear call-put parity value: C - P.""" + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + df = _positive_float(discount, "discount") + return df * (fwd - strike_) + + +def black76_parity_residual( + call_price: Number, + put_price: Number, + forward: Number, + strike: Number, + *, + discount: Number = 1.0, +) -> float: + """Return residual of linear Black-76 put-call parity.""" + return float(call_price) - float(put_price) - black76_parity_value(forward, strike, discount=discount) + + +def inverse_black76_price_base( + forward: Number, + strike: Number, + time_to_expiry: Number, + volatility: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """ + Return inverse option value in base settlement currency. + + The Phase 2 convention prices inverse BTC/ETH options as the corresponding + forward Black-76 quote-currency option divided by forward. This gives the + expiry payoff shape `max(S-K, 0) / S` for calls and `max(K-S, 0) / S` for + puts, and locks inverse parity to `DF * (1 - K/F)`. + """ + fwd = _positive_float(forward, "forward") + return black76_price(fwd, strike, time_to_expiry, volatility, option_kind, discount=discount) / fwd + + +def inverse_black76_intrinsic_base( + forward: Number, + strike: Number, + option_kind: Union[OptionKind, str], + *, + discount: Number = 1.0, +) -> float: + """Return inverse intrinsic value in base settlement currency.""" + fwd = _positive_float(forward, "forward") + return black76_intrinsic(fwd, strike, option_kind, discount=discount) / fwd + + +def inverse_black76_parity_value_base(forward: Number, strike: Number, *, discount: Number = 1.0) -> float: + """Return inverse call-put parity value in base settlement currency.""" + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + df = _positive_float(discount, "discount") + return df * (1.0 - strike_ / fwd) + + +def inverse_black76_parity_residual_base( + call_price_base: Number, + put_price_base: Number, + forward: Number, + strike: Number, + *, + discount: Number = 1.0, +) -> float: + """Return residual of inverse put-call parity in base settlement currency.""" + return ( + float(call_price_base) + - float(put_price_base) + - inverse_black76_parity_value_base(forward, strike, discount=discount) + ) + + +def black76_d1_d2(forward: Number, strike: Number, time_to_expiry: Number, volatility: Number) -> tuple[float, float]: + fwd = _positive_float(forward, "forward") + strike_ = _positive_float(strike, "strike") + tau = _non_negative_float(time_to_expiry, "time_to_expiry") + vol = _non_negative_float(volatility, "volatility") + if tau <= 0.0 or vol <= 0.0: + raise ValueError("d1/d2 require time_to_expiry > 0 and volatility > 0") + vol_sqrt_t = vol * math.sqrt(tau) + d1 = (math.log(fwd / strike_) + 0.5 * vol * vol * tau) / vol_sqrt_t + return d1, d1 - vol_sqrt_t + + +def normal_pdf(x: Number) -> float: + value = float(x) + return math.exp(-0.5 * value * value) / math.sqrt(2.0 * math.pi) + + +def normal_cdf(x: Number) -> float: + return 0.5 * (1.0 + math.erf(float(x) / math.sqrt(2.0))) + + +def _coerce_kind(option_kind: Union[OptionKind, str]) -> OptionKind: + if isinstance(option_kind, OptionKind): + return option_kind + try: + return OptionKind(str(option_kind).lower()) + except ValueError as exc: + raise ValueError("option_kind must be call or put") from exc + + +def _validate_inputs( + forward: Number, + strike: Number, + time_to_expiry: Number, + volatility: Number, + discount: Number, +) -> tuple[float, float, float, float, float]: + return ( + _positive_float(forward, "forward"), + _positive_float(strike, "strike"), + _non_negative_float(time_to_expiry, "time_to_expiry"), + _non_negative_float(volatility, "volatility"), + _positive_float(discount, "discount"), + ) + + +def _positive_float(value: Number, name: str) -> float: + out = float(value) + if not math.isfinite(out) or out <= 0.0: + raise ValueError(f"{name} must be finite and > 0") + return out + + +def _non_negative_float(value: Number, name: str) -> float: + out = float(value) + if not math.isfinite(out) or out < 0.0: + raise ValueError(f"{name} must be finite and >= 0") + return out diff --git a/src/quantbt/options/schema.py b/src/quantbt/options/schema.py new file mode 100644 index 0000000..4752b76 --- /dev/null +++ b/src/quantbt/options/schema.py @@ -0,0 +1,227 @@ +""" +Option domain schema. + +These objects are deliberately dependency-free and do not import Nautilus. They +describe instrument conventions and registry signatures; they do not perform +pricing, execution, or ledger accounting. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Iterable, Optional, Tuple + +from ..core.schema import AssetType, InstrumentSpec + + +class OptionKind(str, Enum): + CALL = "call" + PUT = "put" + + +class ExerciseStyle(str, Enum): + EUROPEAN = "european" + AMERICAN = "american" + + +class PremiumConvention(str, Enum): + LINEAR_QUOTE = "linear_quote" + INVERSE_BASE = "inverse_base" + QUANTO = "quanto" + + +class SettlementStyle(str, Enum): + CASH = "cash" + FUTURE_THEN_CASH = "future_then_cash" + PHYSICAL = "physical" + + +class OptionDecisionFillPolicy(str, Enum): + NEXT_SNAPSHOT = "next_snapshot" + SAME_SNAPSHOT_AFTER_SIGNAL = "same_snapshot_after_signal" + NEXT_BAR_OPEN = "next_bar_open" + EXPLICIT_EVENT_SEQUENCE = "explicit_event_sequence" + + +@dataclass(frozen=True, kw_only=True) +class OptionInstrumentSpec(InstrumentSpec): + """ + Option instrument definition with explicit quote/settlement conventions. + + `contract_size` remains the generic QuantBT multiplier field. `multiplier` + is kept as an option-domain alias for readability; both must match. + + `lot_size` remains the generic QuantBT quantity increment field. `qty_step` + is kept as an option-domain alias because options venues usually describe + order precision this way. If either is supplied, both are normalized to the + same value. + """ + + asset_type: AssetType = AssetType.OPTION + venue: str + underlying_id: str + underlying_index_id: str + option_kind: OptionKind + exercise_style: ExerciseStyle + premium_convention: PremiumConvention + settlement_style: SettlementStyle + strike: float + expiry_ns: int + settlement_currency: str + premium_currency: str + quote_currency: str + multiplier: float = 1.0 + qty_step: float = 0.0 + settlement_time_ns: Optional[int] = None + fee_schedule_id: str = "" + margin_schedule_id: str = "" + convention_version: str = "" + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__(self, "asset_type", _coerce_enum(AssetType, self.asset_type, "asset_type")) + object.__setattr__(self, "option_kind", _coerce_enum(OptionKind, self.option_kind, "option_kind")) + object.__setattr__(self, "exercise_style", _coerce_enum(ExerciseStyle, self.exercise_style, "exercise_style")) + object.__setattr__( + self, + "premium_convention", + _coerce_enum(PremiumConvention, self.premium_convention, "premium_convention"), + ) + object.__setattr__( + self, + "settlement_style", + _coerce_enum(SettlementStyle, self.settlement_style, "settlement_style"), + ) + super().__post_init__() + if self.asset_type is not AssetType.OPTION: + raise ValueError("OptionInstrumentSpec.asset_type must be OPTION") + if not self.venue: + raise ValueError("venue is required") + if not self.underlying_id or not self.underlying_index_id: + raise ValueError("underlying identifiers are required") + if self.strike <= 0.0: + raise ValueError("strike must be > 0") + if int(self.expiry_ns) <= 0: + raise ValueError("expiry_ns must be > 0") + object.__setattr__(self, "expiry_ns", int(self.expiry_ns)) + if self.settlement_time_ns is not None and int(self.settlement_time_ns) <= 0: + raise ValueError("settlement_time_ns must be > 0") + if self.settlement_time_ns is not None: + object.__setattr__(self, "settlement_time_ns", int(self.settlement_time_ns)) + if self.multiplier <= 0.0: + raise ValueError("multiplier must be > 0") + if abs(float(self.multiplier) - float(self.contract_size)) > 1e-15: + raise ValueError("multiplier must match contract_size") + if self.qty_step < 0.0: + raise ValueError("qty_step must be >= 0") + _normalize_quantity_step_alias(self) + for field_name in ("settlement_currency", "premium_currency", "quote_currency"): + value = getattr(self, field_name) + if not value: + raise ValueError(f"{field_name} is required") + object.__setattr__(self, field_name, str(value).upper()) + object.__setattr__(self, "venue", str(self.venue).lower().strip()) + object.__setattr__(self, "underlying_id", str(self.underlying_id).strip()) + object.__setattr__(self, "underlying_index_id", str(self.underlying_index_id).strip()) + _validate_convention_currency_contract(self) + + @property + def convention_signature_tuple(self) -> Tuple: + return ( + self.symbol, + self.venue, + self.underlying_id, + self.option_kind.value, + self.exercise_style.value, + self.premium_convention.value, + self.settlement_style.value, + float(self.strike), + int(self.expiry_ns), + self.premium_currency, + self.settlement_currency, + self.quote_currency, + float(self.multiplier), + float(self.qty_step), + self.fee_schedule_id, + self.margin_schedule_id, + self.convention_version, + ) + + +@dataclass(frozen=True) +class InstrumentRegistrySignature: + count: int + symbols: Tuple[str, ...] + convention_versions: Tuple[str, ...] + signature: Tuple[Tuple, ...] + + +@dataclass(frozen=True) +class OptionInstrumentRegistry: + instruments: Tuple[OptionInstrumentSpec, ...] + + def __post_init__(self) -> None: + if not self.instruments: + raise ValueError("OptionInstrumentRegistry requires at least one instrument") + symbols = [instrument.symbol for instrument in self.instruments] + if len(symbols) != len(set(symbols)): + raise ValueError("option instrument symbols must be unique") + + @classmethod + def from_iterable(cls, instruments: Iterable[OptionInstrumentSpec]) -> "OptionInstrumentRegistry": + return cls(tuple(instruments)) + + @property + def symbols(self) -> Tuple[str, ...]: + return tuple(instrument.symbol for instrument in self.instruments) + + @property + def by_symbol(self) -> Dict[str, OptionInstrumentSpec]: + return {instrument.symbol: instrument for instrument in self.instruments} + + @property + def signature(self) -> InstrumentRegistrySignature: + ordered = tuple(sorted((instrument.convention_signature_tuple for instrument in self.instruments), key=lambda row: row[0])) + return InstrumentRegistrySignature( + count=len(ordered), + symbols=tuple(row[0] for row in ordered), + convention_versions=tuple(row[-1] for row in ordered), + signature=ordered, + ) + + +def _coerce_enum(enum_cls, value, field_name: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(str(value)) + except ValueError as exc: + raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc + + +def _validate_convention_currency_contract(spec: OptionInstrumentSpec) -> None: + if spec.premium_convention is PremiumConvention.INVERSE_BASE: + if spec.premium_currency != spec.settlement_currency: + raise ValueError("inverse options require premium_currency == settlement_currency") + if spec.quote_currency == spec.premium_currency: + raise ValueError("inverse options require quote_currency distinct from premium currency") + elif spec.premium_convention is PremiumConvention.LINEAR_QUOTE: + if spec.premium_currency != spec.quote_currency: + raise ValueError("linear quote options require premium_currency == quote_currency") + if spec.settlement_style is SettlementStyle.PHYSICAL: + raise ValueError("linear quote options cannot use physical settlement in Phase 1 schema") + elif spec.premium_convention is PremiumConvention.QUANTO: + if spec.premium_currency == spec.settlement_currency == spec.quote_currency: + raise ValueError("quanto options require at least one distinct premium/settlement/quote currency") + + +def _normalize_quantity_step_alias(spec: OptionInstrumentSpec) -> None: + lot_size = float(spec.lot_size) + qty_step = float(spec.qty_step) + if lot_size > 0.0 and qty_step > 0.0 and abs(lot_size - qty_step) > 1e-15: + raise ValueError("qty_step must match lot_size when both are provided") + if qty_step <= 0.0 and lot_size > 0.0: + object.__setattr__(spec, "qty_step", lot_size) + elif lot_size <= 0.0 and qty_step > 0.0: + object.__setattr__(spec, "lot_size", qty_step) diff --git a/src/quantbt/options/selectors.py b/src/quantbt/options/selectors.py new file mode 100644 index 0000000..80dc9cc --- /dev/null +++ b/src/quantbt/options/selectors.py @@ -0,0 +1,281 @@ +""" +No-lookahead option selectors. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Union + +import numpy as np + +from .schema import OptionKind +from .tape import PreparedOptionTape, YEAR_NS + + +@dataclass(frozen=True) +class OptionSelectionFilters: + option_kind: Optional[Union[OptionKind, str]] = None + min_bid_size: float = 0.0 + min_ask_size: float = 0.0 + max_spread_bps: Optional[float] = None + min_open_interest: float = 0.0 + min_volume: float = 0.0 + min_dte_days: Optional[float] = None + max_dte_days: Optional[float] = None + min_moneyness: Optional[float] = None + max_moneyness: Optional[float] = None + require_mark_iv: bool = False + require_delta: bool = False + + +@dataclass(frozen=True) +class OptionSelection: + row_index: int + snapshot_index: int + snapshot_timestamp_ns: int + decision_timestamp_ns: int + instrument_id: str + instrument_code: int + option_kind: OptionKind + expiry_ns: int + strike: float + dte_years: float + moneyness: float + bid_price: float + ask_price: float + mark_price: float + mid_price: float + mark_iv: float + delta: float + score: float + + +def select_atm_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the listed option closest to ATM at the observable snapshot.""" + return _select_min_score( + tape, + decision_timestamp_ns, + filters=filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(tape.strike[rows] / tape.forward_price[rows] - 1.0), + ) + + +def select_target_delta_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + target_delta: float, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the option with observable delta closest to `target_delta`.""" + base_filters = _merge_require_delta(filters) + target = float(target_delta) + if not np.isfinite(target): + raise ValueError("target_delta must be finite") + return _select_min_score( + tape, + decision_timestamp_ns, + filters=base_filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(tape.delta[rows] - target), + ) + + +def select_target_dte_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + target_dte_days: float, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the option with expiry closest to target DTE at the snapshot.""" + target_years = _positive_days(target_dte_days, "target_dte_days") / 365.0 + return _select_min_score( + tape, + decision_timestamp_ns, + filters=filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(_dte_years(tape, rows, decision_timestamp_ns) - target_years), + ) + + +def select_target_moneyness_option( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + target_moneyness: float, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> OptionSelection: + """Select the option with strike/forward closest to target moneyness.""" + target = float(target_moneyness) + if not np.isfinite(target) or target <= 0.0: + raise ValueError("target_moneyness must be finite and > 0") + return _select_min_score( + tape, + decision_timestamp_ns, + filters=filters, + max_quote_age_ns=max_quote_age_ns, + score_fn=lambda rows: np.abs(tape.strike[rows] / tape.forward_price[rows] - target), + ) + + +def available_option_rows( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + filters: Optional[OptionSelectionFilters] = None, + max_quote_age_ns: Optional[int] = None, +) -> np.ndarray: + """Return global row indexes listed and tradable at the observable snapshot.""" + snapshot_idx = tape.snapshot_index_at_or_before(decision_timestamp_ns, max_quote_age_ns=max_quote_age_ns) + rows = np.arange(tape.row_ptr[snapshot_idx], tape.row_ptr[snapshot_idx + 1], dtype=np.int64) + mask = _filter_mask(tape, rows, int(decision_timestamp_ns), filters or OptionSelectionFilters()) + return rows[mask] + + +def _select_min_score( + tape: PreparedOptionTape, + decision_timestamp_ns: int, + *, + filters: Optional[OptionSelectionFilters], + max_quote_age_ns: Optional[int], + score_fn, +) -> OptionSelection: + snapshot_idx = tape.snapshot_index_at_or_before(decision_timestamp_ns, max_quote_age_ns=max_quote_age_ns) + rows = np.arange(tape.row_ptr[snapshot_idx], tape.row_ptr[snapshot_idx + 1], dtype=np.int64) + filtered = _filter_mask(tape, rows, int(decision_timestamp_ns), filters or OptionSelectionFilters()) + candidates = rows[filtered] + if len(candidates) == 0: + raise ValueError("no option candidates pass filters at observable snapshot") + scores = np.asarray(score_fn(candidates), dtype=np.float64) + valid_scores = np.isfinite(scores) + if not bool(valid_scores.any()): + raise ValueError("no option candidates have finite selector score") + candidates = candidates[valid_scores] + scores = scores[valid_scores] + local_idx = int(np.argmin(scores)) + return _build_selection(tape, int(candidates[local_idx]), snapshot_idx, int(decision_timestamp_ns), float(scores[local_idx])) + + +def _filter_mask( + tape: PreparedOptionTape, + rows: np.ndarray, + decision_timestamp_ns: int, + filters: OptionSelectionFilters, +) -> np.ndarray: + if len(rows) == 0: + return np.zeros(0, dtype=bool) + mask = np.ones(len(rows), dtype=bool) + if filters.option_kind is not None: + kind = _coerce_kind(filters.option_kind) + mask &= tape.option_kind_code[rows] == (0 if kind is OptionKind.CALL else 1) + mask &= tape.expiry_ns[rows] > int(decision_timestamp_ns) + mask &= tape.bid_size[rows] >= float(filters.min_bid_size) + mask &= tape.ask_size[rows] >= float(filters.min_ask_size) + mask &= tape.open_interest[rows] >= float(filters.min_open_interest) + mask &= tape.volume[rows] >= float(filters.min_volume) + if filters.max_spread_bps is not None: + mid = 0.5 * (tape.bid_price[rows] + tape.ask_price[rows]) + spread_bps = np.divide( + tape.ask_price[rows] - tape.bid_price[rows], + mid, + out=np.full(len(rows), np.inf, dtype=np.float64), + where=mid > 0.0, + ) * 10_000.0 + mask &= spread_bps <= float(filters.max_spread_bps) + dte_days = _dte_years(tape, rows, decision_timestamp_ns) * 365.0 + if filters.min_dte_days is not None: + mask &= dte_days >= float(filters.min_dte_days) + if filters.max_dte_days is not None: + mask &= dte_days <= float(filters.max_dte_days) + moneyness = tape.strike[rows] / tape.forward_price[rows] + if filters.min_moneyness is not None: + mask &= moneyness >= float(filters.min_moneyness) + if filters.max_moneyness is not None: + mask &= moneyness <= float(filters.max_moneyness) + if filters.require_mark_iv: + mask &= np.isfinite(tape.mark_iv[rows]) + if filters.require_delta: + mask &= np.isfinite(tape.delta[rows]) + return mask + + +def _build_selection( + tape: PreparedOptionTape, + row_index: int, + snapshot_index: int, + decision_timestamp_ns: int, + score: float, +) -> OptionSelection: + mid = 0.5 * (float(tape.bid_price[row_index]) + float(tape.ask_price[row_index])) + kind = OptionKind.CALL if int(tape.option_kind_code[row_index]) == 0 else OptionKind.PUT + return OptionSelection( + row_index=row_index, + snapshot_index=snapshot_index, + snapshot_timestamp_ns=int(tape.timestamp_ns[snapshot_index]), + decision_timestamp_ns=int(decision_timestamp_ns), + instrument_id=tape.instrument_id[row_index], + instrument_code=int(tape.instrument_code[row_index]), + option_kind=kind, + expiry_ns=int(tape.expiry_ns[row_index]), + strike=float(tape.strike[row_index]), + dte_years=float((int(tape.expiry_ns[row_index]) - int(decision_timestamp_ns)) / YEAR_NS), + moneyness=float(tape.strike[row_index] / tape.forward_price[row_index]), + bid_price=float(tape.bid_price[row_index]), + ask_price=float(tape.ask_price[row_index]), + mark_price=float(tape.mark_price[row_index]), + mid_price=mid, + mark_iv=float(tape.mark_iv[row_index]), + delta=float(tape.delta[row_index]), + score=float(score), + ) + + +def _dte_years(tape: PreparedOptionTape, rows: np.ndarray, decision_timestamp_ns: int) -> np.ndarray: + return (tape.expiry_ns[rows].astype(np.float64) - float(decision_timestamp_ns)) / float(YEAR_NS) + + +def _merge_require_delta(filters: Optional[OptionSelectionFilters]) -> OptionSelectionFilters: + if filters is None: + return OptionSelectionFilters(require_delta=True) + return OptionSelectionFilters( + option_kind=filters.option_kind, + min_bid_size=filters.min_bid_size, + min_ask_size=filters.min_ask_size, + max_spread_bps=filters.max_spread_bps, + min_open_interest=filters.min_open_interest, + min_volume=filters.min_volume, + min_dte_days=filters.min_dte_days, + max_dte_days=filters.max_dte_days, + min_moneyness=filters.min_moneyness, + max_moneyness=filters.max_moneyness, + require_mark_iv=filters.require_mark_iv, + require_delta=True, + ) + + +def _coerce_kind(option_kind: Union[OptionKind, str]) -> OptionKind: + if isinstance(option_kind, OptionKind): + return option_kind + try: + return OptionKind(str(option_kind).lower()) + except ValueError as exc: + raise ValueError("option_kind must be call or put") from exc + + +def _positive_days(value: float, name: str) -> float: + out = float(value) + if not np.isfinite(out) or out <= 0.0: + raise ValueError(f"{name} must be finite and > 0") + return out diff --git a/src/quantbt/options/strategy.py b/src/quantbt/options/strategy.py new file mode 100644 index 0000000..d50fd23 --- /dev/null +++ b/src/quantbt/options/strategy.py @@ -0,0 +1,249 @@ +"""Option strategy adapters. + +Adapters live above the option execution engine. They convert observable +option-chain snapshots into package intents and audit tables. They do not own +fills, premium accounting, margin, settlement, or PnL. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.schema import OrderSide +from .hedging import OptionHedgeConfig +from .packages import OptionPackageIntent, OptionPackageLeg +from .schema import OptionInstrumentRegistry + + +@dataclass(frozen=True) +class OptionStrategyRun: + """Package-level strategy output consumed by `QuantBTEndpoint.options`.""" + + packages: tuple[OptionPackageIntent, ...] + hedge_policy: Optional[OptionHedgeConfig] = None + selected_contracts: pd.DataFrame = field(default_factory=pd.DataFrame) + metadata: Dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class GammaScalpingConfig: + """Configuration for a simple ATM straddle gamma-scalping adapter.""" + + side: str = "long" + quantity: float = 1.0 + min_dte_days: float = 2.0 + max_dte_days: float = 45.0 + roll_dte_days: float = 2.0 + max_spread_bps: Optional[float] = None + min_bid_size: float = 0.0 + min_ask_size: float = 0.0 + min_volume: float = 0.0 + min_open_interest: float = 0.0 + hedge_policy: Optional[OptionHedgeConfig] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + side = str(self.side).lower().strip() + if side not in {"long", "short"}: + raise ValueError("GammaScalpingConfig.side must be long or short") + object.__setattr__(self, "side", side) + if self.quantity <= 0.0: + raise ValueError("GammaScalpingConfig.quantity must be > 0") + if self.min_dte_days < 0.0 or self.max_dte_days <= 0.0: + raise ValueError("DTE bounds must be non-negative and max_dte_days > 0") + if self.min_dte_days > self.max_dte_days: + raise ValueError("min_dte_days must be <= max_dte_days") + if self.roll_dte_days < 0.0: + raise ValueError("roll_dte_days must be >= 0") + for name in ("min_bid_size", "min_ask_size", "min_volume", "min_open_interest"): + if getattr(self, name) < 0.0: + raise ValueError(f"{name} must be >= 0") + if self.max_spread_bps is not None and self.max_spread_bps < 0.0: + raise ValueError("max_spread_bps must be >= 0") + + +def build_gamma_scalping_strategy_run( + chain: pd.DataFrame, + instruments: OptionInstrumentRegistry, + config: Optional[GammaScalpingConfig] = None, +) -> OptionStrategyRun: + """ + Build open/roll/close straddle packages from observable chain snapshots. + + Selection is snapshot-local: at each decision timestamp, the adapter only + inspects rows with that exact `timestamp_ns`. The selected pair is the + valid same-expiry same-strike call/put closest to the observed index price. + """ + cfg = config or GammaScalpingConfig() + frame = _canonical_strategy_frame(chain) + valid_symbols = set(instruments.symbols) + frame = frame[frame["instrument_id"].isin(valid_symbols)].copy() + if frame.empty: + raise ValueError("gamma scalping adapter found no chain rows matching instrument registry") + + timestamps = [int(ts) for ts in sorted(frame["timestamp_ns"].unique())] + packages: list[OptionPackageIntent] = [] + selected_rows: list[dict] = [] + active: Optional[dict] = None + + for ts in timestamps: + is_last = ts == timestamps[-1] + if active is not None: + dte = (int(active["expiry_ns"]) - ts) / _DAY_NS + if dte <= cfg.roll_dte_days or is_last: + if _has_quotes(frame, ts, (active["call_id"], active["put_id"])): + packages.append(_straddle_package(ts, active, cfg, action="close")) + selected_rows.append({**active, "timestamp_ns": ts, "action": "close", "dte_days": float(dte)}) + active = None + if is_last: + break + + if active is None and not is_last: + selection = _select_atm_pair(frame, ts, cfg) + if selection is None: + continue + packages.append(_straddle_package(ts, selection, cfg, action="open")) + dte = (int(selection["expiry_ns"]) - ts) / _DAY_NS + selected_rows.append({**selection, "timestamp_ns": ts, "action": "open", "dte_days": float(dte)}) + active = selection + + if active is not None: + ts = timestamps[-1] + if _has_quotes(frame, ts, (active["call_id"], active["put_id"])): + packages.append(_straddle_package(ts, active, cfg, action="close")) + selected_rows.append( + { + **active, + "timestamp_ns": ts, + "action": "close", + "dte_days": float((int(active["expiry_ns"]) - ts) / _DAY_NS), + } + ) + + selected = pd.DataFrame(selected_rows) + return OptionStrategyRun( + packages=tuple(packages), + hedge_policy=cfg.hedge_policy, + selected_contracts=selected, + metadata={ + "strategy": "gamma_scalping", + "side": cfg.side, + "quantity": float(cfg.quantity), + "package_count": len(packages), + "selection_count": len(selected), + **cfg.metadata, + }, + ) + + +def _canonical_strategy_frame(chain: pd.DataFrame) -> pd.DataFrame: + required = { + "timestamp_ns", + "instrument_id", + "expiry_ns", + "strike", + "option_kind", + "bid_price", + "ask_price", + "bid_size", + "ask_size", + "index_price", + } + missing = sorted(required.difference(chain.columns)) + if missing: + raise ValueError(f"gamma scalping chain missing columns: {missing}") + frame = chain.copy() + for column in ("timestamp_ns", "expiry_ns"): + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("int64") + for column in ("strike", "bid_price", "ask_price", "bid_size", "ask_size", "index_price"): + frame[column] = pd.to_numeric(frame[column], errors="raise").astype("float64") + if "volume" not in frame: + frame["volume"] = 0.0 + if "open_interest" not in frame: + frame["open_interest"] = 0.0 + frame["volume"] = pd.to_numeric(frame["volume"], errors="coerce").fillna(0.0).astype("float64") + frame["open_interest"] = pd.to_numeric(frame["open_interest"], errors="coerce").fillna(0.0).astype("float64") + frame["option_kind"] = frame["option_kind"].astype(str).str.lower().str.strip() + return frame.sort_values(["timestamp_ns", "expiry_ns", "strike", "option_kind", "instrument_id"]).reset_index(drop=True) + + +def _select_atm_pair(frame: pd.DataFrame, timestamp_ns: int, cfg: GammaScalpingConfig) -> Optional[dict]: + snap = frame[frame["timestamp_ns"] == int(timestamp_ns)].copy() + if snap.empty: + return None + snap = snap[(snap["bid_price"] > 0.0) & (snap["ask_price"] > 0.0) & (snap["ask_price"] >= snap["bid_price"])] + snap = snap[(snap["bid_size"] >= cfg.min_bid_size) & (snap["ask_size"] >= cfg.min_ask_size)] + snap = snap[(snap["volume"] >= cfg.min_volume) & (snap["open_interest"] >= cfg.min_open_interest)] + dte = (snap["expiry_ns"] - int(timestamp_ns)) / _DAY_NS + snap = snap[(dte >= cfg.min_dte_days) & (dte <= cfg.max_dte_days)] + if cfg.max_spread_bps is not None: + mid = 0.5 * (snap["bid_price"] + snap["ask_price"]) + spread_bps = np.where(mid > 0.0, (snap["ask_price"] - snap["bid_price"]) / mid * 10_000.0, np.inf) + snap = snap[spread_bps <= float(cfg.max_spread_bps)] + if snap.empty: + return None + + spot = float(snap["index_price"].median()) + pair_groups = snap.groupby(["expiry_ns", "strike"]) + candidates = [] + for (expiry_ns, strike), group in pair_groups: + kinds = set(group["option_kind"]) + if kinds != {"call", "put"}: + continue + call = group[group["option_kind"] == "call"].iloc[0] + put = group[group["option_kind"] == "put"].iloc[0] + candidates.append( + { + "expiry_ns": int(expiry_ns), + "strike": float(strike), + "spot": spot, + "call_id": str(call["instrument_id"]), + "put_id": str(put["instrument_id"]), + "call_delta": float(call.get("delta", np.nan)), + "put_delta": float(put.get("delta", np.nan)), + "distance": abs(float(strike) - spot), + "dte_days": float((int(expiry_ns) - int(timestamp_ns)) / _DAY_NS), + } + ) + if not candidates: + return None + return min(candidates, key=lambda row: (row["distance"], row["dte_days"])) + + +def _straddle_package(timestamp_ns: int, selection: dict, cfg: GammaScalpingConfig, *, action: str) -> OptionPackageIntent: + if action == "open": + side = OrderSide.BUY if cfg.side == "long" else OrderSide.SELL + elif action == "close": + side = OrderSide.SELL if cfg.side == "long" else OrderSide.BUY + else: + raise ValueError("action must be open or close") + return OptionPackageIntent( + timestamp_ns=int(timestamp_ns), + package_id=f"gamma-{action}:{selection['call_id']}:{selection['put_id']}:{timestamp_ns}", + legs=( + OptionPackageLeg(selection["call_id"], side, 1.0, role=f"{action}_call"), + OptionPackageLeg(selection["put_id"], side, 1.0, role=f"{action}_put"), + ), + quantity=float(cfg.quantity), + tag=f"gamma_scalping_{action}", + metadata={ + "strategy": "gamma_scalping", + "action": action, + "side": cfg.side, + "strike": float(selection["strike"]), + "expiry_ns": int(selection["expiry_ns"]), + "spot": float(selection["spot"]), + }, + ) + + +def _has_quotes(frame: pd.DataFrame, timestamp_ns: int, symbols: Sequence[str]) -> bool: + snap_symbols = set(frame.loc[frame["timestamp_ns"] == int(timestamp_ns), "instrument_id"]) + return all(symbol in snap_symbols for symbol in symbols) + + +_DAY_NS = 24 * 60 * 60 * 1_000_000_000 diff --git a/src/quantbt/options/surface.py b/src/quantbt/options/surface.py new file mode 100644 index 0000000..4018795 --- /dev/null +++ b/src/quantbt/options/surface.py @@ -0,0 +1,142 @@ +""" +Minimal option surface diagnostics. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, Tuple + +import numpy as np +import pandas as pd + + +@dataclass(frozen=True) +class SurfaceDiagnostics: + positive_total_variance: bool + no_future_timestamps: bool + expiries_after_snapshot: bool + calendar_total_variance_non_decreasing: bool + butterfly_convexity_checked: bool + notes: Tuple[str, ...] + + @property + def pass_basic(self) -> bool: + return ( + self.positive_total_variance + and self.no_future_timestamps + and self.expiries_after_snapshot + and self.calendar_total_variance_non_decreasing + ) + + +@dataclass(frozen=True) +class TotalVarianceSurface: + timestamp_ns: int + expiry_ns: np.ndarray + strike: np.ndarray + total_variance: np.ndarray + + def __post_init__(self) -> None: + timestamp = int(self.timestamp_ns) + expiry = np.asarray(self.expiry_ns, dtype=np.int64) + strike = np.asarray(self.strike, dtype=np.float64) + variance = np.asarray(self.total_variance, dtype=np.float64) + if timestamp <= 0: + raise ValueError("timestamp_ns must be > 0") + if expiry.ndim != 1 or strike.ndim != 1 or variance.ndim != 1: + raise ValueError("surface arrays must be 1-D") + if len(expiry) == 0 or len(expiry) != len(strike) or len(expiry) != len(variance): + raise ValueError("surface arrays must be non-empty and equal length") + if bool((expiry <= timestamp).any()): + raise ValueError("surface expiry_ns must be after timestamp_ns") + if bool((strike <= 0.0).any()): + raise ValueError("surface strikes must be > 0") + if bool((~np.isfinite(variance)).any()) or bool((variance < 0.0).any()): + raise ValueError("total_variance must be finite and >= 0") + order = np.lexsort((strike, expiry)) + object.__setattr__(self, "timestamp_ns", timestamp) + object.__setattr__(self, "expiry_ns", expiry[order]) + object.__setattr__(self, "strike", strike[order]) + object.__setattr__(self, "total_variance", variance[order]) + + @classmethod + def from_snapshot_frame( + cls, + frame: pd.DataFrame, + *, + timestamp_ns: int, + volatility_column: str = "mark_iv", + ) -> "TotalVarianceSurface": + required = {"timestamp_ns", "expiry_ns", "strike", volatility_column} + missing = sorted(required.difference(frame.columns)) + if missing: + raise ValueError(f"surface frame missing required columns: {missing}") + timestamp = int(timestamp_ns) + future_rows = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") > timestamp] + if len(future_rows) > 0: + raise ValueError("surface calibration cannot include future timestamp rows") + snapshot = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") == timestamp].copy() + if snapshot.empty: + raise ValueError("surface snapshot has no rows for timestamp_ns") + expiry = pd.to_numeric(snapshot["expiry_ns"], errors="raise").astype("int64").to_numpy() + strike = pd.to_numeric(snapshot["strike"], errors="raise").astype("float64").to_numpy() + vol = pd.to_numeric(snapshot[volatility_column], errors="raise").astype("float64").to_numpy() + tau_years = (expiry.astype(np.float64) - float(timestamp)) / (365.0 * 24.0 * 60.0 * 60.0 * 1_000_000_000.0) + total_variance = vol * vol * tau_years + return cls(timestamp_ns=timestamp, expiry_ns=expiry, strike=strike, total_variance=total_variance) + + @property + def expiries(self) -> np.ndarray: + return np.unique(self.expiry_ns) + + def interpolate_total_variance(self, *, expiry_ns: int, strike: float) -> float: + """Interpolate total variance by strike first, then expiry.""" + target_expiry = int(expiry_ns) + target_strike = float(strike) + if target_expiry <= self.timestamp_ns: + raise ValueError("target expiry must be after surface timestamp") + if target_strike <= 0.0: + raise ValueError("target strike must be > 0") + expiries = self.expiries + per_expiry = np.array([self._interpolate_strike(expiry, target_strike) for expiry in expiries], dtype=np.float64) + if len(expiries) == 1: + return float(per_expiry[0]) + return float(np.interp(float(target_expiry), expiries.astype(np.float64), per_expiry)) + + def diagnostics(self) -> SurfaceDiagnostics: + notes = ["butterfly convexity is placeholder-only in Phase 2"] + by_strike = _group_by_strike(self.expiry_ns, self.strike, self.total_variance) + calendar_ok = True + for rows in by_strike.values(): + rows_sorted = sorted(rows, key=lambda item: item[0]) + variances = np.array([item[1] for item in rows_sorted], dtype=np.float64) + if len(variances) > 1 and bool((np.diff(variances) < -1e-12).any()): + calendar_ok = False + break + return SurfaceDiagnostics( + positive_total_variance=bool((self.total_variance >= 0.0).all()), + no_future_timestamps=True, + expiries_after_snapshot=bool((self.expiry_ns > self.timestamp_ns).all()), + calendar_total_variance_non_decreasing=calendar_ok, + butterfly_convexity_checked=False, + notes=tuple(notes), + ) + + def _interpolate_strike(self, expiry_ns: int, strike: float) -> float: + mask = self.expiry_ns == int(expiry_ns) + strikes = self.strike[mask] + variances = self.total_variance[mask] + if len(strikes) == 0: + raise ValueError("expiry not found") + if len(strikes) == 1: + return float(variances[0]) + order = np.argsort(strikes) + return float(np.interp(strike, strikes[order], variances[order])) + + +def _group_by_strike(expiry_ns: Iterable[int], strike: Iterable[float], total_variance: Iterable[float]) -> Dict[float, list[tuple[int, float]]]: + grouped: Dict[float, list[tuple[int, float]]] = {} + for expiry, strike_value, variance in zip(expiry_ns, strike, total_variance): + grouped.setdefault(float(strike_value), []).append((int(expiry), float(variance))) + return grouped diff --git a/src/quantbt/options/tape.py b/src/quantbt/options/tape.py new file mode 100644 index 0000000..cbda657 --- /dev/null +++ b/src/quantbt/options/tape.py @@ -0,0 +1,226 @@ +""" +Prepared ragged option tape. + +The canonical option chain remains long-form. This module compiles validated +rows into CSR-style arrays so later selectors and execution code can scan the +listed contracts at each observable snapshot without building a dense +bar-by-contract matrix. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .data import validate_option_chain_frame +from .schema import InstrumentRegistrySignature, OptionInstrumentRegistry + + +YEAR_NS = 365 * 24 * 60 * 60 * 1_000_000_000 + + +@dataclass(frozen=True) +class OptionTapeSignature: + row_count: int + snapshot_count: int + first_timestamp_ns: int + last_timestamp_ns: int + instrument_registry_signature: InstrumentRegistrySignature + convention_signature: Tuple + + +@dataclass(frozen=True) +class PreparedOptionTape: + timestamp_ns: np.ndarray + row_ptr: np.ndarray + instrument_code: np.ndarray + instrument_id: Tuple[str, ...] + expiry_ns: np.ndarray + strike: np.ndarray + option_kind_code: np.ndarray + bid_price: np.ndarray + bid_size: np.ndarray + ask_price: np.ndarray + ask_size: np.ndarray + mark_price: np.ndarray + index_price: np.ndarray + forward_price: np.ndarray + mark_iv: np.ndarray + bid_iv: np.ndarray + ask_iv: np.ndarray + delta: np.ndarray + gamma: np.ndarray + vega: np.ndarray + theta: np.ndarray + open_interest: np.ndarray + volume: np.ndarray + source_latency_ns: np.ndarray + registry: OptionInstrumentRegistry + signature: OptionTapeSignature + + def __post_init__(self) -> None: + if self.timestamp_ns.ndim != 1 or self.row_ptr.ndim != 1: + raise ValueError("timestamp_ns and row_ptr must be 1-D") + if len(self.row_ptr) != len(self.timestamp_ns) + 1: + raise ValueError("row_ptr length must equal snapshot_count + 1") + if len(self.instrument_code) != self.signature.row_count: + raise ValueError("instrument_code length must match row_count") + if self.row_ptr[0] != 0 or self.row_ptr[-1] != self.signature.row_count: + raise ValueError("row_ptr bounds do not match row_count") + if bool((np.diff(self.row_ptr) < 0).any()): + raise ValueError("row_ptr must be non-decreasing") + if bool((np.diff(self.timestamp_ns) <= 0).any()): + raise ValueError("timestamp_ns must be strictly increasing") + + @property + def snapshot_count(self) -> int: + return len(self.timestamp_ns) + + @property + def row_count(self) -> int: + return len(self.instrument_code) + + def snapshot_index_at_or_before(self, decision_timestamp_ns: int, *, max_quote_age_ns: Optional[int] = None) -> int: + decision_ts = int(decision_timestamp_ns) + idx = int(np.searchsorted(self.timestamp_ns, decision_ts, side="right") - 1) + if idx < 0: + raise ValueError("no option snapshot is observable at or before decision_timestamp_ns") + if max_quote_age_ns is not None and decision_ts - int(self.timestamp_ns[idx]) > int(max_quote_age_ns): + raise ValueError("latest option snapshot is stale for decision_timestamp_ns") + return idx + + def snapshot_slice(self, snapshot_index: int) -> slice: + idx = int(snapshot_index) + if idx < 0 or idx >= self.snapshot_count: + raise IndexError("snapshot_index out of range") + return slice(int(self.row_ptr[idx]), int(self.row_ptr[idx + 1])) + + def validate_compatible( + self, + *, + registry_signature: Optional[InstrumentRegistrySignature] = None, + convention_signature: Optional[Tuple] = None, + timestamps_ns: Optional[Sequence[int]] = None, + ) -> None: + if registry_signature is not None and registry_signature != self.signature.instrument_registry_signature: + raise ValueError("prepared option tape registry signature mismatch") + if convention_signature is not None and tuple(convention_signature) != self.signature.convention_signature: + raise ValueError("prepared option tape convention signature mismatch") + if timestamps_ns is not None: + expected = np.asarray(timestamps_ns, dtype=np.int64) + if len(expected) != len(self.timestamp_ns) or bool((expected != self.timestamp_ns).any()): + raise ValueError("prepared option tape timestamp mismatch") + + +def prepare_option_tape( + chain: pd.DataFrame, + registry: OptionInstrumentRegistry, + *, + max_spread_bps: Optional[float] = None, + max_source_latency_ns: Optional[int] = None, + convention_signature: Optional[Tuple] = None, +) -> PreparedOptionTape: + """ + Validate long-form chain rows and compile a CSR-style option tape. + + `max_source_latency_ns` checks the per-row venue/source latency column when + present. Decision-time quote age is checked later by selectors because it + depends on the strategy timestamp. + """ + canonical = validate_option_chain_frame(chain, max_spread_bps=max_spread_bps) + registry_symbols = registry.by_symbol + unknown = sorted(set(canonical["instrument_id"]).difference(registry_symbols)) + if unknown: + raise ValueError(f"option chain contains instruments not in registry: {unknown}") + if max_source_latency_ns is not None: + if max_source_latency_ns < 0: + raise ValueError("max_source_latency_ns must be >= 0") + if "source_latency_ns" not in canonical: + raise ValueError("source_latency_ns is required when max_source_latency_ns is set") + if bool((canonical["source_latency_ns"].to_numpy(dtype=np.int64) > int(max_source_latency_ns)).any()): + raise ValueError("option chain contains stale source latency rows") + _validate_registry_static_fields(canonical, registry) + timestamps, row_ptr = _build_row_ptr(canonical["timestamp_ns"].to_numpy(dtype=np.int64)) + ids = tuple(canonical["instrument_id"].astype(str).tolist()) + code_map = {symbol: code for code, symbol in enumerate(registry.symbols)} + instrument_code = np.asarray([code_map[symbol] for symbol in ids], dtype=np.int32) + kind_code = np.asarray([0 if kind == "call" else 1 for kind in canonical["option_kind"].astype(str)], dtype=np.int8) + convention_sig = tuple(convention_signature) if convention_signature is not None else registry.signature.signature + signature = OptionTapeSignature( + row_count=len(canonical), + snapshot_count=len(timestamps), + first_timestamp_ns=int(timestamps[0]), + last_timestamp_ns=int(timestamps[-1]), + instrument_registry_signature=registry.signature, + convention_signature=convention_sig, + ) + return PreparedOptionTape( + timestamp_ns=timestamps, + row_ptr=row_ptr, + instrument_code=instrument_code, + instrument_id=ids, + expiry_ns=canonical["expiry_ns"].to_numpy(dtype=np.int64), + strike=canonical["strike"].to_numpy(dtype=np.float64), + option_kind_code=kind_code, + bid_price=canonical["bid_price"].to_numpy(dtype=np.float64), + bid_size=canonical["bid_size"].to_numpy(dtype=np.float64), + ask_price=canonical["ask_price"].to_numpy(dtype=np.float64), + ask_size=canonical["ask_size"].to_numpy(dtype=np.float64), + mark_price=canonical["mark_price"].to_numpy(dtype=np.float64), + index_price=canonical["index_price"].to_numpy(dtype=np.float64), + forward_price=canonical["forward_price"].to_numpy(dtype=np.float64), + mark_iv=_float_column(canonical, "mark_iv", default=np.nan), + bid_iv=_float_column(canonical, "bid_iv", default=np.nan), + ask_iv=_float_column(canonical, "ask_iv", default=np.nan), + delta=_float_column(canonical, "delta", default=np.nan), + gamma=_float_column(canonical, "gamma", default=np.nan), + vega=_float_column(canonical, "vega", default=np.nan), + theta=_float_column(canonical, "theta", default=np.nan), + open_interest=_float_column(canonical, "open_interest", default=0.0), + volume=_float_column(canonical, "volume", default=0.0), + source_latency_ns=_int_column(canonical, "source_latency_ns", default=0), + registry=registry, + signature=signature, + ) + + +def _build_row_ptr(timestamp_ns: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + timestamps, counts = np.unique(timestamp_ns, return_counts=True) + row_ptr = np.empty(len(timestamps) + 1, dtype=np.int64) + row_ptr[0] = 0 + row_ptr[1:] = np.cumsum(counts, dtype=np.int64) + return timestamps.astype(np.int64), row_ptr + + +def _float_column(frame: pd.DataFrame, column: str, *, default: float) -> np.ndarray: + if column not in frame: + return np.full(len(frame), default, dtype=np.float64) + return frame[column].to_numpy(dtype=np.float64) + + +def _int_column(frame: pd.DataFrame, column: str, *, default: int) -> np.ndarray: + if column not in frame: + return np.full(len(frame), default, dtype=np.int64) + return frame[column].to_numpy(dtype=np.int64) + + +def _validate_registry_static_fields(chain: pd.DataFrame, registry: OptionInstrumentRegistry) -> None: + for row in chain.itertuples(index=False): + spec = registry.by_symbol[getattr(row, "instrument_id")] + if int(getattr(row, "expiry_ns")) != int(spec.expiry_ns): + raise ValueError("option chain expiry_ns does not match registry") + if abs(float(getattr(row, "strike")) - float(spec.strike)) > 1e-12: + raise ValueError("option chain strike does not match registry") + if str(getattr(row, "option_kind")).lower() != spec.option_kind.value: + raise ValueError("option chain option_kind does not match registry") + if str(getattr(row, "venue")).lower() != spec.venue: + raise ValueError("option chain venue does not match registry") + if str(getattr(row, "underlying_id")).strip() != spec.underlying_id: + raise ValueError("option chain underlying_id does not match registry") + if str(getattr(row, "quote_currency")).upper() != spec.quote_currency: + raise ValueError("option chain quote_currency does not match registry") + if str(getattr(row, "settlement_currency")).upper() != spec.settlement_currency: + raise ValueError("option chain settlement_currency does not match registry") diff --git a/src/quantbt/options/templates/__init__.py b/src/quantbt/options/templates/__init__.py new file mode 100644 index 0000000..9078e7b --- /dev/null +++ b/src/quantbt/options/templates/__init__.py @@ -0,0 +1,33 @@ +"""Option package builder templates.""" + +from .packages import ( + butterfly, + calendar, + collar, + condor, + covered_call, + long_call, + long_put, + risk_reversal, + short_call, + short_put, + straddle, + strangle, + vertical, +) + +__all__ = [ + "butterfly", + "calendar", + "collar", + "condor", + "covered_call", + "long_call", + "long_put", + "risk_reversal", + "short_call", + "short_put", + "straddle", + "strangle", + "vertical", +] diff --git a/src/quantbt/options/templates/packages.py b/src/quantbt/options/templates/packages.py new file mode 100644 index 0000000..dd441f0 --- /dev/null +++ b/src/quantbt/options/templates/packages.py @@ -0,0 +1,354 @@ +""" +V1 option package builders. + +Builders intentionally emit `OptionPackageIntent` only. They do not calculate +payoff, PnL, Greeks, margin, or account state. +""" + +from __future__ import annotations + +from typing import Optional, Sequence, Tuple + +from ...core.schema import OrderSide, OrderType, TimeInForce +from ..packages import OptionPackageExecutionPolicy, OptionPackageIntent, OptionPackageLeg + + +def long_call(timestamp_ns: int, call_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Buy one call package.""" + return _single(timestamp_ns, call_id, OrderSide.BUY, "long_call", quantity=quantity, package_id=package_id, **kwargs) + + +def short_call(timestamp_ns: int, call_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Sell one call package.""" + return _single(timestamp_ns, call_id, OrderSide.SELL, "short_call", quantity=quantity, package_id=package_id, **kwargs) + + +def long_put(timestamp_ns: int, put_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Buy one put package.""" + return _single(timestamp_ns, put_id, OrderSide.BUY, "long_put", quantity=quantity, package_id=package_id, **kwargs) + + +def short_put(timestamp_ns: int, put_id: str, *, quantity: float = 1.0, package_id: Optional[str] = None, **kwargs) -> OptionPackageIntent: + """Sell one put package.""" + return _single(timestamp_ns, put_id, OrderSide.SELL, "short_put", quantity=quantity, package_id=package_id, **kwargs) + + +def straddle( + timestamp_ns: int, + call_id: str, + put_id: str, + *, + side: str = "long", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a long or short straddle.""" + order_side = _side_from_direction(side, long_side=OrderSide.BUY) + return _package( + timestamp_ns, + package_id or f"{side}_straddle:{call_id}:{put_id}", + ( + _leg(call_id, order_side, 1.0, role="call", **kwargs), + _leg(put_id, order_side, 1.0, role="put", **kwargs), + ), + quantity=quantity, + strategy="straddle", + **_package_kwargs(kwargs), + ) + + +def strangle( + timestamp_ns: int, + call_id: str, + put_id: str, + *, + side: str = "long", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a long or short strangle.""" + order_side = _side_from_direction(side, long_side=OrderSide.BUY) + return _package( + timestamp_ns, + package_id or f"{side}_strangle:{call_id}:{put_id}", + ( + _leg(call_id, order_side, 1.0, role="call", **kwargs), + _leg(put_id, order_side, 1.0, role="put", **kwargs), + ), + quantity=quantity, + strategy="strangle", + **_package_kwargs(kwargs), + ) + + +def vertical( + timestamp_ns: int, + long_option_id: str, + short_option_id: str, + *, + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a debit vertical: buy one option and sell another same-type option.""" + return _package( + timestamp_ns, + package_id or f"vertical:{long_option_id}:{short_option_id}", + ( + _leg(long_option_id, OrderSide.BUY, 1.0, role="long_strike", **kwargs), + _leg(short_option_id, OrderSide.SELL, 1.0, role="short_strike", **kwargs), + ), + quantity=quantity, + strategy="vertical", + **_package_kwargs(kwargs), + ) + + +def butterfly( + timestamp_ns: int, + lower_id: str, + middle_id: str, + upper_id: str, + *, + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a 1:-2:1 long butterfly.""" + return _package( + timestamp_ns, + package_id or f"butterfly:{lower_id}:{middle_id}:{upper_id}", + ( + _leg(lower_id, OrderSide.BUY, 1.0, role="lower_wing", **kwargs), + _leg(middle_id, OrderSide.SELL, 2.0, role="body", **kwargs), + _leg(upper_id, OrderSide.BUY, 1.0, role="upper_wing", **kwargs), + ), + quantity=quantity, + strategy="butterfly", + **_package_kwargs(kwargs), + ) + + +def condor( + timestamp_ns: int, + lower_long_id: str, + lower_short_id: str, + upper_short_id: str, + upper_long_id: str, + *, + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a 1:-1:-1:1 long condor.""" + return _package( + timestamp_ns, + package_id or f"condor:{lower_long_id}:{lower_short_id}:{upper_short_id}:{upper_long_id}", + ( + _leg(lower_long_id, OrderSide.BUY, 1.0, role="lower_wing", **kwargs), + _leg(lower_short_id, OrderSide.SELL, 1.0, role="lower_body", **kwargs), + _leg(upper_short_id, OrderSide.SELL, 1.0, role="upper_body", **kwargs), + _leg(upper_long_id, OrderSide.BUY, 1.0, role="upper_wing", **kwargs), + ), + quantity=quantity, + strategy="condor", + **_package_kwargs(kwargs), + ) + + +def calendar( + timestamp_ns: int, + near_id: str, + far_id: str, + *, + side: str = "long", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a calendar spread. Long calendar sells near expiry and buys far expiry.""" + near_side = OrderSide.SELL if str(side).lower() == "long" else OrderSide.BUY + far_side = OrderSide.BUY if str(side).lower() == "long" else OrderSide.SELL + return _package( + timestamp_ns, + package_id or f"{side}_calendar:{near_id}:{far_id}", + ( + _leg(near_id, near_side, 1.0, role="near_expiry", **kwargs), + _leg(far_id, far_side, 1.0, role="far_expiry", **kwargs), + ), + quantity=quantity, + strategy="calendar", + **_package_kwargs(kwargs), + ) + + +def covered_call( + timestamp_ns: int, + underlying_id: str, + call_id: str, + *, + quantity: float = 1.0, + underlying_ratio: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a covered call package: long underlying, short call.""" + return _package( + timestamp_ns, + package_id or f"covered_call:{underlying_id}:{call_id}", + ( + _leg(underlying_id, OrderSide.BUY, underlying_ratio, role="underlying", **_with_leg_metadata(kwargs, {"asset_role": "underlying"})), + _leg(call_id, OrderSide.SELL, 1.0, role="short_call", **kwargs), + ), + quantity=quantity, + strategy="covered_call", + **_package_kwargs(kwargs), + ) + + +def collar( + timestamp_ns: int, + underlying_id: str, + put_id: str, + call_id: str, + *, + quantity: float = 1.0, + underlying_ratio: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a collar package: long underlying, long put, short call.""" + return _package( + timestamp_ns, + package_id or f"collar:{underlying_id}:{put_id}:{call_id}", + ( + _leg(underlying_id, OrderSide.BUY, underlying_ratio, role="underlying", **_with_leg_metadata(kwargs, {"asset_role": "underlying"})), + _leg(put_id, OrderSide.BUY, 1.0, role="protective_put", **kwargs), + _leg(call_id, OrderSide.SELL, 1.0, role="covered_call", **kwargs), + ), + quantity=quantity, + strategy="collar", + **_package_kwargs(kwargs), + ) + + +def risk_reversal( + timestamp_ns: int, + put_id: str, + call_id: str, + *, + direction: str = "bullish", + quantity: float = 1.0, + package_id: Optional[str] = None, + **kwargs, +) -> OptionPackageIntent: + """Create a bullish or bearish risk reversal.""" + bullish = str(direction).lower() == "bullish" + return _package( + timestamp_ns, + package_id or f"{direction}_risk_reversal:{put_id}:{call_id}", + ( + _leg(put_id, OrderSide.SELL if bullish else OrderSide.BUY, 1.0, role="put", **kwargs), + _leg(call_id, OrderSide.BUY if bullish else OrderSide.SELL, 1.0, role="call", **kwargs), + ), + quantity=quantity, + strategy="risk_reversal", + **_package_kwargs(kwargs), + ) + + +def _single( + timestamp_ns: int, + instrument_id: str, + side: OrderSide, + strategy: str, + *, + quantity: float, + package_id: Optional[str], + **kwargs, +) -> OptionPackageIntent: + return _package( + timestamp_ns, + package_id or f"{strategy}:{instrument_id}", + (_leg(instrument_id, side, 1.0, role=strategy, **kwargs),), + quantity=quantity, + strategy=strategy, + **_package_kwargs(kwargs), + ) + + +def _package( + timestamp_ns: int, + package_id: str, + legs: Sequence[OptionPackageLeg], + *, + quantity: float, + strategy: str, + execution_policy: OptionPackageExecutionPolicy = OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE, + max_debit: Optional[float] = None, + min_credit: Optional[float] = None, + tag: Optional[str] = None, + metadata: Optional[dict] = None, +) -> OptionPackageIntent: + return OptionPackageIntent( + timestamp_ns=timestamp_ns, + package_id=package_id, + legs=tuple(legs), + quantity=quantity, + execution_policy=execution_policy, + max_debit=max_debit, + min_credit=min_credit, + tag=tag, + metadata={"template": strategy, **(metadata or {})}, + ) + + +def _leg( + instrument_id: str, + side: OrderSide, + ratio: float, + *, + role: str, + order_type: OrderType = OrderType.MARKET, + limit_price: Optional[float] = None, + tif: TimeInForce = TimeInForce.FOK, + tag: Optional[str] = None, + metadata: Optional[dict] = None, + **_, +) -> OptionPackageLeg: + return OptionPackageLeg( + instrument_id=instrument_id, + side=side, + ratio=ratio, + order_type=order_type, + limit_price=limit_price, + tif=tif, + role=role, + tag=tag, + metadata=dict(metadata or {}), + ) + + +def _package_kwargs(kwargs: dict) -> dict: + return { + key: kwargs[key] + for key in ("execution_policy", "max_debit", "min_credit", "tag", "metadata") + if key in kwargs + } + + +def _with_leg_metadata(kwargs: dict, extra: dict) -> dict: + out = dict(kwargs) + out["metadata"] = {**dict(kwargs.get("metadata") or {}), **extra} + return out + + +def _side_from_direction(direction: str, *, long_side: OrderSide) -> OrderSide: + value = str(direction).lower().strip() + if value == "long": + return long_side + if value == "short": + return OrderSide.SELL if long_side is OrderSide.BUY else OrderSide.BUY + raise ValueError("direction must be long or short") diff --git a/src/quantbt/portfolio.py b/src/quantbt/portfolio.py new file mode 100644 index 0000000..9d413bf --- /dev/null +++ b/src/quantbt/portfolio.py @@ -0,0 +1,606 @@ +""" +quantbt.portfolio +----------------- +MultiSymbolPortfolio — independent multi-symbol backtest with portfolio-level +risk management, allocation modes, and attribution. + +Fixes vs original +~~~~~~~~~~~~~~~~~ +* Signal-notional portfolio sizing freezes units until signal changes, avoiding + price-drift micro-rebalancing. +* Market-neutral scaling: long and short sides are scaled simultaneously from + the ORIGINAL signed notional, not unit counts. +* Maintenance margin = notional × mm_rate (Binance formula, not im × mm_rate). +* Funding fires once per 8h window via make_funding_mask, not per-bar within hour. +* _run_portfolio_numba is wired in for crypto intrabar liquidation. + +Modes +~~~~~ +'longshort' raw positions, no adjustment +'market_neutral' gross long notional == gross short notional each bar +'directional' keep only the dominant side (by abs notional) +'equal_weight' equal fractional weight among active symbols +""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np +import pandas as pd + +from .core.preprocessor import ( + validate_datetime, + make_funding_mask, +) +from .metrics.performance import full_report +from .core.types import BacktestResult +from .core.engine import _engine_portfolio +from .sizing.modes import compute_target_units + + +class MultiSymbolPortfolio: + """ + Multi-Symbol Backtest Engine. + + Parameters + ---------- + positions Dict[str, pd.Series] raw signal weights + closes Dict[str, pd.Series] close prices + datetime_index common DatetimeIndex (UTC) + mode 'longshort' | 'market_neutral' | 'directional' | 'equal_weight' + fee_rate canonical one-way fee per accepted trade side + fee optional legacy round-trip fee; halved internally + alloc_per_trade notional per full signal unit; float or per-symbol dict + contract_size float or per-symbol dict + hedge_type 'signal_notional' | 'notional' | 'unit' + initial_capital float + asset_type 'crypto' | 'stock' + use_funding override funding; None → follows asset_type + funding_rate float or per-symbol dict + leverage float or per-symbol dict + maintenance_ratio float Binance: notional × ratio + """ + + _ASSET_CFG = { + "crypto": { + "trading_days": 365, + "fee_rate": 0.0004, + "contract": 1.0, + "funding": True, + }, + "stock": { + "trading_days": 252, + "fee_rate": 0.0001, + "contract": 100.0, + "funding": False, + }, + } + + def __init__( + self, + positions: Dict[str, pd.Series], + closes: Dict[str, pd.Series], + datetime_index: Union[pd.DatetimeIndex, pd.Series], + mode: str = "longshort", + fee_rate: Optional[float] = None, + alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0, + contract_size: Union[float, Dict[str, float]] = None, + hedge_type: str = "signal_notional", + initial_capital: float = 100_000.0, + asset_type: str = "crypto", + use_funding: Optional[bool] = None, + funding_rate: Union[float, Dict[str, float]] = None, + leverage: Union[float, Dict[str, float]] = 1.0, + maintenance_ratio: float = 0.005, + margin_buffer: float = 0.01, + use_binance_netting: bool = False, + # highs / lows for intrabar liquidation (optional) + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + fee: Optional[float] = None, + ): + # ── config ──────────────────────────────────────────────────────── + atype = asset_type.lower() + if atype not in self._ASSET_CFG: + raise ValueError("asset_type must be 'crypto' or 'stock'") + + cfg = self._ASSET_CFG[atype] + self.asset_type = atype + self.trading_days = cfg["trading_days"] + if fee_rate is not None: + self.fee_rate = float(fee_rate) + elif fee is not None: + self.fee_rate = float(fee) / 2.0 + else: + self.fee_rate = float(cfg["fee_rate"]) / 2.0 + self.use_funding = use_funding if use_funding is not None else cfg["funding"] + self.maintenance_ratio = maintenance_ratio + self.initial_capital = initial_capital + self.mode = mode.lower() + self.hedge_type = hedge_type.lower() + self.use_binance_netting = use_binance_netting if atype == "crypto" else False + + valid_modes = {"longshort", "market_neutral", "directional", "equal_weight"} + if self.mode not in valid_modes: + raise ValueError(f"mode must be one of {valid_modes}") + valid_hedge_types = {"signal_notional", "signal", "notional", "unit"} + if self.hedge_type not in valid_hedge_types: + raise ValueError(f"portfolio hedge_type must be one of {valid_hedge_types}") + + # ── symbols ─────────────────────────────────────────────────────── + self.symbols = list(positions.keys()) + if set(self.symbols) != set(closes.keys()): + raise ValueError("positions and closes must have the same symbol keys") + + # ── per-symbol config ───────────────────────────────────────────── + def _per_sym(v, default): + return v if isinstance(v, dict) else {s: (default if v is None else v) for s in self.symbols} + + default_cs = cfg["contract"] + self.cs = _per_sym(contract_size, default_cs) + self.lev = _per_sym(leverage, 1.0) + self.alloc = _per_sym(alloc_per_trade, 100_000.0) + self.fund_rates = _per_sym(funding_rate, 0.0001) if self.use_funding else {s: 0.0 for s in self.symbols} + + if initial_capital <= 0.0: + raise ValueError("initial_capital must be > 0") + if any(v <= 0.0 for v in self.lev.values()): + raise ValueError("leverage must be > 0") + if any(v <= 0.0 for v in self.cs.values()): + raise ValueError("contract_size must be > 0") + if any(v < 0.0 for v in self.alloc.values()): + raise ValueError("alloc_per_trade must be >= 0") + if maintenance_ratio < 0.0: + raise ValueError("maintenance_ratio must be >= 0") + + # ── datetime index ──────────────────────────────────────────────── + self._idx = validate_datetime(datetime_index) + + # ── align data ──────────────────────────────────────────────────── + def _align(d: Dict, fill=0.0): + out = {} + for s in self.symbols: + ser = d[s].copy() + if isinstance(ser.index, pd.DatetimeIndex): + ser.index = ser.index.tz_localize("UTC") if ser.index.tz is None else ser.index.tz_convert("UTC") + ser = ser[~ser.index.duplicated(keep="first")] + out[s] = ser.reindex(self._idx, method="ffill").fillna(fill) + return out + + self._pos = _align(positions, 0.0) + self._close = _align(closes, np.nan) + + fallback = {s: self._close[s] for s in self.symbols} + self._high = _align(highs, np.nan) if highs else fallback + self._low = _align(lows, np.nan) if lows else fallback + + # ── scale positions → notional units ───────────────────────────── + self._scaled: Dict[str, pd.Series] = {} + for s in self.symbols: + self._scaled[s] = compute_target_units( + hedge_type=self.hedge_type, + signal=self._pos[s], + close=self._close[s], + alloc=self.alloc[s], + use_pyramiding=True, + ) + + # ── apply portfolio mode ────────────────────────────────────────── + self._apply_mode() + + # ── run simulation ──────────────────────────────────────────────── + self._result: Optional[BacktestResult] = None + self._pnl_per_sym: Dict[str, pd.Series] = {} + self._daily_fee: pd.Series = pd.Series(dtype=float) + self._daily_turnover: pd.Series = pd.Series(dtype=float) + self.run() + + # ── portfolio mode application ──────────────────────────────────────────── + + def _apply_mode(self): + """ + Adjust scaled positions according to the allocation mode. + All scaling is done from the original notional simultaneously. + """ + pos_df = pd.DataFrame({s: self._scaled[s] for s in self.symbols}) + close_df = pd.DataFrame({s: self._close[s] for s in self.symbols}) + cs = pd.Series({s: float(self.cs[s]) for s in self.symbols}) + + def _signed_notional(units: pd.DataFrame) -> pd.DataFrame: + return units.mul(close_df, axis=0).mul(cs, axis=1) + + if self.mode == "market_neutral": + # Scale each side so gross_long_notional == gross_short_notional every bar. + # Capture long/short sums from the ORIGINAL signed notional in one pass. + notional_df = _signed_notional(pos_df) + long_mask = notional_df > 0 + short_mask = notional_df < 0 + long_sum = (notional_df * long_mask).sum(axis=1) # positive + short_sum = (notional_df * short_mask).abs().sum(axis=1) # positive + + target = (long_sum + short_sum) / 2.0 # equal notional on each side + + for s in self.symbols: + col = notional_df[s] + original_units = pos_df[s] + # scale independently per side; avoids the sequential mutation bug + long_scale = (target / long_sum.replace(0, np.nan)).fillna(1.0) + short_scale = (target / short_sum.replace(0, np.nan)).fillna(1.0) + pos_df[s] = np.where(col > 0, original_units * long_scale, + np.where(col < 0, original_units * short_scale, 0.0)) + + elif self.mode == "directional": + notional = _signed_notional(pos_df).abs() + dominant = notional.idxmax(axis=1) + for s in self.symbols: + pos_df[s] = pos_df[s].where(dominant == s, 0.0) + + elif self.mode == "equal_weight": + notional_df = _signed_notional(pos_df) + active = (notional_df != 0).sum(axis=1) + gross = notional_df.abs().sum(axis=1) + target_abs = (gross / active.replace(0, np.nan)).fillna(0.0) + for s in self.symbols: + denom = (close_df[s] * float(self.cs[s])).replace(0.0, np.nan) + sign = np.sign(notional_df[s]) + pos_df[s] = (sign * target_abs / denom).fillna(0.0) + + for s in self.symbols: + self._scaled[s] = pos_df[s] + + def run(self) -> BacktestResult: + """Simulate and return BacktestResult.""" + idx = self._idx + n = len(idx) + m = len(self.symbols) + is_fund = make_funding_mask(idx) + + closes_m = np.zeros((n, m), dtype=np.float64) + highs_m = np.zeros((n, m), dtype=np.float64) + lows_m = np.zeros((n, m), dtype=np.float64) + target_m = np.zeros((n, m), dtype=np.float64) + funding_m = np.zeros((n, m), dtype=np.float64) + cs_arr = np.zeros(m, dtype=np.float64) + lev_arr = np.zeros(m, dtype=np.float64) + + for j, s in enumerate(self.symbols): + closes_m[:, j] = self._close[s].fillna(0.0).values + highs_m[:, j] = self._high[s].fillna(self._close[s]).values + lows_m[:, j] = self._low[s].fillna(self._close[s]).values + target_m[:, j] = self._scaled[s].fillna(0.0).values + cs_arr[j] = float(self.cs[s]) + lev_arr[j] = float(self.lev[s]) + + fr = self.fund_rates[s] + if isinstance(fr, pd.Series): + ser = fr.copy() + if isinstance(ser.index, pd.DatetimeIndex): + ser.index = ser.index.tz_localize("UTC") if ser.index.tz is None else ser.index.tz_convert("UTC") + funding_m[:, j] = ser.reindex(idx, method="ffill").fillna(0.0).values + elif isinstance(fr, np.ndarray): + if len(fr) != n: + raise ValueError(f"funding_rate array for {s} must have length {n}") + funding_m[:, j] = fr.astype(np.float64) + else: + funding_m[:, j] = float(fr) + + ( + eq_arr, + pos_arr, + sym_arr, + fee_arr, + _slippage_arr, + turn_arr, + liq_flag, + liq_idx, + ) = _engine_portfolio( + n_bars = n, + n_syms = m, + highs = highs_m, + lows = lows_m, + closes = closes_m, + target_pos = target_m, + funding_rates = funding_m, + is_funding_bar = is_fund, + init_capital = self.initial_capital, + leverages = lev_arr, + maint_ratio = self.maintenance_ratio, + fee_rate = self.fee_rate, + slippage_rate = 0.0, + contract_sizes = cs_arr, + use_funding = bool(self.use_funding), + tradable = np.ones((n, m), dtype=np.bool_), + ) + + # ── assemble result ─────────────────────────────────────────────── + equity_s = pd.Series(eq_arr, index=idx, name="equity") + returns = equity_s.pct_change().fillna(0) + + pos_df = pd.DataFrame( + {f"Position_{s}": pos_arr[:, j] for j, s in enumerate(self.symbols)}, index=idx + ) + close_df = pd.DataFrame( + {f"Close_{s}": closes_m[:, j] for j, s in enumerate(self.symbols)}, index=idx + ) + + self._daily_fee = pd.Series(fee_arr, index=idx).resample("1D").sum() + self._daily_turnover = pd.Series(turn_arr, index=idx).resample("1D").sum() + self._pnl_per_sym = { + s: pd.Series(sym_arr[:, j], index=idx) for j, s in enumerate(self.symbols) + } + target_units_report = pd.DataFrame( + {s: target_m[:, j] for j, s in enumerate(self.symbols)}, index=idx + ) + accepted_units_report = pd.DataFrame( + {s: pos_arr[:, j] for j, s in enumerate(self.symbols)}, index=idx + ) + close_report = pd.DataFrame( + {s: closes_m[:, j] for j, s in enumerate(self.symbols)}, index=idx + ) + symbol_pnl_report = self._build_symbol_pnl_report( + accepted_units=accepted_units_report, + closes=close_report, + funding_rates=pd.DataFrame({s: funding_m[:, j] for j, s in enumerate(self.symbols)}, index=idx), + is_funding_bar=pd.Series(is_fund, index=idx), + ) + exposure_report = self._build_exposure_report( + accepted_units=accepted_units_report, + target_units=target_units_report, + closes=close_report, + equity=equity_s, + ) + rebalance_report = self._build_rebalance_report( + target_units=target_units_report, + accepted_units=accepted_units_report, + closes=close_report, + ) + + self._result = BacktestResult( + equity = equity_s, + returns = returns, + positions = pos_df, + closes = close_df, + symbols = self.symbols, + initial_capital = self.initial_capital, + leverage = float(np.mean(list(self.lev.values()))), + liquidated = liq_flag, + liquidation_bar = int(liq_idx), + metadata = { + "mode": self.mode, + "asset_type": self.asset_type, + "hedge_type": self.hedge_type, + "engine": "numba_portfolio", + "initial_buying_power": self.initial_capital * float(np.mean(list(self.lev.values()))), + "funding_rate_unit": "per_event", + "target_units_report": target_units_report, + "accepted_units_report": accepted_units_report, + "target_notional_report": target_units_report.mul(close_report, axis=0).mul(pd.Series(self.cs), axis=1), + "accepted_notional_report": accepted_units_report.mul(close_report, axis=0).mul(pd.Series(self.cs), axis=1), + "exposure_report": exposure_report, + "symbol_pnl_report": symbol_pnl_report, + "rebalance_report": rebalance_report, + "fee_series": pd.Series(fee_arr, index=idx, name="fee"), + "turnover_series": pd.Series(turn_arr, index=idx, name="turnover"), + "fee_total": float(np.sum(fee_arr)), + "fee_rate_oneway": float(self.fee_rate), + "canonical_one_way_fee_rate": float(self.fee_rate), + "turnover_total": float(np.sum(turn_arr)), + }, + ) + return self._result + + def _build_symbol_pnl_report( + self, + accepted_units: pd.DataFrame, + closes: pd.DataFrame, + funding_rates: pd.DataFrame, + is_funding_bar: pd.Series, + ) -> pd.DataFrame: + frames = [] + funding_mask = is_funding_bar.astype(bool) & bool(self.use_funding) + for s in self.symbols: + units = accepted_units[s].astype(float) + close = closes[s].astype(float) + prev_units = units.shift(1).fillna(0.0) + prev_close = close.shift(1).fillna(close) + delta = units.diff().fillna(units) + cs = float(self.cs[s]) + mark_pnl = prev_units * (close - prev_close) * cs + funding_cost = prev_units * close * cs * funding_rates[s].astype(float) + funding_cost = funding_cost.where(funding_mask, 0.0) + fee = delta.abs() * close * cs * float(self.fee_rate) + total_pnl = mark_pnl - funding_cost - fee + frame = pd.DataFrame( + { + "timestamp": self._idx, + "symbol": s, + "position_units": units.to_numpy(dtype=float), + "close": close.to_numpy(dtype=float), + "mark_pnl": mark_pnl.to_numpy(dtype=float), + "funding_cost": funding_cost.to_numpy(dtype=float), + "funding_pnl": (-funding_cost).to_numpy(dtype=float), + "fee": fee.to_numpy(dtype=float), + "fee_pnl": (-fee).to_numpy(dtype=float), + "total_pnl": total_pnl.to_numpy(dtype=float), + } + ) + frames.append(frame) + if not frames: + return pd.DataFrame( + columns=[ + "timestamp", + "symbol", + "position_units", + "close", + "mark_pnl", + "funding_cost", + "funding_pnl", + "fee", + "fee_pnl", + "total_pnl", + ] + ) + return pd.concat(frames, ignore_index=True, copy=False) + + def _build_exposure_report( + self, + accepted_units: pd.DataFrame, + target_units: pd.DataFrame, + closes: pd.DataFrame, + equity: pd.Series, + ) -> pd.DataFrame: + cs = pd.Series({s: float(self.cs[s]) for s in self.symbols}) + lev = pd.Series({s: float(self.lev[s]) for s in self.symbols}) + accepted_notional = accepted_units.mul(closes, axis=0).mul(cs, axis=1) + target_notional = target_units.mul(closes, axis=0).mul(cs, axis=1) + abs_accepted = accepted_notional.abs() + initial_margin = abs_accepted.div(lev, axis=1).sum(axis=1) + maintenance_margin = abs_accepted.sum(axis=1) * float(self.maintenance_ratio) + out = pd.DataFrame( + { + "long_notional": accepted_notional.clip(lower=0.0).sum(axis=1), + "short_notional": accepted_notional.clip(upper=0.0).abs().sum(axis=1), + "gross_notional": abs_accepted.sum(axis=1), + "net_notional": accepted_notional.sum(axis=1), + "target_gross_notional": target_notional.abs().sum(axis=1), + "initial_margin": initial_margin, + "maintenance_margin": maintenance_margin, + "equity": equity, + "available_equity_after_im": equity - initial_margin, + "buying_power": equity * float(np.mean(list(self.lev.values()))), + }, + index=self._idx, + ) + out["gross_leverage"] = out["gross_notional"] / out["equity"].replace(0.0, np.nan) + out["net_exposure_pct"] = out["net_notional"] / out["equity"].replace(0.0, np.nan) + return out.fillna(0.0) + + def _build_rebalance_report( + self, + target_units: pd.DataFrame, + accepted_units: pd.DataFrame, + closes: pd.DataFrame, + ) -> pd.DataFrame: + diff = target_units - accepted_units + cs = pd.Series({s: float(self.cs[s]) for s in self.symbols}) + mask = diff.abs() > 1e-10 + if not mask.to_numpy().any(): + return pd.DataFrame( + columns=[ + "timestamp", + "symbol", + "target_units", + "accepted_units", + "unit_diff", + "notional_diff", + "reason", + ] + ) + notional_diff = diff.mul(closes, axis=0).mul(cs, axis=1) + stacked = diff.where(mask).stack(future_stack=True).dropna() + index = stacked.index + target_stacked = target_units.stack(future_stack=True) + accepted_stacked = accepted_units.stack(future_stack=True) + notional_stacked = notional_diff.stack(future_stack=True) + return pd.DataFrame( + { + "timestamp": index.get_level_values(0), + "symbol": index.get_level_values(1), + "target_units": target_stacked.reindex(index).to_numpy(dtype=float), + "accepted_units": accepted_stacked.reindex(index).to_numpy(dtype=float), + "unit_diff": stacked.to_numpy(dtype=float), + "notional_diff": notional_stacked.reindex(index).to_numpy(dtype=float), + "reason": "margin_or_portfolio_gate", + } + ) + + @property + def result(self) -> BacktestResult: + if self._result is None: + self.run() + return self._result + + # ── analytics ───────────────────────────────────────────────────────────── + + def print_metrics(self) -> None: + rpt = full_report(self.result, self.trading_days) + syms = ", ".join(self.symbols) + + lines = [ + ("Symbols", syms), + ("Asset Type", self.asset_type.upper()), + ("Mode", self.mode), + ("Initial Capital", f"${rpt['initial_capital']:>14,.0f}"), + ("Final Equity", f"${rpt['final_equity']:>14,.2f}"), + ("Total Return", f"{rpt['total_return_pct']:>+13.2f}%"), + ("CAGR", f"{rpt['cagr_pct']:>+13.2f}%"), + ("Sharpe Ratio", f"{rpt['sharpe']:>14.3f}"), + ("Sortino Ratio", f"{rpt['sortino']:>14.3f}"), + ("Calmar Ratio", f"{rpt['calmar']:>14.3f}"), + ("Max Drawdown", f"{rpt['max_drawdown_pct']:>13.2f}%"), + ("Profit Factor", f"{rpt['profit_factor']:>14.3f}"), + ("Long Hit Rate", f"{rpt['long_hitrate_pct']:>13.2f}%"), + ("Short Hit Rate", f"{rpt['short_hitrate_pct']:>13.2f}%"), + ("Number of Trades", f"{rpt['num_trades']:>14,d}"), + ("Liquidated", f"{'Yes' if rpt['liquidated'] else 'No':>14}"), + ] + col_width = max(len(k) for k, _ in lines) + print() + for key, val in lines: + print(f" {key:<{col_width}} {val}") + print() + + def analyze(self, theme: str = "dark", figsize: tuple = (14, 6)) -> None: + from .viz.plots import quick_plot + + self.print_metrics() + quick_plot(self.result, theme=theme, figsize=figsize) + + def tearsheet( + self, + theme: str = "dark", + figsize: tuple = (16, 20), + benchmark: Optional[pd.Series] = None, + ) -> None: + from .viz.plots import tearsheet as _tearsheet + + _tearsheet( + self.result, + theme = theme, + figsize = figsize, + trading_days = self.trading_days, + benchmark = benchmark, + ) + + def hitrate_per_symbol(self) -> Dict[str, Tuple[float, float]]: + """Returns {sym: (long_hr_pct, short_hr_pct)} for every symbol.""" + out = {} + r = self.result + for s in self.symbols: + pos = r.positions[f"Position_{s}"] + cl = r.closes[f"Close_{s}"] + ret = cl.pct_change().fillna(0) + long_mask = pos > 0 + short_mask = pos < 0 + lw = ((ret > 0) & long_mask).sum() + lt = long_mask.sum() + sw = ((ret < 0) & short_mask).sum() + st = short_mask.sum() + out[s] = ( + round(lw / lt * 100, 2) if lt > 0 else 0.0, + round(sw / st * 100, 2) if st > 0 else 0.0, + ) + return out + + def export_log(self, filename: str = "portfolio_log.csv") -> None: + r = self.result + log = pd.DataFrame({ + "cumulative_return": (r.equity / self.initial_capital - 1) * 100, + "daily_return": r.returns * 100, + }, index=r.equity.index) + for s in self.symbols: + log[f"position_{s}"] = r.positions[f"Position_{s}"] + log[f"close_{s}"] = r.closes[f"Close_{s}"] + log.to_csv(filename) + print(f"Portfolio log exported → {filename}") diff --git a/src/quantbt/py.typed b/src/quantbt/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/quantbt/py.typed @@ -0,0 +1 @@ + diff --git a/src/quantbt/reporting/__init__.py b/src/quantbt/reporting/__init__.py new file mode 100644 index 0000000..a82ed98 --- /dev/null +++ b/src/quantbt/reporting/__init__.py @@ -0,0 +1,38 @@ +"""Reporting helpers for QuantBT result artifacts.""" + +from .arbitrage_audit import build_arbitrage_domain_audit, compare_native_arbitrage_results +from .nautilus_bundle import export_nautilus_report_bundle +from .nautilus_certification import ( + NautilusToleranceProfile, + build_nautilus_certification_profile, + write_nautilus_certification_artifacts, +) +from .nautilus_diagnostics import build_nautilus_pct_equity_diagnostic +from .parity import ( + build_native_nautilus_parity_report, + build_nautilus_depth_execution_report, + build_nautilus_depth_parity_summary, + summarize_native_nautilus_parity_report, +) +from .portfolio_audit import build_portfolio_domain_audit +from .portfolio_nautilus import ( + build_portfolio_nautilus_position_report, + build_portfolio_nautilus_validation_report, +) + +__all__ = [ + "build_arbitrage_domain_audit", + "build_native_nautilus_parity_report", + "build_nautilus_depth_execution_report", + "build_nautilus_depth_parity_summary", + "build_nautilus_certification_profile", + "build_nautilus_pct_equity_diagnostic", + "build_portfolio_domain_audit", + "build_portfolio_nautilus_position_report", + "build_portfolio_nautilus_validation_report", + "compare_native_arbitrage_results", + "export_nautilus_report_bundle", + "NautilusToleranceProfile", + "summarize_native_nautilus_parity_report", + "write_nautilus_certification_artifacts", +] diff --git a/src/quantbt/reporting/arbitrage_audit.py b/src/quantbt/reporting/arbitrage_audit.py new file mode 100644 index 0000000..28ebf4b --- /dev/null +++ b/src/quantbt/reporting/arbitrage_audit.py @@ -0,0 +1,211 @@ +""" +Arbitrage domain audit helpers. + +These functions validate accounting invariants on completed native arbitrage +results. They are intentionally report-level checks, not execution logic. +""" + +from __future__ import annotations + +from typing import Dict, Iterable, Optional + +import numpy as np +import pandas as pd + +from ..core.results import BacktestResultV2 + + +def build_arbitrage_domain_audit( + result: BacktestResultV2, + *, + tolerance: float = 1e-9, + raise_on_fail: bool = False, +) -> Dict: + """ + Return a compact audit summary for a native arbitrage result. + + The audit checks that package PnL reconciles to equity deltas, leg PnL sums + to package PnL, fees reconcile to the result fee series, target-unit symbols + match result symbols, and final target/position units are flat when the + strategy exits. + """ + metadata = result.metadata or {} + missing = [ + name + for name in ("package_pnl_report", "leg_pnl_report", "package_target_units") + if name not in metadata or metadata.get(name) is None + ] + + package_report = _frame(metadata.get("package_pnl_report")) + leg_report = _frame(metadata.get("leg_pnl_report")) + target_units = _frame(metadata.get("package_target_units")) + rejection_report = _frame(metadata.get("package_rejection_report")) + order_report = _frame(metadata.get("order_report", metadata.get("orders_report"))) + + max_package_residual = _max_abs(package_report.get("pnl_residual")) if not package_report.empty else np.nan + leg_vs_package_diff = np.nan + if not leg_report.empty and not package_report.empty and "timestamp" in leg_report and "total_pnl" in leg_report: + leg_sum = leg_report.groupby(pd.to_datetime(leg_report["timestamp"], utc=True), sort=False)["total_pnl"].sum() + pkg = _series_from_report(package_report, "package_pnl") + leg_vs_package_diff = _max_abs((leg_sum.reindex(pkg.index, fill_value=0.0) - pkg).to_numpy(dtype=float)) + + fee_residual = np.nan + if not leg_report.empty and "fee" in leg_report: + fee_sum = float(pd.to_numeric(leg_report["fee"], errors="coerce").fillna(0.0).sum()) + result_fee = float(result.fees.sum()) if isinstance(result.fees, pd.Series) and not result.fees.empty else 0.0 + fee_residual = abs(fee_sum - result_fee) + + target_symbols = set(map(str, target_units.columns)) if not target_units.empty else set() + result_symbols = set(map(str, result.symbols)) + target_symbols_match = bool(target_symbols) and target_symbols == result_symbols + final_target_gross = float(target_units.iloc[-1].abs().sum()) if not target_units.empty else np.nan + final_position_gross = _final_position_gross(result.positions, result.symbols) + + checks = { + "has_required_reports": not missing, + "package_pnl_residual_ok": _ok(max_package_residual, tolerance), + "leg_pnl_reconciles_to_package": _ok(leg_vs_package_diff, tolerance), + "fees_reconcile": _ok(fee_residual, tolerance), + "target_symbols_match": target_symbols_match, + "final_target_flat": _ok(final_target_gross, tolerance), + "final_position_flat": _ok(final_position_gross, tolerance), + } + passed = all(checks.values()) + status = "pass" if passed else "fail" + audit = { + "status": status, + "passed": passed, + "tolerance": float(tolerance), + "engine": metadata.get("engine"), + "backend": metadata.get("backend"), + "arb_id": metadata.get("arb_id"), + "arb_type": metadata.get("arb_type"), + "missing_reports": missing, + "checks": checks, + "max_abs_package_pnl_residual": _float_or_none(max_package_residual), + "max_abs_leg_vs_package_pnl_diff": _float_or_none(leg_vs_package_diff), + "max_abs_fee_residual": _float_or_none(fee_residual), + "final_gross_target_units": _float_or_none(final_target_gross), + "final_gross_position_units": _float_or_none(final_position_gross), + "target_symbols": sorted(target_symbols), + "result_symbols": sorted(result_symbols), + "order_count": int(len(order_report)), + "fill_count": int(len(getattr(result, "fills", ()) or ())), + "rejection_count": int(len(rejection_report)), + } + if raise_on_fail and not passed: + raise AssertionError(f"arbitrage domain audit failed: {audit}") + return audit + + +def compare_native_arbitrage_results( + event_result: BacktestResultV2, + vectorized_result: BacktestResultV2, + *, + tolerance: float = 1e-9, + raise_on_fail: bool = False, +) -> Dict: + """ + Compare native event and native vectorized arbitrage outputs. + + This is a high-signal parity check for mock/golden tests. It does not + require identical order reports, only accounting-equivalent equity, + positions, target units, and package residuals. + """ + equity_diff = _max_abs((event_result.equity - vectorized_result.equity.reindex(event_result.equity.index)).to_numpy(dtype=float)) + position_diff = _max_abs((_position_units(event_result) - _position_units(vectorized_result).reindex(event_result.equity.index)).to_numpy(dtype=float)) + + event_target = _frame(event_result.metadata.get("package_target_units")) + vector_target = _frame(vectorized_result.metadata.get("package_target_units")) + target_diff = np.nan + if not event_target.empty and not vector_target.empty: + target_diff = _max_abs((event_target - vector_target.reindex(event_target.index)).to_numpy(dtype=float)) + + event_package = _series_from_report(_frame(event_result.metadata.get("package_pnl_report")), "pnl_residual") + vector_package = _series_from_report(_frame(vectorized_result.metadata.get("package_pnl_report")), "pnl_residual") + residual_diff = np.nan + if not event_package.empty and not vector_package.empty: + residual_diff = max(_max_abs(event_package.to_numpy(dtype=float)), _max_abs(vector_package.to_numpy(dtype=float))) + + checks = { + "equity_matches": _ok(equity_diff, tolerance), + "positions_match": _ok(position_diff, tolerance), + "target_units_match": _ok(target_diff, tolerance), + "package_residuals_ok": _ok(residual_diff, tolerance), + } + passed = all(checks.values()) + report = { + "status": "pass" if passed else "fail", + "passed": passed, + "tolerance": float(tolerance), + "event_engine": event_result.metadata.get("engine"), + "vectorized_engine": vectorized_result.metadata.get("engine"), + "checks": checks, + "max_abs_equity_diff": _float_or_none(equity_diff), + "max_abs_position_diff": _float_or_none(position_diff), + "max_abs_target_unit_diff": _float_or_none(target_diff), + "max_abs_package_residual": _float_or_none(residual_diff), + } + if raise_on_fail and not passed: + raise AssertionError(f"native arbitrage parity failed: {report}") + return report + + +def _frame(value) -> pd.DataFrame: + return value if isinstance(value, pd.DataFrame) else pd.DataFrame() + + +def _series_from_report(report: pd.DataFrame, column: str) -> pd.Series: + if report.empty or column not in report: + return pd.Series(dtype=float) + series = pd.to_numeric(report[column], errors="coerce").fillna(0.0) + if isinstance(report.index, pd.DatetimeIndex): + series.index = pd.to_datetime(report.index, utc=True) + return series.astype(float) + + +def _position_units(result: BacktestResultV2) -> pd.DataFrame: + out = result.positions.copy() + rename = {col: str(col).replace("Position_", "", 1) for col in out.columns} + out = out.rename(columns=rename) + return out.reindex(columns=list(result.symbols)).fillna(0.0) + + +def _final_position_gross(positions: pd.DataFrame, symbols: Iterable[str]) -> float: + if positions.empty: + return 0.0 + frame = positions.rename(columns={col: str(col).replace("Position_", "", 1) for col in positions.columns}) + cols = [symbol for symbol in symbols if symbol in frame.columns] + if not cols: + return float(positions.iloc[-1].abs().sum()) + return float(frame[cols].iloc[-1].abs().sum()) + + +def _max_abs(value) -> float: + if value is None: + return np.nan + arr = np.asarray(value, dtype=np.float64) + arr = arr[np.isfinite(arr)] + if arr.size == 0: + return np.nan + return float(np.max(np.abs(arr))) + + +def _ok(value: Optional[float], tolerance: float) -> bool: + if value is None: + return False + try: + numeric = float(value) + except (TypeError, ValueError): + return False + return bool(np.isfinite(numeric) and numeric <= float(tolerance)) + + +def _float_or_none(value): + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not np.isfinite(numeric): + return None + return numeric diff --git a/src/quantbt/reporting/nautilus_bundle.py b/src/quantbt/reporting/nautilus_bundle.py new file mode 100644 index 0000000..8dc5e05 --- /dev/null +++ b/src/quantbt/reporting/nautilus_bundle.py @@ -0,0 +1,758 @@ +""" +Nautilus trustee report bundle exporter. + +This module is intentionally outside the backtest engines. It consumes a +completed Nautilus-backed `BacktestResultV2` and writes human/audit artifacts: +raw Nautilus reports, normalized trade logs, a run manifest, and optional +QuantStats HTML. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import platform +import subprocess +from typing import Any, Dict, Iterable, List, Optional, Tuple + +import numpy as np +import pandas as pd + +from ..core.results import BacktestResultV2 + + +REPORT_FILES = ( + "equity_curve.csv", + "returns.csv", + "account_report.csv", + "orders_report.csv", + "fills_report.csv", + "positions_report.csv", + "trade_log.csv", + "fill_log.txt", + "metrics_summary.json", + "run_manifest.json", + "config.json", +) + + +def export_nautilus_report_bundle( + result: BacktestResultV2, + output_dir: str | Path, + strategy_id: str, + config: Optional[Dict[str, Any]] = None, + benchmark_returns: Optional[pd.Series] = None, + make_quantstats: bool = True, + quantstats_frequency: str = "1D", + quantstats_periods_per_year: int = 365, + print_fills: bool = False, + fill_log_limit: int = 500, + fill_log_mode: str = "fills_only", +) -> Path: + """ + Export a professional evidence bundle for a Nautilus-backed backtest. + + Parameters + ---------- + result: + Completed `BacktestResultV2`, normally returned by + `QuantBTEndpoint.nautilus_validation(...).simulate(...)`. + output_dir: + Parent folder where `report_{strategy_id}_{timestamp}` is created. + strategy_id: + Stable identifier for the run/report. + config: + Optional JSON-serializable run config to save as `config.json`. + benchmark_returns: + Optional benchmark returns passed through to QuantStats when supported. + make_quantstats: + Generate `quantstats_daily.html` when quantstats is installed. + quantstats_frequency: + Resampling frequency for QuantStats input. Default is daily. + quantstats_periods_per_year: + Annualization factor passed to QuantStats. Default is 365 for crypto. + print_fills: + Print bounded fill/order event lines to stdout while exporting. + fill_log_limit: + Maximum number of human-readable event lines to write/print. + fill_log_mode: + `fills_only`, `order_events`, or `bars_debug`. `bars_debug` is bounded + by `fill_log_limit` and uses position-change rows, not every no-op bar. + + Returns + ------- + Path + The created report directory. + """ + if not isinstance(result, BacktestResultV2): + raise TypeError("export_nautilus_report_bundle requires BacktestResultV2") + if not strategy_id: + raise ValueError("strategy_id is required") + if fill_log_limit < 0: + raise ValueError("fill_log_limit must be >= 0") + + run_id = _run_id(strategy_id) + report_dir = Path(output_dir) / f"report_{_slug(strategy_id)}_{run_id}" + report_dir.mkdir(parents=True, exist_ok=True) + + metadata = dict(result.metadata or {}) + config_payload = _config_payload_from_result(result=result, metadata=metadata) + if config: + config_payload["annotations"] = {**dict(config_payload.get("annotations", {})), **dict(config)} + + account_report = _report_frame(metadata.get("account_report")) + orders_report = _report_frame(metadata.get("orders_report", metadata.get("order_report"))) + fills_report = _report_frame(metadata.get("fills_report")) + positions_report = _report_frame(metadata.get("positions_report")) + + equity_curve = _equity_frame(result) + returns_frame = pd.DataFrame({"timestamp": result.returns.index, "returns": result.returns.to_numpy(dtype=float)}) + trade_log = build_nautilus_trade_log(positions_report, fills_report, strategy_id=strategy_id) + fill_lines = format_nautilus_event_log( + fills_report=fills_report, + orders_report=orders_report, + positions=result.positions, + mode=fill_log_mode, + limit=fill_log_limit, + ) + + _write_frame(equity_curve, report_dir / "equity_curve.csv") + _write_frame(returns_frame, report_dir / "returns.csv") + _write_frame(account_report, report_dir / "account_report.csv") + _write_frame(orders_report, report_dir / "orders_report.csv") + _write_frame(fills_report, report_dir / "fills_report.csv") + _write_frame(positions_report, report_dir / "positions_report.csv") + _write_frame(trade_log, report_dir / "trade_log.csv") + (report_dir / "fill_log.txt").write_text("\n".join(fill_lines) + ("\n" if fill_lines else ""), encoding="utf-8") + + if print_fills: + for line in fill_lines: + print(line) + + metrics_summary = _metrics_summary(result=result, trade_log=trade_log, metadata=metadata) + manifest = _run_manifest( + result=result, + strategy_id=strategy_id, + run_id=run_id, + metadata=metadata, + config=config_payload, + account_report=account_report, + orders_report=orders_report, + fills_report=fills_report, + positions_report=positions_report, + ) + + if make_quantstats: + _try_write_quantstats_html( + result=result, + output_path=report_dir / "quantstats_daily.html", + frequency=quantstats_frequency, + periods_per_year=int(quantstats_periods_per_year), + benchmark_returns=benchmark_returns, + manifest=manifest, + ) + + _write_json(report_dir / "metrics_summary.json", metrics_summary) + _write_json(report_dir / "run_manifest.json", manifest) + _write_json(report_dir / "config.json", config_payload) + + return report_dir + + +def build_nautilus_trade_log( + positions_report: Optional[pd.DataFrame], + fills_report: Optional[pd.DataFrame], + strategy_id: str, +) -> pd.DataFrame: + """Build a stable closed-trade table from Nautilus positions/fills reports.""" + columns = [ + "strategy_id", + "symbol", + "exchange", + "instrument_id", + "position_type", + "open_datetime", + "close_datetime", + "entry_price", + "exit_price", + "quantity", + "realized_pnl", + "fees", + "duration_seconds", + "return_pct", + "order_ids", + ] + positions = _report_frame(positions_report) + if positions.empty: + return pd.DataFrame(columns=columns) + + fills = _report_frame(fills_report) + rows: List[Dict[str, Any]] = [] + for _, pos in positions.iterrows(): + close_dt = _coerce_timestamp_scalar(pos.get("ts_closed", pos.get("closed_time"))) + if pd.isna(close_dt): + continue + instrument_id = str(pos.get("instrument_id", "")) + symbol, exchange = _instrument_parts(instrument_id) + open_dt = _coerce_timestamp_scalar(pos.get("ts_opened", pos.get("opened_time"))) + entry_price = _coerce_float(pos.get("avg_px_open", pos.get("entry_price", np.nan))) + exit_price = _coerce_float(pos.get("avg_px_close", pos.get("exit_price", np.nan))) + quantity = _coerce_float(pos.get("quantity", pos.get("signed_qty", pos.get("qty", np.nan)))) + realized_pnl = _coerce_money(pos.get("realized_pnl", 0.0)) + fees = _fees_for_position(fills=fills, instrument_id=instrument_id, open_dt=open_dt, close_dt=close_dt) + side = _position_type(pos) + duration = _duration_seconds(open_dt, close_dt) + return_pct = _position_return_pct(side=side, entry_price=entry_price, exit_price=exit_price) + + rows.append( + { + "strategy_id": strategy_id, + "symbol": symbol, + "exchange": exchange, + "instrument_id": instrument_id, + "position_type": side, + "open_datetime": open_dt, + "close_datetime": close_dt, + "entry_price": entry_price, + "exit_price": exit_price, + "quantity": quantity, + "realized_pnl": realized_pnl, + "fees": fees, + "duration_seconds": duration, + "return_pct": return_pct, + "order_ids": _order_ids_for_position(fills=fills, instrument_id=instrument_id, open_dt=open_dt, close_dt=close_dt), + } + ) + + out = pd.DataFrame(rows, columns=columns) + if not out.empty: + out = out.sort_values(["close_datetime", "open_datetime"], kind="stable").reset_index(drop=True) + return out + + +def format_nautilus_event_log( + fills_report: Optional[pd.DataFrame] = None, + orders_report: Optional[pd.DataFrame] = None, + positions: Optional[pd.DataFrame] = None, + mode: str = "fills_only", + limit: int = 500, +) -> List[str]: + """Return bounded human-readable event lines for console/file logging.""" + mode = str(mode).lower().strip() + if mode not in {"fills_only", "order_events", "bars_debug"}: + raise ValueError("fill_log_mode must be fills_only, order_events, or bars_debug") + if limit <= 0: + return [] + + if mode == "bars_debug": + return _position_change_lines(positions, limit=limit) + + report = _report_frame(fills_report) + source = "FILL" + if (report.empty or mode == "order_events") and orders_report is not None: + order_report = _report_frame(orders_report) + if not order_report.empty: + report = order_report + source = "ORDER" + if report.empty: + return [] + + lines: List[str] = [] + for _, row in report.head(limit).iterrows(): + ts = _event_timestamp(row) + side = _event_side(row) + qty = _event_qty(row) + instrument = str(row.get("instrument_id", row.get("instrument", ""))) + price = _event_price(row) + fee = _event_fee(row) + lines.append(f"{ts} {source} {side} {qty:g} {instrument} @ {price:g} fee={fee:g}") + return lines + + +def _try_write_quantstats_html( + result: BacktestResultV2, + output_path: Path, + frequency: str, + periods_per_year: int, + benchmark_returns: Optional[pd.Series], + manifest: Dict[str, Any], +) -> None: + manifest["quantstats_frequency"] = frequency + manifest["quantstats_periods_per_year"] = int(periods_per_year) + try: + import quantstats as qs + except Exception as exc: + manifest["quantstats_status"] = f"skipped: {type(exc).__name__}: {exc}" + return + + returns = _resampled_returns_for_quantstats(result.equity, frequency=frequency) + if returns.empty: + manifest["quantstats_status"] = "skipped: empty returns" + return + try: + qs.reports.html( + returns, + benchmark=benchmark_returns, + output=str(output_path), + title=f"QuantBT Nautilus Report - {manifest.get('strategy_id', '')}", + periods_per_year=periods_per_year, + ) + manifest["quantstats_status"] = "written" + manifest["quantstats_file"] = output_path.name + except Exception as exc: + manifest["quantstats_status"] = f"failed: {type(exc).__name__}: {exc}" + + +def _resampled_returns_for_quantstats(equity: pd.Series, frequency: str = "1D") -> pd.Series: + eq = equity.copy() + eq.index = pd.to_datetime(eq.index, utc=True) + eq = pd.to_numeric(eq, errors="coerce").dropna() + if eq.empty: + return pd.Series(dtype=float) + sampled = eq.resample(frequency).last().dropna() + return sampled.pct_change().replace([np.inf, -np.inf], np.nan).dropna() + + +def _equity_frame(result: BacktestResultV2) -> pd.DataFrame: + equity = pd.to_numeric(result.equity, errors="coerce") + returns = pd.to_numeric(result.returns.reindex(result.equity.index), errors="coerce").fillna(0.0) + peak = equity.cummax() + drawdown = (equity / peak.replace(0.0, np.nan) - 1.0).fillna(0.0) + return pd.DataFrame( + { + "timestamp": result.equity.index, + "equity": equity.to_numpy(dtype=float), + "returns": returns.to_numpy(dtype=float), + "drawdown": drawdown.to_numpy(dtype=float), + } + ) + + +def _metrics_summary(result: BacktestResultV2, trade_log: pd.DataFrame, metadata: Dict[str, Any]) -> Dict[str, Any]: + equity = pd.to_numeric(result.equity, errors="coerce").dropna() + final_equity = float(equity.iloc[-1]) if not equity.empty else float("nan") + total_return_pct = (final_equity / float(result.initial_capital) - 1.0) * 100.0 if result.initial_capital else float("nan") + drawdown = result.drawdown if len(result.equity) else pd.Series(dtype=float) + orders_report = _report_frame(metadata.get("orders_report", metadata.get("order_report"))) + status_counts = _order_status_counts(orders_report) + return { + "initial_capital": float(result.initial_capital), + "final_equity": final_equity, + "total_return_pct": float(total_return_pct), + "max_drawdown_pct": float(drawdown.max() * 100.0) if not drawdown.empty else 0.0, + "input_mode": metadata.get("input_mode"), + "order_count_input": _safe_int(metadata.get("order_count_input")), + "orders_count": int(metadata.get("orders_count", 0) or 0), + "fills_count": int(metadata.get("fills_count", 0) or 0), + "positions_count": int(metadata.get("positions_count", 0) or 0), + "cancelled_count": status_counts["cancelled"], + "rejected_count": status_counts["rejected"], + "trade_log_count": int(len(trade_log)), + "liquidated": bool(result.liquidated), + } + + +def _run_manifest( + result: BacktestResultV2, + strategy_id: str, + run_id: str, + metadata: Dict[str, Any], + config: Dict[str, Any], + account_report: pd.DataFrame, + orders_report: pd.DataFrame, + fills_report: pd.DataFrame, + positions_report: pd.DataFrame, +) -> Dict[str, Any]: + idx = result.equity.index + status_counts = _order_status_counts(orders_report) + return { + "strategy_id": strategy_id, + "run_id": run_id, + "created_at": datetime.now(timezone.utc).isoformat(), + "backend": metadata.get("backend", "nautilus"), + "engine": metadata.get("engine", "NautilusTrader BacktestEngine"), + "execution_model": "event-driven bar execution", + "instrument_id": metadata.get("instrument_id") or _first_symbol(result), + "timeframe": metadata.get("timeframe") or _bar_timeframe(metadata.get("bar_type")), + "data_start": str(idx[0]) if len(idx) else None, + "data_end": str(idx[-1]) if len(idx) else None, + "bar_count": int(len(idx)), + "signal_count": metadata.get("signal_count"), + "signal_changes": metadata.get("signal_changes"), + "input_mode": metadata.get("input_mode"), + "order_count_input": _safe_int(metadata.get("order_count_input")), + "initial_capital": float(result.initial_capital), + "leverage": float(result.leverage), + "alloc_per_trade": metadata.get("trade_notional", metadata.get("alloc_per_trade")), + "sizing_mode": metadata.get("sizing_mode"), + "fee_rate": metadata.get("fee_rate"), + "use_funding": metadata.get("use_funding"), + "orders_count": int(metadata.get("orders_count", len(orders_report)) or 0), + "fills_count": int(metadata.get("fills_count", len(fills_report)) or 0), + "positions_count": int(metadata.get("positions_count", len(positions_report)) or 0), + "cancelled_count": status_counts["cancelled"], + "rejected_count": status_counts["rejected"], + "account_report_rows": int(len(account_report)), + "account_final_equity": _safe_float(metadata.get("account_final_equity")), + "reconstructed_final_equity": _safe_float(metadata.get("reconstructed_final_equity")), + "account_reconstructed_diff": _safe_float(metadata.get("account_reconstructed_diff")), + "quantbt_git_commit": _git_commit(), + "nautilus_version": _package_version("nautilus_trader"), + "python_version": platform.python_version(), + "data_hash": _hash_frame(result.closes), + "signal_hash": metadata.get("signal_hash"), + "config_keys": sorted(config.keys()), + } + + +def _config_payload_from_result(result: BacktestResultV2, metadata: Dict[str, Any]) -> Dict[str, Any]: + run_config = dict(metadata.get("run_config") or {}) + account = dict(run_config.get("account") or {}) + execution = dict(run_config.get("execution") or {}) + fees = dict(run_config.get("fees") or {}) + sizing = dict(run_config.get("sizing") or {}) + funding = dict(run_config.get("funding") or {}) + nautilus = dict(run_config.get("nautilus") or {}) + + requested_fee_rate = _safe_float(metadata.get("fee_rate")) + if requested_fee_rate is None: + requested_fee_rate = _safe_float(fees.get("one_way_fee_rate")) + requested_slippage = _safe_float(metadata.get("slippage")) + if requested_slippage is None: + requested_slippage = _safe_float(execution.get("legacy_slippage_rate")) + requested_slippage_bps = _safe_float(metadata.get("slippage_bps")) + if requested_slippage_bps is None: + requested_slippage_bps = _safe_float(execution.get("slippage_bps")) + + return { + "schema_version": 2, + "backend": metadata.get("backend", "nautilus"), + "engine": metadata.get("engine", "NautilusTrader BacktestEngine"), + "instrument": { + "instrument_id": metadata.get("instrument_id") or _first_symbol(result), + "bar_type": metadata.get("bar_type"), + "timeframe": metadata.get("timeframe") or _bar_timeframe(metadata.get("bar_type")), + }, + "effective_account": { + "initial_capital": _safe_float(metadata.get("initial_capital")) or float(result.initial_capital), + "leverage": _safe_float(metadata.get("leverage")) or float(result.leverage), + "maintenance_ratio": _safe_float(metadata.get("maintenance_ratio")), + "margin_mode": account.get("margin_mode"), + "oms_mode": account.get("oms_mode"), + "base_currency": account.get("base_currency"), + }, + "effective_sizing": { + "sizing_mode": metadata.get("sizing_mode") or sizing.get("hedge_type"), + "hedge_type": sizing.get("hedge_type"), + "trade_notional": metadata.get("trade_notional"), + "alloc_per_trade": metadata.get("alloc_per_trade", sizing.get("alloc_per_trade")), + "use_pyramiding": metadata.get("use_pyramiding", sizing.get("use_pyramiding")), + "contract_size": sizing.get("contract_size"), + "contract_size_note": "contract_size is a multiplier for notional/PnL, not the exchange lot size", + "quantity_constraints": { + "qty_step": metadata.get("qty_step"), + "lot_size": metadata.get("lot_size", metadata.get("qty_step")), + "min_qty": metadata.get("min_qty"), + "min_notional": metadata.get("min_notional"), + "price_increment": metadata.get("price_increment"), + "note": "Use qty_step/lot_size/min_qty/min_notional for Binance-style fractional order constraints.", + }, + }, + "effective_fees": { + "requested_fee_rate": requested_fee_rate, + "requested_fee_convention": "one_way", + "requested_fee_source": "endpoint.fee_rate", + "legacy_fee_round_trip_ignored": fees.get("round_trip_fee"), + "applied_by": "NautilusTrader MakerTakerFeeModel", + "custom_fee_rate_applied_to_nautilus": False, + }, + "effective_execution": { + "requested_slippage_rate": requested_slippage, + "requested_slippage_bps": requested_slippage_bps, + "requested_slippage_source": "endpoint.slippage", + "applied_by": "NautilusTrader bar market execution", + "custom_slippage_applied_to_nautilus": False, + "fill_price_policy": execution.get("fill_price_policy"), + "same_bar_policy": execution.get("same_bar_policy"), + "allow_partial_fill": execution.get("allow_partial_fill"), + "reject_on_insufficient_margin": execution.get("reject_on_insufficient_margin"), + }, + "funding": { + "use_funding": metadata.get("use_funding", funding.get("use_funding")), + "funding_rate": metadata.get("funding_rate", funding.get("funding_rate")), + }, + "nautilus": nautilus, + "diagnostics": { + "orders_count": int(metadata.get("orders_count", 0) or 0), + "fills_count": int(metadata.get("fills_count", 0) or 0), + "positions_count": int(metadata.get("positions_count", 0) or 0), + }, + "annotations": {}, + } + + +def _report_frame(value: Any) -> pd.DataFrame: + if isinstance(value, pd.DataFrame): + return value.copy() + if value is None: + return pd.DataFrame() + try: + return pd.DataFrame(value).copy() + except Exception: + return pd.DataFrame() + + +def _order_status_counts(orders_report: pd.DataFrame) -> Dict[str, int]: + if orders_report.empty or "status" not in orders_report: + return {"cancelled": 0, "rejected": 0} + status = orders_report["status"].astype(str).str.upper() + return { + "cancelled": int(status.isin({"CANCELED", "CANCELLED"}).sum()), + "rejected": int(status.eq("REJECTED").sum()), + } + + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or pd.isna(value): + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _write_frame(frame: pd.DataFrame, path: Path) -> None: + frame.copy().to_csv(path, index=False) + + +def _write_json(path: Path, payload: Dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8") + + +def _run_id(strategy_id: str) -> str: + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"{ts}_{_hash_text(strategy_id + ts)[:8]}" + + +def _slug(value: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in str(value).strip()) + return cleaned.strip("_") or "strategy" + + +def _hash_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _hash_frame(frame: pd.DataFrame) -> str: + try: + payload = { + "rows": int(len(frame)), + "columns": list(frame.columns), + "start": str(frame.index[0]) if len(frame) else None, + "end": str(frame.index[-1]) if len(frame) else None, + } + return _hash_text(json.dumps(payload, sort_keys=True, default=str)) + except Exception: + return "unavailable" + + +def _git_commit() -> str: + try: + root = Path(__file__).resolve().parents[1] + completed = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(root), + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + except Exception: + return "unavailable" + + +def _package_version(package: str) -> str: + try: + import importlib.metadata as metadata + + return metadata.version(package) + except Exception: + return "unavailable" + + +def _first_symbol(result: BacktestResultV2) -> Optional[str]: + return result.symbols[0] if result.symbols else None + + +def _bar_timeframe(bar_type: Any) -> Optional[str]: + if not bar_type: + return None + parts = str(bar_type).split("-") + if len(parts) >= 4: + return "-".join(parts[-4:-2]) + return None + + +def _instrument_parts(instrument_id: str) -> Tuple[Optional[str], Optional[str]]: + if not instrument_id: + return None, None + if "." in instrument_id: + main, exchange = instrument_id.split(".", 1) + else: + main, exchange = instrument_id, None + symbol = main.split("-")[0] if "-" in main else main + return symbol, exchange + + +def _position_type(row: pd.Series) -> Optional[str]: + entry = str(row.get("entry", row.get("side", ""))).upper() + if entry in {"BUY", "LONG"}: + return "LONG" + if entry in {"SELL", "SHORT"}: + return "SHORT" + side = str(row.get("position_side", "")).upper() + if side in {"LONG", "SHORT"}: + return side + return None + + +def _coerce_timestamp_scalar(value: Any) -> pd.Timestamp: + return pd.to_datetime(value, utc=True, errors="coerce") + + +def _coerce_float(value: Any) -> float: + try: + return float(value) + except Exception: + return _coerce_money(value) + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None: + return None + out = float(value) + if np.isnan(out): + return None + return out + except Exception: + return None + + +def _coerce_money(value: Any) -> float: + if value is None: + return 0.0 + try: + return float(value) + except Exception: + text = str(value) + if text.startswith("[") and text.endswith("]"): + text = text.strip("[]").strip().strip("'").replace("'", "") + for token in text.replace(",", "").split(): + try: + return float(token) + except Exception: + continue + return 0.0 + + +def _duration_seconds(open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> Optional[float]: + if pd.isna(open_dt) or pd.isna(close_dt): + return None + return float((close_dt - open_dt).total_seconds()) + + +def _position_return_pct(side: Optional[str], entry_price: float, exit_price: float) -> Optional[float]: + if not np.isfinite(entry_price) or not np.isfinite(exit_price) or entry_price == 0.0: + return None + raw = (exit_price / entry_price - 1.0) * 100.0 + return float(raw if side != "SHORT" else -raw) + + +def _fees_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> float: + subset = _fills_for_position(fills, instrument_id, open_dt, close_dt) + if subset.empty: + return 0.0 + fee_cols = [col for col in ("commissions", "commission", "fee") if col in subset.columns] + if not fee_cols: + return 0.0 + return float(subset[fee_cols[0]].apply(_coerce_money).sum()) + + +def _order_ids_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> str: + subset = _fills_for_position(fills, instrument_id, open_dt, close_dt) + for col in ("client_order_id", "order_id", "venue_order_id"): + if col in subset.columns: + return ",".join(str(x) for x in subset[col].dropna().unique()) + return "" + + +def _fills_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> pd.DataFrame: + if fills.empty or pd.isna(open_dt) or pd.isna(close_dt): + return pd.DataFrame() + out = fills.copy() + if "instrument_id" in out.columns: + out = out[out["instrument_id"].astype(str) == instrument_id] + ts_col = _timestamp_column(out) + if ts_col is None: + return pd.DataFrame() + out["_ts"] = pd.to_datetime(out[ts_col], utc=True, errors="coerce") + return out[(out["_ts"] >= open_dt) & (out["_ts"] <= close_dt)] + + +def _timestamp_column(frame: pd.DataFrame) -> Optional[str]: + for col in ("ts_last", "ts_event", "ts_init", "timestamp", "time"): + if col in frame.columns: + return col + return None + + +def _event_timestamp(row: pd.Series) -> str: + for col in ("ts_last", "ts_event", "ts_init", "timestamp", "time"): + if col in row.index: + ts = _coerce_timestamp_scalar(row.get(col)) + if not pd.isna(ts): + return str(ts) + return "NaT" + + +def _event_side(row: pd.Series) -> str: + for col in ("side", "order_side", "entry"): + if col in row.index and str(row.get(col, "")).strip(): + return str(row.get(col)).upper() + return "UNKNOWN" + + +def _event_qty(row: pd.Series) -> float: + for col in ("filled_qty", "last_qty", "quantity", "qty"): + if col in row.index: + return abs(_coerce_float(row.get(col))) + return 0.0 + + +def _event_price(row: pd.Series) -> float: + for col in ("avg_px", "last_px", "price", "avg_px_open"): + if col in row.index: + return _coerce_float(row.get(col)) + return 0.0 + + +def _event_fee(row: pd.Series) -> float: + for col in ("commissions", "commission", "fee"): + if col in row.index: + return _coerce_money(row.get(col)) + return 0.0 + + +def _position_change_lines(positions: Optional[pd.DataFrame], limit: int) -> List[str]: + if positions is None or positions.empty: + return [] + pos = positions.copy().fillna(0.0) + changed = pos.diff().abs().sum(axis=1).fillna(pos.abs().sum(axis=1)) > 0.0 + lines: List[str] = [] + for ts, row in pos.loc[changed].head(limit).iterrows(): + state = ", ".join(f"{col}={float(value):g}" for col, value in row.items()) + lines.append(f"{pd.Timestamp(ts)} POSITION {state}") + return lines diff --git a/src/quantbt/reporting/nautilus_certification.py b/src/quantbt/reporting/nautilus_certification.py new file mode 100644 index 0000000..02f6a2b --- /dev/null +++ b/src/quantbt/reporting/nautilus_certification.py @@ -0,0 +1,226 @@ +"""Nautilus certification artifact helpers.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import json +from pathlib import Path +from typing import Any, Dict, Optional, Sequence + +import numpy as np +import pandas as pd + +from ..core.results import BacktestResultV2 +from .parity import build_native_nautilus_parity_report, summarize_native_nautilus_parity_report + + +@dataclass(frozen=True) +class NautilusToleranceProfile: + """Tolerance contract for native-vs-Nautilus certification artifacts.""" + + fill_price_tolerance: float = 1e-9 + fee_tolerance: float = 1e-9 + position_tolerance: float = 1e-9 + equity_tolerance: float = 1e-6 + quantity_tolerance: float = 1e-9 + slippage_tolerance: float = 1e-9 + + +def build_nautilus_certification_profile( + native_result: Optional[BacktestResultV2], + nautilus_result: BacktestResultV2, + *, + tolerance: NautilusToleranceProfile | Dict[str, float] | None = None, + workflow: str = "nautilus", +) -> Dict[str, Any]: + """ + Build a compact tolerance profile for a native-vs-Nautilus run. + + The profile is deliberately report-layer only. It never changes engine + accounting and is suitable for saved stakeholder bundles. + """ + tol = _coerce_tolerance(tolerance) + if native_result is None: + return { + "workflow": workflow, + "status": "reference_missing", + "passed": False, + "reason": "native reference result is required for tolerance certification", + "tolerance": asdict(tol), + "checks": {}, + } + + parity = build_native_nautilus_parity_report(native_result, nautilus_result) + summary = summarize_native_nautilus_parity_report( + parity, + fill_price_tolerance=tol.fill_price_tolerance, + fee_tolerance=tol.fee_tolerance, + position_tolerance=tol.position_tolerance, + equity_tolerance=tol.equity_tolerance, + ) + quantity_diff = _max_abs_quantity_diff(native_result, nautilus_result) + final_equity_diff = _final_equity_diff(native_result, nautilus_result) + max_equity_diff = max(float(summary.get("max_abs_equity_diff", 0.0)), final_equity_diff) + slippage_diff = float(summary.get("max_abs_fill_price_diff", 0.0)) + checks = { + "fill_price_within_tolerance": float(summary["max_abs_fill_price_diff"]) <= tol.fill_price_tolerance, + "fee_within_tolerance": float(summary["max_abs_fee_diff"]) <= tol.fee_tolerance, + "position_within_tolerance": float(summary["max_abs_position_diff"]) <= tol.position_tolerance, + "equity_within_tolerance": max_equity_diff <= tol.equity_tolerance, + "quantity_within_tolerance": quantity_diff <= tol.quantity_tolerance, + "slippage_within_tolerance": slippage_diff <= tol.slippage_tolerance, + } + passed = bool(all(checks.values())) + status = "pass" if passed else _profile_status(checks) + return { + "workflow": workflow, + "status": status, + "passed": passed, + "tolerance": asdict(tol), + "checks": checks, + "summary": summary, + "max_abs_quantity_diff": float(quantity_diff), + "max_abs_final_equity_diff": float(final_equity_diff), + "max_abs_equity_diff_including_final": float(max_equity_diff), + "max_abs_slippage_proxy_diff": float(slippage_diff), + "rows": int(len(parity)), + } + + +def write_nautilus_certification_artifacts( + *, + native_result: Optional[BacktestResultV2], + nautilus_result: BacktestResultV2, + report_dir: str | Path, + workflow: str, + tolerance: NautilusToleranceProfile | Dict[str, float] | None = None, + known_differences: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + """ + Write parity, tolerance, known-difference, and summary files into a bundle. + """ + path = Path(report_dir) + path.mkdir(parents=True, exist_ok=True) + tol = _coerce_tolerance(tolerance) + parity = ( + build_native_nautilus_parity_report(native_result, nautilus_result) + if native_result is not None + else pd.DataFrame() + ) + profile = build_nautilus_certification_profile( + native_result=native_result, + nautilus_result=nautilus_result, + tolerance=tol, + workflow=workflow, + ) + differences = list(known_differences or ()) + parity_path = path / "native_vs_nautilus_parity.csv" + profile_path = path / "tolerance_profile.json" + known_path = path / "known_differences.md" + summary_path = path / "certification_summary.json" + + parity.to_csv(parity_path, index=False) + _write_json(profile_path, profile) + known_path.write_text(_known_differences_markdown(workflow, differences), encoding="utf-8") + summary = { + "workflow": workflow, + "status": profile["status"], + "passed": profile["passed"], + "report_dir": str(path), + "files": { + "native_vs_nautilus_parity": parity_path.name, + "tolerance_profile": profile_path.name, + "known_differences": known_path.name, + }, + "known_differences_count": len(differences), + } + _write_json(summary_path, summary) + return { + **summary, + "tolerance_profile": profile, + "artifact_files": [parity_path.name, profile_path.name, known_path.name, summary_path.name], + } + + +def _coerce_tolerance(value: NautilusToleranceProfile | Dict[str, float] | None) -> NautilusToleranceProfile: + if value is None: + return NautilusToleranceProfile() + if isinstance(value, NautilusToleranceProfile): + return value + return NautilusToleranceProfile(**{key: float(val) for key, val in dict(value).items()}) + + +def _profile_status(checks: Dict[str, bool]) -> str: + if not checks.get("fill_price_within_tolerance", True) or not checks.get("quantity_within_tolerance", True): + return "execution_diff" + if not checks.get("position_within_tolerance", True): + return "position_diff" + if not checks.get("fee_within_tolerance", True) or not checks.get("equity_within_tolerance", True): + return "accounting_diff" + return "diff" + + +def _max_abs_quantity_diff(native_result: BacktestResultV2, nautilus_result: BacktestResultV2) -> float: + native_qty = _fill_quantities(native_result) + nautilus_qty = _fill_quantities(nautilus_result) + n = max(len(native_qty), len(nautilus_qty)) + if n == 0: + return 0.0 + left = np.zeros(n, dtype=float) + right = np.zeros(n, dtype=float) + left[: len(native_qty)] = native_qty + right[: len(nautilus_qty)] = nautilus_qty + return float(np.max(np.abs(left - right))) + + +def _final_equity_diff(native_result: BacktestResultV2, nautilus_result: BacktestResultV2) -> float: + if len(native_result.equity) == 0 or len(nautilus_result.equity) == 0: + return 0.0 + return float(abs(float(native_result.equity.iloc[-1]) - float(nautilus_result.equity.iloc[-1]))) + + +def _fill_quantities(result: BacktestResultV2) -> np.ndarray: + report = _frame((result.metadata or {}).get("fills_report")) + if report.empty: + report = _frame((result.metadata or {}).get("orders_report")) + if not report.empty: + if "status" in report: + report = report[report["status"].astype(str).str.upper().eq("FILLED")] + for col in ("filled_qty", "quantity", "qty"): + if col in report: + return pd.to_numeric(report[col], errors="coerce").fillna(0.0).to_numpy(dtype=float) + fills = getattr(result, "fills", ()) + if fills: + return np.asarray([float(getattr(fill, "qty", 0.0)) for fill in fills], dtype=float) + return np.asarray([], dtype=float) + + +def _frame(value: Any) -> pd.DataFrame: + if isinstance(value, pd.DataFrame): + return value.copy() + if value is None: + return pd.DataFrame() + try: + return pd.DataFrame(value).copy() + except Exception: + return pd.DataFrame() + + +def _known_differences_markdown(workflow: str, differences: Sequence[str]) -> str: + lines = [f"# Known Differences - {workflow}", ""] + if differences: + for item in differences: + lines.append(f"- {item}") + else: + lines.append("- None recorded for this certification run.") + lines.extend( + [ + "", + "These notes describe known adapter or venue-model differences. They do not override tolerance failures.", + ] + ) + return "\n".join(lines) + "\n" + + +def _write_json(path: Path, payload: Dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8") diff --git a/src/quantbt/reporting/nautilus_diagnostics.py b/src/quantbt/reporting/nautilus_diagnostics.py new file mode 100644 index 0000000..ac48f63 --- /dev/null +++ b/src/quantbt/reporting/nautilus_diagnostics.py @@ -0,0 +1,225 @@ +"""Nautilus validation diagnostics.""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd + +from ..adapters.nautilus.instruments import SUPPORTED_BINANCE_PERP_SPECS, normalize_binance_perp_symbol +from ..core.results import BacktestResultV2 + + +def build_nautilus_pct_equity_diagnostic( + result: BacktestResultV2, + *, + data: pd.DataFrame, + signal: pd.Series, + native_fee_round_trip: Optional[float] = None, + native_fee_one_way: Optional[float] = None, + native_use_funding: Optional[bool] = None, + native_slippage: Optional[float] = None, +) -> Dict: + """ + Compare a Nautilus `%_equity` validation run against native expectations. + + The helper is intentionally diagnostic-only: it does not claim that + Nautilus and native legacy should match. It highlights the most common + sources of divergence: fee convention/application, funding, slippage, + signal transitions, and Binance lot-size constraints. + """ + if not isinstance(result, BacktestResultV2): + raise TypeError("build_nautilus_pct_equity_diagnostic requires BacktestResultV2") + if "close" not in data: + raise ValueError("data must contain a close column") + + metadata = result.metadata or {} + idx = _utc_index(data.index) + sig = _align_signal(signal, idx) + use_pyramiding = bool(metadata.get("use_pyramiding", _nested(metadata, "run_config", "sizing", "use_pyramiding", default=True))) + effective_signal = sig.astype(float) if use_pyramiding else np.sign(sig.astype(float)) + transitions = effective_signal.ne(effective_signal.shift(1).fillna(0.0)) + transition_report = pd.DataFrame( + { + "timestamp": idx[transitions.to_numpy()], + "raw_signal": sig.loc[transitions].to_numpy(dtype=float), + "effective_signal": effective_signal.loc[transitions].to_numpy(dtype=float), + "close": data.reindex(idx)["close"].loc[transitions].to_numpy(dtype=float), + } + ) + + orders_count = int(metadata.get("orders_count", len(_frame(metadata.get("orders_report", metadata.get("order_report")))))) + fills_count = int(metadata.get("fills_count", len(_frame(metadata.get("fills_report"))))) + sizing_mode = str(metadata.get("sizing_mode", _nested(metadata, "run_config", "sizing", "hedge_type", default=""))).lower() + requested_fee = _safe_float(metadata.get("fee_rate")) + requested_slippage = _safe_float(metadata.get("slippage")) + expected_fee = native_fee_one_way + if expected_fee is None and native_fee_round_trip is not None: + expected_fee = float(native_fee_round_trip) / 2.0 + + constraints = _instrument_constraints(metadata) + lot_report = _lot_size_risk_report( + transition_report=transition_report, + initial_capital=float(result.initial_capital), + alloc_per_trade=float(metadata.get("trade_notional", metadata.get("alloc_per_trade", 0.0)) or 0.0), + constraints=constraints, + ) + + checks = { + "sizing_mode_is_pct_equity": sizing_mode in {"%_equity", "pct_equity"}, + "orders_not_more_than_signal_transitions": orders_count <= int(len(transition_report)), + "fills_not_more_than_orders": fills_count <= orders_count, + "fee_convention_matches_native": True if expected_fee is None or requested_fee is None else abs(float(expected_fee) - float(requested_fee)) <= 1e-15, + "custom_fee_rate_applied_to_nautilus": False, + "funding_matches_native": True if native_use_funding is None else bool(native_use_funding) is False, + "slippage_matches_native": True if native_slippage is None else abs(float(native_slippage)) <= 1e-15, + "custom_slippage_applied_to_nautilus": False, + "has_lot_size_constraints": constraints.get("qty_step") is not None, + } + recommendations = _recommendations(checks, native_use_funding=native_use_funding, native_slippage=native_slippage) + status = "ok" if all(checks.values()) else "diff" + return { + "status": status, + "checks": checks, + "signal": { + "rows": int(len(idx)), + "raw_transition_count": int(sig.ne(sig.shift(1).fillna(0.0)).sum()), + "effective_transition_count": int(len(transition_report)), + "use_pyramiding": use_pyramiding, + "signal_index_matches_data_index": bool(_utc_index(signal.index).equals(idx)), + "transition_report": transition_report, + }, + "orders": { + "orders_count": orders_count, + "fills_count": fills_count, + "positions_count": int(metadata.get("positions_count", 0) or 0), + "missing_order_events_vs_transitions": max(0, int(len(transition_report)) - orders_count), + }, + "execution_semantics": { + "requested_fee_rate": requested_fee, + "expected_native_one_way_fee_rate": expected_fee, + "custom_fee_rate_applied_to_nautilus": False, + "requested_slippage": requested_slippage, + "native_slippage": native_slippage, + "custom_slippage_applied_to_nautilus": False, + "native_use_funding": native_use_funding, + "nautilus_signal_funding_supported": False, + "adapter_fill_model": "NautilusTrader bar market execution with instrument maker/taker fee model", + }, + "instrument_constraints": constraints, + "lot_size_risk": lot_report, + "recommendations": recommendations, + } + + +def _instrument_constraints(metadata: Dict) -> Dict: + instrument_id = metadata.get("instrument_id") or _nested(metadata, "run_config", "nautilus", "instrument_id") + out = { + "instrument_id": instrument_id, + "qty_step": metadata.get("qty_step") or metadata.get("lot_size") or metadata.get("size_increment"), + "lot_size": metadata.get("lot_size") or metadata.get("qty_step") or metadata.get("size_increment"), + "min_qty": metadata.get("min_qty") or metadata.get("min_quantity"), + "min_notional": metadata.get("min_notional"), + "contract_size_note": "contract_size is a multiplier; lot_size/qty_step controls fractional crypto order acceptance", + } + if instrument_id: + try: + spec = SUPPORTED_BINANCE_PERP_SPECS[normalize_binance_perp_symbol(str(instrument_id))] + out.update( + { + "qty_step": out["qty_step"] or spec.size_increment, + "lot_size": out["lot_size"] or spec.size_increment, + "min_qty": out["min_qty"] or spec.min_quantity, + "min_notional": out["min_notional"] or "10.0", + "price_increment": spec.price_increment, + "quantity_precision": spec.size_precision, + } + ) + except ValueError: + pass + return out + + +def _lot_size_risk_report( + *, + transition_report: pd.DataFrame, + initial_capital: float, + alloc_per_trade: float, + constraints: Dict, +) -> Dict: + qty_step = _safe_float(constraints.get("qty_step")) + min_qty = _safe_float(constraints.get("min_qty")) + if transition_report.empty or qty_step is None: + return {"status": "unknown", "potential_small_delta_count": 0} + alloc = alloc_per_trade / 100.0 if alloc_per_trade > 1.0 else alloc_per_trade + close = pd.to_numeric(transition_report["close"], errors="coerce").replace(0.0, np.nan) + signal = pd.to_numeric(transition_report["effective_signal"], errors="coerce").abs() + approx_qty = (initial_capital * alloc * signal / close).fillna(0.0) + threshold = max(qty_step, min_qty or 0.0) + small = approx_qty < threshold + return { + "status": "ok" if not bool(small.any()) else "risk", + "potential_small_delta_count": int(small.sum()), + "qty_step": float(qty_step), + "min_qty": None if min_qty is None else float(min_qty), + "min_transition_approx_qty": float(approx_qty.min()) if len(approx_qty) else 0.0, + "note": "Approximation uses initial capital only; live equity and current position can create smaller deltas later.", + } + + +def _recommendations(checks: Dict[str, bool], *, native_use_funding, native_slippage) -> List[str]: + out: List[str] = [] + if not checks["fee_convention_matches_native"]: + out.append("Align fee convention: legacy `fee` is round-trip; Nautilus `fee_rate` is metadata one-way today.") + if not checks["custom_fee_rate_applied_to_nautilus"]: + out.append("Current Nautilus signal adapter uses Nautilus instrument maker/taker fees, not endpoint custom fee_rate.") + if native_use_funding: + out.append("Disable native funding for apples-to-apples, or implement Nautilus funding/carry adapter.") + if native_slippage and abs(float(native_slippage)) > 0.0: + out.append("Disable native slippage for apples-to-apples, or implement Nautilus slippage model.") + if not checks["orders_not_more_than_signal_transitions"]: + out.append("Inspect signal timestamp alignment and Nautilus order reports; order count exceeds transition count.") + return out + + +def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series: + sig = signal.copy() + if not isinstance(sig.index, pd.DatetimeIndex): + sig.index = pd.to_datetime(sig.index, utc=True) + if sig.index.tz is None: + sig.index = sig.index.tz_localize("UTC") + else: + sig.index = sig.index.tz_convert("UTC") + return sig.reindex(idx, method="ffill").fillna(0.0) + + +def _utc_index(index) -> pd.DatetimeIndex: + idx = pd.DatetimeIndex(pd.to_datetime(index, utc=True)) + if idx.tz is None: + idx = idx.tz_localize("UTC") + else: + idx = idx.tz_convert("UTC") + return idx + + +def _nested(mapping: Dict, *keys, default=None): + cur = mapping + for key in keys: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + + +def _frame(value) -> pd.DataFrame: + return value if isinstance(value, pd.DataFrame) else pd.DataFrame() + + +def _safe_float(value): + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None diff --git a/src/quantbt/reporting/parity.py b/src/quantbt/reporting/parity.py new file mode 100644 index 0000000..c9ca713 --- /dev/null +++ b/src/quantbt/reporting/parity.py @@ -0,0 +1,496 @@ +"""Parity helpers for native-vs-Nautilus audit reports.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import numpy as np +import pandas as pd + +from ..core.orders import Fill, OrderIntent +from ..core.results import BacktestResultV2 + + +PARITY_COLUMNS = [ + "row", + "timestamp", + "symbol", + "side", + "requested_qty", + "requested_price", + "native_fill_price", + "nautilus_fill_price", + "fill_price_diff", + "native_fee", + "nautilus_fee", + "fee_diff", + "native_position_after", + "nautilus_position_after", + "position_diff", + "native_equity", + "nautilus_equity", + "equity_diff", + "native_status", + "nautilus_status", +] + + +DEPTH_EXECUTION_COLUMNS = [ + "row", + "timestamp", + "symbol", + "side", + "depth_status", + "depth_filled_qty", + "nautilus_filled_qty", + "filled_qty_diff", + "depth_fill_price", + "nautilus_fill_price", + "fill_price_diff", +] + + +def build_nautilus_depth_execution_report(result: BacktestResultV2) -> pd.DataFrame: + """ + Compare depth-preflight filled rows with Nautilus package fills. + + Rows are sequence-aligned because package orders are submitted to Nautilus + after deterministic preflight filtering. This is the package-level analogue + of the explicit-order parity table. + """ + metadata = result.metadata or {} + depth = _report_frame(metadata.get("nautilus_depth_order_report")) + if depth.empty: + return pd.DataFrame(columns=DEPTH_EXECUTION_COLUMNS) + depth = depth[depth["status"].astype(str).str.lower().isin({"filled", "partial"})].reset_index(drop=True) + fills = _fills_from_result(result) + row_count = max(len(depth), len(fills)) + rows: List[Dict[str, Any]] = [] + for row in range(row_count): + depth_row = _row(depth, row) + fill_row = _row(fills, row) + depth_qty = _safe_float(_get(depth_row, "filled_qty")) + fill_qty = _safe_float(_get(fill_row, "qty")) + depth_price = _safe_float(_get(depth_row, "fill_price")) + fill_price = _safe_float(_get(fill_row, "price")) + rows.append( + { + "row": row, + "timestamp": _first_non_null(_get(fill_row, "timestamp"), _get(depth_row, "effective_timestamp"), _get(depth_row, "timestamp")), + "symbol": _first_non_null(_get(fill_row, "symbol"), _get(depth_row, "symbol")), + "side": _first_non_null(_get(fill_row, "side"), _get(depth_row, "side")), + "depth_status": _get(depth_row, "status"), + "depth_filled_qty": depth_qty, + "nautilus_filled_qty": fill_qty, + "filled_qty_diff": _diff(depth_qty, fill_qty), + "depth_fill_price": depth_price, + "nautilus_fill_price": fill_price, + "fill_price_diff": _diff(depth_price, fill_price), + } + ) + return pd.DataFrame(rows, columns=DEPTH_EXECUTION_COLUMNS) + + +def build_nautilus_depth_parity_summary( + result: BacktestResultV2, + fill_price_tolerance: float = 1e-9, + qty_tolerance: float = 1e-9, +) -> Dict[str, Any]: + """ + Summarize preflight-vs-Nautilus package execution counts. + + This helper is for Phase 5.4 package workflows where an optional + execution-depth preflight may reject, cancel, or partially adjust orders + before accepted orders are submitted to Nautilus. + """ + metadata = result.metadata or {} + depth_order_report = _report_frame(metadata.get("nautilus_depth_order_report")) + depth_package_report = _report_frame(metadata.get("nautilus_depth_package_report")) + package_order_map = _report_frame(metadata.get("package_order_map")) + orders_report = _report_frame(metadata.get("orders_report", metadata.get("order_report"))) + fills_report = _report_frame(metadata.get("fills_report")) + depth_enabled = bool(metadata.get("nautilus_depth_enabled", False)) + accepted = int(metadata.get("order_count_after_depth", len(package_order_map))) + before = int(metadata.get("order_count_before_depth", accepted)) + nautilus_orders = int(metadata.get("orders_count", len(orders_report))) + nautilus_fills = int(metadata.get("fills_count", len(fills_report))) + rejected = _status_count(depth_order_report, "rejected") + partial = _status_count(depth_order_report, "partial") + canceled = _status_count(depth_order_report, "canceled") + submitted_matches = nautilus_orders == accepted or (accepted == 0 and nautilus_orders == 0) + execution_report = build_nautilus_depth_execution_report(result) + max_fill_price_diff = _max_abs(execution_report, "fill_price_diff") + max_qty_diff = _max_abs(execution_report, "filled_qty_diff") + execution_matches = max_fill_price_diff <= float(fill_price_tolerance) and max_qty_diff <= float(qty_tolerance) + summary = { + "status": "pass" if submitted_matches and execution_matches else "execution_diff", + "passed": bool(submitted_matches and execution_matches), + "depth_enabled": depth_enabled, + "input_orders": before, + "accepted_after_depth": accepted, + "nautilus_orders": nautilus_orders, + "nautilus_fills": nautilus_fills, + "depth_rejected": rejected, + "depth_partial": partial, + "depth_canceled": canceled, + "package_rows": int(len(depth_package_report)), + "execution_rows": int(len(execution_report)), + "max_abs_fill_price_diff": float(max_fill_price_diff), + "max_abs_filled_qty_diff": float(max_qty_diff), + "fill_price_tolerance": float(fill_price_tolerance), + "qty_tolerance": float(qty_tolerance), + "engine": metadata.get("engine"), + "input_mode": metadata.get("input_mode"), + } + if not submitted_matches: + summary["status"] = "execution_count_diff" + if not depth_enabled: + summary["status"] = "not_enabled" + summary["passed"] = False + return summary + + +def summarize_native_nautilus_parity_report( + parity_report: pd.DataFrame, + fill_price_tolerance: float = 1e-9, + fee_tolerance: float = 1e-9, + position_tolerance: float = 1e-9, + equity_tolerance: float = 1e-6, +) -> Dict[str, Any]: + """Return compact institutional audit diagnostics for a parity table.""" + frame = parity_report.copy() + summary = { + "rows": int(len(frame)), + "native_filled_rows": int(frame["native_fill_price"].notna().sum()) if "native_fill_price" in frame else 0, + "nautilus_filled_rows": int(frame["nautilus_fill_price"].notna().sum()) if "nautilus_fill_price" in frame else 0, + "max_abs_fill_price_diff": _max_abs(frame, "fill_price_diff"), + "max_abs_fee_diff": _max_abs(frame, "fee_diff"), + "max_abs_position_diff": _max_abs(frame, "position_diff"), + "max_abs_equity_diff": _max_abs(frame, "equity_diff"), + "fill_price_tolerance": float(fill_price_tolerance), + "fee_tolerance": float(fee_tolerance), + "position_tolerance": float(position_tolerance), + "equity_tolerance": float(equity_tolerance), + } + summary["passed"] = bool( + summary["max_abs_fill_price_diff"] <= fill_price_tolerance + and summary["max_abs_fee_diff"] <= fee_tolerance + and summary["max_abs_position_diff"] <= position_tolerance + and summary["max_abs_equity_diff"] <= equity_tolerance + ) + if summary["passed"]: + summary["status"] = "pass" + elif summary["max_abs_fill_price_diff"] > fill_price_tolerance or summary["max_abs_position_diff"] > position_tolerance: + summary["status"] = "execution_diff" + else: + summary["status"] = "accounting_diff" + return summary + + +def build_native_nautilus_parity_report( + native_result: BacktestResultV2, + nautilus_result: BacktestResultV2, +) -> pd.DataFrame: + """ + Build an execution audit table comparing native event and Nautilus results. + + The report is intentionally tolerant of source formats. Native results are + usually dataclass-backed (`orders` and `fills`), while Nautilus results are + report-backed (`orders_report`, `fills_report`, `package_order_map`). Rows + are aligned by order/fill sequence, which is stable for deterministic + single-symbol explicit-order replay. + """ + native_orders = _orders_from_result(native_result) + native_fills = _fills_from_result(native_result) + nautilus_orders = _orders_from_result(nautilus_result) + nautilus_fills = _fills_from_result(nautilus_result) + row_count = max(len(native_orders), len(nautilus_orders), len(native_fills), len(nautilus_fills)) + + rows: List[Dict[str, Any]] = [] + for row in range(row_count): + native_order = _row(native_orders, row) + nautilus_order = _row(nautilus_orders, row) + native_fill = _row(native_fills, row) + nautilus_fill = _row(nautilus_fills, row) + + timestamp = _first_non_null( + _get(native_fill, "timestamp"), + _get(nautilus_fill, "timestamp"), + _get(native_order, "timestamp"), + _get(nautilus_order, "timestamp"), + ) + if timestamp is not None and not pd.isna(timestamp): + timestamp = _coerce_ts(timestamp) + symbol = _first_non_null( + _get(native_order, "symbol"), + _get(nautilus_order, "symbol"), + _get(native_fill, "symbol"), + _get(nautilus_fill, "symbol"), + ) + side = _first_non_null( + _get(native_order, "side"), + _get(nautilus_order, "side"), + _get(native_fill, "side"), + _get(nautilus_fill, "side"), + ) + requested_qty = _first_float(_get(native_order, "qty"), _get(nautilus_order, "qty")) + requested_price = _first_float(_get(native_order, "price"), _get(nautilus_order, "price")) + native_price = _safe_float(_get(native_fill, "price")) + nautilus_price = _safe_float(_get(nautilus_fill, "price")) + native_fee = _safe_float(_get(native_fill, "fee")) + nautilus_fee = _safe_float(_get(nautilus_fill, "fee")) + native_equity = _equity_at(native_result, timestamp) + nautilus_equity = _equity_at(nautilus_result, timestamp) + native_pos = _position_at(native_result, symbol, timestamp) + nautilus_pos = _position_at(nautilus_result, symbol, timestamp) + + rows.append( + { + "row": row, + "timestamp": timestamp, + "symbol": symbol, + "side": side, + "requested_qty": requested_qty, + "requested_price": requested_price, + "native_fill_price": native_price, + "nautilus_fill_price": nautilus_price, + "fill_price_diff": _diff(native_price, nautilus_price), + "native_fee": native_fee, + "nautilus_fee": nautilus_fee, + "fee_diff": _diff(native_fee, nautilus_fee), + "native_position_after": native_pos, + "nautilus_position_after": nautilus_pos, + "position_diff": _diff(native_pos, nautilus_pos), + "native_equity": native_equity, + "nautilus_equity": nautilus_equity, + "equity_diff": _diff(native_equity, nautilus_equity), + "native_status": _get(native_order, "status"), + "nautilus_status": _get(nautilus_order, "status"), + } + ) + return pd.DataFrame(rows, columns=PARITY_COLUMNS) + + +def _orders_from_result(result: BacktestResultV2) -> pd.DataFrame: + package_map = _report_frame(result.metadata.get("package_order_map")) + if not package_map.empty: + out = pd.DataFrame( + { + "timestamp": pd.to_datetime(package_map.get("timestamp"), utc=True, errors="coerce"), + "symbol": package_map.get("symbol", package_map.get("instrument_id")), + "side": package_map.get("side"), + "qty": pd.to_numeric(package_map.get("qty"), errors="coerce"), + "price": pd.to_numeric(package_map.get("price"), errors="coerce"), + "status": pd.Series([None] * len(package_map), dtype=object), + } + ) + orders_report = _report_frame(result.metadata.get("orders_report", result.metadata.get("order_report"))) + if not orders_report.empty and "status" in orders_report: + out.loc[: len(orders_report) - 1, "status"] = list(orders_report["status"].head(len(out))) + return out + + if result.orders: + rows = [] + for order in result.orders: + rows.append( + { + "timestamp": _coerce_ts(order.timestamp), + "symbol": order.symbol, + "side": _enum_value(order.side), + "qty": float(order.qty), + "price": order.price, + "status": None, + } + ) + order_report = _report_frame(result.metadata.get("order_report")) + if not order_report.empty and "status" in order_report: + for idx, status in enumerate(order_report["status"].head(len(rows))): + rows[idx]["status"] = status + return pd.DataFrame(rows) + return pd.DataFrame(columns=["timestamp", "symbol", "side", "qty", "price", "status"]) + + +def _fills_from_result(result: BacktestResultV2) -> pd.DataFrame: + fills_report = _report_frame(result.metadata.get("fills_report")) + if fills_report.empty: + fills_report = _report_frame(result.metadata.get("orders_report")) + if not fills_report.empty: + filled = fills_report + if "status" in filled: + filled = filled[filled["status"].astype(str).str.upper().eq("FILLED")] + return pd.DataFrame( + { + "timestamp": _timestamp_column(filled), + "symbol": filled.get("instrument_id", filled.get("symbol")), + "side": filled.get("side"), + "qty": pd.to_numeric(filled.get("filled_qty", filled.get("quantity")), errors="coerce"), + "price": pd.to_numeric(filled.get("avg_px", filled.get("price")), errors="coerce"), + "fee": filled.apply(_row_fee, axis=1), + } + ).reset_index(drop=True) + + if result.fills: + rows = [] + for fill in result.fills: + rows.append( + { + "timestamp": _coerce_ts(fill.timestamp), + "symbol": fill.symbol, + "side": _enum_value(fill.side), + "qty": float(fill.qty), + "price": float(fill.price), + "fee": float(fill.fee), + } + ) + return pd.DataFrame(rows) + return pd.DataFrame(columns=["timestamp", "symbol", "side", "qty", "price", "fee"]) + + +def _report_frame(value: Any) -> pd.DataFrame: + if isinstance(value, pd.DataFrame): + return value.copy() + if value is None: + return pd.DataFrame() + try: + return pd.DataFrame(value).copy() + except Exception: + return pd.DataFrame() + + +def _status_count(frame: pd.DataFrame, status: str) -> int: + if frame.empty or "status" not in frame: + return 0 + return int(frame["status"].astype(str).str.lower().eq(status).sum()) + + +def _max_abs(frame: pd.DataFrame, column: str) -> float: + if column not in frame: + return 0.0 + series = pd.to_numeric(frame[column], errors="coerce").abs().dropna() + return float(series.max()) if not series.empty else 0.0 + + +def _timestamp_column(frame: pd.DataFrame) -> pd.Series: + for key in ("ts_last", "ts_event", "timestamp", "ts_init"): + if key in frame: + return pd.to_datetime(frame[key], utc=True, errors="coerce") + return pd.Series(pd.NaT, index=frame.index) + + +def _row_fee(row: pd.Series) -> float: + for key in ("fee", "fees", "commissions"): + if key in row and row[key] is not None: + return _money_float(row[key]) + return 0.0 + + +def _money_float(value: Any) -> float: + if isinstance(value, (int, float, np.number)): + return float(value) + text = str(value).replace(",", "").replace("[", "").replace("]", "").strip() + if not text or text.lower() == "nan": + return 0.0 + try: + return float(text.split()[0]) + except (TypeError, ValueError, IndexError): + return 0.0 + + +def _equity_at(result: BacktestResultV2, timestamp: Any) -> float: + if timestamp is None or pd.isna(timestamp) or result.equity.empty: + return float("nan") + ts = _coerce_ts(timestamp) + series = result.equity.sort_index() + loc = series.index.searchsorted(ts, side="right") - 1 + if loc < 0: + return float("nan") + return float(series.iloc[loc]) + + +def _position_at(result: BacktestResultV2, symbol: Any, timestamp: Any) -> float: + if symbol is None or timestamp is None or pd.isna(timestamp) or result.positions.empty: + return float("nan") + col = _position_column(result.positions, str(symbol)) + if col is None: + return float("nan") + ts = _coerce_ts(timestamp) + frame = result.positions.sort_index() + loc = frame.index.searchsorted(ts, side="right") - 1 + if loc < 0: + return float("nan") + return _safe_float(frame[col].iloc[loc]) + + +def _position_column(positions: pd.DataFrame, symbol: str) -> Optional[str]: + candidates = [f"Position_{symbol}", symbol] + if symbol.endswith("-PERP.BINANCE"): + candidates.append(f"Position_{symbol.removesuffix('-PERP.BINANCE')}") + for col in candidates: + if col in positions.columns: + return col + suffix = symbol.split(".")[0] + for col in positions.columns: + if str(col).endswith(symbol) or str(col).endswith(suffix): + return col + return None + + +def _coerce_ts(value: Any) -> pd.Timestamp: + ts = pd.Timestamp(value) + if ts.tz is None: + return ts.tz_localize("UTC") + return ts.tz_convert("UTC") + + +def _safe_float(value: Any) -> float: + try: + if value is None or pd.isna(value): + return float("nan") + return float(value) + except (TypeError, ValueError): + return _money_float(value) + + +def _first_float(*values: Any) -> float: + for value in values: + number = _safe_float(value) + if not np.isnan(number): + return number + return float("nan") + + +def _diff(left: float, right: float) -> float: + if np.isnan(left) or np.isnan(right): + return float("nan") + return float(right - left) + + +def _row(frame: pd.DataFrame, idx: int) -> Optional[pd.Series]: + if frame is None or frame.empty or idx >= len(frame): + return None + return frame.iloc[idx] + + +def _get(row: Optional[pd.Series], key: str) -> Any: + if row is None or key not in row: + return None + return row[key] + + +def _first_non_null(*values: Any) -> Any: + for value in values: + if value is not None and not (isinstance(value, float) and np.isnan(value)) and not pd.isna(value): + if key := _maybe_enum_value(value): + return key + return value + return None + + +def _maybe_enum_value(value: Any) -> Any: + if hasattr(value, "value"): + return value.value + return None + + +def _enum_value(value: Any) -> Any: + return value.value if hasattr(value, "value") else value diff --git a/src/quantbt/reporting/portfolio_audit.py b/src/quantbt/reporting/portfolio_audit.py new file mode 100644 index 0000000..f341231 --- /dev/null +++ b/src/quantbt/reporting/portfolio_audit.py @@ -0,0 +1,206 @@ +""" +Portfolio domain audit helpers. + +These functions validate accounting and exposure invariants on completed +multi-symbol portfolio results. They are report-level checks only; they do not +change execution semantics. +""" + +from __future__ import annotations + +from typing import Dict, Optional + +import numpy as np +import pandas as pd + + +def build_portfolio_domain_audit( + result, + *, + tolerance: float = 1e-9, + raise_on_fail: bool = False, +) -> Dict: + """ + Return a compact audit summary for a multi-symbol portfolio result. + + The audit checks that accepted-position attribution reconciles to the equity + curve, fees reconcile to the per-bar fee series, accepted notionals match + units times closes times contract size, and exposure-report identities hold. + Rebalance rows are informational: a non-empty report means the requested + target matrix differed from accepted positions, usually because a + portfolio/margin gate rejected a rebalance. + """ + metadata = getattr(result, "metadata", {}) or {} + missing = [ + name + for name in ( + "target_units_report", + "accepted_units_report", + "accepted_notional_report", + "exposure_report", + "symbol_pnl_report", + ) + if not isinstance(metadata.get(name), pd.DataFrame) + ] + + accepted_units = _frame(metadata.get("accepted_units_report")) + accepted_notional = _frame(metadata.get("accepted_notional_report")) + exposure_report = _frame(metadata.get("exposure_report")) + symbol_pnl_report = _frame(metadata.get("symbol_pnl_report")) + rebalance_report = _frame(metadata.get("rebalance_report")) + fee_series = _series(metadata.get("fee_series")) + + equity_residual = np.nan + if not symbol_pnl_report.empty: + pnl_sum = ( + symbol_pnl_report.assign( + timestamp=pd.to_datetime(symbol_pnl_report["timestamp"], utc=True), + total_pnl=pd.to_numeric(symbol_pnl_report["total_pnl"], errors="coerce").fillna(0.0), + ) + .groupby("timestamp", sort=False)["total_pnl"] + .sum() + ) + equity_delta = result.equity.diff().fillna(0.0) + if getattr(result, "liquidated", False): + liq_idx = int(getattr(result, "liquidation_bar", -1)) + if liq_idx >= 0: + equity_delta = equity_delta.iloc[:liq_idx] + pnl_sum = pnl_sum.reindex(equity_delta.index, fill_value=0.0) + else: + pnl_sum = pnl_sum.reindex(equity_delta.index, fill_value=0.0) + equity_residual = _max_abs((pnl_sum - equity_delta).to_numpy(dtype=float)) + + fee_residual = np.nan + if not symbol_pnl_report.empty and "fee" in symbol_pnl_report: + pnl_fee = float(pd.to_numeric(symbol_pnl_report["fee"], errors="coerce").fillna(0.0).sum()) + metadata_fee = float(metadata.get("fee_total", fee_series.sum() if not fee_series.empty else 0.0)) + fee_residual = abs(pnl_fee - metadata_fee) + + notional_residual = _accepted_notional_residual(result, accepted_units, accepted_notional) + exposure_residual = _exposure_identity_residual(exposure_report) + + rebalance_abs_notional = 0.0 + if not rebalance_report.empty and "notional_diff" in rebalance_report: + rebalance_abs_notional = float( + pd.to_numeric(rebalance_report["notional_diff"], errors="coerce").fillna(0.0).abs().sum() + ) + + checks = { + "has_required_reports": not missing, + "pnl_reconciles_to_equity": _ok(equity_residual, tolerance), + "fees_reconcile": _ok(fee_residual, tolerance), + "accepted_notional_reconciles": _ok(notional_residual, tolerance), + "exposure_identities_reconcile": _ok(exposure_residual, tolerance), + } + passed = all(checks.values()) + audit = { + "status": "pass" if passed else "fail", + "passed": passed, + "tolerance": float(tolerance), + "engine": metadata.get("engine"), + "backend": metadata.get("backend"), + "mode": metadata.get("mode"), + "asset_type": metadata.get("asset_type"), + "hedge_type": metadata.get("hedge_type"), + "missing_reports": missing, + "checks": checks, + "max_abs_pnl_equity_residual": _float_or_none(equity_residual), + "max_abs_fee_residual": _float_or_none(fee_residual), + "max_abs_accepted_notional_residual": _float_or_none(notional_residual), + "max_abs_exposure_identity_residual": _float_or_none(exposure_residual), + "rebalance_count": int(len(rebalance_report)), + "rebalance_abs_notional": float(rebalance_abs_notional), + "liquidated": bool(getattr(result, "liquidated", False)), + "liquidation_bar": int(getattr(result, "liquidation_bar", -1)), + "symbols": list(map(str, getattr(result, "symbols", ()))), + } + if raise_on_fail and not passed: + raise AssertionError(f"portfolio domain audit failed: {audit}") + return audit + + +def _accepted_notional_residual(result, accepted_units: pd.DataFrame, accepted_notional: pd.DataFrame) -> float: + if accepted_units.empty or accepted_notional.empty: + return np.nan + closes = getattr(result, "closes", pd.DataFrame()).copy() + if closes.empty: + return np.nan + closes = closes.rename(columns={col: str(col).replace("Close_", "", 1) for col in closes.columns}) + closes = closes.reindex(columns=accepted_units.columns) + contract_sizes = _contract_sizes_from_metadata(result, accepted_units.columns) + expected = accepted_units.mul(closes, axis=0).mul(contract_sizes, axis=1) + expected = expected.reindex_like(accepted_notional) + return _max_abs((expected - accepted_notional).to_numpy(dtype=float)) + + +def _contract_sizes_from_metadata(result, columns) -> pd.Series: + metadata = getattr(result, "metadata", {}) or {} + target = _frame(metadata.get("accepted_notional_report")) + units = _frame(metadata.get("accepted_units_report")) + closes = getattr(result, "closes", pd.DataFrame()).copy() + closes = closes.rename(columns={col: str(col).replace("Close_", "", 1) for col in closes.columns}) + values = {} + for symbol in columns: + values[symbol] = 1.0 + if target.empty or units.empty or closes.empty or symbol not in target or symbol not in units or symbol not in closes: + continue + denom = units[symbol] * closes[symbol] + mask = denom.abs() > 1e-12 + if mask.any(): + inferred = (target.loc[mask, symbol] / denom.loc[mask]).replace([np.inf, -np.inf], np.nan).dropna() + if not inferred.empty: + values[symbol] = float(inferred.iloc[0]) + return pd.Series(values) + + +def _exposure_identity_residual(exposure_report: pd.DataFrame) -> float: + if exposure_report.empty: + return np.nan + required = {"long_notional", "short_notional", "gross_notional", "net_notional"} + if not required.issubset(exposure_report.columns): + return np.nan + long_notional = pd.to_numeric(exposure_report["long_notional"], errors="coerce").fillna(0.0) + short_notional = pd.to_numeric(exposure_report["short_notional"], errors="coerce").fillna(0.0) + gross_notional = pd.to_numeric(exposure_report["gross_notional"], errors="coerce").fillna(0.0) + net_notional = pd.to_numeric(exposure_report["net_notional"], errors="coerce").fillna(0.0) + gross_residual = _max_abs((long_notional + short_notional - gross_notional).to_numpy(dtype=float)) + net_residual = _max_abs((long_notional - short_notional - net_notional).to_numpy(dtype=float)) + return max(gross_residual, net_residual) + + +def _frame(value) -> pd.DataFrame: + return value if isinstance(value, pd.DataFrame) else pd.DataFrame() + + +def _series(value) -> pd.Series: + return value if isinstance(value, pd.Series) else pd.Series(dtype=float) + + +def _max_abs(value) -> float: + if value is None: + return np.nan + arr = np.asarray(value, dtype=np.float64) + arr = arr[np.isfinite(arr)] + if arr.size == 0: + return np.nan + return float(np.max(np.abs(arr))) + + +def _ok(value: Optional[float], tolerance: float) -> bool: + if value is None: + return False + try: + numeric = float(value) + except (TypeError, ValueError): + return False + return bool(np.isfinite(numeric) and numeric <= float(tolerance)) + + +def _float_or_none(value): + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not np.isfinite(numeric): + return None + return numeric diff --git a/src/quantbt/reporting/portfolio_nautilus.py b/src/quantbt/reporting/portfolio_nautilus.py new file mode 100644 index 0000000..5a0ab65 --- /dev/null +++ b/src/quantbt/reporting/portfolio_nautilus.py @@ -0,0 +1,140 @@ +"""Portfolio native-vs-Nautilus validation helpers.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import numpy as np +import pandas as pd + +from ..core.results import BacktestResultV2 + + +def build_portfolio_nautilus_position_report( + native_result: BacktestResultV2, + nautilus_result: BacktestResultV2, +) -> pd.DataFrame: + """Return timestamp/symbol position differences between native and Nautilus.""" + native_pos = _position_frame(native_result) + nautilus_pos = _position_frame(nautilus_result) + symbols = sorted(set(native_pos.columns) | set(nautilus_pos.columns)) + idx = native_result.equity.index.union(nautilus_result.equity.index).sort_values() + native_pos = native_pos.reindex(idx).ffill().fillna(0.0).reindex(columns=symbols, fill_value=0.0) + nautilus_pos = nautilus_pos.reindex(idx).ffill().fillna(0.0).reindex(columns=symbols, fill_value=0.0) + + rows: List[Dict[str, Any]] = [] + for timestamp in idx: + for symbol in symbols: + native_value = float(native_pos.loc[timestamp, symbol]) + nautilus_value = float(nautilus_pos.loc[timestamp, symbol]) + rows.append( + { + "timestamp": timestamp, + "symbol": symbol, + "native_position": native_value, + "nautilus_position": nautilus_value, + "position_diff": native_value - nautilus_value, + } + ) + return pd.DataFrame(rows) + + +def build_portfolio_nautilus_validation_report( + native_result: BacktestResultV2, + nautilus_result: BacktestResultV2, + *, + target_tolerance: float = 1e-9, + position_tolerance: float = 1e-6, + equity_tolerance: float = 1e-6, +) -> Dict[str, Any]: + """ + Summarize portfolio package validation between native and Nautilus results. + + This is an institutional audit summary, not a claim that Nautilus is the + optimizer path. It checks that the submitted Nautilus package matches the + native target-unit matrix and, when reports are available, compares + positions and equity. + """ + native_target = _frame((native_result.metadata or {}).get("target_units_report")) + nautilus_target = _frame((nautilus_result.metadata or {}).get("portfolio_target_units")) + package_order_map = _frame((nautilus_result.metadata or {}).get("package_order_map")) + orders_report = _frame((nautilus_result.metadata or {}).get("orders_report", (nautilus_result.metadata or {}).get("order_report"))) + fills_report = _frame((nautilus_result.metadata or {}).get("fills_report")) + + expected_orders = _expected_order_count(native_target) + nautilus_orders = int((nautilus_result.metadata or {}).get("orders_count", len(orders_report))) + if nautilus_orders == 0: + nautilus_orders = int((nautilus_result.metadata or {}).get("order_count_input", len(package_order_map))) + nautilus_fills = int((nautilus_result.metadata or {}).get("fills_count", len(fills_report))) + if nautilus_fills == 0 and nautilus_orders > 0 and len(fills_report) == 0: + nautilus_fills = int((nautilus_result.metadata or {}).get("order_count_input", 0)) + + target_diff = _target_units_diff(native_target, nautilus_target) + position_report = build_portfolio_nautilus_position_report(native_result, nautilus_result) + max_position_diff = _max_abs(position_report, "position_diff") + final_equity_diff = float(native_result.equity.iloc[-1] - nautilus_result.equity.iloc[-1]) + + checks = { + "input_mode_is_portfolio_matrix": (nautilus_result.metadata or {}).get("input_mode") == "portfolio_matrix", + "target_units_match": target_diff <= float(target_tolerance), + "order_count_matches_target_transitions": nautilus_orders == expected_orders, + "fills_do_not_exceed_orders": nautilus_fills <= nautilus_orders, + "positions_within_tolerance": max_position_diff <= float(position_tolerance), + "final_equity_within_tolerance": abs(final_equity_diff) <= float(equity_tolerance), + } + passed = all(bool(value) for value in checks.values()) + return { + "status": "pass" if passed else "diff", + "passed": bool(passed), + "checks": checks, + "expected_order_count": int(expected_orders), + "nautilus_orders": int(nautilus_orders), + "nautilus_fills": int(nautilus_fills), + "max_abs_target_units_diff": float(target_diff), + "max_abs_position_diff": float(max_position_diff), + "final_equity_diff": float(final_equity_diff), + "target_tolerance": float(target_tolerance), + "position_tolerance": float(position_tolerance), + "equity_tolerance": float(equity_tolerance), + "native_backend": (native_result.metadata or {}).get("backend"), + "nautilus_backend": (nautilus_result.metadata or {}).get("backend"), + "engine": (nautilus_result.metadata or {}).get("engine"), + } + + +def _expected_order_count(target_units: pd.DataFrame) -> int: + if target_units.empty: + return 0 + prev = target_units.shift(1).fillna(0.0) + delta = (target_units - prev).abs() + return int((delta > 1e-12).sum().sum()) + + +def _target_units_diff(native_target: pd.DataFrame, nautilus_target: pd.DataFrame) -> float: + if native_target.empty or nautilus_target.empty: + return np.inf + common_cols = sorted(set(native_target.columns) & set(nautilus_target.columns)) + if not common_cols: + return np.inf + idx = native_target.index.union(nautilus_target.index).sort_values() + left = native_target.reindex(idx).ffill().fillna(0.0).reindex(columns=common_cols) + right = nautilus_target.reindex(idx).ffill().fillna(0.0).reindex(columns=common_cols) + arr = (left - right).to_numpy(dtype=float) + return float(np.nanmax(np.abs(arr))) if arr.size else 0.0 + + +def _position_frame(result: BacktestResultV2) -> pd.DataFrame: + frame = result.positions.copy() + frame = frame.rename(columns={col: str(col).replace("Position_", "", 1) for col in frame.columns}) + return frame + + +def _frame(value) -> pd.DataFrame: + return value if isinstance(value, pd.DataFrame) else pd.DataFrame() + + +def _max_abs(frame: pd.DataFrame, column: str) -> float: + if frame.empty or column not in frame: + return np.inf + values = pd.to_numeric(frame[column], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna().abs() + return float(values.max()) if not values.empty else 0.0 diff --git a/src/quantbt/sizing/__init__.py b/src/quantbt/sizing/__init__.py new file mode 100644 index 0000000..e32ee25 --- /dev/null +++ b/src/quantbt/sizing/__init__.py @@ -0,0 +1,3 @@ +from .modes import compute_target_units + +__all__ = ["compute_target_units"] diff --git a/src/quantbt/sizing/fast.py b/src/quantbt/sizing/fast.py new file mode 100644 index 0000000..b74dbde --- /dev/null +++ b/src/quantbt/sizing/fast.py @@ -0,0 +1,67 @@ +""" +Fast ndarray sizing helpers. + +These helpers are internal optimization paths. They must match the public +Series-based sizing functions in `quantbt.sizing.modes`. +""" + +from __future__ import annotations + +import numpy as np +from numba import njit + + +@njit(cache=True) +def _signal_notional_matrix_numba( + signals: np.ndarray, + closes: np.ndarray, + allocs: np.ndarray, + use_pyramiding: bool, +) -> np.ndarray: + n_bars, n_syms = signals.shape + out = np.zeros((n_bars, n_syms), dtype=np.float64) + for j in range(n_syms): + current_scale = 0.0 + prev_sig = 0.0 + for i in range(n_bars): + sig = signals[i, j] + if not use_pyramiding: + if sig > 0.0: + sig = 1.0 + elif sig < 0.0: + sig = -1.0 + else: + sig = 0.0 + if i == 0 or sig != prev_sig: + if sig != 0.0: + current_scale = allocs[j] / closes[i, j] + else: + current_scale = 0.0 + out[i, j] = sig * current_scale + prev_sig = sig + return out + + +def scale_signal_notional_matrix( + signals: np.ndarray, + closes: np.ndarray, + allocs: np.ndarray, + use_pyramiding: bool = True, +) -> np.ndarray: + """ + Return target-unit matrix for signal_notional sizing. + + The behavior is intentionally identical to `scale_signal_notional` applied + per symbol: anchor units on signal transition and keep them frozen between + transitions. + """ + sig = np.ascontiguousarray(signals, dtype=np.float64) + cls = np.ascontiguousarray(closes, dtype=np.float64) + alc = np.ascontiguousarray(allocs, dtype=np.float64) + if sig.shape != cls.shape: + raise ValueError("signals and closes must have the same shape") + if sig.ndim != 2: + raise ValueError("signals and closes must be 2D arrays") + if len(alc) != sig.shape[1]: + raise ValueError("allocs length must match number of symbols") + return _signal_notional_matrix_numba(sig, cls, alc, bool(use_pyramiding)) diff --git a/src/quantbt/sizing/modes.py b/src/quantbt/sizing/modes.py new file mode 100644 index 0000000..d2e7425 --- /dev/null +++ b/src/quantbt/sizing/modes.py @@ -0,0 +1,162 @@ +""" +quantbt.sizing.modes +-------------------- +Position scaling: converts raw signal weights into target *units* (contracts) +that the numba engine can consume directly. + +Five modes +~~~~~~~~~~ +notional + target_units[i] = signal[i] × (alloc / close[i]) + Units recomputed every bar → constant notional exposure. + Generates a trade whenever signal OR price changes. High turnover on + intraday data; intended for EOD / multi-day bars. + +unit + target_units[i] = signal[i] × (alloc / close[0]) + Scale fixed at the *first* bar's price. Units stable as price moves; + notional drifts with the market. + +signal_notional ← recommended for systematic strategies + Units are re-anchored to current price ONLY when the signal weight + changes. Between signal changes the unit count is frozen → no + spurious micro-trades due to price drift. + target_units[i] = signal[i] × (alloc / close[change_bar]) + +pct_equity + Raw weight is passed straight through to the %_equity numba kernel, + which sizes units from live equity at execution time. + Returns the raw signal unchanged; no pre-scaling needed. + +dca_ladder + Raw signed structural level is passed straight through to the DCA ladder + execution kernel. The kernel turns High/Low limit touches into actual + filled units at each grid trigger price. + +Parameters +---------- +signal : pd.Series raw weight (float), e.g. 1.0 / -0.5 / 0.3 +close : pd.Series closing price, same index as signal +alloc : float notional allocation per full signal unit (USD) +use_pyramiding : bool if False, signal is clipped to {-1, 0, 1} + +Returns +------- +pd.Series target units (float), same index as signal +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + + +def scale_notional( + signal: pd.Series, + close: pd.Series, + alloc: float, + use_pyramiding: bool = True, +) -> pd.Series: + sig = signal if use_pyramiding else np.sign(signal) + return sig * (alloc / close) + + +def scale_unit( + signal: pd.Series, + close: pd.Series, + alloc: float, + use_pyramiding: bool = True, +) -> pd.Series: + sig = signal if use_pyramiding else np.sign(signal) + scale = alloc / close.iloc[0] + return sig * scale + + +def scale_signal_notional( + signal: pd.Series, + close: pd.Series, + alloc: float, + use_pyramiding: bool = True, +) -> pd.Series: + """ + Anchor-on-change scaling. + + Units are computed once per signal transition using the prevailing price + at that bar, then held constant until the next transition. This is the + standard approach in institutional systematic desks to avoid phantom + rebalancing trades. + """ + sig_vals = signal.values if use_pyramiding else np.sign(signal.values) + pr_vals = close.values + n = len(sig_vals) + target = np.zeros(n, dtype=np.float64) + + current_scale = 0.0 + for i in range(n): + if i == 0 or sig_vals[i] != sig_vals[i - 1]: + current_scale = (alloc / pr_vals[i]) if sig_vals[i] != 0 else 0.0 + target[i] = sig_vals[i] * current_scale + + return pd.Series(target, index=signal.index) + + +def scale_pct_equity( + signal: pd.Series, + use_pyramiding: bool = True, +) -> pd.Series: + """ + No pre-scaling. Pass raw weight directly; the numba kernel sizes from + live equity at execution time. + """ + return signal if use_pyramiding else np.sign(signal).astype(float) + + +def scale_dca_ladder(signal: pd.Series) -> pd.Series: + """ + No pre-scaling. Pass signed structural levels directly: + +1..+N for long ladders, -1..-N for short ladders, 0 for flat. + """ + return signal.astype(float) + + +# ── dispatcher ────────────────────────────────────────────────────────────── + +def compute_target_units( + hedge_type: str, + signal: pd.Series, + close: pd.Series, + alloc: float, + use_pyramiding: bool = True, +) -> pd.Series: + """ + Central dispatcher. Returns target-unit series for any supported mode. + + Parameters + ---------- + hedge_type : {'notional', 'unit', 'signal_notional', '%_equity', 'dca_ladder'} + signal : raw weight series + close : close price series + alloc : notional per full unit of signal + use_pyramiding : allow fractional weights; if False snaps to {-1,0,1} + """ + ht = hedge_type.lower().strip() + + if ht == "notional": + return scale_notional(signal, close, alloc, use_pyramiding) + + if ht == "unit": + return scale_unit(signal, close, alloc, use_pyramiding) + + if ht in ("signal_notional", "signal"): + return scale_signal_notional(signal, close, alloc, use_pyramiding) + + if ht in ("%_equity", "pct_equity"): + return scale_pct_equity(signal, use_pyramiding) + + if ht in ("dca_ladder", "dca"): + return scale_dca_ladder(signal) + + raise ValueError( + f"Unknown hedge_type '{hedge_type}'. " + "Choose from: 'notional', 'unit', 'signal_notional', '%_equity', 'dca_ladder'." + ) diff --git a/src/quantbt/viz/__init__.py b/src/quantbt/viz/__init__.py new file mode 100644 index 0000000..b9dd4e8 --- /dev/null +++ b/src/quantbt/viz/__init__.py @@ -0,0 +1,4 @@ +from .plots import quick_plot, tearsheet +from .themes import apply_theme, PALETTE + +__all__ = ["quick_plot", "tearsheet", "apply_theme", "PALETTE"] diff --git a/src/quantbt/viz/plots.py b/src/quantbt/viz/plots.py new file mode 100644 index 0000000..88c5b89 --- /dev/null +++ b/src/quantbt/viz/plots.py @@ -0,0 +1,310 @@ +""" +quantbt.viz.plots +----------------- +Two standalone plot functions that accept a BacktestResult. + +quick_plot(result) Cumulative return + drawdown. Used by analyze(). +tearsheet(result) Full dashboard: return, drawdown, rolling metrics, + monthly heatmap, PnL attribution, position exposure. +""" + +from __future__ import annotations + +from typing import Optional + +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import matplotlib.gridspec as gridspec +import matplotlib.ticker as ticker +import numpy as np +import pandas as pd +import seaborn as sns + +from ..core.types import BacktestResult +from ..metrics.performance import ( + full_report, + rolling_sharpe, + rolling_drawdown, +) +from .themes import apply_theme, PALETTE + + +# ── shared helpers ──────────────────────────────────────────────────────── + + +def _fmt_date(ax, interval_months: int = 3): + ax.xaxis.set_major_locator(mdates.MonthLocator(interval=interval_months)) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=30, ha="right", fontsize=7) + ax.tick_params(axis='x', pad=2) + +def _annotate_liq(ax, result: BacktestResult, c: dict): + if result.liquidated and result.liquidation_bar > 0: + liq_dt = result.equity.index[result.liquidation_bar] + ax.axvline(liq_dt, color=c["drawdown"], linewidth=1.2, linestyle="--", alpha=0.8) + ax.text(liq_dt, ax.get_ylim()[1] * 0.95, " liquidation", + color=c["drawdown"], fontsize=7, va="top") + + +# ── quick_plot ──────────────────────────────────────────────────────────────── + +def quick_plot( + result: BacktestResult, + theme: str = "dark", + figsize: tuple = (14, 6), + title: Optional[str] = None, + scope: str = "auto", +) -> None: + """ + Two-panel figure: cumulative return (top) and drawdown (bottom). + Suitable as a fast sanity-check or inline notebook output. + """ + from ..core.scopes import scoped_result + + result = scoped_result(result, scope=scope) + c = apply_theme(theme) + + eq = result.daily_equity + if len(eq) < 2: + eq = result.equity.dropna() + ret = (eq / eq.iloc[0] - 1) * 100 + dd = rolling_drawdown(result) * 100 # already daily + if len(dd) < 2: + peak = eq.cummax() + dd = (peak - eq) / peak.replace(0, np.nan) * 100 + + rpt = full_report(result) + + fig, axes = plt.subplots( + 2, 1, figsize=figsize, + gridspec_kw={"height_ratios": [3, 1], "hspace": 0.04}, + sharex=True, + facecolor=c["bg"], + ) + + ax_ret, ax_dd = axes + + # ── Return ── + ax_ret.plot(ret.index, ret.values, color=c["equity"], linewidth=1.8) + ax_ret.axhline(0, color=c["grid"], linewidth=0.6) + ax_ret.set_ylabel("Cumulative Return (%)", labelpad=8) + ax_ret.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.1f%%")) + + # summary label top-right + label = ( + f"Return {rpt['total_return_pct']:+.1f}% " + f"Sharpe {rpt['sharpe']:.2f} " + f"MDD {rpt['max_drawdown_pct']:.1f}%" + ) + ax_ret.set_title( + title or f"quantbt | {result.symbols[0] if len(result.symbols) == 1 else 'Portfolio'}", + loc="left", fontsize=11, fontweight="normal", + ) + ax_ret.text( + 0.99, 0.97, label, + transform=ax_ret.transAxes, + ha="right", va="top", + fontsize=8, color=c["text"], alpha=0.85, + ) + + _annotate_liq(ax_ret, result, c) + + # ── Drawdown ── + ax_dd.fill_between(dd.index, dd.values, 0, + color=c["drawdown"], alpha=0.55, linewidth=0) + ax_dd.plot(dd.index, dd.values, color=c["drawdown"], linewidth=0.8) + ax_dd.set_ylabel("Drawdown (%)", labelpad=8) + ax_dd.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.1f%%")) + ax_dd.invert_yaxis() + + _fmt_date(ax_dd) + fig.align_ylabels(axes) + # Thêm dòng này trước plt.show() + fig.autofmt_xdate(rotation=30, ha='right') + plt.tight_layout(pad=1.5) + plt.show() + + +# ── tearsheet ───────────────────────────────────────────────────────────────── + +def tearsheet( + result: BacktestResult, + theme: str = "dark", + figsize: tuple = (16, 20), + trading_days: int = 365, + benchmark: Optional[pd.Series] = None, + title: Optional[str] = None, + scope: str = "auto", +) -> None: + """ + Full performance tearsheet. + + Panels + ------ + 1 Cumulative return (+ optional benchmark) + 2 Underwater drawdown + 3 Rolling 30-day Sharpe + 4 Monthly returns heatmap + 5 Per-symbol PnL contribution + 6 Daily position exposure + """ + from ..core.scopes import scoped_result + + result = scoped_result(result, scope=scope) + c = apply_theme(theme) + rpt = full_report(result, trading_days) + + eq = result.daily_equity + ret = (eq / eq.iloc[0] - 1) * 100 + dd = rolling_drawdown(result) * 100 + rs = rolling_sharpe(result, window=30, trading_days=trading_days) + + # ── layout ── + fig = plt.figure(figsize=figsize, facecolor=c["bg"]) + gs = gridspec.GridSpec( + 6, 2, figure=fig, + height_ratios=[2.2, 1.0, 1.0, 1.4, 1.4, 1.4], + hspace=0.45, wspace=0.35, + ) + + # ── 1. cumulative return ── + ax1 = fig.add_subplot(gs[0, :]) + ax1.plot(ret.index, ret.values, color=c["equity"], linewidth=1.8, label="Strategy") + if benchmark is not None: + bm = (benchmark.resample("1D").last().ffill() / benchmark.resample("1D").last().ffill().iloc[0] - 1) * 100 + ax1.plot(bm.index, bm.values, color=c["benchmark"], linewidth=1.2, + linestyle="--", label="Benchmark") + ax1.axhline(0, color=c["grid"], linewidth=0.6) + ax1.set_ylabel("Cumulative Return (%)") + ax1.yaxis.set_major_formatter(ticker.FormatStrFormatter("%.1f%%")) + ax1.legend(loc="upper left") + _annotate_liq(ax1, result, c) + + # header title + header = ( + f"Return {rpt['total_return_pct']:+.1f}% " + f"CAGR {rpt['cagr_pct']:.1f}% " + f"Sharpe {rpt['sharpe']:.2f} " + f"Sortino {rpt['sortino']:.2f} " + f"Calmar {rpt['calmar']:.2f} " + f"MDD {rpt['max_drawdown_pct']:.1f}%" + ) + ax1.set_title( + title or "Performance Tearsheet", + loc="left", fontsize=13, fontweight="normal", pad=12, + ) + ax1.text( + 0.99, 0.97, header, + transform=ax1.transAxes, + ha="right", va="top", + fontsize=8, color=c["text"], alpha=0.9, + ) + + # ── 2. drawdown ── + ax2 = fig.add_subplot(gs[1, :], sharex=ax1) + ax2.fill_between(dd.index, dd.values, 0, + color=c["drawdown"], alpha=0.55, linewidth=0) + ax2.plot(dd.index, dd.values, color=c["drawdown"], linewidth=0.8) + ax2.set_ylabel("Drawdown (%)") + ax2.invert_yaxis() + _annotate_liq(ax2, result, c) + + # ── 3. rolling Sharpe ── + ax3 = fig.add_subplot(gs[2, :], sharex=ax1) + ax3.plot(rs.index, rs.values, color=c["neutral"], linewidth=1.4) + ax3.axhline(0, color=c["grid"], linewidth=0.6) + ax3.axhline(1, color=c["long"], linewidth=0.6, linestyle="--", alpha=0.6) + ax3.set_ylabel("Rolling Sharpe (30d)") + + _fmt_date(ax3) + + # ── 4. monthly heatmap ── + ax4 = fig.add_subplot(gs[3, :]) + _monthly_heatmap(result, ax4, c, trading_days) + + # ── 5. PnL attribution ── + ax5 = fig.add_subplot(gs[4, :]) + _pnl_attribution(result, ax5, c) + + # ── 6. position exposure ── + ax6 = fig.add_subplot(gs[5, :], sharex=ax1) + _position_exposure(result, ax6, c) + + for ax in [ax1, ax2, ax3, ax6]: + ax.set_xlim(eq.index.min(), eq.index.max()) + + fig.align_ylabels([ax1, ax2, ax3, ax6]) + + fig.autofmt_xdate(rotation=30, ha='right') + plt.tight_layout(pad=1.5, rect=[0, 0.02, 1, 1]) + plt.show() + + +# ── tearsheet sub-panels (private) ─────────────────────────────────────────── + +def _monthly_heatmap(result: BacktestResult, ax, c: dict, trading_days: int): + daily = result.daily_equity + try: + monthly = daily.resample("ME").last().pct_change().dropna() * 100 + except Exception: + monthly = daily.resample("M").last().pct_change().dropna() * 100 + + years = sorted(monthly.index.year.unique()) + heat = pd.DataFrame(0.0, index=years, columns=range(1, 13)) + for idx, val in monthly.items(): + heat.loc[idx.year, idx.month] = val + + vmax = max(abs(heat.values).max(), 1.0) + sns.heatmap( + heat, annot=True, fmt=".1f", ax=ax, + cmap="RdYlGn", center=0, vmin=-vmax, vmax=vmax, + linewidths=0.4, linecolor=c["border"], + cbar_kws={"shrink": 0.6, "label": "%"}, + annot_kws={"size": 7}, + ) + ax.set_title("Monthly Returns (%)", loc="left") + ax.set_xticklabels( + ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"], + fontsize=7, + ) + ax.set_yticklabels(ax.get_yticklabels(), fontsize=7) + ax.set_xlabel("") + ax.set_ylabel("") + + +def _pnl_attribution(result: BacktestResult, ax, c: dict): + colors = list(PALETTE["dark"].values())[4:] # cycle through accent colours + for i, sym in enumerate(result.symbols): + price_change = result.closes[f"Close_{sym}"].diff().fillna(0) + prev_pos = result.positions[f"Position_{sym}"].shift(1).fillna(0) + contrib = (prev_pos * price_change).resample("1D").sum().cumsum() + ax.plot( + contrib.index, contrib.values, + label=sym, + color=colors[i % len(colors)], + linewidth=1.4, + ) + ax.axhline(0, color=c["grid"], linewidth=0.6) + ax.set_ylabel("PnL Contribution") + ax.legend(loc="upper left", ncol=min(len(result.symbols), 6)) + ax.set_title("Cumulative PnL Contribution per Symbol", loc="left") + _fmt_date(ax) + + +def _position_exposure(result: BacktestResult, ax, c: dict): + colors = list(PALETTE["dark"].values())[4:] + for i, sym in enumerate(result.symbols): + pos = result.positions[f"Position_{sym}"].resample("1D").last() + ax.fill_between( + pos.index, pos.values, 0, + where=(pos.values > 0), + color=c["long"], alpha=0.5, + ) + ax.fill_between( + pos.index, pos.values, 0, + where=(pos.values < 0), + color=c["short"], alpha=0.5, + ) + ax.axhline(0, color=c["grid"], linewidth=0.6) + ax.set_ylabel("Position (units)") + ax.set_title("Position Exposure", loc="left") diff --git a/src/quantbt/viz/themes.py b/src/quantbt/viz/themes.py new file mode 100644 index 0000000..f20eaf9 --- /dev/null +++ b/src/quantbt/viz/themes.py @@ -0,0 +1,102 @@ +""" +quantbt.viz.themes +------------------ +Centralised styling. Two themes: 'dark' (presentation / screen) +and 'light' (report / print). + +Usage +~~~~~ + from quantbt.viz.themes import apply_theme, PALETTE + apply_theme('dark') +""" + +from __future__ import annotations + +import matplotlib as mpl +import matplotlib.pyplot as plt + +# ── Palettes ───────────────────────────────────────────────────────────────── + +PALETTE = { + "dark": { + "bg": "#0d1117", + "axes_bg": "#161b22", + "text": "#c9d1d9", + "grid": "#21262d", + "border": "#30363d", + "equity": "#58a6ff", + "drawdown": "#f85149", + "benchmark": "#8b949e", + "long": "#3fb950", + "short": "#f78166", + "neutral": "#a371f7", + "bar_pos": "#3fb950", + "bar_neg": "#f85149", + }, + "light": { + "bg": "#ffffff", + "axes_bg": "#f6f8fa", + "text": "#24292f", + "grid": "#d0d7de", + "border": "#d0d7de", + "equity": "#0550ae", + "drawdown": "#cf222e", + "benchmark": "#57606a", + "long": "#1a7f37", + "short": "#cf222e", + "neutral": "#8250df", + "bar_pos": "#1a7f37", + "bar_neg": "#cf222e", + }, +} + + +def apply_theme(theme: str = "dark") -> dict: + """ + Apply matplotlib rcParams for the chosen theme. + Returns the colour palette dict for downstream use. + """ + if theme not in PALETTE: + raise ValueError(f"theme must be 'dark' or 'light', got '{theme}'") + + c = PALETTE[theme] + + mpl.rcParams.update({ + # figure + "figure.facecolor": c["bg"], + "figure.dpi": 130, + # axes + "axes.facecolor": c["axes_bg"], + "axes.edgecolor": c["border"], + "axes.labelcolor": c["text"], + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "axes.grid.axis": "y", + "axes.titlepad": 10, + "axes.titlesize": 11, + "axes.labelsize": 9, + # grid + "grid.color": c["grid"], + "grid.linewidth": 0.5, + "grid.alpha": 1.0, + # ticks + "xtick.color": c["text"], + "ytick.color": c["text"], + "xtick.labelsize": 8, + "ytick.labelsize": 8, + # text + "text.color": c["text"], + # legend + "legend.facecolor": c["axes_bg"], + "legend.edgecolor": c["border"], + "legend.fontsize": 8, + "legend.framealpha": 0.85, + # lines + "lines.linewidth": 1.6, + # font + "font.family": "monospace", + "font.size": 9, + }) + + return c diff --git a/src/quantbt/walkforward.py b/src/quantbt/walkforward.py new file mode 100644 index 0000000..5654311 --- /dev/null +++ b/src/quantbt/walkforward.py @@ -0,0 +1,3136 @@ +""" +quantbt.walkforward +------------------- +WalkForwardEngine foundation. + +This module intentionally stays orchestration-focused. It builds time-safe +folds, calls a strategy adapter, stitches OOS signals/positions, and leaves the +final market simulation to existing QuantBT endpoints. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import hashlib +import json +import time +import warnings +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union + +import numpy as np +import pandas as pd + +from .core.preprocessor import validate_datetime +from .optimization.callbacks import SingleObjectiveEarlyStopping as _OptimizationEarlyStopping +from .optimization.space import stable_params_key, suggest_params as _optimization_suggest_params + +try: # optional acceleration; Python/NumPy baseline remains available + from numba import njit +except Exception: # pragma: no cover - optional dependency guard + njit = None + +_NUMBA_AVAILABLE = njit is not None + +StrategyOutput = Union[pd.Series, pd.DataFrame, Dict[str, pd.Series]] + + +@dataclass(frozen=True) +class WalkForwardCompatibilityEntry: + """One public walk-forward endpoint compatibility row.""" + + target_mode: str + expected_output: str + final_engine: str + status: str + notes: str = "" + + +@dataclass(frozen=True) +class WalkForwardBenchmarkSnapshot: + """Small deterministic kernel benchmark snapshot for audit/CI smoke tests.""" + + n_obs: int + n_samples: int + seed: int + numba_available: bool + numba_requested: bool + python_score_seconds: float + accelerated_score_seconds: float + python_bootstrap_seconds: float + accelerated_bootstrap_seconds: float + max_score_abs_diff: float + max_bootstrap_abs_diff: float + + def to_dict(self) -> Dict[str, Any]: + """Return a JSON-serializable snapshot.""" + return { + "n_obs": self.n_obs, + "n_samples": self.n_samples, + "seed": self.seed, + "numba_available": self.numba_available, + "numba_requested": self.numba_requested, + "python_score_seconds": self.python_score_seconds, + "accelerated_score_seconds": self.accelerated_score_seconds, + "python_bootstrap_seconds": self.python_bootstrap_seconds, + "accelerated_bootstrap_seconds": self.accelerated_bootstrap_seconds, + "max_score_abs_diff": self.max_score_abs_diff, + "max_bootstrap_abs_diff": self.max_bootstrap_abs_diff, + } + + +@dataclass(frozen=True) +class WalkForwardFold: + """One time-safe train/OOS fold.""" + + fold_id: int + train_start: pd.Timestamp + train_end: pd.Timestamp + test_start: pd.Timestamp + test_end: pd.Timestamp + train_index: pd.DatetimeIndex + test_index: pd.DatetimeIndex + + +@dataclass(frozen=True) +class WalkForwardConfig: + """ + Configuration for Phase 1 walk-forward splitting and stitching. + + Parameters + ---------- + split_mode: + String such as `walk_forward_2022`, an integer year, or a timestamp-like + value marking the first OOS period. + split_frequency: + `single`, `yearly`, `semi_yearly`, `quarterly`, `monthly`, or + `weekly`. `single` creates one train/test holdout fold. + window_mode: + `expanding` keeps the first train timestamp fixed. `rolling` uses + `train_window` as the train lookback. + train_window: + Optional pandas offset string such as `365D` or `730D`, required for + rolling mode. + min_train_bars: + Folds with fewer train bars are skipped. + min_test_bars: + Folds with fewer OOS bars are skipped. + target_mode: + Existing QuantBT route used for the final stitched backtest: + `signal_notional`, `pct_equity`, `dca_ladder`, `portfolio`, `basket`, + or `arbitrage`. + fill_value: + Value used outside OOS windows when constructing the stitched output. + """ + + split_mode: Union[str, int, pd.Timestamp] = "walk_forward_2022" + split_frequency: str = "quarterly" + window_mode: str = "expanding" + train_window: Optional[str] = None + min_train_bars: int = 1 + min_test_bars: int = 1 + target_mode: str = "signal_notional" + fill_value: float = 0.0 + optimization_mode: str = "none" + optuna_trials: int = 0 + optuna_early_stopping: Optional[int] = None + random_seed: int = 42 + decay_lambda: float = 0.5 + decay_gamma: float = 0.5 + top_is_fraction: float = 0.10 + top_is_k: Optional[int] = None + candidate_selection_metric: str = "robust_decay" + candidate_decay_lambda: Optional[float] = None + candidate_decay_gamma: Optional[float] = None + sbb_samples: int = 256 + sbb_block_length: int = 20 + sbb_decay_lambda: float = 0.5 + sbb_std_penalty: float = 0.1 + sbb_simulation: str = "stationary" + regime_count: int = 3 + regime_lookback: int = 20 + regime_weights: Optional[Dict[Union[int, str], float]] = None + stress_vol_multiplier: float = 1.0 + garch_p: int = 1 + garch_q: int = 1 + garch_dist: str = "t" + garch_vol_multiplier: float = 1.0 + flat_top_fraction: float = 0.1 + flat_eps: float = 0.15 + flat_min_samples: int = 3 + flat_selector: str = "medoid" + plateau_quantile: float = 0.25 + plateau_median_weight: float = 0.25 + plateau_std_penalty: float = 0.50 + plateau_size_bonus: float = 0.01 + is_subperiods: int = 6 + q25_weight: float = 0.30 + dispersion_penalty: float = 0.50 + temporal_weight: float = 0.65 + plateau_weight: float = 0.35 + use_bootstrap_penalty: bool = False + use_complexity_penalty: bool = False + scoring_backend: str = "proxy" + scoring_trading_days: int = 365 + min_trades_per_year: Optional[float] = None + trade_penalty_factor: Optional[float] = None + use_numba: bool = True + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + freq = self.split_frequency.lower().strip() + if freq not in {"single", "yearly", "semi_yearly", "quarterly", "monthly", "weekly"}: + raise ValueError("split_frequency must be single, yearly, semi_yearly, quarterly, monthly, or weekly") + object.__setattr__(self, "split_frequency", freq) + + mode = self.window_mode.lower().strip() + if mode not in {"expanding", "rolling"}: + raise ValueError("window_mode must be expanding or rolling") + if mode == "rolling" and self.train_window is None: + raise ValueError("rolling window_mode requires train_window") + object.__setattr__(self, "window_mode", mode) + + if self.min_train_bars <= 0 or self.min_test_bars <= 0: + raise ValueError("min_train_bars and min_test_bars must be > 0") + opt_mode = self.optimization_mode.lower().strip() + if opt_mode not in { + "none", + "mode_1_decay", + "mode_2_sbb", + "mode_3_flat_minima", + "mode_4_is_only_robust", + "mode_5_full_robust", + }: + raise NotImplementedError( + "optimization_mode must be one of: none, mode_1_decay, mode_2_sbb, mode_3_flat_minima, " + "mode_4_is_only_robust, mode_5_full_robust" + ) + object.__setattr__(self, "optimization_mode", opt_mode) + if self.optuna_trials < 0: + raise ValueError("optuna_trials must be >= 0") + if self.optuna_early_stopping is not None and self.optuna_early_stopping <= 0: + raise ValueError("optuna_early_stopping must be > 0") + if not 0.0 < self.top_is_fraction <= 1.0: + raise ValueError("top_is_fraction must be in (0, 1]") + if self.top_is_k is not None and self.top_is_k <= 0: + raise ValueError("top_is_k must be > 0 when provided") + metric = self.candidate_selection_metric.lower().strip() + if opt_mode == "mode_4_is_only_robust" and metric == "robust_decay": + metric = "is_only_robust" + if opt_mode == "mode_5_full_robust" and metric == "robust_decay": + metric = "full_robust" + valid_metrics = { + "robust_decay", + "mean_oos_sharpe", + "mean_is_sharpe", + "is_plateau_robust", + "is_only_robust", + "full_robust", + "full_plateau_robust", + "full_temporal_robust", + "full_best", + } + if metric not in valid_metrics: + raise ValueError( + "candidate_selection_metric must be robust_decay, mean_oos_sharpe, " + "mean_is_sharpe, is_plateau_robust, is_only_robust, full_robust, " + "full_plateau_robust, full_temporal_robust, or full_best" + ) + object.__setattr__(self, "candidate_selection_metric", metric) + if opt_mode == "mode_4_is_only_robust" and metric != "is_only_robust": + raise ValueError("mode_4_is_only_robust requires candidate_selection_metric='is_only_robust'") + if opt_mode == "mode_5_full_robust" and metric not in { + "full_robust", + "full_plateau_robust", + "full_temporal_robust", + "full_best", + }: + raise ValueError( + "mode_5_full_robust requires candidate_selection_metric to be one of: " + "full_robust, full_plateau_robust, full_temporal_robust, full_best" + ) + candidate_decay_lambda = None if self.candidate_decay_lambda is None else float(self.candidate_decay_lambda) + if candidate_decay_lambda is not None and candidate_decay_lambda < 0.0: + raise ValueError("candidate_decay_lambda must be >= 0 when provided") + object.__setattr__(self, "candidate_decay_lambda", candidate_decay_lambda) + candidate_decay_gamma = None if self.candidate_decay_gamma is None else float(self.candidate_decay_gamma) + if candidate_decay_gamma is not None and candidate_decay_gamma < 0.0: + raise ValueError("candidate_decay_gamma must be >= 0 when provided") + object.__setattr__(self, "candidate_decay_gamma", candidate_decay_gamma) + if self.sbb_samples <= 0: + raise ValueError("sbb_samples must be > 0") + if self.sbb_block_length <= 0: + raise ValueError("sbb_block_length must be > 0") + sim = self.sbb_simulation.lower().strip() + if sim not in {"stationary", "regime", "stress", "garch"}: + raise ValueError("sbb_simulation must be stationary, regime, stress, or garch") + object.__setattr__(self, "sbb_simulation", sim) + if self.regime_count < 2: + raise ValueError("regime_count must be >= 2") + if self.regime_lookback <= 0: + raise ValueError("regime_lookback must be > 0") + weights = None + if self.regime_weights is not None: + weights = _normalize_regime_weights(self.regime_weights, int(self.regime_count)) + object.__setattr__(self, "regime_weights", weights) + if self.stress_vol_multiplier <= 0.0: + raise ValueError("stress_vol_multiplier must be > 0") + if self.garch_p <= 0 or self.garch_q <= 0: + raise ValueError("garch_p and garch_q must be > 0") + garch_dist = self.garch_dist.lower().strip() + if garch_dist not in {"normal", "gaussian", "t", "studentst"}: + raise ValueError("garch_dist must be normal, gaussian, t, or studentst") + object.__setattr__(self, "garch_dist", "normal" if garch_dist == "gaussian" else garch_dist) + if self.garch_vol_multiplier <= 0.0: + raise ValueError("garch_vol_multiplier must be > 0") + if not 0.0 < self.flat_top_fraction <= 1.0: + raise ValueError("flat_top_fraction must be in (0, 1]") + if self.flat_eps <= 0.0: + raise ValueError("flat_eps must be > 0") + if self.flat_min_samples <= 0: + raise ValueError("flat_min_samples must be > 0") + selector = self.flat_selector.lower().strip() + if selector not in {"medoid", "centroid"}: + raise ValueError("flat_selector must be medoid or centroid") + object.__setattr__(self, "flat_selector", selector) + if not 0.0 <= self.plateau_quantile <= 1.0: + raise ValueError("plateau_quantile must be in [0, 1]") + if self.plateau_median_weight < 0.0: + raise ValueError("plateau_median_weight must be >= 0") + if self.plateau_std_penalty < 0.0: + raise ValueError("plateau_std_penalty must be >= 0") + if self.is_subperiods <= 0: + raise ValueError("is_subperiods must be > 0") + if self.q25_weight < 0.0: + raise ValueError("q25_weight must be >= 0") + if self.dispersion_penalty < 0.0: + raise ValueError("dispersion_penalty must be >= 0") + if self.temporal_weight < 0.0 or self.plateau_weight < 0.0: + raise ValueError("temporal_weight and plateau_weight must be >= 0") + scoring_backend = self.scoring_backend.lower().strip() + if scoring_backend not in {"proxy", "endpoint"}: + raise ValueError("scoring_backend must be proxy or endpoint") + if opt_mode == "mode_2_sbb" and scoring_backend == "endpoint": + raise ValueError("mode_2_sbb requires scoring_backend='proxy' because it simulates train return paths") + object.__setattr__(self, "scoring_backend", scoring_backend) + try: + scoring_days = int(self.scoring_trading_days) + except (TypeError, ValueError) as exc: + raise ValueError("scoring_trading_days must be a positive integer") from exc + if scoring_days <= 0: + raise ValueError("scoring_trading_days must be > 0") + object.__setattr__(self, "scoring_trading_days", scoring_days) + min_trades = None if self.min_trades_per_year is None else float(self.min_trades_per_year) + if min_trades is not None and min_trades < 0.0: + raise ValueError("min_trades_per_year must be >= 0 when provided") + object.__setattr__(self, "min_trades_per_year", min_trades) + penalty_factor = None if self.trade_penalty_factor is None else float(self.trade_penalty_factor) + if penalty_factor is not None and penalty_factor < 0.0: + raise ValueError("trade_penalty_factor must be >= 0 when provided") + object.__setattr__(self, "trade_penalty_factor", penalty_factor) + + +@dataclass +class WalkForwardResult: + """Phase 1 walk-forward artifact returned before/after final backtest.""" + + folds: List[WalkForwardFold] + oos_output: Optional[StrategyOutput] + fold_table: pd.DataFrame + params: Dict[str, Any] + backtest_result: Any = None + trial_table: pd.DataFrame = field(default_factory=pd.DataFrame) + candidate_table: pd.DataFrame = field(default_factory=pd.DataFrame) + best_trial: Optional[Dict[str, Any]] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + @property + def oos_positions(self) -> Optional[StrategyOutput]: + """Alias for `oos_output` used by portfolio-style callers.""" + return self.oos_output + + +@dataclass(frozen=True) +class WalkForwardTrialRecord: + """Audit row for one parameter trial.""" + + trial_id: int + params: Dict[str, Any] + objective: float + mean_is_sharpe: float + mean_oos_sharpe: float + mean_decay: float + std_decay: float + fold_metrics: List[Dict[str, Any]] + pruned: bool = False + selection_metadata: Dict[str, Any] = field(default_factory=dict) + + +class EarlyStoppingCallback(_OptimizationEarlyStopping): + """Stop Optuna if best value does not improve after N trials.""" + + def __init__(self, early_stopping_rounds: int, direction: str = "maximize"): + super().__init__(patience=int(early_stopping_rounds), direction=direction, min_delta=0.0) + self.early_stopping_rounds = int(early_stopping_rounds) + + +class DuplicatePruner: + """Optuna pruner that avoids running duplicate parameter sets.""" + + def __init__(self): + self.trial_params = set() + + def prune(self, study, trial) -> bool: + params_key = stable_params_key(trial.params) + if params_key in self.trial_params: + return True + self.trial_params.add(params_key) + return False + + +def logging_callback(study, frozen_trial) -> None: + """Record previous best value when Optuna improves.""" + previous_best_value = study.user_attrs.get("previous_best_value", None) + if previous_best_value != study.best_value: + study.set_user_attr("previous_best_value", study.best_value) + + +def walkforward_support_matrix(as_dataframe: bool = True): + """ + Return the current walk-forward compatibility matrix. + + This is intentionally public so notebooks/services can validate a route + before wiring a strategy into `QuantBTEndpoint.walk_forward(...)`. + """ + entries = [ + WalkForwardCompatibilityEntry( + target_mode="signal_notional", + expected_output="pd.Series scalar signal", + final_engine="native_vectorized or native_event", + status="supported", + notes="Recommended default for single-symbol systematic alpha.", + ), + WalkForwardCompatibilityEntry( + target_mode="notional", + expected_output="pd.Series scalar target", + final_engine="native_vectorized or native_event", + status="supported", + notes="Explicit notional sizing route.", + ), + WalkForwardCompatibilityEntry( + target_mode="unit", + expected_output="pd.Series scalar target", + final_engine="native_vectorized or native_event", + status="supported", + notes="Explicit unit sizing route.", + ), + WalkForwardCompatibilityEntry( + target_mode="pct_equity", + expected_output="pd.Series scalar weight", + final_engine="legacy BacktestEngine", + status="supported", + notes="Legacy `%_equity` accounting route.", + ), + WalkForwardCompatibilityEntry( + target_mode="dca_ladder", + expected_output="pd.Series structural ladder level", + final_engine="legacy BacktestEngine", + status="supported", + notes="Requires high/low data for intrabar ladder fills.", + ), + WalkForwardCompatibilityEntry( + target_mode="portfolio", + expected_output="pd.DataFrame or dict[str, pd.Series]", + final_engine="PortfolioBacktestEngine", + status="supported", + notes="Multi-symbol portfolio positions stitched across OOS folds.", + ), + WalkForwardCompatibilityEntry( + target_mode="basket", + expected_output="pd.Series scalar basket signal", + final_engine="native_event basket route", + status="supported", + notes="Requires BasketSpec on the endpoint.", + ), + WalkForwardCompatibilityEntry( + target_mode="arbitrage", + expected_output="pd.Series scalar package signal", + final_engine="supported arbitrage package route", + status="partial", + notes="Current supported arbitrage specs only; future specialized engines reserved.", + ), + WalkForwardCompatibilityEntry( + target_mode="nautilus_validation", + expected_output="pd.Series scalar signal", + final_engine="Nautilus adapter", + status="reserved", + notes="Reserved for future WFO parity validation, not routed by walk-forward today.", + ), + ] + rows = [entry.__dict__ for entry in entries] + if as_dataframe: + return pd.DataFrame(rows) + return rows + + +class WalkForwardEngine: + """ + Time-safe walk-forward splitter and OOS stitcher. + + The engine can use fixed `params`, Optuna decay search, SBB robustness, or + flat-minima selection. The final output is always stitched OOS only; + endpoint simulation is still delegated to QuantBT's normal backtest routes. + """ + + def __init__( + self, + strategy: Any, + config: Optional[WalkForwardConfig] = None, + scorer: Optional[Callable[..., Dict[str, float]]] = None, + ): + if strategy is None: + raise ValueError("WalkForwardEngine requires a strategy callable or strategy class/object") + self.strategy = strategy + self.config = config or WalkForwardConfig() + self.scorer = scorer + if self.config.scoring_backend == "endpoint" and self.scorer is None: + raise ValueError("scoring_backend='endpoint' requires a scorer callback") + + def run( + self, + data, + params: Optional[Dict[str, Any]] = None, + param_ranges: Optional[Dict[str, Any]] = None, + datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None, + ) -> WalkForwardResult: + """Build folds, call the strategy per fold, and stitch OOS output.""" + idx = _infer_datetime_index(data, datetime_index) + data_for_strategy = _align_data_to_datetime_index(data, idx) + folds = self.build_folds(idx) + trial_records: List[WalkForwardTrialRecord] = [] + candidate_records: List[WalkForwardTrialRecord] = [] + if params is not None: + chosen_params = dict(params) + selected_record = self.evaluate_params(data=data_for_strategy, folds=folds, params=chosen_params, trial_id=0) + trial_records.append(selected_record) + elif self.config.optimization_mode in { + "mode_1_decay", + "mode_2_sbb", + "mode_3_flat_minima", + "mode_4_is_only_robust", + "mode_5_full_robust", + } and self.config.optuna_trials > 0: + selected_record, trial_records, candidate_records = self.optimize_params( + data=data_for_strategy, + folds=folds, + param_ranges=param_ranges or {}, + ) + chosen_params = dict(selected_record.params) + else: + chosen_params = dict(_default_params_from_ranges(param_ranges or {})) + selected_record = self.evaluate_params(data=data_for_strategy, folds=folds, params=chosen_params, trial_id=0) + trial_records.append(selected_record) + + outputs: List[StrategyOutput] = [] + + for fold in folds: + out = self._call_strategy(data=data_for_strategy, params=chosen_params, fold=fold) + outputs.append(_slice_output_to_test(out, fold.test_index)) + + stitched = stitch_oos_outputs( + outputs=outputs, + folds=folds, + full_index=idx, + fill_value=self.config.fill_value, + ) + fold_table = _fold_table(folds) + return WalkForwardResult( + folds=folds, + oos_output=stitched, + fold_table=fold_table, + params=chosen_params, + trial_table=_trial_table(trial_records), + candidate_table=_trial_table(candidate_records), + best_trial=_trial_to_dict(selected_record), + metadata={ + "engine": "walk_forward_phase4", + "split_mode": str(self.config.split_mode), + "split_frequency": self.config.split_frequency, + "window_mode": self.config.window_mode, + "target_mode": self.config.target_mode, + "optimization_mode": self.config.optimization_mode, + "validation_claim": ( + "none_full_sample_calibration" + if self.config.optimization_mode == "mode_5_full_robust" + else "walk_forward_oos" + ), + "full_sample_used_for_selection": self.config.optimization_mode == "mode_5_full_robust", + "oos_used_for_selection": self.config.optimization_mode not in { + "mode_2_sbb", + "mode_4_is_only_robust", + "mode_5_full_robust", + } + and self.config.candidate_selection_metric not in { + "is_plateau_robust", + "is_only_robust", + "full_robust", + "full_plateau_robust", + "full_temporal_robust", + "full_best", + }, + "n_folds": len(folds), + "n_trials": len(trial_records), + "n_candidates": len(candidate_records), + "top_is_fraction": self.config.top_is_fraction, + "top_is_k": self.config.top_is_k, + "candidate_selection_metric": self.config.candidate_selection_metric, + "data_hash": _data_hash(data_for_strategy), + "config_hash": _config_hash(self.config), + "random_seed": self.config.random_seed, + "scoring_trading_days": self.config.scoring_trading_days, + "min_trades_per_year": self.config.min_trades_per_year, + "trade_penalty_factor": self.config.trade_penalty_factor, + "sbb_simulation": self.config.sbb_simulation, + "sbb_samples": self.config.sbb_samples, + "sbb_block_length": self.config.sbb_block_length, + "regime_count": self.config.regime_count, + "regime_lookback": self.config.regime_lookback, + "regime_weights": self.config.regime_weights, + "stress_vol_multiplier": self.config.stress_vol_multiplier, + "garch_p": self.config.garch_p, + "garch_q": self.config.garch_q, + "garch_dist": self.config.garch_dist, + "garch_vol_multiplier": self.config.garch_vol_multiplier, + "numba_enabled": bool(self.config.use_numba and _NUMBA_AVAILABLE), + "plateau_quantile": self.config.plateau_quantile, + "plateau_median_weight": self.config.plateau_median_weight, + "plateau_std_penalty": self.config.plateau_std_penalty, + "plateau_size_bonus": self.config.plateau_size_bonus, + "is_subperiods": self.config.is_subperiods, + "q25_weight": self.config.q25_weight, + "dispersion_penalty": self.config.dispersion_penalty, + "temporal_weight": self.config.temporal_weight, + "plateau_weight": self.config.plateau_weight, + "use_bootstrap_penalty": self.config.use_bootstrap_penalty, + "use_complexity_penalty": self.config.use_complexity_penalty, + "scoring_backend": self.config.scoring_backend, + **self.config.metadata, + }, + ) + + def optimize_params( + self, + data, + folds: Sequence[WalkForwardFold], + param_ranges: Dict[str, Any], + ) -> tuple[WalkForwardTrialRecord, List[WalkForwardTrialRecord], List[WalkForwardTrialRecord]]: + """Run anti-leakage two-stage optimization and return selected params plus ledgers.""" + if not param_ranges: + raise ValueError(f"{self.config.optimization_mode} optimization requires param_ranges") + validate_param_ranges(param_ranges, context=self.config.optimization_mode) + try: + import optuna + except ImportError as exc: # pragma: no cover - environment guard + raise ImportError("WalkForwardEngine optimization requires optuna") from exc + + records: List[WalkForwardTrialRecord] = [] + seen_params = set() + + def objective(trial): + params = _sample_params(trial, param_ranges) + params_key = stable_params_key(params) + if params_key in seen_params: + record = WalkForwardTrialRecord( + trial_id=int(trial.number), + params=dict(params), + objective=-np.inf, + mean_is_sharpe=0.0, + mean_oos_sharpe=0.0, + mean_decay=0.0, + std_decay=0.0, + fold_metrics=[], + pruned=True, + ) + records.append(record) + raise optuna.TrialPruned("duplicate parameter set") + seen_params.add(params_key) + if self.config.optimization_mode == "mode_2_sbb": + record = self.evaluate_params_sbb(data=data, folds=folds, params=params, trial_id=trial.number) + else: + record = self.evaluate_params_is(data=data, folds=folds, params=params, trial_id=trial.number) + records.append(record) + trial.set_user_attr("fold_metrics", record.fold_metrics) + trial.set_user_attr("params", record.params) + trial.set_user_attr("mean_is_sharpe", record.mean_is_sharpe) + trial.set_user_attr("mean_oos_sharpe", record.mean_oos_sharpe) + trial.set_user_attr("mean_decay", record.mean_decay) + trial.set_user_attr("std_decay", record.std_decay) + return record.objective + + sampler = optuna.samplers.TPESampler(seed=int(self.config.random_seed)) + pruner = DuplicatePruner() + study = optuna.create_study(direction="maximize", sampler=sampler, pruner=pruner) + callbacks = [logging_callback] + if self.config.optuna_early_stopping is not None: + callbacks.append(EarlyStoppingCallback(self.config.optuna_early_stopping)) + study.optimize( + objective, + n_trials=int(self.config.optuna_trials), + callbacks=callbacks, + show_progress_bar=False, + ) + candidates = _select_is_candidate_records(records, param_ranges, self.config) + if self.config.optimization_mode == "mode_5_full_robust": + if not candidates: + raise ValueError("full-sample robust optimization produced no candidates") + selected = _with_selection_metadata( + candidates[0], + { + **candidates[0].selection_metadata, + "stage": "full_sample_candidate_selection", + "candidate_selection_complete": True, + "oos_seen_by_optuna": False, + "oos_used_for_selection": False, + "full_sample_used_for_selection": True, + "validation_claim": "none_full_sample_calibration", + "intended_use": "production_calibration", + }, + ) + records.extend(candidates) + return selected, records, list(candidates) + candidate_records = [] + seen_candidate_params = set() + for candidate_id, candidate in enumerate(candidates): + params_key = tuple(sorted(candidate.params.items())) + if params_key in seen_candidate_params: + continue + seen_candidate_params.add(params_key) + evaluated = self.evaluate_params( + data=data, + folds=folds, + params=dict(candidate.params), + trial_id=int(candidate.trial_id), + ) + evaluated = _with_selection_metadata( + evaluated, + { + **candidate.selection_metadata, + "stage": "oos_candidate_selection", + "candidate_id": int(candidate_id), + "source_trial_id": int(candidate.trial_id), + "source_is_objective": float(candidate.objective), + "oos_seen_by_optuna": False, + }, + ) + candidate_records.append(evaluated) + if not candidate_records: + raise ValueError("anti-leakage optimization produced no OOS candidates") + best = _select_oos_candidate_record(candidate_records, self.config) + records.extend(candidate_records) + return best, records, candidate_records + + def evaluate_params_is( + self, + data, + folds: Sequence[WalkForwardFold], + params: Dict[str, Any], + trial_id: int = 0, + ) -> WalkForwardTrialRecord: + """Score params on in-sample folds only for anti-leakage Optuna search.""" + fold_metrics = [] + is_scores = [] + + for fold in folds: + is_output = self._call_strategy_for_indices( + data=data, + params=params, + train_index=fold.train_index, + test_index=fold.train_index, + fold=fold, + context="anti-leakage in-sample search", + ) + is_metrics = self._score_strategy_output( + data, + is_output, + fold.train_index, + fold=fold, + params=params, + context="anti-leakage in-sample search", + ) + required_trades = _required_trades_for_index(fold.train_index, self.config.min_trades_per_year) + factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor) + penalty = trade_frequency_penalty(is_metrics["trade_count"], required_trades, factor) + is_sharpe = is_metrics["sharpe"] - penalty + shard_stats = self._score_is_subperiods( + data=data, + is_output=is_output, + train_index=fold.train_index, + fold=fold, + params=params, + ) + is_scores.append(is_sharpe) + fold_metrics.append( + { + "fold_id": fold.fold_id, + "train_start": fold.train_start, + "train_end": fold.train_end, + "test_start": fold.test_start, + "test_end": fold.test_end, + "is_sharpe": is_sharpe, + "is_sharpe_raw": is_metrics["sharpe"], + "is_turnover": is_metrics["turnover"], + "is_trade_count": is_metrics["trade_count"], + "is_required_trades": required_trades, + "is_trade_penalty": penalty, + "oos_evaluated": False, + **shard_stats, + } + ) + + mean_is = float(np.mean(is_scores)) if is_scores else 0.0 + shard_values = _collect_subperiod_sharpes(fold_metrics) + temporal_stats = _temporal_robustness_stats( + shard_values, + q25_weight=float(self.config.q25_weight), + dispersion_penalty=float(self.config.dispersion_penalty), + fallback=mean_is, + ) + temporal_stats["is_subperiod_count"] = temporal_stats["temporal_count"] + return WalkForwardTrialRecord( + trial_id=int(trial_id), + params=dict(params), + objective=mean_is, + mean_is_sharpe=mean_is, + mean_oos_sharpe=0.0, + mean_decay=0.0, + std_decay=0.0, + fold_metrics=fold_metrics, + selection_metadata={ + "stage": "is_search", + "objective_mode": self.config.optimization_mode, + "oos_seen_by_optuna": False, + **temporal_stats, + }, + ) + + def _score_is_subperiods( + self, + data, + is_output: StrategyOutput, + train_index: pd.DatetimeIndex, + fold: WalkForwardFold, + params: Dict[str, Any], + ) -> Dict[str, Any]: + if self.config.optimization_mode not in {"mode_4_is_only_robust", "mode_5_full_robust"}: + return {} + shards = _split_index_into_subperiods(train_index, int(self.config.is_subperiods)) + scores = [] + raw_scores = [] + trade_counts = [] + factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor) + for shard_id, shard_index in enumerate(shards): + if len(shard_index) < 2: + continue + shard_output = _slice_output_to_test(is_output, shard_index) + metrics = self._score_strategy_output( + data, + shard_output, + shard_index, + fold=fold, + params=params, + context=f"is-only robustness subperiod {shard_id}", + ) + required = _required_trades_for_index(shard_index, self.config.min_trades_per_year) + penalty = trade_frequency_penalty(metrics["trade_count"], required, factor) + raw = float(metrics["sharpe"]) + score = raw - penalty + raw_scores.append(raw) + scores.append(float(score)) + trade_counts.append(float(metrics["trade_count"])) + stats = _temporal_robustness_stats( + scores, + q25_weight=float(self.config.q25_weight), + dispersion_penalty=float(self.config.dispersion_penalty), + fallback=0.0, + ) + return { + "is_subperiod_sharpes": [float(x) for x in scores], + "is_subperiod_sharpes_raw": [float(x) for x in raw_scores], + "is_subperiod_trade_counts": [float(x) for x in trade_counts], + "is_subperiod_count": int(len(scores)), + "is_subperiod_median": stats["temporal_median"], + "is_subperiod_q25": stats["temporal_q25"], + "is_subperiod_mad": stats["temporal_mad"], + "is_temporal_score": stats["temporal_score"], + } + + def evaluate_params( + self, + data, + folds: Sequence[WalkForwardFold], + params: Dict[str, Any], + trial_id: int = 0, + ) -> WalkForwardTrialRecord: + """Score params with mode_1_decay return-proxy metrics.""" + fold_metrics = [] + is_scores = [] + oos_scores = [] + decay = [] + + for fold in folds: + is_output = self._call_strategy_for_indices( + data=data, + params=params, + train_index=fold.train_index, + test_index=fold.train_index, + fold=fold, + context="in-sample scoring", + ) + oos_output = self._call_strategy_for_indices( + data=data, + params=params, + train_index=fold.train_index, + test_index=fold.test_index, + fold=fold, + context="out-of-sample scoring", + ) + is_metrics = self._score_strategy_output( + data, + is_output, + fold.train_index, + fold=fold, + params=params, + context="in-sample scoring", + ) + oos_metrics = self._score_strategy_output( + data, + oos_output, + fold.test_index, + fold=fold, + params=params, + context="out-of-sample scoring", + ) + is_required_trades = _required_trades_for_index(fold.train_index, self.config.min_trades_per_year) + oos_required_trades = _required_trades_for_index(fold.test_index, self.config.min_trades_per_year) + factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor) + is_penalty = trade_frequency_penalty(is_metrics["trade_count"], is_required_trades, factor) + oos_penalty = trade_frequency_penalty(oos_metrics["trade_count"], oos_required_trades, factor) + is_sharpe = is_metrics["sharpe"] - is_penalty + oos_sharpe = oos_metrics["sharpe"] - oos_penalty + d = is_sharpe - oos_sharpe + is_scores.append(is_sharpe) + oos_scores.append(oos_sharpe) + decay.append(d) + fold_metrics.append( + { + "fold_id": fold.fold_id, + "train_start": fold.train_start, + "train_end": fold.train_end, + "test_start": fold.test_start, + "test_end": fold.test_end, + "is_sharpe": is_sharpe, + "oos_sharpe": oos_sharpe, + "is_sharpe_raw": is_metrics["sharpe"], + "oos_sharpe_raw": oos_metrics["sharpe"], + "decay": d, + "is_turnover": is_metrics["turnover"], + "oos_turnover": oos_metrics["turnover"], + "is_trade_count": is_metrics["trade_count"], + "oos_trade_count": oos_metrics["trade_count"], + "is_required_trades": is_required_trades, + "oos_required_trades": oos_required_trades, + "is_trade_penalty": is_penalty, + "oos_trade_penalty": oos_penalty, + } + ) + + mean_oos = float(np.mean(oos_scores)) if oos_scores else 0.0 + mean_is = float(np.mean(is_scores)) if is_scores else 0.0 + mean_decay = float(np.mean(decay)) if decay else 0.0 + std_decay = float(np.std(decay, ddof=1)) if len(decay) > 1 else 0.0 + decay_lambda = self.config.decay_lambda if self.config.candidate_decay_lambda is None else self.config.candidate_decay_lambda + decay_gamma = self.config.decay_gamma if self.config.candidate_decay_gamma is None else self.config.candidate_decay_gamma + objective = ( + mean_oos + - float(decay_lambda) * std_decay + - float(decay_gamma) * max(0.0, mean_decay) + ) + return WalkForwardTrialRecord( + trial_id=int(trial_id), + params=dict(params), + objective=float(objective), + mean_is_sharpe=mean_is, + mean_oos_sharpe=mean_oos, + mean_decay=mean_decay, + std_decay=std_decay, + fold_metrics=fold_metrics, + ) + + def evaluate_params_sbb( + self, + data, + folds: Sequence[WalkForwardFold], + params: Dict[str, Any], + trial_id: int = 0, + ) -> WalkForwardTrialRecord: + """ + Score params with train-only synthetic OOS robustness. + + The strategy is evaluated on each train fold, then its train return + proxy is simulated with the selected Mode 2 generator. The selected + objective rewards high synthetic Sharpe and penalizes estimated decay + from original IS Sharpe to synthetic Sharpe. OOS bars are not evaluated + inside the Optuna objective. + """ + fold_metrics = [] + is_scores = [] + synthetic_scores = [] + synthetic_stds = [] + decay = [] + + for fold in folds: + is_output = self._call_strategy_for_indices( + data=data, + params=params, + train_index=fold.train_index, + test_index=fold.train_index, + fold=fold, + context="sbb train scoring", + ) + is_metrics = self._score_strategy_output( + data, + is_output, + fold.train_index, + fold=fold, + params=params, + context="sbb train scoring", + ) + returns = strategy_return_series( + data, + is_output, + fold.train_index, + ).to_numpy(dtype=np.float64) + seed = int(self.config.random_seed) + int(trial_id) * 100_003 + int(fold.fold_id) * 9_176 + boot = synthetic_walkforward_sharpes( + returns=returns, + n_samples=int(self.config.sbb_samples), + block_length=int(self.config.sbb_block_length), + seed=seed, + trading_days=int(self.config.scoring_trading_days), + use_numba=bool(self.config.use_numba), + simulation=self.config.sbb_simulation, + regime_count=int(self.config.regime_count), + regime_lookback=int(self.config.regime_lookback), + regime_weights=self.config.regime_weights, + stress_vol_multiplier=float(self.config.stress_vol_multiplier), + garch_p=int(self.config.garch_p), + garch_q=int(self.config.garch_q), + garch_dist=self.config.garch_dist, + garch_vol_multiplier=float(self.config.garch_vol_multiplier), + ) + synthetic_mean = float(np.mean(boot)) if len(boot) else 0.0 + synthetic_std = float(np.std(boot, ddof=1)) if len(boot) > 1 else 0.0 + required_trades = _required_trades_for_index(fold.train_index, self.config.min_trades_per_year) + factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor) + penalty = trade_frequency_penalty(is_metrics["trade_count"], required_trades, factor) + is_sharpe = is_metrics["sharpe"] - penalty + synthetic_sharpe = synthetic_mean - penalty + d = float(is_sharpe - synthetic_sharpe) + fold_objective = ( + synthetic_sharpe + - float(self.config.sbb_decay_lambda) * max(0.0, d) + - float(self.config.sbb_std_penalty) * synthetic_std + ) + is_scores.append(is_sharpe) + synthetic_scores.append(synthetic_sharpe) + synthetic_stds.append(synthetic_std) + decay.append(d) + fold_metrics.append( + { + "fold_id": fold.fold_id, + "train_start": fold.train_start, + "train_end": fold.train_end, + "test_start": fold.test_start, + "test_end": fold.test_end, + "is_sharpe": is_sharpe, + "synthetic_oos_sharpe": synthetic_sharpe, + "is_sharpe_raw": is_metrics["sharpe"], + "synthetic_oos_sharpe_raw": synthetic_mean, + "synthetic_oos_std": synthetic_std, + "decay": d, + "sbb_objective": float(fold_objective), + "sbb_samples": int(self.config.sbb_samples), + "sbb_block_length": int(self.config.sbb_block_length), + "sbb_simulation": self.config.sbb_simulation, + "regime_count": int(self.config.regime_count), + "regime_lookback": int(self.config.regime_lookback), + "regime_weights": self.config.regime_weights, + "stress_vol_multiplier": float(self.config.stress_vol_multiplier), + "garch_p": int(self.config.garch_p), + "garch_q": int(self.config.garch_q), + "garch_dist": self.config.garch_dist, + "garch_vol_multiplier": float(self.config.garch_vol_multiplier), + "is_turnover": is_metrics["turnover"], + "is_trade_count": is_metrics["trade_count"], + "is_required_trades": required_trades, + "is_trade_penalty": penalty, + } + ) + + mean_is = float(np.mean(is_scores)) if is_scores else 0.0 + mean_synthetic = float(np.mean(synthetic_scores)) if synthetic_scores else 0.0 + mean_synthetic_std = float(np.mean(synthetic_stds)) if synthetic_stds else 0.0 + mean_decay = float(np.mean(decay)) if decay else 0.0 + std_decay = float(np.std(decay, ddof=1)) if len(decay) > 1 else 0.0 + objective = ( + mean_synthetic + - float(self.config.sbb_decay_lambda) * max(0.0, mean_decay) + - float(self.config.sbb_std_penalty) * mean_synthetic_std + ) + return WalkForwardTrialRecord( + trial_id=int(trial_id), + params=dict(params), + objective=float(objective), + mean_is_sharpe=mean_is, + mean_oos_sharpe=mean_synthetic, + mean_decay=mean_decay, + std_decay=std_decay, + fold_metrics=fold_metrics, + selection_metadata={ + "stage": "is_search", + "objective_mode": "mode_2_sbb", + "sbb_samples": int(self.config.sbb_samples), + "sbb_block_length": int(self.config.sbb_block_length), + "sbb_simulation": self.config.sbb_simulation, + "regime_count": int(self.config.regime_count), + "regime_lookback": int(self.config.regime_lookback), + "regime_weights": self.config.regime_weights, + "stress_vol_multiplier": float(self.config.stress_vol_multiplier), + "garch_p": int(self.config.garch_p), + "garch_q": int(self.config.garch_q), + "garch_dist": self.config.garch_dist, + "garch_vol_multiplier": float(self.config.garch_vol_multiplier), + "oos_seen_by_optuna": False, + }, + ) + + def build_folds(self, idx: pd.DatetimeIndex) -> List[WalkForwardFold]: + """Return chronological train/OOS folds without lookahead.""" + idx = validate_datetime(idx) + if len(idx) == 0: + raise ValueError("walk-forward datetime index is empty") + + if self.config.optimization_mode == "mode_5_full_robust": + if len(idx) < self.config.min_train_bars: + raise ValueError("full-sample robust calibration produced too few bars") + return [ + WalkForwardFold( + fold_id=0, + train_start=idx[0], + train_end=idx[-1], + test_start=idx[0], + test_end=idx[-1], + train_index=idx, + test_index=idx, + ) + ] + + first_oos = _first_oos_timestamp(self.config.split_mode) + if first_oos <= idx[0]: + raise ValueError("first OOS timestamp must be after the first data timestamp") + if first_oos > idx[-1]: + raise ValueError("first OOS timestamp is after the available data") + + if self.config.split_frequency == "single": + train_start = idx[0] if self.config.window_mode == "expanding" else first_oos - pd.Timedelta(self.config.train_window) + train_index = idx[(idx >= train_start) & (idx < first_oos)] + test_index = idx[idx >= first_oos] + if len(train_index) < self.config.min_train_bars: + raise ValueError("train/test split produced too few train bars") + if len(test_index) < self.config.min_test_bars: + raise ValueError("train/test split produced too few test bars") + return [ + WalkForwardFold( + fold_id=0, + train_start=train_index[0], + train_end=train_index[-1], + test_start=test_index[0], + test_end=test_index[-1], + train_index=train_index, + test_index=test_index, + ) + ] + + step = _frequency_offset(self.config.split_frequency) + folds: List[WalkForwardFold] = [] + test_start = first_oos + fold_id = 0 + while test_start <= idx[-1]: + test_stop = test_start + step + test_mask = (idx >= test_start) & (idx < test_stop) + test_index = idx[test_mask] + if len(test_index) < self.config.min_test_bars: + test_start = test_stop + continue + + if self.config.window_mode == "expanding": + train_start = idx[0] + else: + train_start = test_start - pd.Timedelta(self.config.train_window) + train_mask = (idx >= train_start) & (idx < test_start) + train_index = idx[train_mask] + if len(train_index) < self.config.min_train_bars: + test_start = test_stop + continue + + folds.append( + WalkForwardFold( + fold_id=fold_id, + train_start=train_index[0], + train_end=train_index[-1], + test_start=test_index[0], + test_end=test_index[-1], + train_index=train_index, + test_index=test_index, + ) + ) + fold_id += 1 + test_start = test_stop + + if not folds: + raise ValueError("walk-forward split produced no folds") + return folds + + def _score_strategy_output( + self, + data, + output: StrategyOutput, + index: pd.DatetimeIndex, + fold: WalkForwardFold, + params: Dict[str, Any], + context: str, + ) -> Dict[str, float]: + if self.config.scoring_backend == "endpoint": + assert self.scorer is not None + return self.scorer( + data=data, + output=output, + index=index, + fold=fold, + params=params, + context=context, + trading_days=int(self.config.scoring_trading_days), + ) + return score_strategy_output( + data, + output, + index, + trading_days=int(self.config.scoring_trading_days), + use_numba=bool(self.config.use_numba), + ) + + def _call_strategy(self, data, params: Dict[str, Any], fold: WalkForwardFold) -> StrategyOutput: + return self._call_strategy_for_indices( + data=data, + params=params, + train_index=fold.train_index, + test_index=fold.test_index, + fold=fold, + ) + + def _call_strategy_for_indices( + self, + data, + params: Dict[str, Any], + train_index: pd.DatetimeIndex, + test_index: pd.DatetimeIndex, + fold: WalkForwardFold, + context: str = "out-of-sample generation", + ) -> StrategyOutput: + strategy = self.strategy() if isinstance(self.strategy, type) else self.strategy + try: + if hasattr(strategy, "build_signal"): + output = strategy.build_signal( + data=data, + params=params, + train_index=train_index, + test_index=test_index, + fold=fold, + ) + elif hasattr(strategy, "generate_signal"): + output = strategy.generate_signal( + data=data, + params=params, + train_index=train_index, + test_index=test_index, + fold=fold, + ) + elif callable(strategy): + output = strategy( + data=data, + params=params, + train_index=train_index, + test_index=test_index, + fold=fold, + ) + else: + raise TypeError("strategy must be callable or expose build_signal/generate_signal") + except Exception as exc: + raise RuntimeError( + "walk-forward strategy failed during " + f"{context} for fold_id={fold.fold_id}, " + f"train=[{fold.train_start}, {fold.train_end}], " + f"test=[{test_index[0]}, {test_index[-1]}]" + ) from exc + return validate_walkforward_strategy_output( + output, + expected_index=test_index, + context=f"{context} fold_id={fold.fold_id}", + ) + + +def validate_walkforward_strategy_output( + output: StrategyOutput, + expected_index: pd.DatetimeIndex, + context: str = "walk-forward strategy output", +) -> StrategyOutput: + """ + Validate strategy output before slicing/stitching. + + Walk-forward output must be timestamp-indexed. Accepting RangeIndex or + array-like output would silently reindex to all zeros, which is dangerous in + production research. + """ + idx = validate_datetime(expected_index) + if len(idx) == 0: + raise ValueError(f"{context}: expected_index is empty") + + if isinstance(output, pd.Series): + _validate_timestamped_index(output.index, context=context) + _validate_index_coverage(output.index, idx, context=context) + return output + + if isinstance(output, pd.DataFrame): + if len(output.columns) == 0: + raise ValueError(f"{context}: DataFrame output must have at least one column") + _validate_timestamped_index(output.index, context=context) + _validate_index_coverage(output.index, idx, context=context) + return output + + if isinstance(output, dict): + if not output: + raise ValueError(f"{context}: dict output must contain at least one symbol") + for symbol, series in output.items(): + if not isinstance(symbol, str) or not symbol: + raise ValueError(f"{context}: dict output keys must be non-empty symbol strings") + if not isinstance(series, pd.Series): + raise TypeError(f"{context}: dict output for {symbol!r} must be a pandas Series") + _validate_timestamped_index(series.index, context=f"{context} symbol={symbol}") + _validate_index_coverage(series.index, idx, context=f"{context} symbol={symbol}") + return output + + raise TypeError( + f"{context}: strategy output must be pd.Series, pd.DataFrame, or dict[str, pd.Series]; " + f"got {type(output).__name__}" + ) + + +def validate_param_ranges(param_ranges: Dict[str, Any], context: str = "walk-forward optimization") -> Dict[str, Any]: + """Validate Optuna/default parameter ranges and return the original mapping.""" + if not isinstance(param_ranges, dict): + raise TypeError(f"{context}: param_ranges must be a dict, got {type(param_ranges).__name__}") + if not param_ranges: + raise ValueError(f"{context}: param_ranges must not be empty") + for name, spec in param_ranges.items(): + if not isinstance(name, str) or not name: + raise ValueError(f"{context}: parameter names must be non-empty strings") + if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(x) for x in spec): + low = float(spec[0]) + high = float(spec[1]) + if high < low: + raise ValueError(f"{context}: param_ranges[{name!r}] high must be >= low") + if len(spec) == 3 and float(spec[2]) <= 0.0: + raise ValueError(f"{context}: param_ranges[{name!r}] step must be > 0") + elif isinstance(spec, (list, tuple)): + if not spec: + raise ValueError(f"{context}: param_ranges[{name!r}] categorical choices must not be empty") + elif spec is None: + raise ValueError(f"{context}: param_ranges[{name!r}] fixed value must not be None") + return param_ranges + + +def trade_frequency_penalty( + actual_trades: float, + required_trades: float, + penalty_factor: Optional[float], +) -> float: + """ + Smooth normalized linear penalty for under-trading. + + Returns zero when disabled, when required trades are non-positive, or when + actual trades meet/exceed the required count. + """ + if penalty_factor is None or penalty_factor <= 0.0 or required_trades <= 0.0: + return 0.0 + actual = max(0.0, float(actual_trades)) + required = max(0.0, float(required_trades)) + return float(penalty_factor) * max(0.0, 1.0 - actual / required) + + +def _required_trades_for_index(index: pd.DatetimeIndex, min_trades_per_year: Optional[float]) -> float: + if min_trades_per_year is None or min_trades_per_year <= 0.0 or len(index) == 0: + return 0.0 + idx = validate_datetime(index) + if len(idx) <= 1: + duration_days = 1.0 / 365.0 + else: + duration_days = max((idx[-1] - idx[0]).total_seconds() / 86_400.0, 1.0 / 365.0) + return float(min_trades_per_year) * (duration_days / 365.0) + + +def _validate_timestamped_index(index, context: str) -> None: + if not isinstance(index, pd.DatetimeIndex): + raise TypeError(f"{context}: output must use a pandas DatetimeIndex, got {type(index).__name__}") + if len(index) == 0: + raise ValueError(f"{context}: output index is empty") + + +def _validate_index_coverage(index: pd.DatetimeIndex, expected_index: pd.DatetimeIndex, context: str) -> None: + output_index = validate_datetime(index) + missing = expected_index.difference(output_index) + if len(missing) > 0: + sample = ", ".join(str(ts) for ts in missing[:3]) + raise ValueError( + f"{context}: output index must cover every expected fold timestamp; " + f"missing {len(missing)} of {len(expected_index)} timestamps, first missing: {sample}" + ) + + +def score_strategy_output( + data, + output: StrategyOutput, + index: pd.DatetimeIndex, + trading_days: int = 365, + use_numba: bool = True, +) -> Dict[str, float]: + """ + Score strategy output with a transparent return proxy. + + This is an optimization-time metric, not the final accounting simulation. + Final PnL/fees/slippage/margin still come from the endpoint backtest after + OOS stitching. + """ + idx = validate_datetime(index) + if len(idx) < 2: + return {"sharpe": 0.0, "turnover": 0.0, "mean_return": 0.0, "volatility": 0.0} + strat_returns = strategy_return_series(data, output, idx) + position_matrix = strategy_position_frame(output, idx) + returns_arr = strat_returns.to_numpy(dtype=np.float64) + pos_arr = position_matrix.to_numpy(dtype=np.float64) + if bool(use_numba) and _NUMBA_AVAILABLE: + mean, sd, sharpe, turnover, trade_count = _score_returns_positions_numba(returns_arr, pos_arr, float(trading_days)) + else: + mean, sd, sharpe, turnover, trade_count = _score_returns_positions_python(returns_arr, pos_arr, float(trading_days)) + return { + "sharpe": float(sharpe), + "turnover": float(turnover), + "trade_count": float(trade_count), + "mean_return": float(mean), + "volatility": float(sd), + } + + +def strategy_position_frame(output: StrategyOutput, index: pd.DatetimeIndex) -> pd.DataFrame: + """Return strategy output as a float position DataFrame on `index`.""" + idx = validate_datetime(index) + if isinstance(output, pd.DataFrame): + return _normalize_frame_output(output).reindex(idx).fillna(0.0) + if isinstance(output, dict): + return pd.DataFrame( + {symbol: _normalize_series_output(series).reindex(idx).fillna(0.0) for symbol, series in output.items()}, + index=idx, + ) + return pd.DataFrame({"DEFAULT": _normalize_series_output(output).reindex(idx).fillna(0.0)}, index=idx) + + +def strategy_return_series(data, output: StrategyOutput, index: pd.DatetimeIndex) -> pd.Series: + """Return the transparent position return proxy used by WFO scoring.""" + idx = validate_datetime(index) + if len(idx) == 0: + return pd.Series(dtype=float, index=idx) + close_map = _close_map_from_data(data) + if isinstance(output, pd.DataFrame): + symbols = list(output.columns) + pos = _normalize_frame_output(output, symbols).reindex(idx).fillna(0.0) + returns = pd.DataFrame({s: close_map[s].reindex(idx).pct_change().fillna(0.0) for s in symbols}) + strat_returns = (pos * returns).mean(axis=1) + elif isinstance(output, dict): + symbols = list(output.keys()) + pos = pd.DataFrame({s: _normalize_series_output(output[s]).reindex(idx).fillna(0.0) for s in symbols}) + returns = pd.DataFrame({s: close_map[s].reindex(idx).pct_change().fillna(0.0) for s in symbols}) + strat_returns = (pos * returns).mean(axis=1) + else: + series = _normalize_series_output(output).reindex(idx).fillna(0.0) + close = next(iter(close_map.values())).reindex(idx) + strat_returns = series * close.pct_change().fillna(0.0) + return strat_returns.fillna(0.0).astype(float) + + +def stationary_bootstrap_sharpes( + returns: np.ndarray, + n_samples: int, + block_length: int, + seed: int, + trading_days: int = 365, + use_numba: bool = True, +) -> np.ndarray: + """ + Generate Sharpe values from stationary block bootstrap samples. + + Random index generation stays in NumPy for transparent seeding. The repeated + sample scoring loop is numba-accelerated when numba is available. + """ + clean = np.asarray(returns, dtype=np.float64) + clean = clean[np.isfinite(clean)] + if clean.size < 2: + return np.zeros(int(n_samples), dtype=np.float64) + indices = _stationary_bootstrap_indices( + n_obs=int(clean.size), + n_samples=int(n_samples), + block_length=int(block_length), + seed=int(seed), + ) + if bool(use_numba) and _NUMBA_AVAILABLE: + return _bootstrap_sharpes_numba(clean, indices, float(trading_days)) + return _bootstrap_sharpes_python(clean, indices, float(trading_days)) + + +def synthetic_walkforward_sharpes( + returns: np.ndarray, + n_samples: int, + block_length: int, + seed: int, + trading_days: int = 365, + use_numba: bool = True, + simulation: str = "stationary", + regime_count: int = 3, + regime_lookback: int = 20, + regime_weights: Optional[Dict[Union[int, str], float]] = None, + stress_vol_multiplier: float = 1.0, + garch_p: int = 1, + garch_q: int = 1, + garch_dist: str = "t", + garch_vol_multiplier: float = 1.0, +) -> np.ndarray: + """ + Generate train-only synthetic Sharpe samples for Mode 2 WFO scoring. + + `stationary` preserves the legacy SBB behavior. `regime` bootstraps blocks + from volatility regimes estimated on the IS return proxy. `stress` keeps the + SBB dependence model but scales demeaned returns before sampling. `garch` + fits a GARCH(p, q) model on IS returns and simulates volatility-clustered + paths with a deterministic seed. + """ + sim = str(simulation).lower().strip() + clean = np.asarray(returns, dtype=np.float64) + clean = clean[np.isfinite(clean)] + if clean.size < 2: + return np.zeros(int(n_samples), dtype=np.float64) + if sim == "stationary": + return stationary_bootstrap_sharpes(clean, n_samples, block_length, seed, trading_days, use_numba) + if sim == "stress": + stressed = _stress_returns(clean, float(stress_vol_multiplier)) + return stationary_bootstrap_sharpes(stressed, n_samples, block_length, seed, trading_days, use_numba) + if sim == "regime": + labels = volatility_regime_labels(clean, regime_count=int(regime_count), lookback=int(regime_lookback)) + weights = _normalize_regime_weights(regime_weights, int(regime_count)) if regime_weights is not None else None + indices = _regime_bootstrap_indices( + labels=labels, + n_samples=int(n_samples), + block_length=int(block_length), + seed=int(seed), + regime_weights=weights, + regime_count=int(regime_count), + ) + if bool(use_numba) and _NUMBA_AVAILABLE: + return _bootstrap_sharpes_numba(clean, indices, float(trading_days)) + return _bootstrap_sharpes_python(clean, indices, float(trading_days)) + if sim == "garch": + paths = _garch_simulated_paths( + clean, + n_samples=int(n_samples), + seed=int(seed), + p=int(garch_p), + q=int(garch_q), + dist=str(garch_dist), + vol_multiplier=float(garch_vol_multiplier), + ) + if bool(use_numba) and _NUMBA_AVAILABLE: + return _path_sharpes_numba(paths, float(trading_days)) + return _path_sharpes_python(paths, float(trading_days)) + raise ValueError("simulation must be stationary, regime, stress, or garch") + + +def volatility_regime_labels(returns: np.ndarray, regime_count: int = 3, lookback: int = 20) -> np.ndarray: + """ + Assign trailing-volatility regime labels from 0 (low vol) to N-1 (high vol). + + The function uses only the in-sample return proxy passed by the caller. It + does not inspect future OOS bars, so it is safe inside the WFO objective. + """ + clean = np.asarray(returns, dtype=np.float64) + clean = clean[np.isfinite(clean)] + if clean.size == 0: + return np.zeros(0, dtype=np.int64) + n_regimes = max(2, int(regime_count)) + window = max(1, int(lookback)) + trailing_vol = np.empty(clean.size, dtype=np.float64) + abs_ret = np.abs(clean) + cumsum = np.concatenate(([0.0], np.cumsum(abs_ret))) + for i in range(clean.size): + start = max(0, i + 1 - window) + trailing_vol[i] = (cumsum[i + 1] - cumsum[start]) / float(i + 1 - start) + quantiles = np.linspace(0.0, 1.0, n_regimes + 1)[1:-1] + cuts = np.quantile(trailing_vol, quantiles) if quantiles.size else np.array([], dtype=np.float64) + labels = np.searchsorted(cuts, trailing_vol, side="right").astype(np.int64) + return np.minimum(labels, n_regimes - 1) + + +def benchmark_walkforward_kernels( + n_obs: int = 2_000, + n_samples: int = 128, + seed: int = 42, + use_numba: bool = True, +) -> WalkForwardBenchmarkSnapshot: + """ + Run a deterministic lightweight benchmark for WFO numeric kernels. + + The snapshot is intended for smoke/performance-regression tracking. Unit + tests should assert finite timings and numerical equivalence, not hard wall + clock thresholds. + """ + if n_obs < 2: + raise ValueError("n_obs must be >= 2") + if n_samples < 1: + raise ValueError("n_samples must be >= 1") + rng = np.random.default_rng(int(seed)) + returns = rng.normal(loc=0.0002, scale=0.01, size=int(n_obs)).astype(np.float64) + positions = rng.choice(np.array([-1.0, 0.0, 1.0], dtype=np.float64), size=(int(n_obs), 3)) + indices = _stationary_bootstrap_indices( + n_obs=int(n_obs), + n_samples=int(n_samples), + block_length=max(2, int(np.sqrt(n_obs))), + seed=int(seed), + ) + + start = time.perf_counter() + py_score = _score_returns_positions_python(returns, positions, 365.0) + python_score_seconds = time.perf_counter() - start + + start = time.perf_counter() + accelerated_score = ( + _score_returns_positions_numba(returns, positions, 365.0) + if bool(use_numba) and _NUMBA_AVAILABLE + else _score_returns_positions_python(returns, positions, 365.0) + ) + accelerated_score_seconds = time.perf_counter() - start + + start = time.perf_counter() + py_boot = _bootstrap_sharpes_python(returns, indices, 365.0) + python_bootstrap_seconds = time.perf_counter() - start + + start = time.perf_counter() + accelerated_boot = ( + _bootstrap_sharpes_numba(returns, indices, 365.0) + if bool(use_numba) and _NUMBA_AVAILABLE + else _bootstrap_sharpes_python(returns, indices, 365.0) + ) + accelerated_bootstrap_seconds = time.perf_counter() - start + + return WalkForwardBenchmarkSnapshot( + n_obs=int(n_obs), + n_samples=int(n_samples), + seed=int(seed), + numba_available=bool(_NUMBA_AVAILABLE), + numba_requested=bool(use_numba), + python_score_seconds=float(python_score_seconds), + accelerated_score_seconds=float(accelerated_score_seconds), + python_bootstrap_seconds=float(python_bootstrap_seconds), + accelerated_bootstrap_seconds=float(accelerated_bootstrap_seconds), + max_score_abs_diff=float(np.max(np.abs(np.asarray(py_score) - np.asarray(accelerated_score)))), + max_bootstrap_abs_diff=float(np.max(np.abs(py_boot - accelerated_boot))), + ) + + +def _stationary_bootstrap_indices(n_obs: int, n_samples: int, block_length: int, seed: int) -> np.ndarray: + if n_obs <= 0: + raise ValueError("n_obs must be > 0") + rng = np.random.default_rng(int(seed)) + p = 1.0 / max(1.0, float(block_length)) + indices = np.empty((int(n_samples), int(n_obs)), dtype=np.int64) + for sample in range(int(n_samples)): + current = int(rng.integers(0, n_obs)) + indices[sample, 0] = current + for i in range(1, n_obs): + if rng.random() < p: + current = int(rng.integers(0, n_obs)) + else: + current = (current + 1) % n_obs + indices[sample, i] = current + return indices + + +def _regime_bootstrap_indices( + labels: np.ndarray, + n_samples: int, + block_length: int, + seed: int, + regime_weights: Optional[Dict[Union[int, str], float]] = None, + regime_count: Optional[int] = None, +) -> np.ndarray: + labels = np.asarray(labels, dtype=np.int64) + if labels.size <= 0: + raise ValueError("labels must not be empty") + n_obs = int(labels.size) + n_regimes = max(int(np.max(labels)) + 1, 2 if regime_count is None else int(regime_count)) + rng = np.random.default_rng(int(seed)) + p = 1.0 / max(1.0, float(block_length)) + if regime_weights is None: + counts = np.bincount(labels, minlength=n_regimes).astype(np.float64) + probs = counts / counts.sum() + else: + probs = np.zeros(n_regimes, dtype=np.float64) + for key, value in regime_weights.items(): + idx = _regime_key_to_index(key, n_regimes) + probs[idx] = float(value) + total = float(probs.sum()) + if total <= 0.0: + raise ValueError("regime_weights must sum to a positive value") + probs = probs / total + + starts_by_regime = [np.flatnonzero(labels == regime) for regime in range(n_regimes)] + all_starts = np.arange(n_obs, dtype=np.int64) + indices = np.empty((int(n_samples), n_obs), dtype=np.int64) + for sample in range(int(n_samples)): + current_regime = int(rng.choice(n_regimes, p=probs)) + choices = starts_by_regime[current_regime] + if choices.size == 0: + choices = all_starts + current = int(rng.choice(choices)) + indices[sample, 0] = current + for i in range(1, n_obs): + next_current = (current + 1) % n_obs + if rng.random() < p or labels[next_current] != current_regime: + current_regime = int(rng.choice(n_regimes, p=probs)) + choices = starts_by_regime[current_regime] + if choices.size == 0: + choices = all_starts + current = int(rng.choice(choices)) + else: + current = next_current + indices[sample, i] = current + return indices + + +def _stress_returns(returns: np.ndarray, vol_multiplier: float) -> np.ndarray: + clean = np.asarray(returns, dtype=np.float64) + mean = float(np.mean(clean)) if clean.size else 0.0 + return mean + (clean - mean) * float(vol_multiplier) + + +def _normalize_regime_weights( + weights: Optional[Dict[Union[int, str], float]], + regime_count: int, +) -> Optional[Dict[int, float]]: + if weights is None: + return None + n_regimes = max(2, int(regime_count)) + out: Dict[int, float] = {} + for key, value in weights.items(): + idx = _regime_key_to_index(key, n_regimes) + val = float(value) + if val < 0.0: + raise ValueError("regime_weights values must be >= 0") + out[idx] = out.get(idx, 0.0) + val + total = sum(out.values()) + if total <= 0.0: + raise ValueError("regime_weights must sum to a positive value") + return {key: value / total for key, value in out.items()} + + +def _regime_key_to_index(key: Union[int, str], regime_count: int) -> int: + n_regimes = max(2, int(regime_count)) + if isinstance(key, (int, np.integer)): + idx = int(key) + else: + raw = str(key).lower().strip() + aliases = { + "low": 0, + "low_vol": 0, + "calm": 0, + "mid": n_regimes // 2, + "medium": n_regimes // 2, + "normal": n_regimes // 2, + "high": n_regimes - 1, + "high_vol": n_regimes - 1, + "crash": n_regimes - 1, + "stress": n_regimes - 1, + } + idx = aliases[raw] if raw in aliases else int(raw) + if idx < 0 or idx >= n_regimes: + raise ValueError(f"regime key {key!r} is outside [0, {n_regimes - 1}]") + return idx + + +def _garch_simulated_paths( + returns: np.ndarray, + n_samples: int, + seed: int, + p: int, + q: int, + dist: str, + vol_multiplier: float, +) -> np.ndarray: + clean = np.asarray(returns, dtype=np.float64) + clean = clean[np.isfinite(clean)] + min_obs = max(30, (int(p) + int(q)) * 12) + if clean.size < min_obs: + raise ValueError(f"garch simulation requires at least {min_obs} finite IS returns") + try: + from arch import arch_model + except Exception as exc: # pragma: no cover - optional dependency guard + raise ImportError("sbb_simulation='garch' requires the optional arch package") from exc + + scaled = clean * 100.0 + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + model = arch_model( + scaled, + mean="Constant", + vol="GARCH", + p=int(p), + q=int(q), + dist=str(dist), + rescale=False, + ) + result = model.fit(disp="off", show_warning=False) + + params = result.params + mu = float(params.get("mu", 0.0)) + omega = max(float(params.get("omega", np.var(scaled) * 0.01)), 1e-12) + alphas = np.array([max(float(params.get(f"alpha[{i}]", 0.0)), 0.0) for i in range(1, int(p) + 1)]) + betas = np.array([max(float(params.get(f"beta[{i}]", 0.0)), 0.0) for i in range(1, int(q) + 1)]) + total_persistence = float(alphas.sum() + betas.sum()) + unconditional_var = float(np.var(scaled, ddof=1)) + if total_persistence < 0.999: + unconditional_var = max(omega / max(1e-12, 1.0 - total_persistence), 1e-12) + rng = np.random.default_rng(int(seed)) + paths_pct = np.empty((int(n_samples), clean.size), dtype=np.float64) + max_lag = max(int(p), int(q), 1) + nu = max(float(params.get("nu", 8.0)), 2.1) + for sample_id in range(int(n_samples)): + eps = np.zeros(clean.size + max_lag, dtype=np.float64) + sigma2 = np.full(clean.size + max_lag, unconditional_var, dtype=np.float64) + for t in range(max_lag, clean.size + max_lag): + var_t = omega + for i, alpha in enumerate(alphas, start=1): + var_t += float(alpha) * eps[t - i] * eps[t - i] + for j, beta in enumerate(betas, start=1): + var_t += float(beta) * sigma2[t - j] + sigma2[t] = max(var_t, 1e-12) + if str(dist).lower() in {"t", "studentst"}: + shock = float(rng.standard_t(nu)) * float(np.sqrt((nu - 2.0) / nu)) + else: + shock = float(rng.normal()) + eps[t] = float(np.sqrt(sigma2[t])) * shock + paths_pct[sample_id, t - max_lag] = mu + eps[t] + paths = paths_pct / 100.0 + return _stress_paths(paths, float(vol_multiplier)) + + +def _stress_paths(paths: np.ndarray, vol_multiplier: float) -> np.ndarray: + arr = np.asarray(paths, dtype=np.float64) + means = np.mean(arr, axis=1, keepdims=True) + return means + (arr - means) * float(vol_multiplier) + + +def _score_returns_positions_python( + returns: np.ndarray, + positions: np.ndarray, + trading_days: float, +) -> Tuple[float, float, float, float, float]: + returns = np.asarray(returns, dtype=np.float64) + positions = np.asarray(positions, dtype=np.float64) + if returns.size == 0: + return 0.0, 0.0, 0.0, 0.0, 0.0 + mean = float(np.mean(returns)) + sd = float(np.std(returns, ddof=1)) if returns.size > 1 else 0.0 + sharpe = (mean / sd) * float(np.sqrt(trading_days)) if sd > 0.0 else 0.0 + turnover = 0.0 + trade_count = 0.0 + if positions.ndim == 1: + positions = positions.reshape((-1, 1)) + if positions.shape[0] > 0: + trade_count += float(np.count_nonzero(np.abs(positions[0, :]) > 0.0)) + if positions.shape[0] > 1: + diffs = np.diff(positions, axis=0) + turnover = float(np.abs(diffs).sum()) + trade_count = float(np.count_nonzero(np.abs(diffs) > 0.0)) + trade_count += float(np.count_nonzero(np.abs(positions[0, :]) > 0.0)) + return mean, sd, sharpe, turnover, trade_count + + +def _bootstrap_sharpes_python(returns: np.ndarray, indices: np.ndarray, trading_days: float) -> np.ndarray: + out = np.empty(indices.shape[0], dtype=np.float64) + for i in range(indices.shape[0]): + sample = returns[indices[i]] + mean = float(np.mean(sample)) + sd = float(np.std(sample, ddof=1)) if sample.size > 1 else 0.0 + out[i] = (mean / sd) * float(np.sqrt(trading_days)) if sd > 0.0 else 0.0 + return out + + +def _path_sharpes_python(paths: np.ndarray, trading_days: float) -> np.ndarray: + arr = np.asarray(paths, dtype=np.float64) + out = np.empty(arr.shape[0], dtype=np.float64) + for i in range(arr.shape[0]): + sample = arr[i] + mean = float(np.mean(sample)) + sd = float(np.std(sample, ddof=1)) if sample.size > 1 else 0.0 + out[i] = (mean / sd) * float(np.sqrt(trading_days)) if sd > 0.0 else 0.0 + return out + + +if _NUMBA_AVAILABLE: + + @njit(cache=True) + def _score_returns_positions_numba(returns, positions, trading_days): # pragma: no cover - compared via tests + n = returns.shape[0] + if n == 0: + return 0.0, 0.0, 0.0, 0.0, 0.0 + total = 0.0 + for i in range(n): + total += returns[i] + mean = total / n + sd = 0.0 + if n > 1: + var = 0.0 + for i in range(n): + diff = returns[i] - mean + var += diff * diff + sd = (var / (n - 1)) ** 0.5 + sharpe = 0.0 + if sd > 0.0: + sharpe = (mean / sd) * (trading_days ** 0.5) + turnover = 0.0 + trade_count = 0.0 + if positions.shape[0] > 0: + for j in range(positions.shape[1]): + if abs(positions[0, j]) > 0.0: + trade_count += 1.0 + if positions.shape[0] > 1: + for i in range(1, positions.shape[0]): + for j in range(positions.shape[1]): + diff = positions[i, j] - positions[i - 1, j] + turnover += abs(diff) + if abs(diff) > 0.0: + trade_count += 1.0 + return mean, sd, sharpe, turnover, trade_count + + @njit(cache=True) + def _bootstrap_sharpes_numba(returns, indices, trading_days): # pragma: no cover - compared via tests + n_samples = indices.shape[0] + n_obs = indices.shape[1] + out = np.empty(n_samples, dtype=np.float64) + for sample_id in range(n_samples): + total = 0.0 + for i in range(n_obs): + total += returns[indices[sample_id, i]] + mean = total / n_obs + sd = 0.0 + if n_obs > 1: + var = 0.0 + for i in range(n_obs): + diff = returns[indices[sample_id, i]] - mean + var += diff * diff + sd = (var / (n_obs - 1)) ** 0.5 + if sd > 0.0: + out[sample_id] = (mean / sd) * (trading_days ** 0.5) + else: + out[sample_id] = 0.0 + return out + + @njit(cache=True) + def _path_sharpes_numba(paths, trading_days): # pragma: no cover - compared via tests + n_samples = paths.shape[0] + n_obs = paths.shape[1] + out = np.empty(n_samples, dtype=np.float64) + for sample_id in range(n_samples): + total = 0.0 + for i in range(n_obs): + total += paths[sample_id, i] + mean = total / n_obs + sd = 0.0 + if n_obs > 1: + var = 0.0 + for i in range(n_obs): + diff = paths[sample_id, i] - mean + var += diff * diff + sd = (var / (n_obs - 1)) ** 0.5 + if sd > 0.0: + out[sample_id] = (mean / sd) * (trading_days ** 0.5) + else: + out[sample_id] = 0.0 + return out + +else: + + def _score_returns_positions_numba(returns, positions, trading_days): # pragma: no cover - fallback alias + return _score_returns_positions_python(returns, positions, trading_days) + + def _bootstrap_sharpes_numba(returns, indices, trading_days): # pragma: no cover - fallback alias + return _bootstrap_sharpes_python(returns, indices, trading_days) + + def _path_sharpes_numba(paths, trading_days): # pragma: no cover - fallback alias + return _path_sharpes_python(paths, trading_days) + + +def select_flat_minima_record( + records: Sequence[WalkForwardTrialRecord], + param_ranges: Dict[str, Any], + config: WalkForwardConfig, +) -> WalkForwardTrialRecord: + """ + Select a robust top-trial cluster member instead of a sharp isolated peak. + + This implements the Phase 3 flat-minima selector with a small deterministic + DBSCAN-style clustering pass over normalized parameter coordinates. + """ + candidates = [r for r in records if not r.pruned and np.isfinite(r.objective)] + if not candidates: + raise ValueError("flat-minima selection received no completed trials") + ranked = sorted(candidates, key=lambda record: record.objective, reverse=True) + top_n = max(1, int(np.ceil(len(ranked) * float(config.flat_top_fraction)))) + top_n = min(len(ranked), max(top_n, int(config.flat_min_samples))) + top = ranked[:top_n] + matrix, names = _param_matrix(top, param_ranges) + if matrix.shape[0] == 1 or matrix.shape[1] == 0: + return _with_selection_metadata( + top[0], + { + "objective_mode": "mode_3_flat_minima", + "selector": "fallback_best", + "reason": "insufficient_cluster_points", + "top_trials": int(top_n), + }, + ) + + labels, cluster_method = _dbscan_cluster_labels( + matrix, + eps=float(config.flat_eps), + min_samples=int(config.flat_min_samples), + ) + cluster_ids = sorted(label for label in set(labels.tolist()) if label >= 0) + if not cluster_ids: + return _with_selection_metadata( + top[0], + { + "objective_mode": "mode_3_flat_minima", + "selector": "fallback_best", + "reason": "no_dense_cluster", + "top_trials": int(top_n), + "eps": float(config.flat_eps), + "min_samples": int(config.flat_min_samples), + "cluster_method": cluster_method, + }, + ) + + best_cluster = None + best_key = None + for cluster_id in cluster_ids: + member_idx = np.flatnonzero(labels == cluster_id) + member_objectives = np.array([top[i].objective for i in member_idx], dtype=np.float64) + key = (len(member_idx), float(np.mean(member_objectives)), float(np.max(member_objectives))) + if best_key is None or key > best_key: + best_key = key + best_cluster = member_idx + assert best_cluster is not None + centroid = np.mean(matrix[best_cluster], axis=0) + distances = np.sqrt(((matrix[best_cluster] - centroid) ** 2).sum(axis=1)) + selected_idx = int(best_cluster[int(np.argmin(distances))]) + medoid = top[selected_idx] + centroid_params = _centroid_params( + centroid=centroid, + names=names, + param_ranges=param_ranges, + base_params=medoid.params, + ) + selected = medoid + requires_evaluation = False + if config.flat_selector == "centroid": + selected = WalkForwardTrialRecord( + trial_id=-1, + params=centroid_params, + objective=float(np.mean([top[i].objective for i in best_cluster])), + mean_is_sharpe=float(np.mean([top[i].mean_is_sharpe for i in best_cluster])), + mean_oos_sharpe=float(np.mean([top[i].mean_oos_sharpe for i in best_cluster])), + mean_decay=float(np.mean([top[i].mean_decay for i in best_cluster])), + std_decay=float(np.mean([top[i].std_decay for i in best_cluster])), + fold_metrics=[], + ) + requires_evaluation = True + return _with_selection_metadata( + selected, + { + "objective_mode": "mode_3_flat_minima", + "selector": str(config.flat_selector), + "param_names": names, + "selected_trial_id": int(selected.trial_id), + "medoid_trial_id": int(medoid.trial_id), + "medoid_params": dict(medoid.params), + "centroid_params": centroid_params, + "centroid_normalized": [float(x) for x in centroid.tolist()], + "requires_evaluation": requires_evaluation, + "cluster_size": int(len(best_cluster)), + "cluster_mean_objective": float(np.mean([top[i].objective for i in best_cluster])), + "cluster_best_objective": float(np.max([top[i].objective for i in best_cluster])), + "top_trials": int(top_n), + "eps": float(config.flat_eps), + "min_samples": int(config.flat_min_samples), + "cluster_method": cluster_method, + }, + ) + + +def select_is_plateau_robust_record( + records: Sequence[WalkForwardTrialRecord], + param_ranges: Dict[str, Any], + config: WalkForwardConfig, +) -> WalkForwardTrialRecord: + """ + Select robust train-only params from the top IS/search trial plateau. + + The selector first takes the top `top_is_fraction`/`top_is_k` trials by the + train-side objective. Inside that candidate pool it prefers dense parameter + regions whose lower-tail and median scores remain strong while penalizing + noisy, isolated peaks. OOS metrics are intentionally not used. + """ + completed = [record for record in records if not record.pruned and np.isfinite(record.objective)] + if not completed: + raise ValueError("is_plateau_robust selection received no completed trials") + ranked = sorted(completed, key=lambda record: record.objective, reverse=True) + top_n = _candidate_count(len(ranked), config) + top = ranked[:top_n] + matrix, names = _param_matrix(top, param_ranges) + if matrix.shape[0] == 1 or matrix.shape[1] == 0: + return _with_selection_metadata( + top[0], + { + "objective_mode": config.optimization_mode, + "selector": "fallback_best_train_objective", + "selected_by": "is_plateau_robust", + "oos_used_for_selection": False, + "reason": "insufficient_cluster_points", + "top_trials": int(top_n), + }, + ) + + labels, cluster_method = _dbscan_cluster_labels( + matrix, + eps=float(config.flat_eps), + min_samples=int(config.flat_min_samples), + ) + cluster_ids = sorted(label for label in set(labels.tolist()) if label >= 0) + if not cluster_ids: + return _with_selection_metadata( + top[0], + { + "objective_mode": config.optimization_mode, + "selector": "fallback_best_train_objective", + "selected_by": "is_plateau_robust", + "oos_used_for_selection": False, + "reason": "no_dense_train_plateau", + "top_trials": int(top_n), + "eps": float(config.flat_eps), + "min_samples": int(config.flat_min_samples), + "cluster_method": cluster_method, + }, + ) + + best_cluster = None + best_key = None + best_cluster_stats = None + for cluster_id in cluster_ids: + member_idx = np.flatnonzero(labels == cluster_id) + values = np.array([top[i].objective for i in member_idx], dtype=np.float64) + q = float(np.quantile(values, float(config.plateau_quantile))) + median = float(np.median(values)) + std = float(np.std(values, ddof=1)) if len(values) > 1 else 0.0 + cluster_score = ( + q + + float(config.plateau_median_weight) * median + - float(config.plateau_std_penalty) * std + + float(config.plateau_size_bonus) * float(np.log1p(len(member_idx))) + ) + key = (cluster_score, q, median, len(member_idx), float(np.max(values))) + if best_key is None or key > best_key: + best_key = key + best_cluster = member_idx + best_cluster_stats = { + "plateau_score": float(cluster_score), + "plateau_quantile_score": q, + "plateau_median_score": median, + "plateau_std_score": std, + "cluster_best_objective": float(np.max(values)), + } + assert best_cluster is not None and best_cluster_stats is not None + + centroid = np.mean(matrix[best_cluster], axis=0) + distances = np.sqrt(((matrix[best_cluster] - centroid) ** 2).sum(axis=1)) + selected_idx = int(best_cluster[int(np.argmin(distances))]) + medoid = top[selected_idx] + centroid_params = _centroid_params( + centroid=centroid, + names=names, + param_ranges=param_ranges, + base_params=medoid.params, + ) + selected = medoid + requires_evaluation = False + if config.flat_selector == "centroid": + selected = WalkForwardTrialRecord( + trial_id=-1, + params=centroid_params, + objective=float(best_cluster_stats["plateau_score"]), + mean_is_sharpe=float(np.mean([top[i].mean_is_sharpe for i in best_cluster])), + mean_oos_sharpe=0.0, + mean_decay=0.0, + std_decay=0.0, + fold_metrics=[], + ) + requires_evaluation = True + + return _with_selection_metadata( + selected, + { + **best_cluster_stats, + "objective_mode": config.optimization_mode, + "selector": str(config.flat_selector), + "selected_by": "is_plateau_robust", + "oos_used_for_selection": False, + "param_names": names, + "selected_trial_id": int(selected.trial_id), + "medoid_trial_id": int(medoid.trial_id), + "medoid_params": dict(medoid.params), + "centroid_params": centroid_params, + "centroid_normalized": [float(x) for x in centroid.tolist()], + "requires_evaluation": requires_evaluation, + "cluster_size": int(len(best_cluster)), + "top_trials": int(top_n), + "eps": float(config.flat_eps), + "min_samples": int(config.flat_min_samples), + "plateau_quantile": float(config.plateau_quantile), + "plateau_median_weight": float(config.plateau_median_weight), + "plateau_std_penalty": float(config.plateau_std_penalty), + "plateau_size_bonus": float(config.plateau_size_bonus), + "cluster_method": cluster_method, + }, + ) + + +def select_is_only_robust_record( + records: Sequence[WalkForwardTrialRecord], + param_ranges: Dict[str, Any], + config: WalkForwardConfig, +) -> WalkForwardTrialRecord: + """ + Select strict train-only robust params from IS temporal stability + plateau. + + This selector is designed for `mode_4_is_only_robust`. It never reads OOS + metrics. It combines two IS-only robustness signals: + + * temporal robustness across train subperiod shards; + * plateau robustness across dense top-trial parameter regions. + """ + completed = [record for record in records if not record.pruned and np.isfinite(record.objective)] + if not completed: + raise ValueError("is_only_robust selection received no completed trials") + ranked = sorted(completed, key=lambda record: record.objective, reverse=True) + top_n = _candidate_count(len(ranked), config) + top = ranked[:top_n] + matrix, names = _param_matrix(top, param_ranges) + if matrix.shape[0] == 1 or matrix.shape[1] == 0: + selected = _best_temporal_record(top) + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + "objective_mode": config.optimization_mode, + "selector": "fallback_best_is_temporal", + "selected_by": "is_only_robust", + "oos_used_for_selection": False, + "reason": "insufficient_cluster_points", + "top_trials": int(top_n), + "candidate_selection_complete": True, + }, + ) + + labels, cluster_method = _dbscan_cluster_labels( + matrix, + eps=float(config.flat_eps), + min_samples=int(config.flat_min_samples), + ) + cluster_ids = sorted(label for label in set(labels.tolist()) if label >= 0) + if not cluster_ids: + selected = _best_temporal_record(top) + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + "objective_mode": config.optimization_mode, + "selector": "fallback_best_is_temporal", + "selected_by": "is_only_robust", + "oos_used_for_selection": False, + "reason": "no_dense_train_plateau", + "top_trials": int(top_n), + "eps": float(config.flat_eps), + "min_samples": int(config.flat_min_samples), + "cluster_method": cluster_method, + "candidate_selection_complete": True, + }, + ) + + best_cluster = None + best_key = None + best_cluster_stats = None + for cluster_id in cluster_ids: + member_idx = np.flatnonzero(labels == cluster_id) + objective_values = np.array([top[i].objective for i in member_idx], dtype=np.float64) + temporal_values = np.array( + [float(top[i].selection_metadata.get("temporal_score", top[i].mean_is_sharpe)) for i in member_idx], + dtype=np.float64, + ) + q = float(np.quantile(objective_values, float(config.plateau_quantile))) + median = float(np.median(objective_values)) + std = float(np.std(objective_values, ddof=1)) if len(objective_values) > 1 else 0.0 + plateau_score = ( + q + + float(config.plateau_median_weight) * median + - float(config.plateau_std_penalty) * std + + float(config.plateau_size_bonus) * float(np.log1p(len(member_idx))) + ) + temporal_stats = _temporal_robustness_stats( + temporal_values, + q25_weight=float(config.q25_weight), + dispersion_penalty=float(config.dispersion_penalty), + fallback=float(np.mean(temporal_values)) if len(temporal_values) else 0.0, + ) + bootstrap_penalty = 0.0 + complexity_penalty = 0.0 + final_score = ( + float(config.temporal_weight) * float(temporal_stats["temporal_score"]) + + float(config.plateau_weight) * float(plateau_score) + - bootstrap_penalty + - complexity_penalty + ) + key = ( + final_score, + float(temporal_stats["temporal_q25"]), + float(temporal_stats["temporal_median"]), + plateau_score, + len(member_idx), + float(np.max(objective_values)), + ) + if best_key is None or key > best_key: + best_key = key + best_cluster = member_idx + best_cluster_stats = { + "is_only_robust_score": float(final_score), + "temporal_score": float(temporal_stats["temporal_score"]), + "temporal_median": float(temporal_stats["temporal_median"]), + "temporal_q25": float(temporal_stats["temporal_q25"]), + "temporal_mad": float(temporal_stats["temporal_mad"]), + "plateau_score": float(plateau_score), + "plateau_quantile_score": q, + "plateau_median_score": median, + "plateau_std_score": std, + "cluster_best_objective": float(np.max(objective_values)), + "bootstrap_penalty": bootstrap_penalty, + "complexity_penalty": complexity_penalty, + } + assert best_cluster is not None and best_cluster_stats is not None + + centroid = np.mean(matrix[best_cluster], axis=0) + distances = np.sqrt(((matrix[best_cluster] - centroid) ** 2).sum(axis=1)) + selected_idx = int(best_cluster[int(np.argmin(distances))]) + medoid = top[selected_idx] + centroid_params = _centroid_params( + centroid=centroid, + names=names, + param_ranges=param_ranges, + base_params=medoid.params, + ) + selected = medoid + requires_evaluation = False + if config.flat_selector == "centroid": + selected = WalkForwardTrialRecord( + trial_id=-1, + params=centroid_params, + objective=float(best_cluster_stats["is_only_robust_score"]), + mean_is_sharpe=float(np.mean([top[i].mean_is_sharpe for i in best_cluster])), + mean_oos_sharpe=0.0, + mean_decay=0.0, + std_decay=0.0, + fold_metrics=[], + ) + requires_evaluation = True + + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + **best_cluster_stats, + "objective_mode": config.optimization_mode, + "selector": str(config.flat_selector), + "selected_by": "is_only_robust", + "oos_used_for_selection": False, + "param_names": names, + "selected_trial_id": int(selected.trial_id), + "medoid_trial_id": int(medoid.trial_id), + "medoid_params": dict(medoid.params), + "centroid_params": centroid_params, + "centroid_normalized": [float(x) for x in centroid.tolist()], + "requires_evaluation": requires_evaluation, + "cluster_size": int(len(best_cluster)), + "top_trials": int(top_n), + "eps": float(config.flat_eps), + "min_samples": int(config.flat_min_samples), + "plateau_quantile": float(config.plateau_quantile), + "plateau_median_weight": float(config.plateau_median_weight), + "plateau_std_penalty": float(config.plateau_std_penalty), + "plateau_size_bonus": float(config.plateau_size_bonus), + "is_subperiods": int(config.is_subperiods), + "q25_weight": float(config.q25_weight), + "dispersion_penalty": float(config.dispersion_penalty), + "temporal_weight": float(config.temporal_weight), + "plateau_weight": float(config.plateau_weight), + "use_bootstrap_penalty": bool(config.use_bootstrap_penalty), + "use_complexity_penalty": bool(config.use_complexity_penalty), + "cluster_method": cluster_method, + }, + ) + + +def select_full_sample_robust_record( + records: Sequence[WalkForwardTrialRecord], + param_ranges: Dict[str, Any], + config: WalkForwardConfig, +) -> WalkForwardTrialRecord: + """ + Select params for full-sample robust calibration. + + This is not an OOS validation selector. The whole supplied history is + treated as one calibration sample, then top trials are filtered by temporal + subperiod robustness and parameter-surface plateau robustness. + """ + metric = config.candidate_selection_metric + completed = [record for record in records if not record.pruned and np.isfinite(record.objective)] + if not completed: + raise ValueError("full-sample robust selection received no completed trials") + ranked = sorted(completed, key=lambda record: record.objective, reverse=True) + + if metric == "full_best": + return _with_selection_metadata( + ranked[0], + { + **ranked[0].selection_metadata, + "objective_mode": config.optimization_mode, + "selected_by": "full_best", + "selector": "best_full_sample_objective", + "oos_used_for_selection": False, + "full_sample_used_for_selection": True, + "validation_claim": "none_full_sample_calibration", + "candidate_selection_complete": True, + }, + ) + + if metric == "full_temporal_robust": + top_n = _candidate_count(len(ranked), config) + top = ranked[:top_n] + selected = _best_temporal_record(top) + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + "objective_mode": config.optimization_mode, + "selected_by": "full_temporal_robust", + "selector": "best_full_sample_temporal_score", + "oos_used_for_selection": False, + "full_sample_used_for_selection": True, + "validation_claim": "none_full_sample_calibration", + "top_trials": int(top_n), + "candidate_selection_complete": True, + }, + ) + + if metric == "full_plateau_robust": + selected = select_is_plateau_robust_record(completed, param_ranges, config=config) + selected_by = "full_plateau_robust" + else: + selected = select_is_only_robust_record(completed, param_ranges, config=config) + selected_by = "full_robust" + + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + "objective_mode": config.optimization_mode, + "selected_by": selected_by, + "oos_used_for_selection": False, + "full_sample_used_for_selection": True, + "validation_claim": "none_full_sample_calibration", + "candidate_selection_complete": True, + }, + ) + + +def _select_is_candidate_records( + records: Sequence[WalkForwardTrialRecord], + param_ranges: Dict[str, Any], + config: WalkForwardConfig, +) -> List[WalkForwardTrialRecord]: + completed = [record for record in records if not record.pruned and np.isfinite(record.objective)] + if not completed: + raise ValueError("anti-leakage optimization completed no valid in-sample trials") + ranked = sorted(completed, key=lambda record: record.objective, reverse=True) + top_n = _candidate_count(len(ranked), config) + top = ranked[:top_n] + if config.optimization_mode == "mode_5_full_robust": + full = select_full_sample_robust_record(completed, param_ranges, config=config) + return [full, *top] + if config.candidate_selection_metric == "is_only_robust" or config.optimization_mode == "mode_4_is_only_robust": + robust = select_is_only_robust_record(completed, param_ranges, config=config) + return [robust, *top] + if config.candidate_selection_metric == "is_plateau_robust": + plateau = select_is_plateau_robust_record(completed, param_ranges, config=config) + return [plateau, *top] + if config.optimization_mode == "mode_3_flat_minima": + flat = select_flat_minima_record(completed, param_ranges, config=config) + return [flat, *top] + return top + + +def _candidate_count(n_records: int, config: WalkForwardConfig) -> int: + if n_records <= 0: + return 0 + if config.top_is_k is not None: + return max(1, min(n_records, int(config.top_is_k))) + return max(1, min(n_records, int(np.ceil(n_records * float(config.top_is_fraction))))) + + +def _best_temporal_record(records: Sequence[WalkForwardTrialRecord]) -> WalkForwardTrialRecord: + return max( + records, + key=lambda record: ( + float(record.selection_metadata.get("temporal_score", record.mean_is_sharpe)), + float(record.selection_metadata.get("temporal_q25", record.mean_is_sharpe)), + float(record.objective), + ), + ) + + +def _split_index_into_subperiods(index: pd.DatetimeIndex, n_parts: int) -> List[pd.DatetimeIndex]: + idx = validate_datetime(index) + if len(idx) == 0: + return [] + n = max(1, min(int(n_parts), len(idx))) + return [pd.DatetimeIndex(part) for part in np.array_split(idx, n) if len(part) > 0] + + +def _collect_subperiod_sharpes(fold_metrics: Sequence[Dict[str, Any]]) -> List[float]: + values: List[float] = [] + for metrics in fold_metrics: + for value in metrics.get("is_subperiod_sharpes", []) or []: + try: + numeric = float(value) + except (TypeError, ValueError): + continue + if np.isfinite(numeric): + values.append(numeric) + return values + + +def _temporal_robustness_stats( + values, + q25_weight: float, + dispersion_penalty: float, + fallback: float, +) -> Dict[str, float]: + arr = np.asarray(list(values), dtype=np.float64) + arr = arr[np.isfinite(arr)] + if arr.size == 0: + fallback_value = float(fallback) + return { + "temporal_score": fallback_value, + "temporal_median": fallback_value, + "temporal_q25": fallback_value, + "temporal_mad": 0.0, + "temporal_count": 0.0, + } + median = float(np.median(arr)) + q25 = float(np.quantile(arr, 0.25)) + mad = float(np.median(np.abs(arr - median))) + score = median + float(q25_weight) * q25 - float(dispersion_penalty) * mad + return { + "temporal_score": float(score), + "temporal_median": median, + "temporal_q25": q25, + "temporal_mad": mad, + "temporal_count": float(arr.size), + } + + +def _select_oos_candidate_record( + records: Sequence[WalkForwardTrialRecord], + config: WalkForwardConfig, +) -> WalkForwardTrialRecord: + metric = config.candidate_selection_metric + if metric == "robust_decay": + key = lambda record: record.objective + elif metric == "mean_oos_sharpe": + key = lambda record: record.mean_oos_sharpe + elif metric == "mean_is_sharpe": + key = lambda record: record.mean_is_sharpe + elif metric == "is_plateau_robust": + selected = next( + ( + record + for record in records + if record.selection_metadata.get("selected_by") == "is_plateau_robust" + ), + None, + ) + if selected is None: + key = lambda record: record.selection_metadata.get("plateau_score", record.mean_is_sharpe) + selected = max(records, key=key) + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + "selected_by": metric, + "candidate_selection_complete": True, + "oos_seen_by_optuna": False, + "oos_used_for_selection": False, + }, + ) + elif metric == "is_only_robust": + selected = next( + ( + record + for record in records + if record.selection_metadata.get("selected_by") == "is_only_robust" + ), + None, + ) + if selected is None: + key = lambda record: record.selection_metadata.get("is_only_robust_score", record.selection_metadata.get("temporal_score", record.mean_is_sharpe)) + selected = max(records, key=key) + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + "selected_by": metric, + "candidate_selection_complete": True, + "oos_seen_by_optuna": False, + "oos_used_for_selection": False, + }, + ) + else: # pragma: no cover - validated in config + raise ValueError(f"unsupported candidate_selection_metric: {metric}") + selected = max(records, key=key) + return _with_selection_metadata( + selected, + { + **selected.selection_metadata, + "selected_by": metric, + "candidate_selection_complete": True, + "oos_seen_by_optuna": False, + }, + ) + + +def _with_selection_metadata(record: WalkForwardTrialRecord, metadata: Dict[str, Any]) -> WalkForwardTrialRecord: + return WalkForwardTrialRecord( + trial_id=record.trial_id, + params=dict(record.params), + objective=record.objective, + mean_is_sharpe=record.mean_is_sharpe, + mean_oos_sharpe=record.mean_oos_sharpe, + mean_decay=record.mean_decay, + std_decay=record.std_decay, + fold_metrics=list(record.fold_metrics), + pruned=record.pruned, + selection_metadata=dict(metadata), + ) + + +def _param_matrix( + records: Sequence[WalkForwardTrialRecord], + param_ranges: Dict[str, Any], +) -> Tuple[np.ndarray, List[str]]: + names = [name for name in param_ranges.keys() if _is_clusterable_param(name, param_ranges[name], records)] + if not names: + return np.zeros((len(records), 0), dtype=np.float64), [] + matrix = np.zeros((len(records), len(names)), dtype=np.float64) + for col, name in enumerate(names): + spec = param_ranges[name] + values = [record.params.get(name) for record in records] + matrix[:, col] = _normalize_param_values(values, spec) + return matrix, names + + +def _is_clusterable_param(name: str, spec: Any, records: Sequence[WalkForwardTrialRecord]) -> bool: + values = [record.params.get(name) for record in records] + return any(value is not None for value in values) and len(set(map(str, values))) > 1 + + +def _normalize_param_values(values: Sequence[Any], spec: Any) -> np.ndarray: + if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(x) for x in spec): + low = float(spec[0]) + high = float(spec[1]) + denom = high - low + if denom == 0.0: + return np.zeros(len(values), dtype=np.float64) + return np.array([(float(value) - low) / denom for value in values], dtype=np.float64) + if isinstance(spec, (list, tuple)): + choices = list(spec) + denom = max(1, len(choices) - 1) + encoded = [] + for value in values: + try: + encoded.append(float(choices.index(value)) / float(denom)) + except ValueError: + encoded.append(0.0) + return np.array(encoded, dtype=np.float64) + numeric = np.array([float(value) if _is_number(value) else 0.0 for value in values], dtype=np.float64) + span = float(np.max(numeric) - np.min(numeric)) + if span == 0.0: + return np.zeros(len(values), dtype=np.float64) + return (numeric - float(np.min(numeric))) / span + + +def _centroid_params( + centroid: np.ndarray, + names: Sequence[str], + param_ranges: Dict[str, Any], + base_params: Dict[str, Any], +) -> Dict[str, Any]: + params = dict(base_params) + for value, name in zip(centroid, names): + params[name] = _denormalize_param_value(float(value), param_ranges[name]) + return params + + +def _denormalize_param_value(value: float, spec: Any) -> Any: + clipped = min(1.0, max(0.0, float(value))) + if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(x) for x in spec): + low = float(spec[0]) + high = float(spec[1]) + raw = low + clipped * (high - low) + step = spec[2] if len(spec) == 3 else None + if step is not None: + step_f = float(step) + if step_f > 0.0: + raw = low + round((raw - low) / step_f) * step_f + raw = min(high, max(low, raw)) + if _looks_int(spec[0]) and _looks_int(spec[1]) and (step is None or _looks_int(step)): + return int(round(raw)) + return float(raw) + if isinstance(spec, (list, tuple)): + choices = list(spec) + if not choices: + raise ValueError("cannot denormalize an empty categorical parameter range") + idx = int(round(clipped * (len(choices) - 1))) + return choices[min(len(choices) - 1, max(0, idx))] + return spec + + +def _dbscan_cluster_labels(matrix: np.ndarray, eps: float, min_samples: int) -> Tuple[np.ndarray, str]: + try: + from sklearn.cluster import DBSCAN + + labels = DBSCAN(eps=float(eps), min_samples=int(min_samples), metric="euclidean").fit_predict(matrix) + return labels.astype(np.int64), "sklearn.DBSCAN" + except Exception: + return _density_cluster_labels(matrix, eps=float(eps), min_samples=int(min_samples)), "numpy_dbscan_fallback" + + +def _density_cluster_labels(matrix: np.ndarray, eps: float, min_samples: int) -> np.ndarray: + n = matrix.shape[0] + labels = np.full(n, -1, dtype=np.int64) + visited = np.zeros(n, dtype=bool) + cluster_id = 0 + for point in range(n): + if visited[point]: + continue + visited[point] = True + neighbors = _region_query(matrix, point, eps) + if len(neighbors) < min_samples: + continue + labels[point] = cluster_id + seeds = list(neighbors) + cursor = 0 + while cursor < len(seeds): + neighbor = seeds[cursor] + if not visited[neighbor]: + visited[neighbor] = True + neighbor_neighbors = _region_query(matrix, int(neighbor), eps) + if len(neighbor_neighbors) >= min_samples: + for candidate in neighbor_neighbors: + if int(candidate) not in seeds: + seeds.append(int(candidate)) + if labels[neighbor] < 0: + labels[neighbor] = cluster_id + cursor += 1 + cluster_id += 1 + return labels + + +def _region_query(matrix: np.ndarray, point: int, eps: float) -> List[int]: + diff = matrix - matrix[int(point)] + distances = np.sqrt((diff * diff).sum(axis=1)) + return [int(i) for i in np.flatnonzero(distances <= eps)] + + +def stitch_oos_outputs( + outputs: Sequence[StrategyOutput], + folds: Sequence[WalkForwardFold], + full_index: Union[pd.DatetimeIndex, pd.Series], + fill_value: float = 0.0, +) -> Optional[StrategyOutput]: + """Stitch per-fold OOS strategy output into one full-index object.""" + idx = validate_datetime(full_index) + if len(outputs) != len(folds): + raise ValueError("outputs and folds must have the same length") + if not outputs: + return None + + first = outputs[0] + if isinstance(first, pd.DataFrame): + columns = list(first.columns) + stitched = pd.DataFrame(fill_value, index=idx, columns=columns, dtype=float) + for out, fold in zip(outputs, folds): + frame = _normalize_frame_output(out, columns) + stitched.loc[fold.test_index, columns] = frame.reindex(fold.test_index).fillna(fill_value).values + return stitched + + if isinstance(first, dict): + symbols = list(first.keys()) + stitched = {symbol: pd.Series(fill_value, index=idx, dtype=float) for symbol in symbols} + for out, fold in zip(outputs, folds): + if not isinstance(out, dict) or set(out.keys()) != set(symbols): + raise TypeError("all walk-forward dict outputs must have the same symbol keys") + for symbol in symbols: + series = _normalize_series_output(out[symbol]) + stitched[symbol].loc[fold.test_index] = series.reindex(fold.test_index).fillna(fill_value).values + return stitched + + stitched = pd.Series(fill_value, index=idx, dtype=float) + for out, fold in zip(outputs, folds): + series = _normalize_series_output(out) + stitched.loc[fold.test_index] = series.reindex(fold.test_index).fillna(fill_value).values + return stitched + + +def _infer_datetime_index(data, datetime_index) -> pd.DatetimeIndex: + if datetime_index is not None: + return validate_datetime(datetime_index) + if isinstance(data, pd.DataFrame): + return validate_datetime(data.index) + if isinstance(data, dict): + if not data: + raise ValueError("walk-forward data dict is empty") + first = next(iter(data.values())) + if isinstance(first, pd.DataFrame) or isinstance(first, pd.Series): + return validate_datetime(first.index) + raise ValueError("datetime_index is required when data has no DatetimeIndex") + + +def _align_data_to_datetime_index(data, idx: pd.DatetimeIndex): + """ + Return a data view/copy whose timestamp index matches WFO fold indices. + + `validate_datetime` normalizes fold indices to UTC. Real research frames + are often tz-naive; passing them unchanged into a strategy makes common + code like `series.reindex(test_index)` silently return all NaN. Alignment is + length-preserving and does not inspect future values. + """ + if isinstance(data, pd.DataFrame): + if len(data) != len(idx): + return data + out = data.copy() + out.index = idx + return out + if isinstance(data, pd.Series): + if len(data) != len(idx): + return data + out = data.copy() + out.index = idx + return out + if isinstance(data, dict): + out = {} + for key, value in data.items(): + if isinstance(value, pd.DataFrame) and len(value) == len(idx): + item = value.copy() + item.index = idx + out[key] = item + elif isinstance(value, pd.Series) and len(value) == len(idx): + item = value.copy() + item.index = idx + out[key] = item + else: + out[key] = value + return out + return data + + +def _first_oos_timestamp(split_mode) -> pd.Timestamp: + if isinstance(split_mode, int): + ts = pd.Timestamp(year=int(split_mode), month=1, day=1, tz="UTC") + else: + raw = str(split_mode) + if raw.startswith("walk_forward_"): + raw = raw.replace("walk_forward_", "", 1) + if raw.isdigit() and len(raw) == 4: + ts = pd.Timestamp(year=int(raw), month=1, day=1, tz="UTC") + else: + ts = pd.Timestamp(raw) + if ts.tz is None: + return ts.tz_localize("UTC") + return ts.tz_convert("UTC") + + +def _frequency_offset(split_frequency: str) -> pd.DateOffset: + if split_frequency == "yearly": + return pd.DateOffset(years=1) + if split_frequency == "semi_yearly": + return pd.DateOffset(months=6) + if split_frequency == "quarterly": + return pd.DateOffset(months=3) + if split_frequency == "monthly": + return pd.DateOffset(months=1) + if split_frequency == "weekly": + return pd.DateOffset(weeks=1) + raise ValueError("unsupported split_frequency") + + +def _default_params_from_ranges(param_ranges: Dict[str, Any]) -> Dict[str, Any]: + params: Dict[str, Any] = {} + for key, value in param_ranges.items(): + if isinstance(value, (list, tuple)): + if len(value) == 0: + raise ValueError(f"param_ranges[{key!r}] is empty") + params[key] = value[0] + else: + params[key] = value + return params + + +def _slice_output_to_test(output: StrategyOutput, test_index: pd.DatetimeIndex) -> StrategyOutput: + if isinstance(output, pd.DataFrame): + return _normalize_frame_output(output).reindex(test_index).fillna(0.0) + if isinstance(output, dict): + return {key: _normalize_series_output(value).reindex(test_index).fillna(0.0) for key, value in output.items()} + return _normalize_series_output(output).reindex(test_index).fillna(0.0) + + +def _normalize_series_output(output) -> pd.Series: + if not isinstance(output, pd.Series): + output = pd.Series(output) + series = output.copy() + if isinstance(series.index, pd.DatetimeIndex): + series.index = series.index.tz_localize("UTC") if series.index.tz is None else series.index.tz_convert("UTC") + return series[~series.index.duplicated(keep="first")].astype(float) + + +def _normalize_frame_output(output, columns: Optional[List[str]] = None) -> pd.DataFrame: + if not isinstance(output, pd.DataFrame): + raise TypeError("walk-forward output must be a pandas DataFrame") + frame = output.copy() + if isinstance(frame.index, pd.DatetimeIndex): + frame.index = frame.index.tz_localize("UTC") if frame.index.tz is None else frame.index.tz_convert("UTC") + frame = frame[~frame.index.duplicated(keep="first")] + if columns is not None: + missing = set(columns) - set(frame.columns) + if missing: + raise ValueError(f"walk-forward output missing columns: {sorted(missing)}") + frame = frame[columns] + return frame.astype(float) + + +def _fold_table(folds: Sequence[WalkForwardFold]) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "fold_id": fold.fold_id, + "train_start": fold.train_start, + "train_end": fold.train_end, + "test_start": fold.test_start, + "test_end": fold.test_end, + "train_bars": len(fold.train_index), + "test_bars": len(fold.test_index), + } + for fold in folds + ] + ) + + +def _sample_params(trial, param_ranges: Dict[str, Any]) -> Dict[str, Any]: + return _optimization_suggest_params(trial, param_ranges) + + +def _looks_int(value: Any) -> bool: + return isinstance(value, (int, np.integer)) or (isinstance(value, float) and float(value).is_integer()) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float, np.integer, np.floating)) + + +def _trial_table(records: Sequence[WalkForwardTrialRecord]) -> pd.DataFrame: + return pd.DataFrame([_trial_to_dict(record, include_fold_metrics=False) for record in records]) + + +def _trial_to_dict(record: WalkForwardTrialRecord, include_fold_metrics: bool = True) -> Dict[str, Any]: + out = { + "trial_id": record.trial_id, + "params": record.params, + "objective": record.objective, + "mean_is_sharpe": record.mean_is_sharpe, + "mean_oos_sharpe": record.mean_oos_sharpe, + "mean_decay": record.mean_decay, + "std_decay": record.std_decay, + "pruned": record.pruned, + } + if include_fold_metrics: + out["fold_metrics"] = record.fold_metrics + if record.selection_metadata: + out["selection_metadata"] = record.selection_metadata + for key in ( + "temporal_score", + "temporal_median", + "temporal_q25", + "temporal_mad", + "temporal_count", + "is_subperiod_count", + "is_only_robust_score", + "plateau_score", + ): + if key in record.selection_metadata: + out[key] = record.selection_metadata[key] + return out + + +def _close_map_from_data(data) -> Dict[str, pd.Series]: + if isinstance(data, pd.DataFrame): + if "close" not in data.columns: + raise ValueError("walk-forward scoring requires a close column") + return {"DEFAULT": _series_utc(data["close"])} + if isinstance(data, dict): + out: Dict[str, pd.Series] = {} + for key, value in data.items(): + if isinstance(value, pd.DataFrame): + if "close" not in value.columns: + raise ValueError(f"walk-forward scoring data[{key!r}] requires a close column") + out[key] = _series_utc(value["close"]) + elif isinstance(value, pd.Series): + out[key] = _series_utc(value) + else: + raise TypeError("walk-forward scoring dict values must be DataFrame or Series") + if not out: + raise ValueError("walk-forward scoring data dict is empty") + return out + raise TypeError("walk-forward scoring requires DataFrame or dict data") + + +def _series_utc(series: pd.Series) -> pd.Series: + out = series.copy() + if isinstance(out.index, pd.DatetimeIndex): + out.index = out.index.tz_localize("UTC") if out.index.tz is None else out.index.tz_convert("UTC") + return out[~out.index.duplicated(keep="first")].astype(float) + + +def _data_hash(data) -> str: + try: + if isinstance(data, pd.DataFrame): + idx = validate_datetime(data.index) + payload = {"kind": "frame", "rows": len(data), "start": str(idx[0]), "end": str(idx[-1]), "columns": list(data.columns)} + elif isinstance(data, dict): + payload = {"kind": "dict", "keys": sorted(data.keys())} + spans = {} + for key, value in data.items(): + if isinstance(value, (pd.DataFrame, pd.Series)): + idx = validate_datetime(value.index) + spans[key] = {"rows": len(value), "start": str(idx[0]), "end": str(idx[-1])} + payload["spans"] = spans + else: + payload = {"kind": type(data).__name__} + return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest() + except Exception: + return "unavailable" + + +def _config_hash(config: WalkForwardConfig) -> str: + payload = { + "split_mode": str(config.split_mode), + "split_frequency": config.split_frequency, + "window_mode": config.window_mode, + "train_window": config.train_window, + "min_train_bars": config.min_train_bars, + "min_test_bars": config.min_test_bars, + "target_mode": config.target_mode, + "optimization_mode": config.optimization_mode, + "optuna_trials": config.optuna_trials, + "optuna_early_stopping": config.optuna_early_stopping, + "random_seed": config.random_seed, + "decay_lambda": config.decay_lambda, + "decay_gamma": config.decay_gamma, + "top_is_fraction": config.top_is_fraction, + "top_is_k": config.top_is_k, + "candidate_selection_metric": config.candidate_selection_metric, + "candidate_decay_lambda": config.candidate_decay_lambda, + "candidate_decay_gamma": config.candidate_decay_gamma, + "sbb_samples": config.sbb_samples, + "sbb_block_length": config.sbb_block_length, + "sbb_decay_lambda": config.sbb_decay_lambda, + "sbb_std_penalty": config.sbb_std_penalty, + "sbb_simulation": config.sbb_simulation, + "regime_count": config.regime_count, + "regime_lookback": config.regime_lookback, + "regime_weights": config.regime_weights, + "stress_vol_multiplier": config.stress_vol_multiplier, + "garch_p": config.garch_p, + "garch_q": config.garch_q, + "garch_dist": config.garch_dist, + "garch_vol_multiplier": config.garch_vol_multiplier, + "flat_top_fraction": config.flat_top_fraction, + "flat_eps": config.flat_eps, + "flat_min_samples": config.flat_min_samples, + "flat_selector": config.flat_selector, + "plateau_quantile": config.plateau_quantile, + "plateau_median_weight": config.plateau_median_weight, + "plateau_std_penalty": config.plateau_std_penalty, + "plateau_size_bonus": config.plateau_size_bonus, + "is_subperiods": config.is_subperiods, + "q25_weight": config.q25_weight, + "dispersion_penalty": config.dispersion_penalty, + "temporal_weight": config.temporal_weight, + "plateau_weight": config.plateau_weight, + "use_bootstrap_penalty": config.use_bootstrap_penalty, + "use_complexity_penalty": config.use_complexity_penalty, + "scoring_backend": config.scoring_backend, + "scoring_trading_days": config.scoring_trading_days, + "min_trades_per_year": config.min_trades_per_year, + "trade_penalty_factor": config.trade_penalty_factor, + "use_numba": config.use_numba, + } + return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest() diff --git a/tests/native_event/__init__.py b/tests/native_event/__init__.py new file mode 100644 index 0000000..83f6212 --- /dev/null +++ b/tests/native_event/__init__.py @@ -0,0 +1 @@ +"""Native-event certification tests.""" diff --git a/tests/native_event/conftest.py b/tests/native_event/conftest.py new file mode 100644 index 0000000..637d065 --- /dev/null +++ b/tests/native_event/conftest.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, is_dataclass +from typing import Iterable, Mapping, Sequence + +import numpy as np +import pandas as pd + +from quantbt import OrderCommand, QuantBTEndpoint + + +SEED = 20260801 + + +def bars(n: int = 18, *, start: str = "2024-01-01", freq: str = "1h") -> pd.DataFrame: + idx = pd.date_range(start, periods=n, freq=freq, tz="UTC") + base = 100.0 + np.sin(np.arange(n, dtype=np.float64) / 3.0) * 3.0 + np.arange(n) * 0.15 + close = pd.Series(base, index=idx) + return pd.DataFrame( + { + "open": close.shift(1).fillna(close.iloc[0]), + "high": close + 2.5, + "low": close - 2.5, + "close": close, + "volume": 1_000.0 + np.arange(n, dtype=np.float64), + }, + index=idx, + ) + + +def multi_bars(n: int = 18) -> Mapping[str, pd.DataFrame]: + left = bars(n) + right = bars(n).copy() + right[["open", "high", "low", "close"]] *= 1.12 + right["volume"] *= 1.5 + return {"BTC": left, "ETH": right} + + +class ScheduledCommandStrategy: + def __init__(self, schedule: Mapping[int, Sequence[OrderCommand]]): + self.schedule = {int(k): tuple(v) for k, v in schedule.items()} + self.seen = [] + + def initialize(self, context): + self.seen.append(("initialize", context.bar_index, context.timestamp)) + return list(self.schedule.get(-1, ())) + + def on_bar_close(self, context): + self.seen.append(("on_bar_close", context.bar_index, context.timestamp)) + return list(self.schedule.get(context.bar_index, ())) + + def finalize(self, context): + self.seen.append(("finalize", context.bar_index, context.timestamp)) + return list(self.schedule.get(10**9, ())) + + +def run_reactive(mode: str, strategy, data=None, symbols=None, **kwargs): + data = bars() if data is None else data + symbols = ["BTC"] if symbols is None else list(symbols) + datetime_index = kwargs.pop("datetime_index", None) + if datetime_index is None and isinstance(data, Mapping): + datetime_index = next(iter(data.values())).index + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=kwargs.pop("initial_capital", 10_000), + leverage=kwargs.pop("leverage", 10), + use_funding=kwargs.pop("use_funding", False), + fee_rate=kwargs.pop("fee_rate", 0.0002), + report_level=kwargs.pop("report_level", "audit"), + reactive_execution_mode=kwargs.pop("reactive_execution_mode", "audit"), + reactive_kernel_mode=mode, + **kwargs, + ) + return endpoint.simulate(data=data, strategy=strategy, symbols=symbols, datetime_index=datetime_index) + + +def assert_accounting_equal(candidate, oracle) -> None: + pd.testing.assert_series_equal(candidate.equity, oracle.equity, check_names=True) + pd.testing.assert_series_equal(candidate.returns, oracle.returns, check_names=True) + pd.testing.assert_frame_equal(candidate.positions, oracle.positions) + pd.testing.assert_series_equal(candidate.fees, oracle.fees, check_names=True) + pd.testing.assert_series_equal(candidate.funding, oracle.funding, check_names=True) + pd.testing.assert_frame_equal(candidate.margin, oracle.margin) + assert candidate.liquidated == oracle.liquidated + assert candidate.liquidation_bar == oracle.liquidation_bar + + +def _stable_value(value): + if isinstance(value, pd.Timestamp): + return value.isoformat() + if isinstance(value, np.generic): + value = value.item() + if isinstance(value, float): + if np.isnan(value): + return "NaN" + if np.isposinf(value): + return "Inf" + if np.isneginf(value): + return "-Inf" + return format(value, ".17g") + if is_dataclass(value): + return {k: _stable_value(v) for k, v in asdict(value).items()} + if isinstance(value, dict): + return {str(k): _stable_value(v) for k, v in sorted(value.items(), key=lambda item: str(item[0]))} + if isinstance(value, (list, tuple)): + return [_stable_value(v) for v in value] + return value + + +def _frame_records(frame: pd.DataFrame) -> list[dict]: + if frame is None or frame.empty: + return [] + ordered = frame.copy() + if "original_index" in ordered.columns: + ordered = ordered.sort_values("original_index") + elif "bar" in ordered.columns: + ordered = ordered.sort_values(list(c for c in ("bar", "command_index") if c in ordered.columns)) + ordered = ordered.reindex(sorted(ordered.columns), axis=1) + return [{col: _stable_value(row[col]) for col in ordered.columns} for _, row in ordered.iterrows()] + + +def _fill_records(fills: Iterable) -> list[dict]: + records = [] + for fill in fills or (): + records.append( + { + "timestamp": _stable_value(getattr(fill, "timestamp", None)), + "symbol": getattr(fill, "symbol", None), + "side": _stable_value(getattr(fill, "side", None)), + "qty": _stable_value(float(getattr(fill, "qty", 0.0))), + "price": _stable_value(float(getattr(fill, "price", 0.0))), + "fee": _stable_value(float(getattr(fill, "fee", 0.0))), + "order_id": getattr(fill, "order_id", None), + } + ) + return records + + +def native_event_fingerprint(result) -> str: + h = hashlib.sha256() + for frame in (result.positions, result.margin): + arr = np.ascontiguousarray(frame.to_numpy(dtype=np.float64)) + h.update(arr.shape.__repr__().encode()) + h.update(arr.tobytes()) + for series in (result.equity, result.fees, result.funding): + arr = np.ascontiguousarray(series.to_numpy(dtype=np.float64)) + h.update(arr.shape.__repr__().encode()) + h.update(arr.tobytes()) + payload = { + "liquidated": bool(result.liquidated), + "liquidation_bar": int(result.liquidation_bar), + "fills": _fill_records(getattr(result, "fills", ())), + "command_report": _frame_records(result.metadata.get("command_report")), + "order_events": _frame_records(result.metadata.get("order_events")), + "derived_counts": { + "fills": len(_fill_records(getattr(result, "fills", ()))), + "command_report_rows": len(_frame_records(result.metadata.get("command_report"))), + "order_event_rows": len(_frame_records(result.metadata.get("order_events"))), + }, + } + h.update(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")) + return h.hexdigest() + + +def assert_native_event_full_parity(candidate, oracle) -> None: + assert_accounting_equal(candidate, oracle) + assert _fill_records(candidate.fills) == _fill_records(oracle.fills) + pd.testing.assert_frame_equal( + candidate.metadata.get("command_report", pd.DataFrame()).reset_index(drop=True), + oracle.metadata.get("command_report", pd.DataFrame()).reset_index(drop=True), + check_like=True, + ) + pd.testing.assert_frame_equal( + candidate.metadata.get("order_events", pd.DataFrame()).reset_index(drop=True), + oracle.metadata.get("order_events", pd.DataFrame()).reset_index(drop=True), + check_like=True, + ) + assert native_event_fingerprint(candidate) == native_event_fingerprint(oracle) diff --git a/tests/native_event/contract/test_phase47b_full_contract.py b/tests/native_event/contract/test_phase47b_full_contract.py new file mode 100644 index 0000000..1e551e8 --- /dev/null +++ b/tests/native_event/contract/test_phase47b_full_contract.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pandas as pd +import pytest + +from quantbt import ( + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderAction, + OrderCommand, + OrderSide, + OrderType, + TimeInForce, +) + + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native full-contract wheel is not installed", +) + + +def _market(n: int = 24): + index = pd.date_range("2024-01-01 07:00", periods=n, freq="1h", tz="UTC") + a = pd.Series(100.0 + np.arange(n, dtype=np.float64) * 0.25, index=index) + b = pd.Series(200.0 - np.arange(n, dtype=np.float64) * 0.10, index=index) + return index, {"A": a, "B": b}, { + "A": a + 2.0, "B": b + 2.0, + }, {"A": a - 2.0, "B": b - 2.0} + + +def _backend(backend: str, *, initial_capital: float = 10_000.0, leverage: float = 5.0, maintenance_ratio: float = 0.005): + return NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=initial_capital, leverage=leverage, maintenance_ratio=maintenance_ratio), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=True, + native_backend=backend, + report_level="audit", + ) + ) + + +def _run( + backend: str, + index, + closes, + highs, + lows, + funding, + commands, + *, + qty_step=None, + min_qty=None, + min_notional=None, + **account, +): + engine = _backend(backend, **account) + market = engine.prepare_market_arrays( + index, closes=closes, highs=highs, lows=lows, + funding_rate=funding, symbols=["A", "B"], + ) + compiled = engine.compile_order_commands(index, commands, symbols=["A", "B"]) + return engine.run_order_commands( + datetime_index=index, + commands=commands, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding, + contract_size={"A": 1.0, "B": 1.0}, + symbols=["A", "B"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", + qty_step=qty_step, + min_qty=min_qty, + min_notional=min_notional, + ) + + +def _event_signature(result): + """Normalize the Python compact ledger and Rust report to one ABI view.""" + + metadata = result.metadata + if "compact_order_event_ledger" in metadata: + ledger = metadata["compact_order_event_ledger"] + commands = metadata["compact_command_ledger"] + ids = tuple(metadata["id_values"]) + + def command_id(command_index): + if int(command_index) < 0: + return None + code = int(commands.order_id_code[int(command_index)]) + return ids[code] if 0 <= code < len(ids) else None + + return tuple( + ( + int(bar), + int(kind), + int(status), + command_id(command_index), + command_id(related_index), + int(commands.reject_code[int(command_index)]) if int(command_index) >= 0 else 0, + ) + for bar, kind, status, command_index, related_index in zip( + ledger.bar, + ledger.event_type, + ledger.status, + ledger.command_index, + ledger.related_command_index, + ) + ) + + report = metadata["order_report"] + return tuple( + ( + int(row.bar), + int(row.event_kind), + int(row.event_status), + row.order_id, + row.target_order_id, + int(row.reject_code), + ) + for row in report.itertuples(index=False) + ) + + +def _assert_numeric_parity(left, right): + np.testing.assert_allclose(left.equity.to_numpy(), right.equity.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(left.positions.to_numpy(), right.positions.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(left.fees.to_numpy(), right.fees.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(left.funding.to_numpy(), right.funding.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(left.margin.to_numpy(), right.margin.to_numpy(), rtol=0.0, atol=1e-12) + assert len(left.fills) == len(right.fills) + assert _event_signature(left) == _event_signature(right) + assert _fill_signature(left) == _fill_signature(right) + + +def _fill_signature(result): + metadata = result.metadata + if "compact_fill_ledger" in metadata: + ledger = metadata["compact_fill_ledger"] + ids = tuple(metadata["id_values"]) + symbols = tuple(ledger.symbols) + + def decode(values, code): + code = int(code) + return values[code] if 0 <= code < len(values) else None + + return tuple( + ( + int(bar), + decode(ids, order_code), + decode(symbols, symbol_code), + int(side), + float(qty), + float(price), + float(fee), + ) + for bar, order_code, symbol_code, side, qty, price, fee in zip( + ledger.bar, + ledger.order_id_code, + ledger.symbol_code, + ledger.side, + ledger.qty, + ledger.price, + ledger.fee, + ) + ) + + report = metadata["fills_report"] + return tuple( + ( + int(row.bar), row.order_id, row.symbol, + 1 if row.side == "BUY" else -1, + float(row.qty), float(row.price), float(row.fee), + ) + for row in report.itertuples(index=False) + ) + left_counters = left.metadata["lifecycle_counters"] + right_counters = right.metadata["lifecycle_counters"] + for key in ("fill_count", "event_count", "rejected_count", "canceled_count"): + assert left_counters[key] == right_counters[key] + + +def test_phase47b_full_contract_multisymbol_funding_parent_and_oco_parity(): + index, closes, highs, lows = _market() + funding = { + "A": pd.Series(0.0, index=index), + "B": pd.Series(0.0, index=index), + } + funding["A"].iloc[9] = 0.001 # 16:00 UTC, after the entry at 08:00. + commands = ( + OrderCommand( + timestamp=index[1], symbol="A", side=OrderSide.BUY, + order_type=OrderType.MARKET, qty=2.0, order_id="entry-a", + ), + OrderCommand( + timestamp=index[1], symbol="B", side=OrderSide.SELL, + order_type=OrderType.MARKET, qty=1.0, order_id="entry-b", + ), + OrderCommand( + timestamp=index[2], symbol="A", side=OrderSide.SELL, + order_type=OrderType.LIMIT, qty=2.0, price=100.25, + reduce_only=True, order_id="tp-a", parent_order_id="entry-a", + activation_policy="on_parent_first_fill", oco_group_id="exit-a", + ), + OrderCommand( + timestamp=index[2], symbol="A", side=OrderSide.SELL, + order_type=OrderType.STOP_MARKET, qty=2.0, trigger_price=99.0, + reduce_only=True, order_id="sl-a", parent_order_id="entry-a", + activation_policy="on_parent_first_fill", oco_group_id="exit-a", + ), + ) + python = _run("python", index, closes, highs, lows, funding, commands) + rust = _run("rust", index, closes, highs, lows, funding, commands) + _assert_numeric_parity(python, rust) + assert rust.metadata["rust_contract"] == "native_event_v2_full_contract" + assert float(rust.funding.sum()) > 0.0 + + +def test_phase47b_full_contract_tif_expiry_cancel_all_parity(): + index, closes, highs, lows = _market(12) + zero = {symbol: pd.Series(0.0, index=index) for symbol in closes} + commands = ( + OrderCommand( + timestamp=index[1], symbol="A", side=OrderSide.BUY, + order_type=OrderType.LIMIT, qty=1.0, price=1.0, + tif=TimeInForce.IOC, order_id="ioc", + ), + OrderCommand( + timestamp=index[1], symbol="A", side=OrderSide.BUY, + order_type=OrderType.LIMIT, qty=1.0, price=1.0, + tif=TimeInForce.GTD, expires_at=index[4], order_id="gtd", + ), + OrderCommand( + timestamp=index[3], action=OrderAction.CANCEL_ALL, + symbol="A", order_id="cancel-all", + ), + ) + python = _run("python", index, closes, highs, lows, zero, commands) + rust = _run("rust", index, closes, highs, lows, zero, commands) + _assert_numeric_parity(python, rust) + assert python.metadata["lifecycle_counters"]["canceled_count"] == rust.metadata["lifecycle_counters"]["canceled_count"] + + +def test_phase47b_full_contract_gtd_expiry_event_parity(): + index, closes, highs, lows = _market(10) + zero = {symbol: pd.Series(0.0, index=index) for symbol in closes} + commands = ( + OrderCommand( + timestamp=index[1], symbol="A", side=OrderSide.BUY, + order_type=OrderType.LIMIT, qty=1.0, price=1.0, + tif=TimeInForce.GTD, expires_at=index[4], order_id="expires", + ), + ) + python = _run("python", index, closes, highs, lows, zero, commands) + rust = _run("rust", index, closes, highs, lows, zero, commands) + _assert_numeric_parity(python, rust) + assert rust.metadata["lifecycle_counters"]["event_count"] == 2 + + +def test_phase47b_full_contract_intrabar_liquidation_parity(): + index = pd.date_range("2024-01-01", periods=5, freq="1h", tz="UTC") + close = pd.Series([100.0, 100.0, 100.0, 100.0, 100.0], index=index) + high = close + 1.0 + low = pd.Series([99.0, 99.0, 10.0, 99.0, 99.0], index=index) + closes = {"A": close, "B": close} + highs = {"A": high, "B": high} + lows = {"A": low, "B": low} + zero = {"A": pd.Series(0.0, index=index), "B": pd.Series(0.0, index=index)} + commands = ( + OrderCommand(timestamp=index[1], symbol="A", side=OrderSide.BUY, + order_type=OrderType.MARKET, qty=2.0, order_id="levered-long"), + ) + python = _run("python", index, closes, highs, lows, zero, commands, initial_capital=100.0, leverage=10.0) + rust = _run("rust", index, closes, highs, lows, zero, commands, initial_capital=100.0, leverage=10.0) + _assert_numeric_parity(python, rust) + assert python.liquidated is True + assert rust.liquidated is True + assert python.metadata["liquidation_reason"] == rust.metadata["liquidation_reason"] + + +def test_phase47b_full_contract_replace_alias_and_amend_parity(): + index, closes, highs, lows = _market(10) + zero = {symbol: pd.Series(0.0, index=index) for symbol in closes} + commands = ( + OrderCommand( + timestamp=index[1], symbol="A", side=OrderSide.BUY, + order_type=OrderType.LIMIT, qty=1.0, price=1.0, order_id="old", + ), + OrderCommand( + timestamp=index[2], symbol="A", side=OrderSide.BUY, + order_type=OrderType.LIMIT, qty=2.0, price=1.0, + order_id="new", target_order_id="old", action=OrderAction.REPLACE, + ), + # Python's compiler aliases the replaced target to the replacement + # slot. This cancel must therefore cancel ``new`` in both backends. + OrderCommand( + timestamp=index[3], symbol="A", action=OrderAction.CANCEL, + target_order_id="old", + ), + ) + python = _run("python", index, closes, highs, lows, zero, commands) + rust = _run("rust", index, closes, highs, lows, zero, commands) + _assert_numeric_parity(python, rust) + + +def test_phase47b_full_contract_order_types_tif_reduce_only_and_constraints_parity(): + index, closes, highs, lows = _market(14) + zero = {symbol: pd.Series(0.0, index=index) for symbol in closes} + commands = ( + OrderCommand( + timestamp=index[1], symbol="A", side=OrderSide.BUY, + order_type=OrderType.MARKET, qty=1.37, order_id="market-a", + ), + OrderCommand( + timestamp=index[1], symbol="B", side=OrderSide.SELL, + order_type=OrderType.LIMIT, qty=2.49, price=199.0, + tif=TimeInForce.FOK, order_id="fok-b", + ), + OrderCommand( + timestamp=index[2], symbol="A", side=OrderSide.SELL, + order_type=OrderType.STOP_MARKET, qty=1.0, trigger_price=99.0, + reduce_only=True, order_id="stop-a", + ), + OrderCommand( + timestamp=index[2], symbol="B", side=OrderSide.SELL, + order_type=OrderType.STOP_LIMIT, qty=1.0, price=199.5, + trigger_price=199.8, tif=TimeInForce.IOC, order_id="stop-limit-b", + ), + ) + kwargs = {"A": 0.1, "B": 0.5} + python = _run( + "python", index, closes, highs, lows, zero, commands, + qty_step=kwargs, min_qty={"A": 0.1, "B": 0.5}, + ) + rust = _run( + "rust", index, closes, highs, lows, zero, commands, + qty_step=kwargs, min_qty={"A": 0.1, "B": 0.5}, + ) + _assert_numeric_parity(python, rust) + + +def test_phase47b_full_capability_is_explicit_and_old_api_is_not_silent_fallback(): + import _quantbt_native + + capabilities = _quantbt_native.capabilities() + required = { + "native_event_v2_full_contract", "native_event_v2_multisymbol", + "native_event_v2_funding", "native_event_v2_liquidation", + "native_event_v2_cancel_all_oco", "native_event_v2_tif_expiry", + "native_event_v2_relationships", + } + assert required.issubset({name for name, enabled in capabilities.items() if enabled}) + assert _quantbt_native.api_version() == "0.4" + + +def test_phase47b_reactive_rust_and_python_context_path_parity(): + index, closes, highs, lows = _market(16) + funding = {symbol: pd.Series(0.0, index=index) for symbol in closes} + + class Strategy: + def initialize(self, context): + return ( + OrderCommand(timestamp=context.timestamp, symbol="A", side=OrderSide.BUY, + order_type=OrderType.MARKET, qty=1.0, order_id="a"), + OrderCommand(timestamp=context.timestamp, symbol="B", side=OrderSide.SELL, + order_type=OrderType.MARKET, qty=1.0, order_id="b"), + ) + + def on_bar_close(self, context): + if context.bar_index == 3: + return (OrderCommand(timestamp=context.timestamp, action=OrderAction.CANCEL_ALL, symbol="A"),) + return () + + def run(backend): + return NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0, maintenance_ratio=0.005), + execution=ExecutionConfig(slippage_bps=2.0), fee_rate=0.0002, + use_funding=True, native_backend=backend, report_level="minimal", + ) + ).run_strategy( + index, Strategy(), closes, highs, lows, funding, + symbols=["A", "B"], execution_mode="fast", reactive_kernel_mode="single_pass", + ) + + python = run("python") + rust = run("rust") + np.testing.assert_allclose(python.equity.to_numpy(), rust.equity.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python.positions.to_numpy(), rust.positions.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python.fees.to_numpy(), rust.fees.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python.funding.to_numpy(), rust.funding.to_numpy(), rtol=0.0, atol=1e-12) + + +def test_phase47b_reactive_active_snapshot_relationship_metadata_parity(): + index, closes, highs, lows = _market(8) + zero = {symbol: pd.Series(0.0, index=index) for symbol in closes} + + class Strategy: + def __init__(self): + self.observed = [] + + def initialize(self, context): + return ( + OrderCommand( + timestamp=context.timestamp, symbol="A", side=OrderSide.BUY, + order_type=OrderType.MARKET, qty=1.0, order_id="parent", + ), + OrderCommand( + timestamp=context.timestamp, symbol="A", side=OrderSide.SELL, + order_type=OrderType.LIMIT, qty=1.0, price=1_000.0, + reduce_only=True, order_id="take-profit", parent_order_id="parent", + group_id="bracket", oco_group_id="bracket", + activation_policy="on_parent_first_fill", + tag="tp", metadata={"campaign_id": "grid-1", "level_id": "tp0"}, + ), + OrderCommand( + timestamp=context.timestamp, symbol="A", side=OrderSide.SELL, + order_type=OrderType.STOP_MARKET, qty=1.0, trigger_price=1.0, + reduce_only=True, order_id="stop-loss", parent_order_id="parent", + group_id="bracket", oco_group_id="bracket", + activation_policy="on_parent_first_fill", + tag="sl", metadata={"campaign_id": "grid-1", "level_id": "sl0"}, + ), + ) + + def on_bar_close(self, context): + if context.bar_index == 1: + self.observed = [ + ( + order.order_id, + order.parent_order_id, + order.group_id, + order.oco_group_id, + order.tag, + order.campaign_id, + order.level_id, + ) + for order in context.active_orders + ] + return () + + def run(backend): + strategy = Strategy() + result = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0), + execution=ExecutionConfig(slippage_bps=2.0), fee_rate=0.0002, + use_funding=False, native_backend=backend, report_level="minimal", + ) + ).run_strategy( + index, strategy, closes, highs, lows, zero, + symbols=["A", "B"], execution_mode="fast", reactive_kernel_mode="single_pass", + ) + return result, tuple(sorted(strategy.observed)) + + python, python_active = run("python") + rust, rust_active = run("rust") + np.testing.assert_allclose(python.equity.to_numpy(), rust.equity.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python.positions.to_numpy(), rust.positions.to_numpy(), rtol=0.0, atol=1e-12) + assert python_active == rust_active == ( + ("stop-loss", "parent", "bracket", "bracket", "sl", "grid-1", "sl0"), + ("take-profit", "parent", "bracket", "bracket", "tp", "grid-1", "tp0"), + ) diff --git a/tests/native_event/test_phase46e_dual_backend_contract.py b/tests/native_event/test_phase46e_dual_backend_contract.py new file mode 100644 index 0000000..ac104a2 --- /dev/null +++ b/tests/native_event/test_phase46e_dual_backend_contract.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pandas as pd +import pytest + +from quantbt import ( + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderCommand, + OrderSide, + OrderType, + TimeInForce, + QuantBTEndpoint, +) +from quantbt.backends.native_event import NativeEventScoreRequirements +from quantbt.backends._native_event_rust import ( + NativeEventRustBackendError, + NativeEventRustExtensionStatus, + resolve_native_event_backend, +) + + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native batched wheel is not installed in this environment", +) + + +def _bars(n: int = 12) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.arange(n, dtype=np.float64), index=index) + return pd.DataFrame( + {"open": close, "high": close + 2.0, "low": close - 2.0, "close": close, "volume": 1_000.0}, + index=index, + ) + + +def _backend(frame: pd.DataFrame, *, native_backend: str = "python") -> NativeEventBackend: + return NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + native_backend=native_backend, + ) + ) + + +def _commands(index: pd.DatetimeIndex) -> tuple[OrderCommand, ...]: + return ( + OrderCommand( + timestamp=index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=index[3], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=1.0, + price=103.0, + tif=TimeInForce.GTC, + order_id="exit", + ), + ) + + +def test_phase46e_selector_contract_is_explicit_and_auto_stays_python(): + status = NativeEventRustExtensionStatus( + available=True, + compatible=True, + executable=True, + version="test", + api_version="0.3", + capabilities={"reactive_session": True}, + ) + assert resolve_native_event_backend("python", extension_status=status).resolved == "python" + assert resolve_native_event_backend("auto", extension_status=status).resolved == "python" + assert resolve_native_event_backend("replay_certified", extension_status=status).resolved == "replay_certified" + assert resolve_native_event_backend("rust", extension_status=status).resolved == "rust" + + +def test_phase46e_rust_explicit_tape_adapts_to_common_result_and_python_parity(): + frame = _bars() + index = frame.index + python_backend = _backend(frame, native_backend="python") + market = python_backend.prepare_market_arrays( + datetime_index=index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + commands = _commands(index) + compiled = python_backend.compile_order_commands(index, commands, symbols=["BTC"]) + python_result = python_backend.run_order_commands( + datetime_index=index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", + ) + rust_result = _backend(frame, native_backend="rust").run_order_commands( + datetime_index=index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", + ) + np.testing.assert_allclose(rust_result.equity, python_result.equity, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust_result.positions, python_result.positions, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust_result.fees, python_result.fees, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust_result.margin, python_result.margin, rtol=0.0, atol=1e-12) + assert rust_result.metadata["native_event_backend_resolved"] == "rust" + assert len(rust_result.fills) == 2 + assert len(rust_result.metadata["fills_report"]) == 2 + assert len(rust_result.metadata["order_report"]) >= 2 + report = rust_result.full_report() + assert np.isfinite(float(report["final_equity"])) + + +def test_phase47b_rust_backend_executes_full_accounting_contract(): + frame = _bars() + index = frame.index + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0, maintenance_ratio=0.005), + execution=ExecutionConfig(), + fee_rate=0.0002, + use_funding=True, + native_backend="rust", + ) + ) + funding = pd.Series(0.0, index=index) + funding.iloc[8] = 0.001 + result = backend.run_order_commands( + datetime_index=index, + commands=_commands(index), + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + funding_rate=funding, + symbols=["BTC"], + report_level="audit", + ) + assert result.metadata["native_event_backend_resolved"] == "rust" + assert "native_event_v2_full_contract" in result.metadata["native_event_rust_capabilities"] + + +class _MetadataOrderStrategy: + native_context_requirements = { + "fills": False, + "events": False, + "active_orders": False, + "positions": False, + "margin": False, + } + + def on_bar_close(self, context): + if context.bar_index == 1: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=1.0, + tif=TimeInForce.GTC, + order_id="pending", + metadata={"large_strategy_payload": "must_not_be_retained"}, + ) + ] + return () + + +def test_phase46e_scalar_contract_marks_compact_python_order_state(): + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=10_000.0, + leverage=5.0, + use_funding=False, + fee_rate=0.0002, + report_level="audit", + ) + prepared = endpoint.prepare_native_event_strategy(data=_bars(), symbols=["BTC"]) + strategy = _MetadataOrderStrategy() + score = prepared.score( + strategy, + score_requirements=NativeEventScoreRequirements.from_strategy( + strategy, + base=NativeEventScoreRequirements.scalar_score_contract(), + ), + ) + assert score.metadata["score_primitive_order_state"] is True diff --git a/tests/native_event/test_phase48e1_closure.py b/tests/native_event/test_phase48e1_closure.py new file mode 100644 index 0000000..ff8f6f6 --- /dev/null +++ b/tests/native_event/test_phase48e1_closure.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +try: + import _quantbt_native +except ImportError: + _quantbt_native = None + +from quantbt import OrderCommand, OrderSide, OrderType, TimeInForce +from quantbt.backends._native_event_rust import RustFullRunner + +from .test_phase48e_reuse import _bars, _runner + + +pytestmark = pytest.mark.skipif( + _quantbt_native is None, + reason="quantbt-native full-contract wheel is not installed in this environment", +) + + +def _prepared_core(frame: pd.DataFrame): + n = len(frame) + return _quantbt_native.FullPreparedMarketCore( + np.ascontiguousarray(frame.index.asi8, dtype=np.int64), + np.ascontiguousarray(frame[["open"]].to_numpy(), dtype=np.float64), + np.ascontiguousarray(frame[["high"]].to_numpy(), dtype=np.float64), + np.ascontiguousarray(frame[["low"]].to_numpy(), dtype=np.float64), + np.ascontiguousarray(frame[["close"]].to_numpy(), dtype=np.float64), + np.ascontiguousarray(frame[["volume"]].to_numpy(), dtype=np.float64), + np.zeros((n, 1), dtype=np.float64), + np.zeros(n, dtype=np.bool_), + ) + + +def _session(frame: pd.DataFrame): + return _quantbt_native.FullReactiveSessionCore.from_prepared( + _prepared_core(frame), + np.array([1.0], dtype=np.float64), + np.array([5.0], dtype=np.float64), + np.array([0.0002], dtype=np.float64), + 10_000.0, + 0.0, + 0.0002, + False, + ) + + +def _entry_batch(): + codes = np.full((1, 16), -1, dtype=np.int64) + values = np.zeros((1, 3), dtype=np.float64) + expiry = np.full(1, -1, dtype=np.int64) + codes[0, :7] = [0, 0, 1, 0, 0, 0, 7] + codes[0, 11] = 0 + codes[0, 12] = 0 + values[0, 0] = 1.0 + return codes, values, expiry + + +def test_phase48e1_typed_score_is_count_only_and_typed_audit_projects_rows(): + frame = _bars(4) + codes, values, expiry = _entry_batch() + + score_session = _session(frame) + score_session.set_output_mask(1) + score = score_session.step_typed(0, codes, values, expiry) + assert type(score).__name__ == "FullStepResultCore" + assert score.fill_count == 1 + assert score.event_count >= 2 + assert score.positions == [1.0] + assert score.fills is None + assert score.events is None + assert score.active_orders is None + assert score_session.step_buffer_capacities() == (0, 0, 0) + + audit_session = _session(frame) + audit_session.set_output_mask(15) + audit = audit_session.step_typed(0, codes, values, expiry) + assert audit.positions == [1.0] + assert len(audit.fills) == 1 + assert len(audit.events) >= 2 + assert audit.active_orders == [] + assert audit_session.step_buffer_capacities()[0] >= 1 + assert audit_session.step_buffer_capacities()[1] >= 2 + + scalar_fingerprints = [] + for mask in (0, 1, 2, 4, 8, 3, 5, 15): + session = _session(frame) + session.set_output_mask(mask) + result = session.step_typed(0, codes, values, expiry) + scalar_fingerprints.append( + (result.equity, result.fee, result.turnover, result.fill_count, result.event_count) + ) + assert (result.positions is None) is not bool(mask & 1) + assert (result.fills is None) is not bool(mask & 2) + assert (result.events is None) is not bool(mask & 4) + assert (result.active_orders is None) is not bool(mask & 8) + assert all(fingerprint == scalar_fingerprints[0] for fingerprint in scalar_fingerprints) + + +def test_phase48e1_static_audit_uses_distinct_command_and_lifecycle_reports(): + frame = _bars(12) + commands = ( + OrderCommand( + timestamp=frame.index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="entry", + metadata={"campaign_id": "phase48e1", "level_id": 1}, + ), + OrderCommand( + timestamp=frame.index[4], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="exit", + ), + ) + runner, compiled = _runner(frame, commands) + audit = runner.run_tape_audit(compiled) + result = audit.to_backtest_result( + datetime_index=frame.index, + closes=pd.DataFrame({"BTC": frame["close"]}, index=frame.index), + symbols=["BTC"], + initial_capital=10_000.0, + leverage=5.0, + ) + command_report = result.metadata["command_report"] + order_report = result.metadata["order_report"] + fills_report = result.metadata["fills_report"] + assert command_report is not order_report + assert not command_report.empty + assert set(command_report["report_kind"]) == {"command_intent"} + assert "event_kind" in order_report.columns + assert "tag" in fills_report.columns + assert fills_report.loc[fills_report["order_id"] == "entry", "campaign_id"].iloc[0] == "phase48e1" + + +def test_phase48e1_reset_and_compaction_relationships_keep_fresh_parity(): + frame = _bars(96) + commands = tuple( + OrderCommand( + timestamp=frame.index[bar], + symbol="BTC", + side=OrderSide.BUY if bar % 2 else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id=f"order-{bar}", + ) + for bar in range(1, len(frame)) + ) + runner, compiled = _runner(frame, commands) + first = runner.run_tape_audit(compiled) + first_fingerprint = (first.final_equity, first.fill_count, first.event_count) + second = runner.run_tape_audit(compiled) + assert (second.final_equity, second.fill_count, second.event_count) == first_fingerprint + info = runner.cache_info() + assert info["order_compactions"] >= 1 + assert info["terminal_orders_removed"] >= 64 + + +def test_phase48e1_score_reset_has_bounded_reuse_for_100_runs(): + frame = _bars(64) + commands = ( + OrderCommand( + timestamp=frame.index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + order_id="entry", + ), + ) + runner, compiled = _runner(frame, commands) + first = runner.run_tape_score(compiled) + first_info = runner.cache_info() + for _ in range(100): + current = runner.run_tape_score(compiled) + assert current["final_equity"] == first["final_equity"] + assert current["fill_count"] == first["fill_count"] + final_info = runner.cache_info() + assert final_info["tape_cache_entries"] == 1 + assert final_info["command_buffer_capacity"] == first_info["command_buffer_capacity"] + assert final_info["command_buffer_growth_count"] == first_info["command_buffer_growth_count"] + assert final_info.get("step_fill_buffer_capacity", 0) == 0 + assert final_info.get("step_event_buffer_capacity", 0) == 0 + assert 0 < final_info.get("margin_recompute_count", 0) <= len(frame) diff --git a/tests/native_event/test_phase48e_reuse.py b/tests/native_event/test_phase48e_reuse.py new file mode 100644 index 0000000..4fc05c1 --- /dev/null +++ b/tests/native_event/test_phase48e_reuse.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pandas as pd +import pytest + +from quantbt import ( + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderCommand, + OrderSide, + OrderType, + TimeInForce, +) +from quantbt.backends._native_event_rust import ( + RustFullCommandBuffer, + RustFullRunner, +) +from quantbt.backends.native_event import NativeEventScoreRequirements + + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native full-contract wheel is not installed in this environment", +) + + +def _bars(n: int = 16) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.arange(n, dtype=np.float64), index=index) + return pd.DataFrame( + { + "open": close, + "high": close + 2.0, + "low": close - 2.0, + "close": close, + "volume": 1_000.0, + }, + index=index, + ) + + +def _runner(frame: pd.DataFrame, commands: tuple[OrderCommand, ...]) -> tuple[RustFullRunner, object]: + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + native_backend="rust", + ) + ) + market = backend.prepare_market_arrays( + datetime_index=frame.index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + compiled = backend.compile_order_commands(frame.index, commands, symbols=["BTC"]) + return ( + RustFullRunner( + idx=frame.index, + symbols=["BTC"], + market_arrays=market, + contract_sizes=np.array([1.0]), + leverages=np.array([5.0]), + fee_rates=np.array([0.0002]), + initial_capital=10_000.0, + maintenance_ratio=0.0, + slippage=0.0002, + use_funding=False, + ), + compiled, + ) + + +def test_phase48e_full_command_buffer_reuses_capacity_and_clears_explicitly(): + buffer = RustFullCommandBuffer() + first_codes, first_values, first_expiry = buffer.reserve(3) + first_codes[0, 0] = 7 + capacity = buffer.capacity + second_codes, second_values, second_expiry = buffer.reserve(2) + assert buffer.capacity == capacity + assert first_codes is not second_codes + assert second_codes.shape == (2, 16) + assert second_values.shape == (2, 3) + assert second_expiry.shape == (2,) + assert np.all(second_codes == -1) + assert np.all(second_values == 0.0) + assert np.all(second_expiry == -1) + assert buffer.commands_compiled == 5 + buffer.clear() + assert buffer.capacity == 0 + assert buffer.commands_compiled == 0 + + +def test_phase48e_full_runner_reset_and_tape_cache_are_exactly_reusable(): + frame = _bars() + commands = ( + OrderCommand( + timestamp=frame.index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=frame.index[4], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="exit", + ), + ) + runner, compiled = _runner(frame, commands) + first = runner.run_tape_score(compiled) + assert "equity" not in first + assert "fills" not in first + info_after_first = runner.cache_info() + second = runner.run_tape_score(compiled) + info_after_second = runner.cache_info() + for key in ( + "final_equity", + "total_fee", + "total_turnover", + "fill_count", + "event_count", + "rejected_count", + "canceled_count", + "liquidated", + ): + assert getattr(first, key, first[key] if isinstance(first, dict) else None) == getattr( + second, key, second[key] if isinstance(second, dict) else None + ) + assert info_after_first["tape_cache_entries"] == 1 + assert info_after_second["tape_cache_entries"] == 1 + assert info_after_second["commands_compiled"] == info_after_first["commands_compiled"] + runner.clear_caches() + assert runner.cache_info()["tape_cache_bytes"] == 0 + assert runner.cache_info()["tape_cache_entries"] == 0 + + +def test_phase48e_context_projection_mask_does_not_materialize_unused_state(): + frame = _bars() + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0), + execution=ExecutionConfig(), + fee_rate=0.0002, + native_backend="rust", + ) + ) + + class ScalarStrategy: + native_context_requirements = { + "fills": False, + "events": False, + "active_orders": False, + "positions": False, + "margin": False, + } + + def on_bar_close(self, context): + assert context.fills_this_bar == () + assert context.order_events_this_bar == () + assert context.active_orders == () + assert context.positions == {} + assert context.initial_margin == 0.0 + assert context.maintenance_margin == 0.0 + return () + + strategy = ScalarStrategy() + requirements = NativeEventScoreRequirements.from_strategy( + strategy, + base=NativeEventScoreRequirements.scalar_score_contract(), + ) + score = backend.run_strategy_score( + datetime_index=frame.index, + strategy=strategy, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + score_requirements=requirements, + ) + counters = score.metadata["execution_counters"] + assert counters["active_snapshot_materializations"] == 0 + # The runner may ask for bar zero once before and once during the normal + # callback loop; the important contract is that no active snapshots cross + # the boundary for this declaration. + assert counters["contexts_materialized"] >= len(frame) + assert score.metadata["score_primitive_order_state"] is True + + +def test_phase48e_terminal_compaction_preserves_full_contract_parity(): + frame = _bars(192) + commands = tuple( + OrderCommand( + timestamp=frame.index[bar], + symbol="BTC", + side=OrderSide.BUY if bar % 2 == 0 else OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id=f"order-{bar}", + ) + for bar in range(1, len(frame)) + ) + runner, compiled = _runner(frame, commands) + rust = runner.run_tape_audit(compiled) + arena = runner.cache_info() + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + ) + ) + market = backend.prepare_market_arrays( + datetime_index=frame.index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + python = backend.run_order_commands( + datetime_index=frame.index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + report_level="minimal", + ) + np.testing.assert_allclose(rust.equity, python.equity.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose( + rust.positions[:, 0], + python.positions["Position_BTC"].to_numpy(), + rtol=0.0, + atol=1e-12, + ) + np.testing.assert_allclose(rust.fees, python.fees.to_numpy(), rtol=0.0, atol=1e-12) + assert rust.fill_count == len(commands) + assert arena["order_compactions"] >= 1 + assert arena["terminal_orders_removed"] >= 64 diff --git a/tests/native_event/test_reactive_accounting_parity.py b/tests/native_event/test_reactive_accounting_parity.py new file mode 100644 index 0000000..070b7a5 --- /dev/null +++ b/tests/native_event/test_reactive_accounting_parity.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import numpy as np + +from quantbt import AccountConfig, ExecutionConfig, NativeEventBackend, NativeEventConfig, OrderCommand, OrderSide, OrderType, TimeInForce + +from .conftest import ScheduledCommandStrategy, assert_native_event_full_parity, bars, multi_bars, run_reactive + + +def _c(timestamp, **kwargs) -> OrderCommand: + return OrderCommand(timestamp=timestamp, **kwargs) + + +def _assert_strategy_parity(strategy, df=None, symbols=None, **kwargs): + df = bars(10) if df is None else df + oracle = run_reactive("replay_certified", strategy, data=df, symbols=symbols, **kwargs) + candidate = run_reactive("single_pass", strategy, data=df, symbols=symbols, **kwargs) + assert_native_event_full_parity(candidate, oracle) + return candidate, oracle + + +def test_native_event_funding_parity(): + df = bars(12) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [_c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=2.0, tif=TimeInForce.IOC, order_id="entry")], + 8: [_c(t0, symbol="BTC", side=OrderSide.SELL, order_type=OrderType.MARKET, qty=2.0, tif=TimeInForce.IOC, reduce_only=True, order_id="exit")], + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df, use_funding=True, funding_rate=0.0001) + assert float(np.abs(candidate.funding).sum()) > 0.0 + + +def test_native_event_margin_sequence_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [_c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=500.0, tif=TimeInForce.IOC, order_id="too-large")] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df, initial_capital=1_000, leverage=1) + assert len(candidate.fills) == 0 + assert int(candidate.metadata["lifecycle_counters"]["rejected_count"]) >= 1 + + +def test_native_event_liquidation_priority_parity(): + df = bars(10) + df.loc[df.index[2], "low"] = 1.0 + df.loc[df.index[2], "close"] = 5.0 + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [_c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=20.0, tif=TimeInForce.IOC, order_id="levered-entry")] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df, initial_capital=1_000, leverage=10) + assert candidate.liquidated is True + assert candidate.liquidation_bar >= 0 + assert int(candidate.metadata["liquidation_reason"]) >= 0 + + +def test_native_event_multisymbol_parity(): + data = multi_bars(12) + idx = data["BTC"].index + commands = [ + _c(idx[1], symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=1.0, tif=TimeInForce.IOC, order_id="btc-entry"), + _c(idx[1], symbol="ETH", side=OrderSide.SELL, order_type=OrderType.MARKET, qty=1.0, tif=TimeInForce.IOC, order_id="eth-entry"), + _c(idx[7], symbol="BTC", side=OrderSide.SELL, order_type=OrderType.MARKET, qty=1.0, tif=TimeInForce.IOC, reduce_only=True, order_id="btc-exit"), + _c(idx[7], symbol="ETH", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=1.0, tif=TimeInForce.IOC, reduce_only=True, order_id="eth-exit"), + ] + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=20_000, leverage=5), + execution=ExecutionConfig(slippage_bps=0.0), + fee_rate=0.0002, + use_funding=False, + report_level="audit", + ) + ) + result = backend.run_order_commands( + idx, + commands, + closes={symbol: frame["close"] for symbol, frame in data.items()}, + highs={symbol: frame["high"] for symbol, frame in data.items()}, + lows={symbol: frame["low"] for symbol, frame in data.items()}, + symbols=["BTC", "ETH"], + ) + + assert list(result.symbols) == ["BTC", "ETH"] + assert len(result.fills) == 4 + assert result.positions["Position_BTC"].iloc[-1] == 0.0 + assert result.positions["Position_ETH"].iloc[-1] == 0.0 diff --git a/tests/native_event/test_reactive_backend_matrix.py b/tests/native_event/test_reactive_backend_matrix.py new file mode 100644 index 0000000..48ec688 --- /dev/null +++ b/tests/native_event/test_reactive_backend_matrix.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pytest + +from quantbt import OrderCommand, OrderSide, OrderType, TimeInForce + +from .conftest import SEED, ScheduledCommandStrategy, assert_native_event_full_parity, bars, run_reactive + + +def test_native_event_python_vs_replay_randomized(): + rng = np.random.default_rng(SEED) + df = bars(64) + schedule = {} + long = False + order_seq = 0 + for bar in range(0, len(df) - 2): + commands = [] + if not long and rng.random() < 0.22: + order_seq += 1 + order_type = OrderType.MARKET if rng.random() < 0.7 else OrderType.LIMIT + commands.append( + OrderCommand( + timestamp=df.index[bar], + symbol="BTC", + side=OrderSide.BUY, + order_type=order_type, + qty=float(rng.choice([0.25, 0.5, 1.0])), + price=float(df["close"].iloc[bar]) if order_type is OrderType.LIMIT else None, + tif=TimeInForce.IOC, + order_id=f"rnd-entry-{order_seq}", + ) + ) + long = True + elif long and rng.random() < 0.25: + order_seq += 1 + commands.append( + OrderCommand( + timestamp=df.index[bar], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=0.25, + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"rnd-exit-{order_seq}", + ) + ) + long = False + if commands: + schedule[bar] = commands + + strategy = ScheduledCommandStrategy(schedule) + oracle = run_reactive("replay_certified", strategy, data=df) + candidate = run_reactive("single_pass", ScheduledCommandStrategy(schedule), data=df) + try: + assert_native_event_full_parity(candidate, oracle) + except AssertionError as exc: + raise AssertionError(f"seed={SEED}") from exc + + +def test_native_event_rust_vs_replay_randomized(): + if importlib.util.find_spec("quantbt_native") is None and importlib.util.find_spec("_quantbt_native") is None: + pytest.skip("quantbt-native extension is Phase 44; rust parity activates when the wheel exists") + pytest.skip("rust native-event routing is not exposed until Phase 44") + + +def test_native_event_backend_fallback_without_extension(): + df = bars(8) + strategy = ScheduledCommandStrategy( + { + 0: [ + OrderCommand( + timestamp=df.index[0], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ) + ] + } + ) + + result = run_reactive("single_pass", strategy, data=df) + assert result.metadata["engine"] == "event_v2_reactive_single_pass" + assert result.metadata["reactive_kernel_mode"] == "single_pass" + + +def test_native_event_backend_version_mismatch_falls_back(): + if importlib.util.find_spec("quantbt_native") is None and importlib.util.find_spec("_quantbt_native") is None: + pytest.skip("native extension version negotiation is Phase 44; Python fallback is current baseline") + pytest.skip("version mismatch fallback requires a native wheel test fixture") diff --git a/tests/native_event/test_reactive_callback_contract.py b/tests/native_event/test_reactive_callback_contract.py new file mode 100644 index 0000000..008b9f8 --- /dev/null +++ b/tests/native_event/test_reactive_callback_contract.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from quantbt import OrderCommand, OrderSide, OrderType, TimeInForce + +from .conftest import bars, run_reactive + + +def test_native_event_initialize_and_bar0_ordering(): + df = bars(6) + + class Strategy: + def __init__(self): + self.calls = [] + + def initialize(self, context): + self.calls.append(("initialize", context.bar_index)) + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="init-entry", + ) + ] + + def on_bar_close(self, context): + self.calls.append(("on_bar_close", context.bar_index)) + if context.bar_index == 0: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + reduce_only=True, + order_id="bar0-exit", + ) + ] + return [] + + strategy = Strategy() + result = run_reactive("replay_certified", strategy, data=df) + tape = result.metadata["emitted_command_tape"] + + assert strategy.calls[:2] == [("initialize", 0), ("on_bar_close", 0)] + assert [cmd.order_id for cmd in tape[:2]] == ["init-entry", "bar0-exit"] + assert [cmd.timestamp for cmd in tape[:2]] == [df.index[1], df.index[1]] + assert [fill.order_id for fill in result.fills] == ["init-entry", "bar0-exit"] + assert [fill.timestamp for fill in result.fills] == [df.index[1], df.index[1]] + + +def test_native_event_commands_effective_next_bar(): + df = bars(5) + + class Strategy: + def on_bar_close(self, context): + if context.bar_index == 0: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=float(df["close"].iloc[0]), + tif=TimeInForce.GTC, + order_id="next-bar-limit", + ) + ] + return [] + + result = run_reactive("replay_certified", Strategy(), data=df) + assert result.metadata["emitted_command_tape"][0].timestamp == df.index[1] + assert result.fills[0].timestamp == df.index[1] + + +def test_native_event_same_bar_command_sequence(): + df = bars(6) + + class Strategy: + def on_bar_close(self, context): + if context.bar_index == 0: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="seq-1", + ), + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + reduce_only=True, + order_id="seq-2", + ), + ] + return [] + + result = run_reactive("replay_certified", Strategy(), data=df) + report = result.metadata["command_report"].sort_values("original_index") + + assert [cmd.order_id for cmd in result.metadata["emitted_command_tape"]] == ["seq-1", "seq-2"] + assert report["order_id"].tolist() == ["seq-1", "seq-2"] + assert [fill.order_id for fill in result.fills] == ["seq-1", "seq-2"] + + +def test_native_event_finalize_command_is_recorded_beyond_executable_tape(): + df = bars(4) + + class Strategy: + def finalize(self, context): + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="finalize-outside-tape", + ) + ] + + result = run_reactive("replay_certified", Strategy(), data=df) + tape = result.metadata["emitted_command_tape"] + + assert len(result.fills) == 0 + assert len(tape) == 1 + assert tape[0].order_id == "finalize-outside-tape" + assert tape[0].metadata["outside_executable_tape"] is True + assert tape[0].metadata["reactive_effective_bar"] == len(df) + assert result.metadata["emitted_executable_command_count"] == 0 diff --git a/tests/native_event/test_reactive_lifecycle_parity.py b/tests/native_event/test_reactive_lifecycle_parity.py new file mode 100644 index 0000000..5da71c3 --- /dev/null +++ b/tests/native_event/test_reactive_lifecycle_parity.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import pytest + +from quantbt import ( + OrderAction, + OrderActivationPolicy, + OrderCommand, + OrderSide, + OrderType, + TimeInForce, +) + +from .conftest import ScheduledCommandStrategy, assert_native_event_full_parity, bars, run_reactive + + +def _c(timestamp, **kwargs) -> OrderCommand: + return OrderCommand(timestamp=timestamp, **kwargs) + + +def _assert_strategy_parity(strategy, df=None, **kwargs): + df = bars(8) if df is None else df + oracle = run_reactive("replay_certified", strategy, data=df, **kwargs) + candidate = run_reactive("single_pass", strategy, data=df, **kwargs) + assert_native_event_full_parity(candidate, oracle) + return candidate, oracle + + +def test_native_event_cancel_replace_amend_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.LIMIT, qty=1.0, price=50.0, order_id="amend-me"), + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.LIMIT, qty=1.0, price=50.0, order_id="cancel-me"), + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.LIMIT, qty=1.0, price=50.0, order_id="replace-me"), + ], + 1: [ + _c(t0, action=OrderAction.AMEND, target_order_id="amend-me", price=99.0), + _c(t0, action=OrderAction.CANCEL, target_order_id="cancel-me"), + _c( + t0, + action=OrderAction.REPLACE, + target_order_id="replace-me", + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=99.0, + order_id="replace-new", + ), + ], + 2: [_c(t0, action=OrderAction.CANCEL_ALL, symbol="BTC")], + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df) + report = candidate.metadata["command_report"].sort_values("original_index") + assert "amend-me" in set(report["order_id"]) + assert "replace-new" in set(report["order_id"]) + assert "cancel-me" in set(report["order_id"]) + + +def test_native_event_parent_activation_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c( + t0, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="parent", + ), + _c( + t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=0.5, + price=102.0, + tif=TimeInForce.GTC, + reduce_only=True, + parent_order_id="parent", + activation_policy=OrderActivationPolicy.ON_PARENT_FIRST_FILL, + order_id="child-first-fill", + ), + _c( + t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=0.5, + price=102.5, + tif=TimeInForce.GTC, + reduce_only=True, + parent_order_id="parent", + activation_policy=OrderActivationPolicy.ON_PARENT_FULL_FILL, + order_id="child-full-fill", + ), + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df) + assert "activate" in set(candidate.metadata["order_events"]["event_name"]) + + +def test_native_event_oco_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=1.0, tif=TimeInForce.IOC, order_id="entry"), + _c( + t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=1.0, + price=102.0, + reduce_only=True, + parent_order_id="entry", + activation_policy=OrderActivationPolicy.ON_PARENT_FIRST_FILL, + oco_group_id="bracket", + order_id="take-profit", + ), + _c( + t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.STOP_MARKET, + qty=1.0, + trigger_price=95.0, + reduce_only=True, + parent_order_id="entry", + activation_policy=OrderActivationPolicy.ON_PARENT_FIRST_FILL, + oco_group_id="bracket", + order_id="stop-loss", + ), + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df) + assert [fill.order_id for fill in candidate.fills][:2] == ["entry", "take-profit"] + assert "cancel" in set(candidate.metadata["order_events"]["event_name"]) + + +def test_native_event_gtd_expiry_bar_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c( + t0, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=50.0, + tif=TimeInForce.GTD, + expires_at=df.index[3], + order_id="gtd-bid", + ) + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df) + assert "expire" in set(candidate.metadata["order_events"]["event_name"]) + + +def test_native_event_ioc_fok_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.LIMIT, qty=1.0, price=50.0, tif=TimeInForce.IOC, order_id="ioc-bid"), + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.LIMIT, qty=1.0, price=50.0, tif=TimeInForce.FOK, order_id="fok-bid"), + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=0.25, tif=TimeInForce.IOC, order_id="ioc-market"), + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df) + assert [fill.order_id for fill in candidate.fills] == ["ioc-market"] + + +def test_native_event_reduce_only_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [_c(t0, symbol="BTC", side=OrderSide.SELL, order_type=OrderType.MARKET, qty=1.0, reduce_only=True, order_id="bad-reduce")], + 1: [ + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=1.0, tif=TimeInForce.IOC, order_id="entry"), + _c(t0, symbol="BTC", side=OrderSide.SELL, order_type=OrderType.MARKET, qty=3.0, reduce_only=True, tif=TimeInForce.IOC, order_id="clip-exit"), + ], + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df) + assert [fill.qty for fill in candidate.fills] == [1.0, 1.0] + + +def test_native_event_quantity_constraint_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=1.07, tif=TimeInForce.IOC, order_id="rounded"), + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.MARKET, qty=0.01, tif=TimeInForce.IOC, order_id="min-drop"), + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df, qty_step={"BTC": 0.1}, min_qty={"BTC": 0.1}) + assert [fill.qty for fill in candidate.fills] == [1.0] + assert candidate.metadata["quantity_preflight"]["changed_count"] == 1 + assert candidate.metadata["quantity_preflight"]["dropped_count"] == 1 + + +@pytest.mark.parametrize( + ("quantity_kwargs", "qty", "expected_qty", "changed", "dropped"), + [ + ({"lot_size": {"BTC": 0.25}}, 1.13, [1.0], 1, 0), + ({"min_qty": {"BTC": 0.2}}, 0.19, [], 0, 1), + ({"min_notional": {"BTC": 150.0}}, 1.0, [], 0, 1), + ({"qty_step": {"BTC": 0.1}}, 0.30000000000000004, [0.3], 0, 0), + ], +) +def test_native_event_quantity_constraint_edge_case_parity( + quantity_kwargs, + qty, + expected_qty, + changed, + dropped, +): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c( + t0, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=qty, + tif=TimeInForce.IOC, + order_id="quantity-edge", + ) + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df, **quantity_kwargs) + assert [fill.qty for fill in candidate.fills] == pytest.approx(expected_qty, abs=1e-15) + assert candidate.metadata["quantity_preflight"]["changed_count"] == changed + assert candidate.metadata["quantity_preflight"]["dropped_count"] == dropped + + +def test_native_event_reduce_only_quantity_constraint_clip_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c( + t0, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.07, + tif=TimeInForce.IOC, + order_id="rounded-entry", + ), + _c( + t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=3.07, + tif=TimeInForce.IOC, + reduce_only=True, + order_id="rounded-reduce", + ), + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df, qty_step={"BTC": 0.1}, min_qty={"BTC": 0.1}) + assert [fill.qty for fill in candidate.fills] == [1.0, 1.0] + assert candidate.positions["Position_BTC"].iloc[-1] == 0.0 + + +def test_native_event_stop_order_parity(): + df = bars(8) + t0 = df.index[0] + strategy = ScheduledCommandStrategy( + { + 0: [ + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.STOP_MARKET, qty=0.5, trigger_price=102.0, order_id="stop-market"), + _c(t0, symbol="BTC", side=OrderSide.BUY, order_type=OrderType.STOP_LIMIT, qty=0.5, trigger_price=102.0, price=101.0, order_id="stop-limit"), + ] + } + ) + + candidate, _ = _assert_strategy_parity(strategy, df) + assert {fill.order_id for fill in candidate.fills} == {"stop-market", "stop-limit"} diff --git a/tests/native_event/test_reactive_memory_lifetime.py b/tests/native_event/test_reactive_memory_lifetime.py new file mode 100644 index 0000000..bf3faca --- /dev/null +++ b/tests/native_event/test_reactive_memory_lifetime.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import gc +import tracemalloc + +from quantbt import OrderCommand, OrderSide, OrderType, QuantBTEndpoint, TimeInForce + +from .conftest import SEED, bars + + +class LowChurnStrategy: + def __init__(self, entry_bar: int = 0, exit_bar: int = 8): + self.entry_bar = int(entry_bar) + self.exit_bar = int(exit_bar) + + def on_bar_close(self, context): + if context.bar_index == self.entry_bar: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id=f"entry-{self.entry_bar}", + ) + ] + if context.bar_index == self.exit_bar: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + reduce_only=True, + order_id=f"exit-{self.exit_bar}", + ) + ] + return [] + + +def _prepared(n=64): + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=10_000, + leverage=10, + use_funding=False, + fee_rate=0.0002, + report_level="audit", + ) + return endpoint.prepare_native_event_strategy(data=bars(n), symbols=["BTC"]) + + +def test_native_event_score_no_pandas_materialization(): + prepared = _prepared() + score = prepared.score(LowChurnStrategy()) + + assert score.metadata["engine"] == "event_v2_reactive_score" + assert not hasattr(score, "fills") + assert not hasattr(score, "orders") + assert score.equity.ndim == 1 + assert score.positions.ndim == 2 + + +def test_native_event_score_does_not_retain_terminal_orders(): + prepared = _prepared() + score = prepared.score(LowChurnStrategy()) + + assert "command_report" not in score.metadata + assert "order_events" not in score.metadata + assert "emitted_command_tape" not in score.metadata + + +def test_native_event_consumed_queues_are_released(): + prepared = _prepared() + for i in range(10): + prepared.score(LowChurnStrategy(entry_bar=i % 3, exit_bar=8 + i % 5)) + + assert prepared.metadata["scores"] == 10 + assert prepared.metadata.get("last_score_fill_count", 0) <= 2 + + +def test_native_event_repeated_score_rss_plateaus(): + prepared = _prepared(96) + gc.collect() + tracemalloc.start() + try: + for i in range(30): + prepared.score(LowChurnStrategy(entry_bar=i % 5, exit_bar=12 + i % 7)) + current, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + + assert prepared.metadata["scores"] == 30, f"seed={SEED}" + assert current < 2_000_000, f"seed={SEED} current={current}" + assert peak < 8_000_000, f"seed={SEED} peak={peak}" diff --git a/tests/native_event/test_rust_batched_full_tape.py b/tests/native_event/test_rust_batched_full_tape.py new file mode 100644 index 0000000..4668606 --- /dev/null +++ b/tests/native_event/test_rust_batched_full_tape.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pandas as pd +import pytest + +from quantbt import ( + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderAction, + OrderCommand, + OrderSide, + OrderType, + RustBatchedRunner, + TimeInForce, +) +from quantbt.backends._native_event_rust import ( + NativeEventRustBackendError, + compile_rust_batched_tape, + probe_native_event_rust_extension, +) + + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native batched wheel is not installed in this environment", +) + + +def _bars(n: int = 12) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.arange(n, dtype=np.float64) * 0.5, index=index) + return pd.DataFrame( + { + "open": close, + "high": close + 2.0, + "low": close - 2.0, + "close": close, + "volume": 1_000.0, + }, + index=index, + ) + + +def _fixture(): + frame = _bars() + index = frame.index + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + market = backend.prepare_market_arrays( + datetime_index=index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + commands = ( + OrderCommand( + timestamp=index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=index[2], + action=OrderAction.PLACE, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=0.5, + price=103.0, + tif=TimeInForce.GTC, + order_id="partial-exit", + ), + OrderCommand( + timestamp=index[3], + action=OrderAction.CANCEL, + target_order_id="partial-exit", + ), + OrderCommand( + timestamp=index[4], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.STOP_MARKET, + qty=1.0, + trigger_price=102.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="stop-exit", + ), + ) + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + runner = RustBatchedRunner( + idx=index, + symbols=["BTC"], + market_arrays=market, + contract_size=1.0, + leverage=5.0, + fee_rate=0.0002, + initial_capital=10_000.0, + maintenance_ratio=0.0, + slippage=0.0002, + use_funding=False, + ) + return backend, frame, market, commands, compiled, runner + + +def test_extension_advertises_batched_tape_contract(): + status = probe_native_event_rust_extension() + assert status.available and status.compatible and status.executable + assert status.capabilities["rust_batched_tape"] is True + assert status.capabilities["rust_batched_tape_score"] is True + assert status.capabilities["rust_batched_tape_audit"] is True + + +def test_tape_compilation_is_contiguous_and_bar_indexed(): + _, _, _, _, compiled, _ = _fixture() + ptr, codes, values, expiry = compile_rust_batched_tape(compiled, symbol="BTC") + assert ptr.flags.c_contiguous + assert codes.flags.c_contiguous + assert values.flags.c_contiguous + assert expiry.flags.c_contiguous + assert ptr.shape == (13,) + assert ptr[-1] == len(compiled.sorted_commands) + assert codes.shape == (4, 8) + assert values.shape == (4, 3) + np.testing.assert_array_equal(codes[:, 7], np.arange(4, dtype=np.int64)) + np.testing.assert_array_equal(np.flatnonzero(np.diff(ptr)), np.array([1, 2, 3, 4])) + + +def test_rust_batched_score_and_audit_have_exact_internal_parity(): + _, _, _, _, compiled, runner = _fixture() + score = runner.run_tape_score(compiled) + audit = runner.run_tape_audit(compiled) + + assert score.metadata == {"backend": "rust_batched", "mode": "score", "pycalls": 1} + assert audit.metadata == {"backend": "rust_batched", "mode": "audit", "pycalls": 1} + np.testing.assert_allclose(score.final_equity, audit.equity[-1], rtol=0.0, atol=1e-12) + np.testing.assert_allclose(score.final_position, audit.positions[-1], rtol=0.0, atol=1e-12) + np.testing.assert_allclose(score.total_fee, audit.total_fee, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(score.total_turnover, audit.total_turnover, rtol=0.0, atol=1e-12) + assert score.fill_count == audit.fill_count + assert score.event_count == audit.event_count + assert score.rejected_count == audit.rejected_count + assert score.canceled_count == audit.canceled_count + for name in ( + "equity", + "positions", + "fees", + "turnover", + "initial_margin", + "maintenance_margin", + "fill_bar", + "fill_order_id", + "fill_side", + "fill_qty", + "fill_price", + "fill_fee", + "event_bar", + "event_kind", + "event_status", + "event_order_id", + "event_target_id", + ): + assert getattr(audit, name).flags.c_contiguous + + +def test_rust_batched_matches_python_v2_for_certified_tape(): + backend, frame, market, commands, compiled, runner = _fixture() + rust = runner.run_tape_audit(compiled) + python = backend.run_order_commands( + datetime_index=frame.index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + contract_size=1.0, + leverage=5.0, + fee_rate=0.0002, + report_level="minimal", + ) + + np.testing.assert_allclose(rust.equity, python.equity.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust.positions, python.positions["Position_BTC"].to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust.fees, python.fees.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust.turnover, python.diagnostics["turnover"].to_numpy(), rtol=0.0, atol=1e-12) + assert rust.fill_count == int(python.metadata["lifecycle_counters"]["fill_count"]) + assert rust.event_count == int(python.metadata["lifecycle_counters"]["event_count"]) + assert rust.rejected_count == int(python.metadata["lifecycle_counters"]["rejected_count"]) + np.testing.assert_allclose(rust.total_fee, python.fees.sum(), rtol=0.0, atol=1e-12) + + +def test_batched_runner_rejects_unsupported_accounting_before_execution(): + _, _, market, _, compiled, _ = _fixture() + with pytest.raises(NativeEventRustBackendError, match="funding"): + RustBatchedRunner( + idx=market.idx, + symbols=["BTC"], + market_arrays=market, + use_funding=True, + ) + with pytest.raises(NativeEventRustBackendError, match="liquidation"): + RustBatchedRunner( + idx=market.idx, + symbols=["BTC"], + market_arrays=market, + maintenance_ratio=0.005, + ) + assert compiled.n_commands == 4 diff --git a/tests/native_event/test_rust_batched_sparse.py b/tests/native_event/test_rust_batched_sparse.py new file mode 100644 index 0000000..a574b4f --- /dev/null +++ b/tests/native_event/test_rust_batched_sparse.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pytest + +from quantbt import RustBatchedSession +from quantbt.backends._native_event_rust import NativeEventRustBackendError + +from .test_rust_batched_full_tape import _fixture + + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native sparse wheel is not installed in this environment", +) + + +def test_sparse_chunks_preserve_full_tape_accounting_and_ledger() -> None: + _, _, _, _, compiled, runner = _fixture() + full = runner.run_tape_audit(compiled) + session = runner.open_sparse_session(compiled) + + chunks = [ + session.run_until(3), + session.run_until(7), + session.run_until(11), + ] + + assert isinstance(session, RustBatchedSession) + assert [(chunk.start_bar, chunk.stop_bar) for chunk in chunks] == [(0, 3), (4, 7), (8, 11)] + assert session.next_bar == 12 + np.testing.assert_allclose(chunks[-1].final_equity, full.equity[-1], rtol=0.0, atol=1e-12) + np.testing.assert_allclose(chunks[-1].final_position, full.positions[-1], rtol=0.0, atol=1e-12) + np.testing.assert_allclose(sum(chunk.total_fee for chunk in chunks), full.total_fee, rtol=0.0, atol=1e-12) + np.testing.assert_allclose( + sum(chunk.total_turnover for chunk in chunks), full.total_turnover, rtol=0.0, atol=1e-12 + ) + assert sum(chunk.fill_count for chunk in chunks) == full.fill_count + assert sum(chunk.event_count for chunk in chunks) == full.event_count + assert sum(chunk.rejected_count for chunk in chunks) == full.rejected_count + assert sum(chunk.canceled_count for chunk in chunks) == full.canceled_count + + for name in ( + "fill_bar", + "fill_order_id", + "fill_side", + "fill_qty", + "fill_price", + "fill_fee", + "event_bar", + "event_kind", + "event_status", + "event_order_id", + "event_target_id", + ): + combined = np.concatenate([getattr(chunk, name) for chunk in chunks]) + np.testing.assert_array_equal(combined, getattr(full, name)) + assert all(chunk.wake_kind[-1] == 2 for chunk in chunks) + + +def test_sparse_wake_filters_do_not_change_accounting() -> None: + _, _, _, _, compiled, runner = _fixture() + session = runner.open_sparse_session(compiled) + chunk = session.run_until(11, wake_on_fill=False, wake_on_order_event=False, wake_on_liquidation=False) + + np.testing.assert_array_equal(chunk.wake_kind, np.array([2], dtype=np.int64)) + assert chunk.liquidation_seen is False + assert chunk.metadata["dense_paths_materialized"] is False + for name in ( + "fill_bar", + "fill_order_id", + "fill_side", + "fill_qty", + "fill_price", + "fill_fee", + "event_bar", + "event_kind", + "event_status", + "event_order_id", + "event_target_id", + ): + assert getattr(chunk, name).size == 0 + + +def test_sparse_session_rejects_missing_or_replaced_tape() -> None: + _, _, _, _, compiled, runner = _fixture() + with pytest.raises(NativeEventRustBackendError, match="compiled command tape"): + runner.open_sparse_session().run_until(2) + + session = runner.open_sparse_session(compiled) + session.run_until(2) + with pytest.raises(NativeEventRustBackendError, match="replace the command tape"): + session.run_until(3, _fixture()[4]) + with pytest.raises(ValueError, match="must advance"): + session.run_until(2) diff --git a/tests/native_event/test_rust_phase46d_ownership.py b/tests/native_event/test_rust_phase46d_ownership.py new file mode 100644 index 0000000..7a9767d --- /dev/null +++ b/tests/native_event/test_rust_phase46d_ownership.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import gc +import importlib.util + +import numpy as np +import pytest + +import quantbt.backends._native_event_rust as rust_adapter +from quantbt import OrderAction, OrderCommand, OrderSide, OrderType, TimeInForce +from quantbt.backends._native_event_rust import RustBatchedRunner + +from .test_rust_batched_full_tape import _bars + + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native batched wheel is not installed in this environment", +) + + +def _replacement_fixture(): + frame = _bars(16) + index = frame.index + from quantbt import AccountConfig, ExecutionConfig, NativeEventBackend, NativeEventConfig + + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + market = backend.prepare_market_arrays( + datetime_index=index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + commands = ( + OrderCommand( + timestamp=index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=50.0, + tif=TimeInForce.GTC, + order_id="a", + ), + OrderCommand( + timestamp=index[2], + action=OrderAction.REPLACE, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=51.0, + tif=TimeInForce.GTC, + order_id="b", + target_order_id="a", + ), + OrderCommand( + timestamp=index[3], + action=OrderAction.REPLACE, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=52.0, + tif=TimeInForce.GTC, + order_id="c", + target_order_id="b", + ), + OrderCommand( + timestamp=index[4], + action=OrderAction.CANCEL, + target_order_id="a", + ), + ) + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + runner = RustBatchedRunner( + idx=index, + symbols=["BTC"], + market_arrays=market, + contract_size=1.0, + leverage=5.0, + fee_rate=0.0002, + initial_capital=10_000.0, + slippage=0.0002, + use_funding=False, + ) + return backend, frame, market, commands, compiled, runner + + +def test_phase46d_prepared_market_copy_survives_python_input_release(): + _, _, market, _, compiled, runner = _replacement_fixture() + del market + gc.collect() + score = runner.run_tape_score(compiled) + assert score.bars == len(runner.idx) + assert np.isfinite(score.final_equity) + + +def test_phase46d_score_boundary_is_typed_and_has_no_audit_payload(): + _, _, _, _, compiled, runner = _replacement_fixture() + ptr, codes, values, expiry = runner._tape_arrays(compiled) + payload = runner._new_session().run_tape_score(ptr, codes, values, expiry) + assert type(payload).__name__ == "BatchedScoreResultCore" + assert payload.bars == len(runner.idx) + assert not hasattr(payload, "equity") + assert not hasattr(payload, "fills") + + +def test_phase46d_tape_cache_is_fingerprint_bounded_and_clearable(): + _, _, market, _, compiled, runner = _replacement_fixture() + runner.run_tape_score(compiled) + assert runner.tape_cache_bytes > 0 + runner.clear_tape_cache() + assert runner.tape_cache_bytes == 0 + + bounded = RustBatchedRunner( + idx=runner.idx, + symbols=runner.symbols, + market_arrays=market, + contract_size=runner.contract_size, + leverage=runner.leverage, + fee_rate=runner.fee_rate, + initial_capital=runner.initial_capital, + slippage=runner.slippage, + use_funding=False, + max_tape_cache_bytes=1, + ) + bounded.run_tape_score(compiled) + assert bounded.tape_cache_bytes == 0 + + +def test_phase46d_compiled_tape_fingerprint_is_precomputed_and_arrays_are_read_only(): + _, _, _, _, compiled, _ = _replacement_fixture() + assert compiled.tape_fingerprint + for name in ( + "command_ptr", + "command_bar", + "command_action", + "command_symbol", + "command_side", + "command_type", + "command_qty", + "command_price", + "command_trigger_price", + "command_tif", + "command_reduce_only", + "command_order_id", + "command_target_order_id", + "command_parent_order_id", + "command_group_id", + "command_oco_group_id", + "command_activation", + "command_expires_bar", + "original_index", + ): + assert getattr(compiled, name).flags.writeable is False + + +def test_phase46d_score_cache_does_not_rehash_compiled_tape(monkeypatch): + _, _, _, _, compiled, runner = _replacement_fixture() + runner.run_tape_score(compiled) + + def fail_if_rehashed(_): + raise AssertionError("compiled tape was rehashed on the cache-hit path") + + monkeypatch.setattr(rust_adapter, "_command_tape_fingerprint", fail_if_rehashed) + second = runner.run_tape_score(compiled) + assert second.bars == len(runner.idx) + + +def test_phase46d_replacement_chain_preserves_audit_accounting(): + backend, frame, market, commands, compiled, runner = _replacement_fixture() + rust = runner.run_tape_audit(compiled) + python = backend.run_order_commands( + datetime_index=frame.index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="minimal", + ) + np.testing.assert_allclose(rust.equity, python.equity.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust.positions, python.positions["Position_BTC"].to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust.fees, python.fees.to_numpy(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(rust.total_turnover, python.diagnostics["turnover"].sum(), rtol=0.0, atol=1e-12) + assert rust.fill_count == 0 + assert rust.canceled_count == 1 + + +def test_phase46d_cycle_alias_is_finite_and_does_not_fill(): + backend, frame, market, _, _, runner = _replacement_fixture() + index = frame.index + commands = ( + OrderCommand( + timestamp=index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=50.0, + tif=TimeInForce.GTC, + order_id="a", + ), + OrderCommand( + timestamp=index[2], + action=OrderAction.REPLACE, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=51.0, + tif=TimeInForce.GTC, + order_id="b", + target_order_id="a", + ), + OrderCommand( + timestamp=index[3], + action=OrderAction.REPLACE, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=52.0, + tif=TimeInForce.GTC, + order_id="a", + target_order_id="b", + ), + OrderCommand(timestamp=index[4], action=OrderAction.CANCEL, target_order_id="a"), + ) + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + result = runner.run_tape_audit(compiled) + assert result.fill_count == 0 + assert result.event_count == 6 + assert np.isfinite(result.final_equity if hasattr(result, "final_equity") else result.equity[-1]) + + +def test_phase46d_sparse_session_reset_reuses_state_and_preserves_parity(): + _, _, _, _, compiled, runner = _replacement_fixture() + session = runner.open_sparse_session(compiled) + first = session.run_until(len(runner.idx) - 1) + session.reset() + second = session.run_until(len(runner.idx) - 1) + assert session.next_bar == len(runner.idx) + for name in ( + "final_equity", + "final_position", + "total_fee", + "total_turnover", + "fill_count", + "event_count", + "rejected_count", + "canceled_count", + ): + assert getattr(first, name) == getattr(second, name) + for name in ( + "wake_bar", + "wake_kind", + "fill_bar", + "fill_order_id", + "event_bar", + "event_kind", + "event_status", + ): + np.testing.assert_array_equal(getattr(first, name), getattr(second, name)) diff --git a/tests/native_event/test_rust_r0_fallback.py b/tests/native_event/test_rust_r0_fallback.py new file mode 100644 index 0000000..f32c814 --- /dev/null +++ b/tests/native_event/test_rust_r0_fallback.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path +from types import ModuleType +import tomllib + +import pytest + +from quantbt.backends._native_event_rust import ( + NativeEventRustBackendError, + probe_native_event_rust_extension, + resolve_native_event_backend, +) + +from .conftest import ScheduledCommandStrategy, bars, run_reactive + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _native_module(*, api_version: str = "0.3", reactive_session: bool = False) -> ModuleType: + module = ModuleType("_quantbt_native") + module.version = lambda: "0.3.0" + module.api_version = lambda: api_version + module.capabilities = lambda: {"r0_import_smoke": True, "reactive_session": reactive_session} + return module + + +def test_native_event_auto_resolves_to_python_without_importing_extension() -> None: + selection = resolve_native_event_backend(requested="auto") + assert selection.requested == "auto" + assert selection.resolved == "python" + + +def test_native_event_r1_crate_declares_reactive_session_capability() -> None: + cargo = (PROJECT_ROOT / "rust" / "native_event" / "Cargo.toml").read_text(encoding="utf-8") + metadata = tomllib.loads((PROJECT_ROOT / "rust" / "native_event" / "pyproject.toml").read_text(encoding="utf-8")) + source = (PROJECT_ROOT / "rust" / "native_event" / "src" / "lib.rs").read_text(encoding="utf-8") + + assert 'name = "quantbt-native"' in cargo + assert 'name = "_quantbt_native"' in cargo + assert metadata["project"]["name"] == "quantbt-native" + assert metadata["tool"]["maturin"]["module-name"] == "_quantbt_native" + assert '"r0_import_smoke", true' in source + assert '"reactive_session", true' in source + assert '"r2_stop_amend_replace_reduce_only_constraints", true' in source + assert "ReactiveSessionCore" in source + + +def test_native_event_explicit_rust_fails_clearly_when_extension_is_absent() -> None: + status = probe_native_event_rust_extension(module_loader=lambda: None) + with pytest.raises(NativeEventRustBackendError, match="not installed"): + resolve_native_event_backend(requested="rust", extension_status=status) + + +def test_native_event_version_mismatch_is_never_silently_accepted() -> None: + status = probe_native_event_rust_extension(module=_native_module(api_version="0.2")) + assert status.available + assert not status.compatible + with pytest.raises(NativeEventRustBackendError, match="version mismatch"): + resolve_native_event_backend(requested="rust", extension_status=status) + + +def test_native_event_r0_extension_is_compatible_but_not_executable() -> None: + status = probe_native_event_rust_extension(module=_native_module()) + assert status.compatible + assert not status.executable + with pytest.raises(NativeEventRustBackendError, match="reactive_session"): + resolve_native_event_backend(requested="rust", extension_status=status) + + +def test_native_event_replay_certified_environment_preserves_replay_mode(monkeypatch) -> None: + monkeypatch.setenv("QUANTBT_NATIVE_BACKEND", "replay_certified") + result = run_reactive("single_pass", ScheduledCommandStrategy({}), data=bars(4)) + assert result.metadata["reactive_kernel_mode"] == "replay_certified" + assert result.metadata["native_event_backend_requested"] == "replay_certified" + assert result.metadata["native_event_backend_resolved"] == "replay_certified" diff --git a/tests/native_event/test_rust_r1_single_symbol.py b/tests/native_event/test_rust_r1_single_symbol.py new file mode 100644 index 0000000..1b563c7 --- /dev/null +++ b/tests/native_event/test_rust_r1_single_symbol.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import importlib.util +import sys +from types import ModuleType + +import numpy as np +import pandas as pd +import pytest + +from quantbt import OrderAction, OrderCommand, OrderSide, OrderType, TimeInForce +from quantbt.backends._native_event_rust import ( + NativeEventRustBackendError, + RustCommandBuffer, + compile_rust_r1_command_batch, + validate_rust_r1_support, +) +from quantbt.core.constraints import build_quantity_constraints + +from .conftest import ScheduledCommandStrategy, assert_native_event_full_parity, bars, run_reactive + + +def _interner(): + codes = {} + + def intern(value): + if value is None: + return -1 + return codes.setdefault(value, len(codes)) + + return intern + + +def _nullable(value): + return None if pd.isna(value) else value + + +def _session_event_records(events): + return [ + ( + pd.Timestamp(event.timestamp), + int(event.bar), + event.event_name, + int(event.status), + event.order_id, + event.target_order_id, + ) + for event in events + ] + + +def _replay_event_records(result): + frame = result.metadata["order_events"] + return [ + ( + pd.Timestamp(row["timestamp"]), + int(row["bar"]), + str(row["event_name"]), + int(row["status"]), + _nullable(row.get("order_id")), + _nullable(row.get("target_order_id")), + ) + for row in frame.to_dict("records") + ] + + +def test_rust_r1_compiles_contiguous_place_cancel_buffers() -> None: + df = bars(4) + commands = ( + OrderCommand( + timestamp=df.index[0], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.25, + price=99.5, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand(timestamp=df.index[0], action=OrderAction.CANCEL, target_order_id="entry"), + ) + batch = compile_rust_r1_command_batch(commands, symbol="BTC", intern_id=_interner()) + + assert batch.codes.dtype == np.int64 + assert batch.values.dtype == np.float64 + assert batch.codes.flags.c_contiguous + assert batch.values.flags.c_contiguous + assert batch.codes.shape == (2, 8) + assert batch.values.shape == (2, 3) + np.testing.assert_array_equal(batch.codes[:, 0], np.array([0, 1], dtype=np.int64)) + np.testing.assert_allclose(batch.values[0], np.array([1.25, 99.5, 0.0])) + + +def test_rust_r2_reuses_capacity_managed_command_buffers() -> None: + df = bars(4) + command = OrderCommand( + timestamp=df.index[0], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="reuse", + ) + buffer = RustCommandBuffer() + first = compile_rust_r1_command_batch((command,), symbol="BTC", intern_id=_interner(), buffer=buffer) + second = compile_rust_r1_command_batch((command,), symbol="BTC", intern_id=_interner(), buffer=buffer) + + assert np.shares_memory(first.codes, second.codes) + assert second.codes.flags.c_contiguous + assert second.values.flags.c_contiguous + + +def test_rust_r1_rejects_features_not_in_certified_scope() -> None: + constraints = build_quantity_constraints(["BTC"]) + with pytest.raises(NativeEventRustBackendError, match="exactly one symbol"): + validate_rust_r1_support( + symbols=["BTC", "ETH"], constraints=constraints, use_funding=False, maintenance_ratio=0.0 + ) + with pytest.raises(NativeEventRustBackendError, match="funding"): + validate_rust_r1_support(symbols=["BTC"], constraints=constraints, use_funding=True, maintenance_ratio=0.0) + with pytest.raises(NativeEventRustBackendError, match="liquidation"): + validate_rust_r1_support(symbols=["BTC"], constraints=constraints, use_funding=False, maintenance_ratio=0.005) + + +def test_rust_r1_rejects_non_gtc_or_contingent_commands() -> None: + df = bars(4) + with pytest.raises(NativeEventRustBackendError, match="GTC"): + compile_rust_r1_command_batch( + [ + OrderCommand( + timestamp=df.index[0], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + ) + ], + symbol="BTC", + intern_id=_interner(), + ) + + +def test_rust_r2_compiles_stop_amend_replace_reduce_only_and_quantity_constraints() -> None: + df = bars(4) + commands = ( + OrderCommand( + timestamp=df.index[0], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.STOP_MARKET, + qty=1.25, + trigger_price=101.0, + tif=TimeInForce.GTC, + order_id="entry-stop", + ), + OrderCommand( + timestamp=df.index[0], + action=OrderAction.AMEND, + target_order_id="entry-stop", + trigger_price=102.0, + ), + OrderCommand( + timestamp=df.index[0], + action=OrderAction.REPLACE, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.STOP_LIMIT, + qty=1.5, + price=102.0, + trigger_price=103.0, + tif=TimeInForce.GTC, + order_id="entry-replaced", + target_order_id="entry-stop", + ), + OrderCommand( + timestamp=df.index[0], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=2.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="reduce", + ), + ) + batch = compile_rust_r1_command_batch(commands, symbol="BTC", intern_id=_interner()) + + np.testing.assert_array_equal(batch.codes[:, 0], np.array([0, 2, 3, 0], dtype=np.int64)) + assert batch.codes[0, 2] == 2 + assert batch.codes[2, 2] == 3 + assert batch.codes[1, 6] == 4 + assert batch.codes[3, 3] == 1 + np.testing.assert_allclose(batch.values[0], np.array([1.25, 0.0, 101.0])) + np.testing.assert_allclose(batch.values[1], np.array([0.0, 0.0, 102.0])) + + +def test_rust_r2_accepts_shared_quantity_constraints() -> None: + constraints = build_quantity_constraints(["BTC"], qty_step=0.25, min_qty=0.25, min_notional=10.0) + validate_rust_r1_support(symbols=["BTC"], constraints=constraints, use_funding=False, maintenance_ratio=0.0) + + +def test_native_event_r1_routes_a_compatible_extension_through_callback_boundaries(monkeypatch) -> None: + class FakeReactiveSessionCore: + def __init__(self, *args): + self.equity = float(args[11]) + + def step(self, bar_index, command_codes, command_values, command_expiry): + return { + "equity": self.equity, + "position": 0.0, + "fee": 0.0, + "turnover": 0.0, + "initial_margin": 0.0, + "maintenance_margin": 0.0, + "fills": [], + "events": [], + "active_orders": [], + } + + module = ModuleType("_quantbt_native") + module.version = lambda: "0.3.0" + module.api_version = lambda: "0.3" + module.capabilities = lambda: { + "r0_import_smoke": True, + "reactive_session": True, + "r2_stop_amend_replace_reduce_only_constraints": True, + } + module.ReactiveSessionCore = FakeReactiveSessionCore + monkeypatch.setitem(sys.modules, "_quantbt_native", module) + monkeypatch.setenv("QUANTBT_NATIVE_BACKEND", "rust") + + result = run_reactive( + "single_pass", + ScheduledCommandStrategy({}), + data=bars(5), + maintenance_ratio=0.0, + use_funding=False, + report_level="minimal", + reactive_execution_mode="fast", + ) + + assert result.metadata["native_event_backend_resolved"] == "rust" + assert result.metadata["reactive_kernel_mode"] == "single_pass" + + +@pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native R1 wheel is not installed in this environment", +) +def test_native_event_rust_r1_matches_replay_for_market_limit_and_cancel(monkeypatch) -> None: + df = bars(10) + t0 = df.index[0] + schedule = { + 0: [ + OrderCommand( + timestamp=t0, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=0.5, + price=500.0, + tif=TimeInForce.GTC, + order_id="cancel-me", + ), + ], + 1: [OrderCommand(timestamp=t0, action=OrderAction.CANCEL, target_order_id="cancel-me")], + 3: [ + OrderCommand( + timestamp=t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=1.0, + price=float(df["high"].iloc[4] - 0.1), + tif=TimeInForce.GTC, + order_id="exit", + ) + ], + } + kwargs = { + "initial_capital": 10_000, + "leverage": 5, + "maintenance_ratio": 0.0, + "use_funding": False, + "fee_rate": 0.0002, + "report_level": "standard", + "reactive_execution_mode": "fast", + } + monkeypatch.setenv("QUANTBT_NATIVE_BACKEND", "rust") + rust = run_reactive("single_pass", ScheduledCommandStrategy(schedule), data=df, **kwargs) + monkeypatch.setenv("QUANTBT_NATIVE_BACKEND", "replay_certified") + replay = run_reactive("single_pass", ScheduledCommandStrategy(schedule), data=df, **kwargs) + + assert rust.metadata["native_event_backend_resolved"] == "rust" + assert_native_event_full_parity(rust, replay) + assert [(fill.order_id, fill.qty, fill.price, fill.fee) for fill in rust.metadata["rust_r1_session_fills"]] == [ + (fill.order_id, fill.qty, fill.price, fill.fee) for fill in replay.fills + ] + assert _session_event_records(rust.metadata["rust_r1_session_events"]) == _replay_event_records(replay) + + +@pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="quantbt-native R2 wheel is not installed in this environment", +) +def test_native_event_rust_r2_matches_replay_for_stop_amend_replace_reduce_only_and_constraints(monkeypatch) -> None: + df = bars(12) + t0 = df.index[0] + schedule = { + 0: [ + OrderCommand( + timestamp=t0, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.STOP_MARKET, + qty=1.37, + trigger_price=float(df["high"].iloc[1] - 0.1), + tif=TimeInForce.GTC, + order_id="entry-stop", + ), + ], + 1: [ + OrderCommand( + timestamp=t0, + action=OrderAction.AMEND, + target_order_id="entry-stop", + trigger_price=float(df["high"].iloc[2] - 0.1), + ), + ], + 2: [ + OrderCommand( + timestamp=t0, + action=OrderAction.REPLACE, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.STOP_LIMIT, + qty=1.63, + price=float(df["low"].iloc[3] + 0.1), + trigger_price=float(df["high"].iloc[3] - 0.1), + tif=TimeInForce.GTC, + order_id="entry-replaced", + target_order_id="entry-stop", + ), + ], + 4: [ + OrderCommand( + timestamp=t0, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=3.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="reduce", + ), + ], + } + kwargs = { + "initial_capital": 10_000, + "leverage": 5, + "maintenance_ratio": 0.0, + "use_funding": False, + "fee_rate": 0.0002, + "qty_step": 0.25, + "min_qty": 0.25, + "report_level": "standard", + "reactive_execution_mode": "fast", + } + monkeypatch.setenv("QUANTBT_NATIVE_BACKEND", "rust") + rust = run_reactive("single_pass", ScheduledCommandStrategy(schedule), data=df, **kwargs) + monkeypatch.setenv("QUANTBT_NATIVE_BACKEND", "replay_certified") + replay = run_reactive("single_pass", ScheduledCommandStrategy(schedule), data=df, **kwargs) + + assert rust.metadata["native_event_backend_resolved"] == "rust" + assert_native_event_full_parity(rust, replay) + assert [(fill.order_id, fill.qty, fill.price, fill.fee) for fill in rust.metadata["rust_r1_session_fills"]] == [ + (fill.order_id, fill.qty, fill.price, fill.fee) for fill in replay.fills + ] + assert _session_event_records(rust.metadata["rust_r1_session_events"]) == _replay_event_records(replay) diff --git a/tests/test_phase31d_certification.py b/tests/test_phase31d_certification.py index 0d9a736..0dea36a 100644 --- a/tests/test_phase31d_certification.py +++ b/tests/test_phase31d_certification.py @@ -10,7 +10,7 @@ scan_alpha_directory, ) from quantbt.benchmarks.run_phase31_intrabar import make_markdown, run_benchmark -from quantbt.tools.audit_alpha_execution_contracts import main as audit_main +from tools.audit_alpha_execution_contracts import main as audit_main def test_phase31d_classifies_execution_sensitive_sources(): diff --git a/tests/test_phase34b_native_event_prepared_score.py b/tests/test_phase34b_native_event_prepared_score.py index 9e81403..56d54aa 100644 --- a/tests/test_phase34b_native_event_prepared_score.py +++ b/tests/test_phase34b_native_event_prepared_score.py @@ -122,6 +122,37 @@ def test_prepared_native_event_score_reuses_market_arrays_and_keeps_endpoint_res assert second.metadata["prepared_native_event_strategy"]["market_signature"] == signature +def test_prepared_native_event_score_bypasses_public_pandas_result_materialization(monkeypatch): + df = _bars(32) + endpoint = QuantBTEndpoint.native_event_strategy(initial_capital=10_000, leverage=10, use_funding=False) + prepared = endpoint.prepare_native_event_strategy(data=df, symbols=["BTC"]) + + def fail_public_result(*args, **kwargs): + raise AssertionError("prepared score must not materialize BacktestResultV2/pandas") + + monkeypatch.setattr(prepared.backend, "_reactive_session_result", fail_public_result) + score = prepared.score(TwoTradeStrategy(entry_bar=0, exit_bar=5)) + + assert score.metadata["score_direct_arrays"] is True + assert score.metadata["score_pandas_materialized"] is False + assert score.metadata["score_requirements"]["need_terminal_orders"] is False + assert score.fill_count == 2 + assert endpoint.result is None + + +def test_prepared_native_event_score_repeated_runs_release_terminal_trial_state(): + df = _bars(64) + endpoint = QuantBTEndpoint.native_event_strategy(initial_capital=10_000, leverage=10, use_funding=False) + prepared = endpoint.prepare_native_event_strategy(data=df, symbols=["BTC"]) + + for _ in range(100): + score = prepared.score(TwoTradeStrategy(entry_bar=0, exit_bar=5)) + assert score.fill_count == 2 + + assert prepared.metadata["scores"] == 100 + assert endpoint.result is None + + def test_prepared_native_event_strategy_evaluator_uses_score_result_contract(): df = _bars() endpoint = QuantBTEndpoint.native_event_strategy(initial_capital=10_000, leverage=10, use_funding=False) @@ -142,10 +173,30 @@ def objective_builder(result, params): objective = evaluator.evaluate({"entry_bar": 0, "exit_bar": 5}) assert isinstance(objective, ObjectiveResult) - assert evaluator.last_result.metadata["engine"] == "event_v2_reactive_score" + assert evaluator.last_result is None + assert evaluator.last_strategy is None assert prepared.metadata["scores"] == 1 +def test_prepared_native_event_evaluator_only_retains_trial_objects_when_requested(): + df = _bars() + endpoint = QuantBTEndpoint.native_event_strategy(initial_capital=10_000, leverage=10, use_funding=False) + prepared = endpoint.prepare_native_event_strategy(data=df, symbols=["BTC"]) + evaluator = PreparedNativeEventStrategyEvaluator( + runner=prepared, + strategy_factory=lambda params: TwoTradeStrategy(entry_bar=int(params["entry_bar"]), exit_bar=5), + objective_builder=lambda result, params: ObjectiveResult( + values=(float(result.metrics["sharpe"]),), metrics=result.metrics + ), + retain_last=True, + ) + + evaluator.evaluate({"entry_bar": 0}) + + assert evaluator.last_strategy is not None + assert evaluator.last_result.metadata["engine"] == "event_v2_reactive_score" + + def test_public_native_event_phase34_contract_is_available_from_quantbt(): fields = EndpointConfig.__dataclass_fields__ diff --git a/tests/test_phase42_packaging_layout.py b/tests/test_phase42_packaging_layout.py new file mode 100644 index 0000000..dfd51cf --- /dev/null +++ b/tests/test_phase42_packaging_layout.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_phase42_distribution_name_preserves_import_module() -> None: + metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text()) + + assert metadata["project"]["name"] == "quantbt-engine" + assert metadata["tool"]["setuptools"]["packages"]["find"]["where"] == ["src"] + assert "quantbt*" in metadata["tool"]["setuptools"]["packages"]["find"]["include"] + + +def test_phase42_src_quantbt_layout_exists() -> None: + package_root = PROJECT_ROOT / "src" / "quantbt" + + assert (package_root / "__init__.py").is_file() + assert (package_root / "endpoint.py").is_file() + assert (package_root / "core").is_dir() + assert (package_root / "backends").is_dir() + assert (package_root / "py.typed").is_file() + + +def test_phase42_root_source_kept_during_migration() -> None: + assert (PROJECT_ROOT / "__init__.py").is_file() + assert (PROJECT_ROOT / "endpoint.py").is_file() diff --git a/tests/test_phase42c_ci_release.py b/tests/test_phase42c_ci_release.py new file mode 100644 index 0000000..aa8e5dc --- /dev/null +++ b/tests/test_phase42c_ci_release.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tomllib + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_yaml(path: Path) -> dict: + yaml = pytest.importorskip("yaml") + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _event_block(payload: dict) -> dict: + # YAML 1.1 treats the key "on" as a boolean. PyYAML still follows that + # behavior, while GitHub Actions treats it as a string. + return payload.get("on", payload.get(True, {})) + + +def test_phase42c_ci_uses_uv_matrix_and_installed_package_smoke() -> None: + payload = _load_yaml(PROJECT_ROOT / ".github" / "workflows" / "ci.yml") + + versions = payload["jobs"]["package"]["strategy"]["matrix"]["python-version"] + assert versions == ["3.11", "3.12", "3.13"] + + workflow_text = (PROJECT_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + assert "uv sync --extra optimization --extra reports --extra viz --dev" in workflow_text + assert "--ignore=tests/test_real.py" in workflow_text + assert "--ignore=tests/test_real_endpoints.py" in workflow_text + assert "--ignore=tests/native_event" in workflow_text + assert "--ignore=tests/test_phase47a_grid_adapter.py" in workflow_text + assert "--ignore=tests/test_phase47c_grid_parity.py" in workflow_text + assert "--ignore=tests/test_phase47d_grid_optimizer.py" in workflow_text + assert "uv build" in workflow_text + assert "uv run twine check" in workflow_text + assert "pip install dist/quantbt_engine-*.whl" in workflow_text + assert "pip install dist/quantbt_engine-*.tar.gz" in workflow_text + assert "from quantbt import QuantBTEndpoint" in workflow_text + assert "PYTHONPATH" not in workflow_text + + +def test_phase42c_publish_requires_release_event_oidc_and_pypi_environment() -> None: + payload = _load_yaml(PROJECT_ROOT / ".github" / "workflows" / "publish.yml") + + events = _event_block(payload) + assert events == {"release": {"types": ["published"]}} + + publish_job = payload["jobs"]["publish"] + assert publish_job["environment"]["name"] == "pypi" + assert publish_job["permissions"]["id-token"] == "write" + assert publish_job["permissions"]["contents"] == "read" + + workflow_text = (PROJECT_ROOT / ".github" / "workflows" / "publish.yml").read_text(encoding="utf-8") + assert "gh-action-pypi-publish" in workflow_text + assert "PYPI_API_TOKEN" not in workflow_text + assert "uv run twine check" in workflow_text + assert "pip install dist/quantbt_engine-*.tar.gz" in workflow_text + + +def test_phase42c_version_gate_accepts_matching_tag_and_rejects_mismatch() -> None: + metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + version = metadata["project"]["version"] + script = PROJECT_ROOT / "tools" / "check_release_version.py" + + env = {**os.environ, "GITHUB_REF_NAME": f"v{version}"} + accepted = subprocess.run([sys.executable, str(script)], env=env, capture_output=True, text=True, check=False) + assert accepted.returncode == 0, accepted.stderr + + env = {**os.environ, "GITHUB_REF_NAME": f"v{version}.broken"} + rejected = subprocess.run([sys.executable, str(script)], env=env, capture_output=True, text=True, check=False) + assert rejected.returncode == 1 + assert "release tag mismatch" in rejected.stderr diff --git a/tests/test_phase45a_source_tree_sync.py b/tests/test_phase45a_source_tree_sync.py new file mode 100644 index 0000000..a8a9164 --- /dev/null +++ b/tests/test_phase45a_source_tree_sync.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +CANONICAL_ROOT = PROJECT_ROOT / "src" / "quantbt" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_phase45a_root_and_src_python_trees_are_identical_during_migration() -> None: + """Prevent editable/root imports from drifting away from wheel source.""" + canonical_files = sorted(CANONICAL_ROOT.rglob("*.py")) + assert canonical_files, "src/quantbt must contain the canonical Python package" + + for canonical in canonical_files: + relative = canonical.relative_to(CANONICAL_ROOT) + compatibility_mirror = PROJECT_ROOT / relative + assert compatibility_mirror.is_file(), f"root compatibility mirror missing: {relative}" + assert _sha256(canonical) == _sha256(compatibility_mirror), f"root/src source drift: {relative}" diff --git a/tests/test_phase45d_native_event_zero_object.py b/tests/test_phase45d_native_event_zero_object.py new file mode 100644 index 0000000..d65ec67 --- /dev/null +++ b/tests/test_phase45d_native_event_zero_object.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import math + +import numpy as np +import pandas as pd + +from quantbt import NativeCommandBatch, OrderCommand, OrderSide, OrderType, QuantBTEndpoint, TimeInForce +from quantbt.backends.native_event import NativeEventScoreRequirements +from quantbt.optimization.evaluators.native_event import PreparedNativeEventStrategyEvaluator +from quantbt.optimization.result import ObjectiveResult + + +def _bars(n: int = 72) -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.sin(np.arange(n) / 4.0) * 2.0 + np.arange(n) * 0.08, index=idx) + return pd.DataFrame( + { + "open": close.shift(1).fillna(close.iloc[0]), + "high": close + 1.5, + "low": close - 1.5, + "close": close, + "volume": 1_000.0, + }, + index=idx, + ) + + +class EnterExitStrategy: + def __init__(self, entry_bar: int = 2, exit_bar: int = 40): + self.entry_bar = int(entry_bar) + self.exit_bar = int(exit_bar) + + def on_bar_close(self, context): + if context.bar_index == self.entry_bar: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ) + ] + if context.bar_index == self.exit_bar: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + reduce_only=True, + order_id="exit", + ) + ] + return () + + +class NoContextObjectsStrategy(EnterExitStrategy): + native_context_requirements = { + "fills": False, + "events": False, + "active_orders": False, + "positions": False, + "margin": False, + } + + def __init__(self): + super().__init__(entry_bar=2, exit_bar=40) + self.context_shapes = [] + + def on_bar_close(self, context): + self.context_shapes.append( + ( + len(context.fills_this_bar), + len(context.order_events_this_bar), + len(context.active_orders), + len(context.positions), + context.initial_margin, + context.maintenance_margin, + ) + ) + return super().on_bar_close(context) + + +class BatchStrategy(EnterExitStrategy): + def on_bar_close(self, context): + return NativeCommandBatch.from_commands(super().on_bar_close(context)) + + +def _prepared(): + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=10_000, + leverage=10, + use_funding=False, + fee_rate=0.0002, + report_level="audit", + ) + return endpoint, endpoint.prepare_native_event_strategy(data=_bars(), symbols=["BTC"]) + + +def test_scalar_score_matches_public_audit_metrics_without_accounting_paths(): + endpoint, prepared = _prepared() + strategy = EnterExitStrategy() + scalar = prepared.score( + strategy, + score_requirements=NativeEventScoreRequirements.scalar_score_contract(), + ) + audit = prepared.run(EnterExitStrategy(), report_level="audit") + report = audit.full_report() + + assert scalar.metadata["score_scalar"] is True + assert scalar.metadata["score_pandas_materialized"] is False + assert all(value is False for value in scalar.metadata["score_retained_paths"].values()) + assert not hasattr(scalar, "accounting") + for key, expected in report.items(): + actual = scalar.metrics[key] + if isinstance(expected, (float, int)) and not isinstance(expected, bool): + if math.isinf(float(expected)): + assert math.isinf(float(actual)) and (float(expected) > 0) == (float(actual) > 0) + else: + np.testing.assert_allclose(actual, expected, rtol=0.0, atol=1e-12) + else: + assert actual == expected + assert scalar.final_equity == audit.equity.iloc[-1] + assert endpoint.result is audit + + +def test_context_declaration_avoids_current_bar_event_objects_and_snapshots(): + _, prepared = _prepared() + strategy = NoContextObjectsStrategy() + scalar = prepared.score( + strategy, + score_requirements=NativeEventScoreRequirements.from_strategy( + strategy, + base=NativeEventScoreRequirements.scalar_score_contract(), + ), + ) + + assert scalar.metrics["num_trades"] == 3 + assert all(shape == (0, 0, 0, 0, 0.0, 0.0) for shape in strategy.context_shapes) + assert scalar.metadata["score_requirements"]["need_context_fills"] is False + assert scalar.metadata["score_requirements"]["need_context_events"] is False + + +def test_prepared_evaluator_uses_scalar_score_and_keeps_strategy_compatibility(): + _, prepared = _prepared() + + evaluator = PreparedNativeEventStrategyEvaluator( + runner=prepared, + strategy_factory=lambda params: EnterExitStrategy( + entry_bar=int(params["entry_bar"]), + exit_bar=int(params["exit_bar"]), + ), + objective_builder=lambda result, params: ObjectiveResult( + values=(float(result.metrics["sharpe"]),), + metrics=result.metrics, + metadata={"params": dict(params)}, + ), + ) + objective = evaluator.evaluate({"entry_bar": 2, "exit_bar": 40}) + + assert isinstance(objective, ObjectiveResult) + assert evaluator.last_result is None + assert prepared.metadata["scores"] == 1 + + +def test_native_command_batch_preserves_legacy_callback_execution(): + _, prepared = _prepared() + scalar = prepared.score( + BatchStrategy(), + score_requirements=NativeEventScoreRequirements.scalar_score_contract(), + ) + assert scalar.fill_count == 2 + assert scalar.metrics["num_trades"] == 3 + + +def test_scalar_fee_and_funding_counters_reconcile_to_audit_paths(): + endpoint = QuantBTEndpoint.native_event_strategy( + initial_capital=10_000, + leverage=10, + use_funding=True, + funding_rate=0.0001, + fee_rate=0.0002, + report_level="audit", + ) + prepared = endpoint.prepare_native_event_strategy(data=_bars(96), symbols=["BTC"]) + scalar = prepared.score( + EnterExitStrategy(entry_bar=2, exit_bar=80), + score_requirements=NativeEventScoreRequirements.scalar_score_contract(), + ) + audit = prepared.run(EnterExitStrategy(entry_bar=2, exit_bar=80), report_level="audit") + + np.testing.assert_allclose( + scalar.metrics["total_fee"], + float(audit.fees.sum()), + rtol=0.0, + atol=1e-12, + ) + np.testing.assert_allclose( + scalar.metrics["total_funding"], + float(audit.funding.sum()), + rtol=0.0, + atol=1e-12, + ) + np.testing.assert_allclose( + scalar.metrics["final_equity"], + float(audit.equity.iloc[-1]), + rtol=0.0, + atol=1e-12, + ) diff --git a/tests/test_phase46a_correctness_certification.py b/tests/test_phase46a_correctness_certification.py new file mode 100644 index 0000000..27f5f87 --- /dev/null +++ b/tests/test_phase46a_correctness_certification.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +import hashlib +import json +import tomllib + +import numpy as np +import pytest + +import quantbt +from quantbt.core.native_event_capabilities import ( + NATIVE_EVENT_CAPABILITY_MATRIX, + NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + capability_matrix_fingerprint, + normalize_native_event_capabilities, + validate_native_event_capability_matrix, +) +from quantbt.core.native_event_parity import ( + NativeEventParityError, + assert_native_event_full_parity, +) +from quantbt.backends._native_event_rust import probe_native_event_rust_extension + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _audit_fixture(seed: int = 42) -> SimpleNamespace: + rng = np.random.default_rng(seed) + bars = 18 + equity = 10_000.0 + np.cumsum(rng.normal(0.0, 0.25, bars)) + positions = rng.choice((-1.0, 0.0, 1.0), size=(bars, 1)).astype(np.float64) + fees = np.abs(rng.normal(0.02, 0.005, bars)) + funding = rng.normal(0.0, 0.01, bars) + turnover = np.abs(rng.normal(100.0, 2.0, bars)) + initial_margin = np.abs(positions[:, 0]) * 20.0 + maintenance_margin = np.abs(positions[:, 0]) * 1.0 + return SimpleNamespace( + equity=equity, + positions=positions, + fees=fees, + funding=funding, + turnover=turnover, + initial_margin=initial_margin, + maintenance_margin=maintenance_margin, + liquidated=False, + liquidation_bar=-1, + fill_bar=np.array([2, 7, 11], dtype=np.int64), + fill_order_id=np.array([0, 1, 2], dtype=np.int64), + fill_side=np.array([1, -1, 1], dtype=np.int64), + fill_qty=np.array([1.0, 0.5, 0.5], dtype=np.float64), + fill_price=np.array([100.0, 101.0, 102.0], dtype=np.float64), + fill_fee=np.array([0.02, 0.01, 0.01], dtype=np.float64), + event_bar=np.array([1, 2, 7, 11], dtype=np.int64), + event_kind=np.array([0, 4, 4, 4], dtype=np.int64), + event_status=np.array([0, 1, 1, 1], dtype=np.int64), + event_order_id=np.array([0, 0, 1, 2], dtype=np.int64), + event_target_id=np.array([-1, -1, -1, -1], dtype=np.int64), + ) + + +def test_phase46a_capability_matrix_is_canonical_and_fingerprinted() -> None: + assert NATIVE_EVENT_CAPABILITY_MATRIX_VERSION == "full-contract-v2-0.4" + assert NATIVE_EVENT_CAPABILITY_MATRIX["single_symbol"] is True + assert NATIVE_EVENT_CAPABILITY_MATRIX["market"] is True + assert NATIVE_EVENT_CAPABILITY_MATRIX["stop_limit"] is True + assert NATIVE_EVENT_CAPABILITY_MATRIX["funding"] is True + assert NATIVE_EVENT_CAPABILITY_MATRIX["liquidation"] is True + assert NATIVE_EVENT_CAPABILITY_MATRIX["multi_symbol"] is True + assert len(capability_matrix_fingerprint()) == 64 + validate_native_event_capability_matrix(NATIVE_EVENT_CAPABILITY_MATRIX) + with pytest.raises(ValueError, match="unknown"): + validate_native_event_capability_matrix({"future_feature": True}) + + +def test_phase46a_rust_raw_flags_normalize_without_overclaiming() -> None: + capabilities = normalize_native_event_capabilities( + { + "reactive_session": True, + "r1_place_cancel_market_limit_gtc": True, + "r2_stop_amend_replace_reduce_only_constraints": True, + "rust_batched_tape_audit": True, + "future_unreviewed_feature": True, + } + ) + assert capabilities["single_symbol"] is True + assert capabilities["amend"] is True + assert capabilities["quantity_constraints"] is True + assert capabilities["oco"] is False + assert capabilities["funding"] is False + assert capabilities["multi_symbol"] is False + assert "future_unreviewed_feature" not in capabilities + + +def test_phase46a_rust_probe_exposes_canonical_capabilities() -> None: + class FakeNative: + @staticmethod + def api_version() -> str: + return "0.3" + + @staticmethod + def version() -> str: + return "0.3.0" + + @staticmethod + def capabilities() -> dict[str, bool]: + return { + "reactive_session": True, + "r1_place_cancel_market_limit_gtc": True, + "r2_stop_amend_replace_reduce_only_constraints": True, + } + + status = probe_native_event_rust_extension(module=FakeNative()) + assert status.canonical_capabilities["single_symbol"] is True + assert status.canonical_capabilities["amend"] is True + assert status.canonical_capabilities["funding"] is False + + +def test_phase46a_full_parity_compares_accounting_and_lifecycle() -> None: + candidate = _audit_fixture() + oracle = _audit_fixture() + command_tape = ( + {"effective_bar": np.array([1, 2, 7, 11]), "sequence": np.array([0, 1, 2, 3])}, + {"effective_bar": np.array([1, 2, 7, 11]), "sequence": np.array([0, 1, 2, 3])}, + ) + certificate = assert_native_event_full_parity(candidate, oracle, command_tape=command_tape) + assert certificate["passed"] is True + assert certificate["candidate_fingerprint"] == certificate["oracle_fingerprint"] + assert set(("equity", "positions", "fees", "turnover", "fills", "events")) <= set( + certificate["compared_fields"] + ) + + +def test_phase46a_numeric_tolerance_does_not_change_discrete_decisions() -> None: + candidate = _audit_fixture() + oracle = _audit_fixture() + candidate.equity = candidate.equity.copy() + candidate.equity[5] += 5e-13 + assert_native_event_full_parity(candidate, oracle) + + candidate.equity[5] += 5e-12 + with pytest.raises(NativeEventParityError, match="equity mismatch"): + assert_native_event_full_parity(candidate, oracle) + + candidate = _audit_fixture() + candidate.event_status = candidate.event_status.copy() + candidate.event_status[1] = 2 + with pytest.raises(NativeEventParityError, match="events lifecycle"): + assert_native_event_full_parity(candidate, oracle) + + +def test_phase46a_strict_mode_rejects_minimal_artifacts() -> None: + candidate = _audit_fixture() + oracle = _audit_fixture() + for result in (candidate, oracle): + for name in ( + "fill_bar", "fill_order_id", "fill_side", "fill_qty", "fill_price", "fill_fee", + "event_bar", "event_kind", "event_status", "event_order_id", "event_target_id", + ): + delattr(result, name) + with pytest.raises(NativeEventParityError, match="artifacts missing"): + assert_native_event_full_parity(candidate, oracle) + certificate = assert_native_event_full_parity(candidate, oracle, require_full=False) + assert certificate["passed"] is True + + +def test_phase46a_seeded_randomized_differential_fingerprints() -> None: + for seed in range(32): + candidate = _audit_fixture(seed) + oracle = _audit_fixture(seed) + certificate = assert_native_event_full_parity(candidate, oracle) + assert certificate["candidate_fingerprint"] == certificate["oracle_fingerprint"] + + +def test_phase46a_public_import_and_package_metadata_baseline() -> None: + assert quantbt.assert_native_event_full_parity is assert_native_event_full_parity + assert quantbt.NATIVE_EVENT_CAPABILITY_MATRIX_VERSION == NATIVE_EVENT_CAPABILITY_MATRIX_VERSION + + metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = metadata["project"] + assert project["name"] == "quantbt-engine" + assert project["version"] in {"1.0.7rc1", "1.0.7"} + assert metadata["tool"]["setuptools"]["packages"]["find"]["where"] == ["src"] + assert "quantbt*" in metadata["tool"]["setuptools"]["packages"]["find"]["include"] + + +def test_phase46a_capability_fingerprint_is_stable() -> None: + payload = { + "version": NATIVE_EVENT_CAPABILITY_MATRIX_VERSION, + "capabilities": dict(sorted(NATIVE_EVENT_CAPABILITY_MATRIX.items())), + } + expected = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + assert capability_matrix_fingerprint() == expected diff --git a/tests/test_phase46b_score_rss.py b/tests/test_phase46b_score_rss.py new file mode 100644 index 0000000..bc6800a --- /dev/null +++ b/tests/test_phase46b_score_rss.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from quantbt import ( + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderCommand, + OrderSide, + OrderType, + TimeInForce, +) +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _fixture(rows: int = 48): + index = pd.date_range("2024-01-01", periods=rows, freq="1h", tz="UTC") + close = pd.Series(100.0 + np.arange(rows, dtype=np.float64) * 0.25, index=index) + frame = pd.DataFrame( + { + "open": close, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "volume": 1_000.0, + }, + index=index, + ) + backend = NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=10_000.0, leverage=5.0, maintenance_ratio=0.0), + execution=ExecutionConfig(slippage_bps=2.0), + fee_rate=0.0002, + use_funding=False, + ) + ) + market = backend.prepare_market_arrays( + datetime_index=index, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + ) + commands = ( + OrderCommand( + timestamp=index[2], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=index[21], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="exit", + ), + ) + compiled = backend.compile_order_commands(index, commands, symbols=["BTC"]) + return backend, frame, market, commands, compiled + + +def test_phase46b_python_compiled_score_matches_public_audit_scalars(): + backend, frame, market, commands, compiled = _fixture() + scalar = backend.run_compiled_tape_score(frame.index, compiled, market_arrays=market) + audit = backend.run_order_commands( + datetime_index=frame.index, + commands=commands, + closes={"BTC": frame["close"]}, + highs={"BTC": frame["high"]}, + lows={"BTC": frame["low"]}, + symbols=["BTC"], + market_arrays=market, + compiled_commands=compiled, + report_level="audit", + ) + + np.testing.assert_allclose(scalar.final_equity, audit.equity.iloc[-1], rtol=0.0, atol=1e-12) + np.testing.assert_allclose(scalar.final_positions[0], audit.positions["Position_BTC"].iloc[-1], rtol=0.0, atol=1e-12) + np.testing.assert_allclose(scalar.total_fee, audit.fees.sum(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(scalar.total_turnover, audit.diagnostics["turnover"].sum(), rtol=0.0, atol=1e-12) + assert scalar.fill_count == int(audit.metadata["lifecycle_counters"]["fill_count"]) + assert scalar.event_count == int(audit.metadata["lifecycle_counters"]["event_count"]) + assert scalar.rejected_count == int(audit.metadata["lifecycle_counters"]["rejected_count"]) + assert scalar.canceled_count == int(audit.metadata["lifecycle_counters"]["canceled_count"]) + np.testing.assert_allclose(scalar.max_initial_margin, audit.margin["initial_margin"].max(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose(scalar.max_maintenance_margin, audit.margin["maintenance_margin"].max(), rtol=0.0, atol=1e-12) + assert scalar.metadata["score_pandas_materialized"] is False + assert scalar.metadata["score_full_ledgers_materialized"] is False + assert "accounting" not in scalar.__dict__ if hasattr(scalar, "__dict__") else True + + +def test_phase46b_prepared_and_compiled_signatures_are_hard_gates(): + backend, frame, market, _, compiled = _fixture() + with pytest.raises(ValueError, match="prepared market_arrays"): + backend.run_compiled_tape_score(frame.index, compiled, market_arrays=None) + + wrong_index = frame.index + pd.Timedelta(minutes=1) + with pytest.raises(ValueError, match="prepared market arrays"): + backend.run_compiled_tape_score(wrong_index, compiled, market_arrays=market) + + other_backend, other_frame, other_market, _, other_compiled = _fixture(rows=49) + assert other_backend is not backend + with pytest.raises(ValueError, match="prepared market arrays"): + backend.run_compiled_tape_score(frame.index, other_compiled, market_arrays=other_market) + + +def test_phase46b_rust_scalar_matches_python_scalar_when_wheel_is_available(): + backend, frame, market, _, compiled = _fixture() + try: + from quantbt.backends._native_event_rust import ( + NativeEventRustBackendError, + RustBatchedRunner, + ) + + runner = RustBatchedRunner( + idx=frame.index, + symbols=["BTC"], + market_arrays=market, + contract_size=1.0, + leverage=5.0, + fee_rate=0.0002, + initial_capital=10_000.0, + maintenance_ratio=0.0, + slippage=0.0002, + use_funding=False, + ) + except (ImportError, OSError, NativeEventRustBackendError) as exc: + pytest.skip(f"optional Rust wheel unavailable: {exc}") + + python_scalar = backend.run_compiled_tape_score(frame.index, compiled, market_arrays=market) + rust_scalar = runner.run_tape_score(compiled) + np.testing.assert_allclose(python_scalar.final_equity, rust_scalar.final_equity, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python_scalar.final_positions[0], rust_scalar.final_position, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python_scalar.total_fee, rust_scalar.total_fee, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python_scalar.total_turnover, rust_scalar.total_turnover, rtol=0.0, atol=1e-12) + assert python_scalar.fill_count == rust_scalar.fill_count + assert python_scalar.event_count == rust_scalar.event_count + assert python_scalar.rejected_count == rust_scalar.rejected_count + assert python_scalar.canceled_count == rust_scalar.canceled_count + np.testing.assert_allclose(python_scalar.max_initial_margin, rust_scalar.max_initial_margin, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(python_scalar.max_maintenance_margin, rust_scalar.max_maintenance_margin, rtol=0.0, atol=1e-12) + + +def test_phase46b_benchmark_declares_staged_rss_and_scalar_contract(): + script = (PROJECT_ROOT / "benchmarks/native_event/benchmark_phase46b_score_rss.py").read_text(encoding="utf-8") + for field in ( + "rss_interpreter", + "rss_after_import_quantbt", + "rss_after_market_prepare", + "rss_after_command_compile", + "rss_after_runner_prepare", + "peak_rss_during_run", + "rss_after_run", + "incremental_prepared_rss", + "incremental_execution_peak", + "full_parity_passed", + "oracle_fingerprint", + "python_fingerprint", + "rust_fingerprint", + ): + assert field in script + assert "--repeats" in script + assert "PLATEAU_REPEATS = 100" in script diff --git a/tests/test_phase46c_import_graph.py b/tests/test_phase46c_import_graph.py new file mode 100644 index 0000000..0612c36 --- /dev/null +++ b/tests/test_phase46c_import_graph.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import os +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import subprocess +import sys +import tomllib + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = PROJECT_ROOT / "src" +FORBIDDEN_CORE_MODULES = ( + "matplotlib", + "seaborn", + "optuna", + "nautilus_trader", + "quantstats", +) + + +def _fresh_source_import(code: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update( + { + "PYTHONNOUSERSITE": "1", + "PYTHONPATH": str(SOURCE_ROOT), + "MPLCONFIGDIR": "/tmp", + } + ) + return subprocess.run( + [sys.executable, "-c", code], + cwd="/tmp", + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def test_phase46c_core_import_does_not_load_optional_modules() -> None: + code = """ +import json +import sys +import quantbt +from quantbt import QuantBTEndpoint + +forbidden = ("matplotlib", "seaborn", "optuna", "nautilus_trader", "quantstats") +loaded = sorted( + name for name in sys.modules + if any(name == prefix or name.startswith(prefix + ".") for prefix in forbidden) +) +print(json.dumps({"loaded": loaded, "endpoint_module": QuantBTEndpoint.__module__})) +assert not loaded, loaded +assert QuantBTEndpoint.__module__ == "quantbt.endpoint" +""" + completed = _fresh_source_import(code) + assert completed.returncode == 0, completed.stderr or completed.stdout + evidence = json.loads(completed.stdout.strip().splitlines()[-1]) + assert evidence["loaded"] == [] + assert evidence["endpoint_module"] == "quantbt.endpoint" + + +def test_phase46c_core_public_exports_remain_accessible() -> None: + import quantbt + + for name in ( + "BacktestEngine", + "MultiSymbolPortfolio", + "QuantBTEndpoint", + "BacktestResult", + "NativeEventBackend", + "NativeVectorizedBackend", + "OptionBacktestEngine", + "PortfolioBacktestEngine", + ): + assert getattr(quantbt, name).__name__ == name + + +def test_phase46c_lazy_export_identity() -> None: + import quantbt + from quantbt.optimization import OptunaOptimizer + from quantbt.viz import quick_plot as direct_quick_plot + from quantbt.walkforward import WalkForwardConfig + + assert quantbt.OptunaOptimizer is OptunaOptimizer + assert quantbt.quick_plot is direct_quick_plot + assert quantbt.WalkForwardConfig is WalkForwardConfig + + +def test_phase46c_lazy_export_access_is_thread_safe_after_resolution() -> None: + import quantbt + + names = ("OptunaOptimizer", "WalkForwardConfig", "quick_plot", "tearsheet") + expected = {name: getattr(quantbt, name) for name in names} + with ThreadPoolExecutor(max_workers=len(names) * 2) as executor: + resolved = list( + executor.map( + lambda name: getattr(quantbt, name), + names * 4, + ) + ) + + for name, value in zip(names * 4, resolved): + assert value is expected[name] + + +def test_phase46c_dependency_ownership_is_explicit() -> None: + metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = metadata["project"] + core_dependencies = {item.split(">", 1)[0].split("=", 1)[0] for item in project["dependencies"]} + assert core_dependencies == {"numpy", "pandas", "numba"} + + optional = project["optional-dependencies"] + assert any(item.startswith("matplotlib") for item in optional["viz"]) + assert any(item.startswith("seaborn") for item in optional["viz"]) + assert any(item.startswith("optuna") for item in optional["optimization"]) + assert any(item.startswith("quantstats") for item in optional["reports"]) + assert any(item.startswith("nautilus-trader") for item in optional["validation"]) + + +@pytest.mark.parametrize("name", ("quick_plot", "tearsheet", "OptunaOptimizer", "WalkForwardConfig")) +def test_phase46c_lazy_export_is_listed_for_discovery(name: str) -> None: + import quantbt + + assert name in dir(quantbt) + assert name in quantbt.__all__ diff --git a/tests/test_phase46f_packaging_release.py b/tests/test_phase46f_packaging_release.py new file mode 100644 index 0000000..582fcc9 --- /dev/null +++ b/tests/test_phase46f_packaging_release.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_yaml(path: Path) -> dict: + yaml = pytest.importorskip("yaml") + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _event_block(payload: dict) -> dict: + return payload.get("on", payload.get(True, {})) + + +def test_phase46f_core_metadata_and_release_notes_are_complete() -> None: + metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = metadata["project"] + + assert project["name"] == "quantbt-engine" + assert project["version"] in {"1.0.7rc1", "1.0.7"} + assert {"3.11", "3.12", "3.13"} <= { + classifier.rsplit(" :: ", 1)[-1] + for classifier in project["classifiers"] + if classifier.startswith("Programming Language :: Python :: 3.") + } + assert project["urls"]["Documentation"].endswith("/docs") + assert project["urls"]["Changelog"].endswith("/CHANGELOG.md") + assert (PROJECT_ROOT / "CHANGELOG.md").read_text(encoding="utf-8").find("[1.0.7]") >= 0 + assert metadata["project"]["optional-dependencies"]["native"] == [] + + +def test_phase46f_testpypi_workflow_has_manual_and_rc_tag_oidc_paths() -> None: + payload = _load_yaml(PROJECT_ROOT / ".github" / "workflows" / "publish-testpypi.yml") + events = _event_block(payload) + assert "workflow_dispatch" in events + assert events["push"]["tags"] == ["v*rc*"] + assert "ref" in events["workflow_dispatch"]["inputs"] + + publish = payload["jobs"]["publish"] + assert publish["environment"]["name"] == "testpypi" + assert publish["permissions"]["id-token"] == "write" + workflow_text = (PROJECT_ROOT / ".github" / "workflows" / "publish-testpypi.yml").read_text( + encoding="utf-8" + ) + assert "https://test.pypi.org/legacy/" in workflow_text + assert "PYPI_API_TOKEN" not in workflow_text + assert "tools/check_release_version.py" in workflow_text + assert "inputs.ref || github.ref_name" in workflow_text + + +def test_phase46f_production_publish_rejects_prereleases() -> None: + payload = _load_yaml(PROJECT_ROOT / ".github" / "workflows" / "publish.yml") + assert _event_block(payload) == {"release": {"types": ["published"]}} + assert "github.event.release.prerelease" in payload["jobs"]["publish"]["if"] + assert "github.event.release.draft" in payload["jobs"]["publish"]["if"] + + +def test_phase46f_native_extra_is_not_claimed_as_a_core_dependency() -> None: + metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + dependencies = metadata["project"]["dependencies"] + all_extra = metadata["project"]["optional-dependencies"]["all"] + assert not any("quantbt-native" in item for item in dependencies + all_extra) + assert metadata["project"]["optional-dependencies"]["native"] == [] diff --git a/tests/test_phase47a_grid_adapter.py b/tests/test_phase47a_grid_adapter.py new file mode 100644 index 0000000..8b677ea --- /dev/null +++ b/tests/test_phase47a_grid_adapter.py @@ -0,0 +1,253 @@ +"""Phase 47A integration tests for the external Grid alpha adapter.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + +import numpy as np +import pandas as pd +import pytest + +from quantbt import NativeEventScoreRequirements +from quantbt.core.results import NativeEventScalarScoreResult + + +GRID_PATH = Path( + "/root/bobby/pool_alpha/alphas_storage/TA/" + "dynamic_grid_quantbt_native_event.py" +) + + +@pytest.fixture(scope="module") +def grid_module(): + if not GRID_PATH.exists(): + pytest.skip(f"external Grid module is not available: {GRID_PATH}") + + module_name = "phase47a_dynamic_grid_quantbt_native_event" + spec = importlib.util.spec_from_file_location(module_name, GRID_PATH) + if spec is None or spec.loader is None: + raise AssertionError(f"cannot load Grid module from {GRID_PATH}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def grid_data() -> pd.DataFrame: + index = pd.date_range( + "2025-01-01", + periods=240, + freq="h", + tz="UTC", + ) + x = np.arange(len(index), dtype=np.float64) + close = 100.0 + 3.5 * np.sin(x / 7.0) + 0.025 * x + open_ = close + 0.15 * np.sin(x / 3.0) + high = np.maximum(open_, close) + 1.2 + low = np.minimum(open_, close) - 1.2 + return pd.DataFrame( + { + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": np.full(len(index), 1000.0), + }, + index=index, + ) + + +@pytest.fixture(scope="module") +def grid_params() -> dict: + return { + "grid_mode": "long_only", + "ma_type": "EMA", + "ma_len": 8, + "ema_len_short": 3, + "logic": "ATR", + "band_mult": 0.25, + "zone_smoothing_len": 2, + "warmup_bars": 12, + "pyramiding": 3, + "neutral_position_mode": "hold", + "one_entry_fill_per_bar": True, + "one_exit_fill_per_bar": True, + "campaign_id": "PHASE47A", + } + + +def _execution(module, *, backend: str, report_level: str): + return module.GridExecutionConfig( + symbol="ETHUSDT", + initial_capital=20_000.0, + cash_per_entry=1_000.0, + leverage=5.0, + maintenance_ratio=0.0, + contract_size=1.0, + fee_rate=0.0, + slippage_bps=0.0, + use_funding=False, + funding_rate=0.0, + native_backend=backend, + reactive_execution_mode="fast", + reactive_kernel_mode=( + "replay_certified" + if backend == "replay_certified" + else "single_pass" + ), + report_level=report_level, + audit_sink="none", + ) + + +def test_grid_native_backend_selector_is_validated_and_forwarded(grid_module): + for selected in ("python", "rust", "auto", "replay_certified"): + execution = _execution( + grid_module, + backend=selected, + report_level="score", + ) + assert execution.native_backend == selected + endpoint = grid_module.build_grid_endpoint(execution) + assert endpoint.config.native_backend == selected + + with pytest.raises(ValueError, match="native_backend"): + _execution(grid_module, backend="unsupported", report_level="score") + + +def test_grid_prepare_and_scalar_score_do_not_materialize_public_result( + grid_module, + grid_data, + grid_params, +): + execution = _execution( + grid_module, + backend="python", + report_level="score", + ) + endpoint, prepared = grid_module.prepare_grid_score_runner( + df=grid_data, + execution=execution, + ) + + assert prepared.endpoint is endpoint + assert prepared.scores == 0 + assert endpoint.result is None + + score = grid_module.score_grid_params( + prepared_runner=prepared, + df=grid_data, + params=grid_params, + execution=execution, + trading_days=365, + ) + + assert isinstance(score, NativeEventScalarScoreResult) + assert prepared.scores == 1 + assert prepared.runs == 0 + assert endpoint.result is None + assert score.metadata["score_pandas_materialized"] is False + assert score.metadata["score_full_ledgers_materialized"] is False + + second = grid_module.score_grid_params( + prepared_runner=prepared, + df=grid_data, + params=grid_params, + execution=execution, + trading_days=365, + ) + assert prepared.scores == 2 + assert endpoint.result is None + assert second.final_equity == pytest.approx(score.final_equity) + assert second.fill_count == score.fill_count + assert second.metrics["num_trades"] == score.metrics["num_trades"] + + +def test_grid_public_run_still_returns_reportable_result( + grid_module, + grid_data, + grid_params, +): + execution = _execution( + grid_module, + backend="python", + report_level="minimal", + ) + run = grid_module.run_grid_backtest( + df=grid_data, + params=grid_params, + execution=execution, + ) + + assert run.result is run.endpoint.result + assert len(run.result.equity) == len(grid_data) + assert len(run.frame) == len(grid_data) + report = run.result.full_report( + trading_days=365, + scope="full", + ) + assert report["initial_capital"] == pytest.approx(20_000.0) + assert "final_equity" in report + + +def test_grid_python_single_pass_matches_replay_baseline( + grid_module, + grid_data, + grid_params, +): + oracle = grid_module.run_grid_backtest( + df=grid_data, + params=grid_params, + execution=_execution( + grid_module, + backend="replay_certified", + report_level="audit", + ), + ) + candidate = grid_module.run_grid_backtest( + df=grid_data, + params=grid_params, + execution=_execution( + grid_module, + backend="python", + report_level="audit", + ), + ) + + np.testing.assert_array_equal( + oracle.result.positions.to_numpy(dtype=np.float64), + candidate.result.positions.to_numpy(dtype=np.float64), + ) + np.testing.assert_allclose( + oracle.result.equity.to_numpy(dtype=np.float64), + candidate.result.equity.to_numpy(dtype=np.float64), + rtol=0.0, + atol=1e-12, + ) + np.testing.assert_allclose( + oracle.result.fees.to_numpy(dtype=np.float64), + candidate.result.fees.to_numpy(dtype=np.float64), + rtol=0.0, + atol=1e-12, + ) + np.testing.assert_allclose( + oracle.result.funding.to_numpy(dtype=np.float64), + candidate.result.funding.to_numpy(dtype=np.float64), + rtol=0.0, + atol=1e-12, + ) + assert len(oracle.result.fills) == len(candidate.result.fills) + assert len(oracle.command_tape) == len(candidate.command_tape) + + +def test_grid_score_contract_is_public_and_scalar(grid_module): + contract = NativeEventScoreRequirements.scalar_score_contract() + assert contract.need_trade_stats is True + assert contract.need_equity_path is False + assert contract.need_fill_ledger is False + assert contract.need_context_fills is True + assert contract.need_context_active_orders is True diff --git a/tests/test_phase47c_grid_parity.py b/tests/test_phase47c_grid_parity.py new file mode 100644 index 0000000..bce40c2 --- /dev/null +++ b/tests/test_phase47c_grid_parity.py @@ -0,0 +1,281 @@ +"""Phase 47C: Grid 2,000-bar parity and scalar retention gates. + +The Grid alpha remains an external, read-only integration fixture. These tests +load that module directly and exercise only the public QuantBT adapter. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from quantbt import NativeEventScalarScoreResult + + +GRID_PATH = Path( + "/root/bobby/pool_alpha/alphas_storage/TA/" + "dynamic_grid_quantbt_native_event.py" +) + + +def _load_grid_module(): + if not GRID_PATH.exists(): + pytest.skip( + f"external Grid fixture is unavailable: {GRID_PATH}", + allow_module_level=True, + ) + spec = importlib.util.spec_from_file_location("phase47c_grid_alpha", GRID_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import Grid fixture: {GRID_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +GRID = _load_grid_module() + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("_quantbt_native") is None, + reason="Phase 47C requires the installed Rust full-contract extension", +) + + +def _data_2000() -> pd.DataFrame: + n = 2000 + index = pd.date_range("2023-01-01", periods=n, freq="h", tz="UTC") + x = np.arange(n, dtype=np.float64) + close = 100.0 + 5.0 * np.sin(x / 11.0) + 0.01 * x + 1.5 * np.sin(x / 47.0) + open_ = close + 0.2 * np.sin(x / 3.0) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close) + 1.5, + "low": np.minimum(open_, close) - 1.5, + "close": close, + "volume": np.full(n, 1000.0), + }, + index=index, + ) + + +def _params(grid_mode: str) -> dict: + return { + "grid_mode": grid_mode, + "ma_type": "EMA", + "ma_len": 8, + "ema_len_short": 3, + "logic": "ATR", + "band_mult": 0.25, + "zone_smoothing_len": 2, + "warmup_bars": 12, + "pyramiding": 3, + "neutral_position_mode": "hold", + "one_entry_fill_per_bar": True, + "one_exit_fill_per_bar": True, + "campaign_id": "PHASE47C", + } + + +def _execution(backend: str, *, audit: bool) : + return GRID.GridExecutionConfig( + symbol="ETHUSDT", + initial_capital=20_000.0, + cash_per_entry=1_000.0, + leverage=5.0, + maintenance_ratio=0.005, + contract_size=1.0, + fee_rate=0.0005, + slippage_bps=2.0, + use_funding=True, + funding_rate=0.0001, + native_backend=backend, + reactive_execution_mode="audit" if audit else "fast", + reactive_kernel_mode=("replay_certified" if backend == "replay_certified" else "single_pass"), + report_level="audit" if audit else "score", + audit_sink="memory" if audit else "none", + ) + + +@pytest.fixture(scope="module") +def data_2000(): + data = _data_2000() + assert len(data) == 2000 + assert data.index.is_monotonic_increasing + assert not data.index.has_duplicates + return data + + +@pytest.fixture(scope="module") +def audit_runs(data_2000): + runs = {} + for grid_mode in ("long_only", "long_short"): + params = _params(grid_mode) + for backend in ("replay_certified", "python", "rust"): + runs[(grid_mode, backend)] = GRID.run_grid_backtest( + df=data_2000, + params=params, + execution=_execution(backend, audit=True), + ) + return runs + + +def _fill_signature(result): + return tuple( + ( + int(pd.Timestamp(fill.timestamp).value), + str(fill.symbol), + getattr(fill.side, "value", str(fill.side)), + float(fill.qty), + float(fill.price), + float(fill.fee), + fill.order_id, + ) + for fill in result.fills + ) + + +def _audit_fingerprint(run) -> tuple: + result = run.result + command_signature = tuple( + ( + int(pd.Timestamp(command.timestamp).value), + command.action.value, + command.symbol, + None if command.side is None else command.side.value, + None if command.order_type is None else command.order_type.value, + float(command.qty or 0.0), + None if command.price is None else float(command.price), + None if command.trigger_price is None else float(command.trigger_price), + command.tif.value, + bool(command.reduce_only), + command.order_id, + command.target_order_id, + command.parent_order_id, + command.group_id, + command.oco_group_id, + None if command.expires_at is None else int(pd.Timestamp(command.expires_at).value), + ) + for command in run.command_tape + ) + event_frame = run.order_events.reset_index(drop=True) + event_signature = tuple( + tuple(None if pd.isna(value) else str(value) for value in row) + for row in event_frame.itertuples(index=False, name=None) + ) + return ( + command_signature, + event_signature, + _fill_signature(result), + tuple(np.asarray(result.equity, dtype=np.float64)), + tuple(np.asarray(result.fees, dtype=np.float64)), + tuple(np.asarray(result.funding, dtype=np.float64)), + tuple(np.asarray(result.positions, dtype=np.float64).ravel()), + tuple(np.asarray(result.margin, dtype=np.float64).ravel()), + bool(result.liquidated), + int(result.liquidation_bar), + ) + + +def _assert_parity(reference, candidate): + np.testing.assert_allclose(reference.result.equity, candidate.result.equity, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(reference.result.positions, candidate.result.positions, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(reference.result.fees, candidate.result.fees, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(reference.result.funding, candidate.result.funding, rtol=0.0, atol=1e-12) + np.testing.assert_allclose(reference.result.margin, candidate.result.margin, rtol=0.0, atol=1e-12) + assert reference.command_tape == candidate.command_tape + assert _fill_signature(reference.result) == _fill_signature(candidate.result) + pd.testing.assert_frame_equal( + reference.order_events.reset_index(drop=True), + candidate.order_events.reset_index(drop=True), + check_dtype=False, + ) + assert reference.result.liquidated == candidate.result.liquidated + assert reference.result.liquidation_bar == candidate.result.liquidation_bar + reference_counters = reference.result.metadata["lifecycle_counters"] + candidate_counters = candidate.result.metadata["lifecycle_counters"] + # ``filled_command_count`` is intentionally not part of the canonical + # parity surface yet: replay reports filled command-state transitions, + # while reactive sessions report fill records. The exact order-event and + # fill ledgers above are the authoritative lifecycle evidence. + for key in ( + "fill_count", + "event_count", + "rejected_count", + "canceled_count", + "pending_command_count", + "expired_event_count", + ): + assert reference_counters[key] == candidate_counters[key] + + +def test_phase47c_grid_2000_long_only_and_long_short_full_parity(audit_runs): + for grid_mode in ("long_only", "long_short"): + oracle = audit_runs[(grid_mode, "replay_certified")] + _assert_parity(oracle, audit_runs[(grid_mode, "python")]) + _assert_parity(oracle, audit_runs[(grid_mode, "rust")]) + + +def test_phase47c_scalar_v2_matches_same_backend_audit(audit_runs, data_2000): + for grid_mode in ("long_only", "long_short"): + params = _params(grid_mode) + for backend in ("python", "rust"): + execution = _execution(backend, audit=False) + endpoint, prepared = GRID.prepare_grid_score_runner( + df=data_2000, + execution=execution, + ) + score = GRID.score_grid_params( + prepared_runner=prepared, + df=data_2000, + params=params, + execution=execution, + ) + audit = audit_runs[(grid_mode, backend)] + assert isinstance(score, NativeEventScalarScoreResult) + assert endpoint.result is None + assert prepared.scores == 1 + assert score.metadata["score_pandas_materialized"] is False + assert score.metadata["score_full_ledgers_materialized"] is False + np.testing.assert_allclose(score.final_equity, audit.result.equity.iloc[-1], rtol=0.0, atol=1e-12) + np.testing.assert_allclose( + score.final_positions, + audit.result.positions.iloc[-1].to_numpy(dtype=np.float64), + rtol=0.0, + atol=1e-12, + ) + np.testing.assert_allclose(score.total_fee, audit.result.fees.sum(), rtol=0.0, atol=1e-12) + np.testing.assert_allclose( + score.metadata["total_funding"], audit.result.funding.sum(), rtol=0.0, atol=1e-12 + ) + assert score.fill_count == len(audit.result.fills) + assert score.rejection_count == audit.result.metadata["lifecycle_counters"]["rejected_count"] + assert score.cancellation_count == audit.result.metadata["lifecycle_counters"]["canceled_count"] + assert score.liquidated == audit.result.liquidated + assert score.liquidation_bar == audit.result.liquidation_bar + # The audit fingerprint is the retained proof. Scalar mode is + # intentionally not expected to retain the command tape itself. + assert len(_audit_fingerprint(audit)[0]) == len(audit.command_tape) + + +def test_phase47c_backend_policy_is_explicit_and_no_silent_rust_fallback(data_2000): + params = _params("long_only") + rust = GRID.run_grid_backtest( + df=data_2000, + params=params, + execution=_execution("rust", audit=True), + ) + auto = GRID.run_grid_backtest( + df=data_2000, + params=params, + execution=_execution("auto", audit=True), + ) + assert rust.result.metadata["native_event_backend_requested"] == "rust" + assert rust.result.metadata["native_event_backend_resolved"] == "rust" + assert auto.result.metadata["native_event_backend_requested"] == "auto" + assert auto.result.metadata["native_event_backend_resolved"] == "python" diff --git a/tests/test_phase47d_grid_optimizer.py b/tests/test_phase47d_grid_optimizer.py new file mode 100644 index 0000000..c5526d4 --- /dev/null +++ b/tests/test_phase47d_grid_optimizer.py @@ -0,0 +1,240 @@ +"""Phase 47D: safe Grid optimizer hot-path and retention gates.""" + +from __future__ import annotations + +import importlib.util +import sys +from dataclasses import replace +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from quantbt import NativeEventScoreRequirements, NativeEventScalarScoreResult + + +GRID_PATH = Path( + "/root/bobby/pool_alpha/alphas_storage/TA/" + "dynamic_grid_quantbt_native_event.py" +) + + +def _load_grid_module(): + if not GRID_PATH.exists(): + pytest.skip( + f"external Grid fixture is unavailable: {GRID_PATH}", + allow_module_level=True, + ) + spec = importlib.util.spec_from_file_location("phase47d_grid_alpha", GRID_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load Grid fixture: {GRID_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +GRID = _load_grid_module() + + +def _data(bars: int = 240) -> pd.DataFrame: + index = pd.date_range("2025-01-01", periods=bars, freq="h", tz="UTC") + x = np.arange(bars, dtype=np.float64) + close = 100.0 + 3.5 * np.sin(x / 7.0) + 0.025 * x + open_ = close + 0.15 * np.sin(x / 3.0) + return pd.DataFrame( + { + "open": open_, + "high": np.maximum(open_, close) + 1.2, + "low": np.minimum(open_, close) - 1.2, + "close": close, + "volume": np.full(bars, 1000.0), + }, + index=index, + ) + + +def _params() -> dict: + return { + "grid_mode": "long_only", + "ma_type": "EMA", + "ma_len": 8, + "ema_len_short": 3, + "logic": "ATR", + "band_mult": 0.25, + "zone_smoothing_len": 2, + "warmup_bars": 12, + "pyramiding": 3, + "neutral_position_mode": "hold", + "one_entry_fill_per_bar": True, + "one_exit_fill_per_bar": True, + "campaign_id": "PHASE47D", + } + + +def _execution(*, collect_diagnostics: bool = True): + return GRID.GridExecutionConfig( + symbol="ETHUSDT", + initial_capital=20_000.0, + cash_per_entry=1_000.0, + leverage=5.0, + maintenance_ratio=0.0, + contract_size=1.0, + fee_rate=0.0005, + slippage_bps=2.0, + use_funding=False, + funding_rate=0.0, + native_backend="python", + reactive_execution_mode="fast", + reactive_kernel_mode="single_pass", + report_level="score", + audit_sink="none", + collect_diagnostics=collect_diagnostics, + ) + + +def test_grid_declares_only_context_payload_it_consumes(): + assert GRID.ReactiveDynamicGridStrategy.native_context_requirements == { + "fills": True, + "events": False, + "active_orders": True, + "positions": True, + "margin": False, + } + strategy = GRID.build_grid_strategy( + df=_data(), + params=_params(), + execution=_execution(collect_diagnostics=False), + ) + requirements = NativeEventScoreRequirements.from_strategy( + strategy, + base=NativeEventScoreRequirements.scalar_score_contract(), + ) + assert requirements.need_context_fills is True + assert requirements.need_context_active_orders is True + assert requirements.need_context_positions is True + assert requirements.need_context_events is False + assert requirements.need_context_margin is False + + +def test_scalar_strategy_drops_diagnostics_and_alias_columns(): + strategy = GRID.build_grid_strategy( + df=_data(), + params=_params(), + execution=_execution(collect_diagnostics=False), + ) + assert strategy.collect_diagnostics is False + for name in ( + "_diag_position_qty", + "_diag_equity", + "_diag_open_long_legs", + "_diag_open_short_legs", + "_diag_active_entry_orders", + "_diag_active_exit_orders", + "_diag_fill_count", + "_diag_command_count", + ): + assert getattr(strategy, name) is None + assert not any(column.startswith("long_entry_") for column in strategy.alpha_frame) + assert not any(column.startswith("long_exit_") for column in strategy.alpha_frame) + assert not any(column.startswith("short_entry_") for column in strategy.alpha_frame) + assert not any(column.startswith("short_exit_") for column in strategy.alpha_frame) + + +def test_alias_switch_preserves_execution_columns_and_values(): + data = _data() + params = _params() + with_aliases = GRID.prepare_grid_alpha_frame( + data, + params, + include_diagnostic_aliases=True, + ) + without_aliases = GRID.prepare_grid_alpha_frame( + data, + params, + include_diagnostic_aliases=False, + ) + assert set(without_aliases.columns).issubset(with_aliases.columns) + for column in without_aliases.columns: + pd.testing.assert_series_equal( + with_aliases[column], + without_aliases[column], + check_names=True, + ) + + +def test_score_helper_uses_scalar_gate_and_keeps_public_endpoint_empty(): + data = _data() + execution = _execution() + endpoint, prepared = GRID.prepare_grid_score_runner( + df=data, + execution=execution, + ) + before_scores = prepared.scores + before_runs = prepared.runs + score = GRID.score_grid_params( + prepared_runner=prepared, + df=data, + params=_params(), + execution=execution, + ) + assert isinstance(score, NativeEventScalarScoreResult) + assert prepared.scores == before_scores + 1 + assert prepared.runs == before_runs + assert endpoint.result is None + assert score.metadata["score_pandas_materialized"] is False + assert score.metadata["score_full_ledgers_materialized"] is False + + +def test_scalar_score_matches_public_accounting_and_false_mode_has_no_frame(): + data = _data() + params = _params() + public = GRID.run_grid_backtest( + df=data, + params=params, + execution=replace( + _execution(collect_diagnostics=True), + report_level="audit", + audit_sink="memory", + ), + ) + endpoint, prepared = GRID.prepare_grid_score_runner( + df=data, + execution=_execution(collect_diagnostics=True), + ) + scalar = GRID.score_grid_params( + prepared_runner=prepared, + df=data, + params=params, + execution=_execution(collect_diagnostics=True), + ) + np.testing.assert_allclose( + scalar.final_equity, + public.result.equity.iloc[-1], + rtol=0.0, + atol=1e-12, + ) + np.testing.assert_allclose( + scalar.total_fee, + public.result.fees.sum(), + rtol=0.0, + atol=1e-12, + ) + assert scalar.fill_count == len(public.result.fills) + assert endpoint.result is None + + score_execution = replace(_execution(), collect_diagnostics=False) + score_strategy = GRID.build_grid_strategy( + df=data, + params=params, + execution=score_execution, + ) + score_endpoint = GRID.build_grid_endpoint(score_execution) + score_result = score_endpoint.simulate( + data=data, + strategy=score_strategy, + symbols=[score_execution.symbol], + ) + with pytest.raises(RuntimeError, match="collect_diagnostics=True"): + score_strategy.build_output_frame(score_result) diff --git a/tests/test_phase48a_release_surfaces.py b/tests/test_phase48a_release_surfaces.py new file mode 100644 index 0000000..a871f11 --- /dev/null +++ b/tests/test_phase48a_release_surfaces.py @@ -0,0 +1,72 @@ +"""Phase 48A release-surface and Native Event API 0.4 locks.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "native.yml" +LEGACY_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "native-r0.yml" +PACKAGING_DOC = PROJECT_ROOT / "docs" / "release_packaging.md" +NATIVE_CARGO = PROJECT_ROOT / "rust" / "native_event" / "Cargo.toml" +NATIVE_PYPROJECT = PROJECT_ROOT / "rust" / "native_event" / "pyproject.toml" +NATIVE_LIB = PROJECT_ROOT / "rust" / "native_event" / "src" / "lib.rs" + + +REQUIRED_CAPABILITIES = ( + "native_event_v2_full_contract", + "native_event_v2_multisymbol", + "native_event_v2_funding", + "native_event_v2_liquidation", + "native_event_v2_cancel_all_oco", + "native_event_v2_tif_expiry", + "native_event_v2_relationships", + "native_event_v2_quantity_preflight", +) + + +def test_native_workflow_is_api_04_and_not_the_r0_surface(): + text = WORKFLOW.read_text() + + assert WORKFLOW.is_file() + assert not LEGACY_WORKFLOW.exists() + assert "Native Event API 0.4 Gate" in text + assert "api_version() == \"0.4\"" in text + assert "api_version() == '0.3'" not in text + assert "Native Event API 0.4 capabilities: PASS" in text + for capability in REQUIRED_CAPABILITIES: + assert capability in text + + +def test_native_distribution_metadata_matches_executable_version(): + cargo = NATIVE_CARGO.read_text() + native_pyproject = NATIVE_PYPROJECT.read_text() + native_lib = NATIVE_LIB.read_text() + + assert re.search(r'^version\s*=\s*"0\.4\.0"', cargo, re.MULTILINE) + assert re.search(r'^version\s*=\s*"0\.4\.0"', native_pyproject, re.MULTILINE) + assert 'const VERSION: &str = "0.4.0";' in native_lib + assert 'const API_VERSION: &str = "0.4";' in native_lib + + +def test_release_packaging_docs_describe_current_api_04_policy(): + text = PACKAGING_DOC.read_text() + + current_section = text.split( + "## Native Event Rust API 0.4", + maxsplit=1, + )[1].split( + "### Historical R0/R1/R2 scaffold", + maxsplit=1, + )[0] + + assert "public Native Event V2" in current_section + assert "native_backend=\"rust\"` is explicit and fail-fast" in current_section + assert "native_backend=\"auto\"` remains Python" in current_section + assert "one symbol, GTC, no funding" not in current_section + assert "Parent/child, OCO, expiry, IOC/FOK" not in current_section + assert "single- and multi-symbol execution" in current_section + assert "funding, margin and liquidation" in current_section + assert "parent/group/OCO and expiry" in current_section diff --git a/tests/test_phase48b_release_hygiene.py b/tests/test_phase48b_release_hygiene.py new file mode 100644 index 0000000..b30ce86 --- /dev/null +++ b/tests/test_phase48b_release_hygiene.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys +import zipfile + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.check_release_artifacts import inspect_artifact # noqa: E402 +from tools.scan_public_secrets import content_matches # noqa: E402 +from tools.source_mirror_manifest import ( # noqa: E402 + MIRROR_ENTRIES, + compare_file_maps, + mirror_differences, +) + + +def test_phase48b_explicit_mirror_is_byte_identical_and_excludes_benchmarks() -> None: + differences = mirror_differences(PROJECT_ROOT) + + assert not any(differences.values()), differences + assert "benchmarks" not in MIRROR_ENTRIES + + +def test_phase48b_manifest_comparison_detects_missing_extra_and_drift(tmp_path: Path) -> None: + canonical_path = tmp_path / "canonical.py" + mirror_path = tmp_path / "mirror.py" + extra_path = tmp_path / "extra.py" + canonical_path.write_bytes(b"canonical") + mirror_path.write_bytes(b"different") + extra_path.write_bytes(b"extra") + + differences = compare_file_maps( + {Path("module.py"): canonical_path, Path("missing.py"): canonical_path}, + {Path("module.py"): mirror_path, Path("extra.py"): extra_path}, + ) + + assert differences["missing"] == (Path("missing.py"),) + assert differences["extra"] == (Path("extra.py"),) + assert differences["drift"] == (Path("module.py"),) + + +def test_phase48b_sync_check_mode_and_agent_plan_visibility() -> None: + tool = PROJECT_ROOT / "tools" / "sync_source_mirror.py" + completed = subprocess.run( + [sys.executable, str(tool), "--check"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + assert "mirror check: PASS" in completed.stdout + + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", "upgrade/implement.md"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert tracked.returncode == 0 + + ignored = subprocess.run( + ["git", "check-ignore", "--no-index", "upgrade/implement.md"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert ignored.returncode != 0, ignored.stdout + + +def test_phase48b_gitignore_keeps_public_engineering_files_visible() -> None: + text = (PROJECT_ROOT / ".gitignore").read_text(encoding="utf-8") + + assert "upgrade/\n" not in text + assert "benchmarks/\n" not in text + assert "upgrade/private/" in text + assert "benchmarks/**/profiles/" in text + assert ".pypirc" in text + + +def test_phase48b_release_workflows_run_visibility_and_artifact_gates() -> None: + workflow_root = PROJECT_ROOT / ".github" / "workflows" + for name in ("ci.yml", "publish-testpypi.yml", "publish.yml"): + text = (workflow_root / name).read_text(encoding="utf-8") + assert "git ls-files --error-unmatch upgrade/implement.md" in text + assert 'tools/scan_public_secrets.py" --root "$GITHUB_WORKSPACE' in text + assert 'tools/check_release_artifacts.py" --dist "$GITHUB_WORKSPACE/dist' in text + + +def test_phase48b_manifest_has_sdist_private_path_prunes() -> None: + text = (PROJECT_ROOT / "MANIFEST.in").read_text(encoding="utf-8") + for private_path in ("upgrade/private", "upgrade/local", "upgrade/drafts", "data/private"): + assert f"prune {private_path}" in text + assert "global-exclude .pypirc" in text + + +def test_phase48b_secret_scan_uses_high_confidence_patterns() -> None: + assert content_matches("docs/example.md", b"token and password are documented") == [] + findings = content_matches("notes.txt", b"pypi-" + b"A" * 40) + assert len(findings) == 1 + assert "credential-like content" in findings[0] + assert content_matches("credentials/prod.json", b"{}") + + +def test_phase48b_artifact_gate_rejects_secret_path_and_non_core_member(tmp_path: Path) -> None: + artifact = tmp_path / "quantbt_engine-1.0.7-py3-none-any.whl" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("quantbt/__init__.py", "") + archive.writestr("quantbt_engine-1.0.7.dist-info/METADATA", "") + archive.writestr("quantbt/.env", "TOKEN=secret") + archive.writestr("private/readme.txt", "not package source") + + findings = inspect_artifact(artifact) + + assert any("secret-like archive path" in finding for finding in findings) + assert any("non-core wheel member" in finding for finding in findings) diff --git a/tests/test_phase48c_event_driven_facade.py b/tests/test_phase48c_event_driven_facade.py new file mode 100644 index 0000000..5365e7f --- /dev/null +++ b/tests/test_phase48c_event_driven_facade.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from quantbt import ( + NativeEventProfile, + NativeEventStrategy, + OrderCommand, + OrderSide, + OrderType, + QuantBTEndpoint, + TimeInForce, +) + + +def _bars(n: int = 12) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=n, freq="1h", tz="UTC") + close = 100.0 + np.arange(n, dtype=float) + return pd.DataFrame( + { + "open": close, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "volume": 1_000.0, + }, + index=index, + ) + + +class EnterExitStrategy: + def initialize(self, context): + return () + + def on_bar_close(self, context): + if context.bar_index == 0: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol=context.symbols[0], + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ) + ] + if context.bar_index == 4: + return [ + OrderCommand( + timestamp=context.timestamp, + symbol=context.symbols[0], + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + reduce_only=True, + order_id="exit", + ) + ] + return () + + def finalize(self, context): + return () + + +def _assert_accounting_equal(left, right) -> None: + pd.testing.assert_series_equal(left.equity, right.equity) + pd.testing.assert_series_equal(left.returns, right.returns) + pd.testing.assert_frame_equal(left.positions, right.positions) + pd.testing.assert_series_equal(left.fees, right.fees) + pd.testing.assert_series_equal(left.funding, right.funding) + pd.testing.assert_frame_equal(left.margin, right.margin) + assert left.liquidated == right.liquidated + assert left.liquidation_bar == right.liquidation_bar + + +def test_phase48c_profile_mapping_and_public_backend_contract(): + expected = { + "research": ("fast", "single_pass", "minimal", "none"), + "optimize": ("fast", "single_pass", "score", "none"), + "audit": ("audit", "replay_certified", "audit", "memory"), + } + + for profile, values in expected.items(): + endpoint = QuantBTEndpoint.event_driven( + profile=profile, + backend="auto", + initial_capital=10_000, + use_funding=False, + ) + config = endpoint.config + assert config.mode == "native_event_strategy" + assert config.backend == "native_event" + assert config.native_backend == "auto" + assert ( + config.reactive_execution_mode, + config.reactive_kernel_mode, + config.report_level, + config.audit_sink, + ) == values + assert config.metadata["event_driven_facade"] == { + "input_mode": "strategy", + "profile": profile, + "backend": "auto", + } + + assert QuantBTEndpoint.event_driven(backend="python").config.native_backend == "python" + assert QuantBTEndpoint.event_driven(backend="rust").config.native_backend == "rust" + assert NativeEventProfile.AUDIT.value == "audit" + assert isinstance(EnterExitStrategy(), NativeEventStrategy) + + +def test_phase48c_orders_profile_maps_to_lifecycle_endpoint(): + endpoint = QuantBTEndpoint.event_driven( + input_mode="orders", + profile=NativeEventProfile.AUDIT, + backend="python", + initial_capital=10_000, + use_funding=False, + ) + + assert endpoint.config.mode == "orders" + assert endpoint.config.backend == "native_event" + assert endpoint.config.native_backend == "python" + assert endpoint.config.event_engine_version == "v2" + assert endpoint.config.metadata["event_driven_facade"]["input_mode"] == "orders" + + +def test_phase48c_profile_controls_are_explicitly_conflict_checked(): + with pytest.raises(ValueError, match="profile='optimize' controls report_level"): + QuantBTEndpoint.event_driven(profile="optimize", report_level="audit") + + with pytest.raises(ValueError, match="profile='audit' controls reactive_kernel_mode"): + QuantBTEndpoint.event_driven(profile="audit", reactive_kernel_mode="single_pass") + + with pytest.raises(ValueError, match="input_mode must be"): + QuantBTEndpoint.event_driven(input_mode="signal") + + with pytest.raises(ValueError, match="backend must be one of"): + QuantBTEndpoint.event_driven(backend="replay_certified") + + +def test_phase48c_advanced_native_backend_selector_remains_available(): + endpoint = QuantBTEndpoint.event_driven( + profile="audit", + backend="auto", + native_backend="replay_certified", + ) + + assert endpoint.config.native_backend == "replay_certified" + + with pytest.raises(ValueError, match="either backend=.*native_backend"): + QuantBTEndpoint.event_driven(backend="python", native_backend="replay_certified") + + +def test_phase48c_strategy_facade_delegates_without_accounting_change(): + data = _bars() + facade = QuantBTEndpoint.event_driven( + profile="audit", + backend="python", + initial_capital=10_000, + leverage=5, + fee_rate=0.0002, + use_funding=False, + ) + direct = QuantBTEndpoint.native_event_strategy( + reactive_execution_mode="audit", + reactive_kernel_mode="replay_certified", + report_level="audit", + audit_sink="memory", + native_backend="python", + initial_capital=10_000, + leverage=5, + fee_rate=0.0002, + use_funding=False, + ) + + facade_result = facade.simulate(data=data, strategy=EnterExitStrategy(), symbols=["BTC"]) + direct_result = direct.simulate(data=data, strategy=EnterExitStrategy(), symbols=["BTC"]) + + _assert_accounting_equal(facade_result, direct_result) + assert facade.config.metadata["event_driven_facade"]["profile"] == "audit" + + +def test_phase48c_orders_facade_delegates_without_accounting_change(): + data = _bars() + command = OrderCommand( + timestamp=data.index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ) + facade = QuantBTEndpoint.event_driven( + input_mode="orders", + profile="audit", + backend="python", + initial_capital=10_000, + leverage=5, + fee_rate=0.0002, + use_funding=False, + ) + direct = QuantBTEndpoint.native_event_lifecycle( + native_backend="python", + report_level="audit", + reactive_kernel_mode="replay_certified", + audit_sink="memory", + initial_capital=10_000, + leverage=5, + fee_rate=0.0002, + use_funding=False, + ) + + facade_result = facade.simulate(data=data, order_commands=[command], symbols=["BTC"]) + direct_result = direct.simulate(data=data, order_commands=[command], symbols=["BTC"]) + + _assert_accounting_equal(facade_result, direct_result) + assert facade.config.metadata["event_driven_facade"]["input_mode"] == "orders" + + +def test_phase48c_public_result_and_endpoint_report_helpers_remain_available(): + endpoint = QuantBTEndpoint.event_driven( + profile="research", + backend="python", + initial_capital=10_000, + use_funding=False, + ) + result = endpoint.simulate(data=_bars(), strategy=EnterExitStrategy(), symbols=["BTC"]) + + result_report = result.full_report() + endpoint_report = endpoint.full_report() + assert result_report["final_equity"] == pytest.approx(endpoint_report["final_equity"]) + assert endpoint.show_metrics()["num_trades"] == result_report["num_trades"] diff --git a/tests/test_phase48f_release_gate.py b/tests/test_phase48f_release_gate.py new file mode 100644 index 0000000..3b1f66c --- /dev/null +++ b/tests/test_phase48f_release_gate.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import io +from pathlib import Path +import subprocess +import sys +import tarfile +import tomllib +import zipfile + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _project_version() -> str: + payload = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return str(payload["project"]["version"]) + + +def _load_yaml(path: Path) -> dict: + yaml = pytest.importorskip("yaml") + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _event_block(payload: dict) -> dict: + return payload.get("on", payload.get(True, {})) + + +def test_phase48f_testpypi_workflow_has_pre_upload_clean_artifact_gate() -> None: + path = PROJECT_ROOT / ".github/workflows/publish-testpypi.yml" + payload = _load_yaml(path) + events = _event_block(payload) + assert "workflow_dispatch" in events + assert events["push"]["tags"] == ["v*rc*"] + text = path.read_text(encoding="utf-8") + for expected in ( + "pip install dist/quantbt_engine-*.whl", + "pip install dist/quantbt_engine-*.tar.gz", + "pip check", + 'tools/check_release_artifacts.py" --dist "$GITHUB_WORKSPACE/dist', + "tools/create_release_manifest.py", + "uv run twine check --strict dist/*", + ): + assert expected in text + assert "gh-action-pypi-publish" in text + assert "PYPI_API_TOKEN" not in text + assert payload["jobs"]["publish"]["environment"]["name"] == "testpypi" + assert payload["jobs"]["publish"]["permissions"]["id-token"] == "write" + assert "inputs.ref || github.ref_name" in text + + +def test_phase48f_production_workflow_is_release_only_and_manifested() -> None: + path = PROJECT_ROOT / ".github/workflows/publish.yml" + payload = _load_yaml(path) + assert _event_block(payload) == {"release": {"types": ["published"]}} + text = path.read_text(encoding="utf-8") + assert "tools/create_release_manifest.py" in text + assert "github.event.release.prerelease" in str(payload["jobs"]["publish"]["if"]) + assert "github.event.release.draft" in str(payload["jobs"]["publish"]["if"]) + assert payload["jobs"]["publish"]["environment"]["name"] == "pypi" + assert payload["jobs"]["publish"]["permissions"]["id-token"] == "write" + + +def test_phase48f_release_manifest_contains_sha_and_backend_policy(tmp_path: Path) -> None: + version = _project_version() + artifact = tmp_path / f"quantbt_engine-{version}-py3-none-any.whl" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("quantbt/__init__.py", f"__version__ = '{version}'\n") + archive.writestr(f"quantbt_engine-{version}.dist-info/METADATA", "Name: quantbt-engine\n") + dist = tmp_path / "dist" + dist.mkdir() + artifact.rename(dist / artifact.name) + sdist = dist / f"quantbt_engine-{version}.tar.gz" + with tarfile.open(sdist, "w:gz") as archive: + member = tarfile.TarInfo(f"quantbt_engine-{version}/pyproject.toml") + payload = ( + "[project]\nname = 'quantbt-engine'\nversion = " + f"'{version}'\n" + ).encode() + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + sys.path.insert(0, str(PROJECT_ROOT)) + from tools.create_release_manifest import build_manifest + + manifest = build_manifest(dist) + assert manifest["schema"] == "quantbt-release-manifest-v1" + assert manifest["distribution"] == "quantbt-engine" + assert manifest["version"] == version + assert len(manifest["git_sha"]) == 40 + assert {item["kind"] for item in manifest["artifacts"]} == {"wheel", "sdist"} + assert all(len(item["sha256"]) == 64 for item in manifest["artifacts"]) + assert manifest["backend_policy"] == { + "auto": "python", + "native_extra": "empty", + "rust": "explicit_experimental", + } + + (dist / "quantbt_engine-0.0.0-py3-none-any.whl").write_bytes(b"wrong version") + with pytest.raises(RuntimeError, match="does not match"): + build_manifest(dist) + + +def test_phase48f_release_manifest_allows_detached_head( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tools import create_release_manifest + + calls = [] + + def fake_run(args, **kwargs): + calls.append(tuple(args)) + if args[1:3] == ["symbolic-ref", "--short"]: + return subprocess.CompletedProcess(args, 1, "", "") + if args[1:] == ["status", "--porcelain"]: + return subprocess.CompletedProcess(args, 0, "", "") + if args[1:] == ["rev-parse", "HEAD"]: + return subprocess.CompletedProcess(args, 0, "a" * 40 + "\n", "") + raise AssertionError(args) + + monkeypatch.setattr(create_release_manifest.subprocess, "run", fake_run) + + version = _project_version() + dist = tmp_path / "dist" + dist.mkdir() + with zipfile.ZipFile(dist / f"quantbt_engine-{version}-py3-none-any.whl", "w"): + pass + with tarfile.open(dist / f"quantbt_engine-{version}.tar.gz", "w:gz"): + pass + + manifest = create_release_manifest.build_manifest(dist) + + assert manifest["git_sha"] == "a" * 40 + assert manifest["git_ref"] is None + assert any(call[1] == "symbolic-ref" for call in calls) + + +def test_phase48f_archive_gate_rejects_private_and_build_members(tmp_path: Path) -> None: + from tools.check_release_artifacts import inspect_artifact + + version = _project_version() + wheel = tmp_path / f"quantbt_engine-{version}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("quantbt/__init__.py", "") + archive.writestr(f"quantbt_engine-{version}.dist-info/METADATA", "") + archive.writestr("quantbt/.env", "PYPI_TOKEN=pypi-" + "A" * 40) + archive.writestr("quantbt/local.prof", "profile") + findings = inspect_artifact(wheel) + assert any("secret-like archive path" in finding for finding in findings) + assert any("build/profiling artifact" in finding for finding in findings) + assert any("credential-like content" in finding for finding in findings) + + sdist = tmp_path / f"quantbt_engine-{version}.tar.gz" + with tarfile.open(sdist, "w:gz") as archive: + member = tarfile.TarInfo(f"quantbt_engine-{version}/data/private/secret.csv") + payload = b"profile" + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + findings = inspect_artifact(sdist) + assert any("private/local archive path" in finding for finding in findings) + + +def test_phase48f_version_gate_is_exact_for_current_release() -> None: + metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + version = metadata["project"]["version"] + script = PROJECT_ROOT / "tools/check_release_version.py" + accepted = subprocess.run( + [sys.executable, str(script)], + cwd=PROJECT_ROOT, + env={"GITHUB_REF_NAME": f"v{version}"}, + capture_output=True, + text=True, + check=False, + ) + assert accepted.returncode == 0, accepted.stderr diff --git a/tests/test_pre48e_native_event_fast_paths.py b/tests/test_pre48e_native_event_fast_paths.py new file mode 100644 index 0000000..ae7ff94 --- /dev/null +++ b/tests/test_pre48e_native_event_fast_paths.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd + +from quantbt import OrderCommand, OrderSide, OrderType, QuantBTEndpoint, TimeInForce + + +def _bars(n: int = 32) -> pd.DataFrame: + index = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC") + close = 100.0 + np.arange(n, dtype=np.float64) + return pd.DataFrame( + { + "open": close, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "volume": 1_000.0, + }, + index=index, + ) + + +class SparseStrategy: + def initialize(self, context): + return () + + def on_bar_close(self, context): + if context.bar_index == 2: + return ( + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ), + ) + if context.bar_index == 8: + return ( + OrderCommand( + timestamp=context.timestamp, + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + reduce_only=True, + order_id="exit", + ), + ) + return () + + def finalize(self, context): + return () + + +def _endpoint(**kwargs): + return QuantBTEndpoint.native_event_strategy( + initial_capital=10_000.0, + leverage=5.0, + maintenance_ratio=0.0, + fee_rate=0.0002, + use_funding=False, + native_backend="python", + reactive_kernel_mode="single_pass", + report_level="score", + **kwargs, + ) + + +def test_pre48e_empty_batches_skip_retime_and_quantity_preflight(): + result = _endpoint().simulate(data=_bars(), strategy=SparseStrategy(), symbols=["BTC"]) + counters = result.metadata["execution_counters"] + + assert counters["bars_processed"] == len(_bars()) + assert counters["contexts_materialized"] == len(_bars()) + 1 + assert counters["bars_with_commands"] == 2 + assert counters["empty_command_batches_skipped"] >= len(_bars()) - 1 + assert counters["constraint_preflight_calls"] == 0 + assert counters["constraint_preflight_skipped"] == 2 + + +def test_pre48e_zero_constraint_fast_path_matches_explicit_zero_constraint_path(): + data = _bars() + base = _endpoint().simulate(data=data, strategy=SparseStrategy(), symbols=["BTC"]) + explicit_zero = _endpoint(qty_step=0.0, min_qty=0.0, min_notional=0.0).simulate( + data=data, + strategy=SparseStrategy(), + symbols=["BTC"], + ) + + pd.testing.assert_series_equal(base.equity, explicit_zero.equity) + pd.testing.assert_frame_equal(base.positions, explicit_zero.positions) + pd.testing.assert_series_equal(base.fees, explicit_zero.fees) + pd.testing.assert_series_equal(base.funding, explicit_zero.funding) + assert base.metadata["lifecycle_counters"] == explicit_zero.metadata["lifecycle_counters"] + + +def test_pre48e_enabled_constraints_keep_quantity_preflight(): + result = _endpoint(qty_step=0.1, min_qty=0.1).simulate( + data=_bars(), + strategy=SparseStrategy(), + symbols=["BTC"], + ) + counters = result.metadata["execution_counters"] + + assert counters["constraint_preflight_calls"] == 2 + assert counters["constraint_preflight_skipped"] == 0 + assert counters["commands_quantized"] == 2 diff --git a/tools/check_release_artifacts.py b/tools/check_release_artifacts.py new file mode 100644 index 0000000..0527d89 --- /dev/null +++ b/tools/check_release_artifacts.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Inspect wheel/sdist members before a public package upload.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import posixpath +import re +import sys +import tarfile +import zipfile + + +SUSPICIOUS_PATH = re.compile( + r"(^|/)(\.env($|\.)|\.pypirc$|credentials|secrets?)(/|$)|" + r"\.(pem|key|p12|pfx|jks)$", + re.IGNORECASE, +) +PRIVATE_ARCHIVE_PATH = re.compile( + r"(^|/)(upgrade/(private|local|drafts)|data/(raw|private|local)|" + r"benchmarks/(local|tmp|profiles)|artifacts/(local|tmp)|\.git|\.venv|" + r"__pycache__|\.pytest_cache)(/|$)", + re.IGNORECASE, +) +BUILD_ARTIFACT = re.compile( + r"(\.py[cod]|\.pyo|\.prof|\.lprof|\.memray|\.flamegraph\.svg|" + r"\.so|\.pyd|\.dylib)$", + re.IGNORECASE, +) +CORE_WHEEL_MEMBER = re.compile(r"^quantbt_engine-[^/]+\.dist-info/") +SECRET_CONTENT = re.compile( + r"pypi-[A-Za-z0-9_-]{32,}|" + r"ghp_[A-Za-z0-9]{36,}|" + r"github_pat_[A-Za-z0-9_]{50,}|" + r"AKIA[0-9A-Z]{16}|" + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", + re.IGNORECASE, +) + + +def _path_findings(name: str) -> list[str]: + normalized = name.replace("\\", "/") + findings: list[str] = [] + if normalized.startswith("/") or ".." in posixpath.normpath(normalized).split("/"): + findings.append(f"unsafe archive path: {name}") + if SUSPICIOUS_PATH.search(normalized): + findings.append(f"secret-like archive path: {name}") + if PRIVATE_ARCHIVE_PATH.search(normalized): + findings.append(f"private/local archive path: {name}") + if BUILD_ARTIFACT.search(normalized): + findings.append(f"build/profiling artifact: {name}") + return findings + + +def _content_findings(path: Path, name: str, payload: bytes) -> list[str]: + if not payload: + return [] + match = SECRET_CONTENT.search(payload.decode("utf-8", errors="ignore")) + if match is None: + return [] + return [f"{path.name}: credential-like content in {name}: {match.group(0)[:24]}..."] + + +def inspect_artifact(path: Path) -> list[str]: + """Return findings for one core wheel or source distribution.""" + + findings: list[str] = [] + if path.suffix == ".whl": + with zipfile.ZipFile(path) as archive: + for name in archive.namelist(): + findings.extend(f"{path.name}: {item}" for item in _path_findings(name)) + normalized = name.replace("\\", "/") + if not (normalized.startswith("quantbt/") or CORE_WHEEL_MEMBER.match(normalized)): + findings.append(f"{path.name}: non-core wheel member: {name}") + item = archive.getinfo(name) + if not item.is_dir() and item.file_size <= 4 * 1024 * 1024: + findings.extend(_content_findings(path, name, archive.read(name))) + elif path.name.endswith(".tar.gz"): + with tarfile.open(path) as archive: + for member in archive.getmembers(): + findings.extend(f"{path.name}: {item}" for item in _path_findings(member.name)) + if member.isfile() and member.size <= 4 * 1024 * 1024: + extracted = archive.extractfile(member) + if extracted is not None: + findings.extend(_content_findings(path, member.name, extracted.read())) + else: + findings.append(f"unsupported artifact type: {path}") + return findings + + +def inspect_dist(dist: Path) -> list[str]: + artifacts = sorted((*dist.glob("*.whl"), *dist.glob("*.tar.gz"))) + if not artifacts: + return [f"no wheel or sdist artifacts found in {dist}"] + findings: list[str] = [] + for artifact in artifacts: + findings.extend(inspect_artifact(artifact)) + return findings + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dist", type=Path, required=True) + args = parser.parse_args(argv) + findings = inspect_dist(args.dist.resolve()) + if findings: + print("\n".join(findings), file=sys.stderr) + return 1 + print("release artifact allowlist/secret-path gate: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_release_version.py b/tools/check_release_version.py new file mode 100644 index 0000000..96db0d7 --- /dev/null +++ b/tools/check_release_version.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os +from pathlib import Path +import sys +import tomllib + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _project_version() -> str: + payload = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return str(payload["project"]["version"]) + + +def main() -> int: + version = _project_version() + ref_name = os.environ.get("GITHUB_REF_NAME", "") + + if ref_name: + expected_tag = f"v{version}" + if ref_name != expected_tag: + print( + f"release tag mismatch: GITHUB_REF_NAME={ref_name!r}, " + f"expected {expected_tag!r} from pyproject.toml", + file=sys.stderr, + ) + return 1 + + print(f"quantbt-engine version check passed: {version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/create_release_manifest.py b/tools/create_release_manifest.py new file mode 100644 index 0000000..8b98a8a --- /dev/null +++ b/tools/create_release_manifest.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Create a deterministic release evidence manifest for wheel/sdist artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tomllib + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _run_git(*args: str, required: bool = True) -> str: + completed = subprocess.run( + ["git", *args], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + ) + if required and completed.returncode != 0: + raise subprocess.CalledProcessError( + completed.returncode, + completed.args, + output=completed.stdout, + stderr=completed.stderr, + ) + if completed.returncode != 0: + return "" + return completed.stdout.strip() + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _project_metadata() -> dict: + payload = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = payload["project"] + return { + "distribution": str(project["name"]), + "version": str(project["version"]), + "python_requires": str(project["requires-python"]), + } + + +def _normalized_distribution(name: str) -> str: + return re.sub(r"[-_.]+", "_", name).lower() + + +def _artifact_kind(path: Path, metadata: dict) -> str: + normalized = _normalized_distribution(metadata["distribution"]) + version = metadata["version"] + if path.suffix == ".whl" and path.name.startswith(f"{normalized}-{version}-"): + return "wheel" + if path.name == f"{normalized}-{version}.tar.gz": + return "sdist" + raise RuntimeError( + "artifact name does not match the current distribution/version: " + f"{path.name}" + ) + + +def build_manifest(dist: Path, *, require_clean: bool = False) -> dict: + metadata = _project_metadata() + status = _run_git("status", "--porcelain") + if require_clean and status: + raise RuntimeError("release manifest requires a clean Git worktree") + + artifacts = [] + for path in sorted((*dist.glob("*.whl"), *dist.glob("*.tar.gz"))): + kind = _artifact_kind(path, metadata) + artifacts.append( + { + "name": path.name, + "kind": kind, + "bytes": path.stat().st_size, + "sha256": _sha256(path), + } + ) + if not artifacts: + raise RuntimeError(f"no release artifacts found in {dist}") + kinds = {item["kind"] for item in artifacts} + if not {"wheel", "sdist"}.issubset(kinds): + raise RuntimeError("release manifest requires both a wheel and an sdist") + + benchmark_files = [] + for relative in ( + "benchmarks/native_event/results/phase48e1/after.json", + "benchmarks/native_event/results/phase48e1/after.md", + ): + path = PROJECT_ROOT / relative + if path.is_file(): + benchmark_files.append({"path": relative, "sha256": _sha256(path)}) + + return { + "schema": "quantbt-release-manifest-v1", + **metadata, + "git_sha": _run_git("rev-parse", "HEAD"), + "git_ref": _run_git( + "symbolic-ref", "--short", "-q", "HEAD", required=False + ) or None, + "release_ref": os.environ.get("GITHUB_REF_NAME") or None, + "working_tree_clean": not bool(status), + "backend_policy": { + "auto": "python", + "native_extra": "empty", + "rust": "explicit_experimental", + }, + "artifacts": artifacts, + "benchmark_evidence": benchmark_files, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dist", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--require-clean", action="store_true") + args = parser.parse_args(argv) + try: + manifest = build_manifest(args.dist.resolve(), require_clean=args.require_clean) + except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: + print(f"release manifest failed: {exc}", file=sys.stderr) + return 1 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"release manifest written: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/scan_public_secrets.py b/tools/scan_public_secrets.py new file mode 100644 index 0000000..8dcf04b --- /dev/null +++ b/tools/scan_public_secrets.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Scan tracked files for high-confidence credentials and secret paths. + +Generic words such as ``token`` or ``password`` are intentionally not treated +as leaks: they occur in documentation and domain schemas. Matches must be +reviewed before release, even when a scanner reports a false positive. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import subprocess +import sys + + +SECRET_PATH = re.compile( + r"(^|/)(\.env($|\.)|\.pypirc$|credentials|secrets?)(/|$)|" + r"\.(pem|key|p12|pfx|jks)$", + re.IGNORECASE, +) +SECRET_CONTENT = re.compile( + r"pypi-[A-Za-z0-9_-]{32,}|" + r"ghp_[A-Za-z0-9]{36,}|" + r"github_pat_[A-Za-z0-9_]{50,}|" + r"AKIA[0-9A-Z]{16}|" + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", + re.IGNORECASE, +) + + +def content_matches(path: str, payload: bytes) -> list[str]: + """Return high-confidence content/path findings for one tracked file.""" + + findings: list[str] = [] + if SECRET_PATH.search(path): + findings.append(f"secret-like tracked path: {path}") + text = payload.decode("utf-8", errors="ignore") + for match in SECRET_CONTENT.finditer(text): + findings.append(f"credential-like content in {path}: {match.group(0)[:24]}...") + return findings + + +def tracked_paths(project_root: Path) -> list[Path]: + completed = subprocess.run( + ["git", "ls-files", "-z"], + cwd=project_root, + check=True, + capture_output=True, + ) + return [ + project_root / raw.decode("utf-8") + for raw in completed.stdout.split(b"\0") + if raw + ] + + +def scan_tracked_files(project_root: Path) -> list[str]: + """Scan the current Git index without treating ignored files as public.""" + + findings: list[str] = [] + for path in tracked_paths(project_root): + if path.is_file(): + findings.extend(content_matches(str(path.relative_to(project_root)), path.read_bytes())) + return findings + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args(argv) + findings = scan_tracked_files(args.root.resolve()) + if findings: + print("\n".join(findings), file=sys.stderr) + return 1 + print("tracked secret scan: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/source_mirror_manifest.py b/tools/source_mirror_manifest.py new file mode 100644 index 0000000..f58f53b --- /dev/null +++ b/tools/source_mirror_manifest.py @@ -0,0 +1,109 @@ +"""Manifest and hash checks for the temporary root/source package mirror. + +``src/quantbt`` is the wheel source of truth. The root-level Python tree is a +compatibility mirror for local Pool Alpha imports and is deliberately kept +outside the wheel build. The manifest is explicit so benchmarks and tools are +never mistaken for package source. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +CANONICAL_ROOT = PROJECT_ROOT / "src" / "quantbt" + +MIRROR_ENTRIES = ( + "__init__.py", + "backtester.py", + "endpoint.py", + "engines.py", + "portfolio.py", + "walkforward.py", + "adapters", + "backends", + "core", + "metrics", + "optimization", + "options", + "reporting", + "sizing", + "viz", +) + + +def sha256(path: Path) -> str: + """Return the content hash used by mirror parity checks.""" + + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def canonical_files(project_root: Path = PROJECT_ROOT) -> dict[Path, Path]: + """Collect manifest-approved Python files keyed relative to ``src/quantbt``.""" + + root = project_root / "src" / "quantbt" + files: dict[Path, Path] = {} + for entry_name in MIRROR_ENTRIES: + entry = root / entry_name + if entry.is_file() and entry.suffix == ".py": + files[Path(entry.name)] = entry + elif entry.is_dir(): + for path in entry.rglob("*.py"): + files[path.relative_to(root)] = path + return files + + +def mirror_files(project_root: Path = PROJECT_ROOT) -> dict[Path, Path]: + """Collect only manifest-approved root mirror files.""" + + files: dict[Path, Path] = {} + for entry_name in MIRROR_ENTRIES: + entry = project_root / entry_name + if entry.is_file() and entry.suffix == ".py": + files[Path(entry.name)] = entry + elif entry.is_dir(): + for path in entry.rglob("*.py"): + files[path.relative_to(project_root)] = path + return files + + +def compare_file_maps( + canonical: dict[Path, Path], + mirror: dict[Path, Path], +) -> dict[str, tuple[Path, ...]]: + """Compare two relative-path maps without modifying either tree.""" + + missing = sorted(canonical.keys() - mirror.keys()) + extra = sorted(mirror.keys() - canonical.keys()) + drift = sorted( + relative + for relative in canonical.keys() & mirror.keys() + if sha256(canonical[relative]) != sha256(mirror[relative]) + ) + return { + "missing": tuple(missing), + "extra": tuple(extra), + "drift": tuple(drift), + } + + +def mirror_differences(project_root: Path = PROJECT_ROOT) -> dict[str, tuple[Path, ...]]: + """Return missing, extra, and byte-drifted manifest entries.""" + + return compare_file_maps( + canonical_files(project_root), + mirror_files(project_root), + ) + + +def format_differences(differences: dict[str, tuple[Path, ...]]) -> str: + """Format mirror differences for CLI and CI output.""" + + lines: list[str] = [] + for label in ("missing", "extra", "drift"): + values = differences[label] + if values: + lines.append(f"{label}: " + ", ".join(str(value) for value in values)) + return "\n".join(lines) or "mirror check: PASS" diff --git a/tools/sync_source_mirror.py b/tools/sync_source_mirror.py new file mode 100644 index 0000000..a5444ce --- /dev/null +++ b/tools/sync_source_mirror.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Synchronize the explicit root/source compatibility mirror. + +The direction is mandatory. The tool never merges both trees and never +deletes an unknown root-only file. An extra root file is reported by the final +check so it can be reviewed and removed or added intentionally. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import shutil +import sys + +from source_mirror_manifest import ( + CANONICAL_ROOT, + PROJECT_ROOT, + canonical_files, + format_differences, + mirror_differences, + mirror_files, +) + + +def _copy_files(source: dict[Path, Path], destination_root: Path) -> int: + copied = 0 + for relative, path in sorted(source.items()): + destination = destination_root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, destination) + copied += 1 + return copied + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + directions = parser.add_mutually_exclusive_group(required=True) + directions.add_argument("--src-to-root", action="store_true") + directions.add_argument("--root-to-src", action="store_true") + directions.add_argument("--check", action="store_true") + args = parser.parse_args(argv) + + if args.check: + differences = mirror_differences(PROJECT_ROOT) + print(format_differences(differences)) + return 0 if not any(differences.values()) else 1 + + if args.src_to_root: + copied = _copy_files( + canonical_files(PROJECT_ROOT), + PROJECT_ROOT, + ) + direction = "src/quantbt -> root" + else: + copied = _copy_files( + mirror_files(PROJECT_ROOT), + CANONICAL_ROOT, + ) + direction = "root -> src/quantbt" + + differences = mirror_differences(PROJECT_ROOT) + print(f"copied {copied} files ({direction})") + print(format_differences(differences)) + if any(differences.values()): + print( + "mirror sync stopped with reviewed differences; no unknown files " + "were deleted", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/upgrade/implement.md b/upgrade/implement.md index 4d50542..715e926 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -7092,3 +7092,4150 @@ Remaining debt: - Full L2/intrabar portfolio simulation remains out of scope. - Prepared portfolio cache can later store the tradable/stale mask directly to avoid recomputation in larger WFO/service loops. + +## Phase 42-44 - QuantBT Engine Packaging, Native Event, And PyO3 Roadmap + +Status: + +- Planned only. No code/package-layout/native-event implementation has started + for this roadmap yet. + +Source guide: + +- `upgrade/quantbt_engine_packaging_pypi_pyo3_final_plan_v2_expanded.md` + +Hard rules from the guide: + +- Do not change public imports: + - `from quantbt import QuantBTEndpoint` +- Do not rename existing endpoints. +- Do not force old alphas to migrate. +- Do not change domain semantics for speed. +- Do not merge an optimization if parity fails. +- Keep Python/Numba as fallback and accounting oracle. +- Rust/PyO3 is optional acceleration only; users should not import + `_quantbt_native` directly. +- Do not force-push `main`; avoid rewriting remote history on `dev`. +- Prefer follow-up commits over amend once a commit has reached a shared + remote branch. + +Distribution targets: + +- PyPI distribution: `quantbt-engine` +- Python import: `quantbt` +- Optional native distribution: `quantbt-native` +- Optional native module: `_quantbt_native` +- Extra install target: `pip install "quantbt-engine[native]"` + +### Phase 42A - Packaging Baseline And Implementation Link + +Branch: + +- Start from `dev`. +- Create branch: `feat/quantbt-engine-packaging`. + +Scope: + +- Link this roadmap to the source guide in `upgrade/implement.md`. +- Create rollback/reference tag before migration: + - `pre-quantbt-engine-packaging-20260731` +- Capture baseline: + - commit SHA; + - Python version; + - NumPy/Pandas/Numba versions; + - full test result; + - current Native Event benchmark/RSS baseline if available. +- Run baseline tests using the current environment before any package layout + changes. + +Non-goals: + +- No source move yet unless Phase 42A baseline has passed. +- No Native Event optimization. +- No Rust/PyO3. +- No PyPI publish. + +Validation target: + +```bash +git status +python --version +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q quantbt/tests +``` + +### Phase 42B - Python Package Layout And Wheel Install + +Branch: + +- Continue on `feat/quantbt-engine-packaging`. + +Scope: + +- Add package metadata for `quantbt-engine`. +- Add `pyproject.toml` using PEP 621 / uv-compatible build metadata. +- Add `src/quantbt/` package layout. +- Copy current package source into `src/quantbt` safely. +- Add `src/quantbt/py.typed`. +- Keep existing public API/import behavior unchanged. +- Keep the root source during migration until wheel install/parity pass. +- Build and install the package in a clean environment. +- Run public import smoke outside the repository root. + +Non-goals: + +- Do not optimize Native Event in this branch. +- Do not introduce Rust. +- Do not delete root source until the clean wheel import path and pool_alpha + compatibility are proven. + +Merge gate: + +- Public imports pass from clean wheel install. +- Full tests pass. +- Wheel build/install pass. +- Backtest fingerprints unchanged. +- Pool Alpha smoke can import `quantbt` without `PYTHONPATH` hacks. + +Validation target: + +```bash +uv sync --all-extras --dev +uv run pytest +uv build +python -m pip install dist/quantbt_engine-*.whl +python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +### Phase 42C - CI, Pool Alpha Compatibility, And PyPI Preparation + +Branch: + +- Continue on `feat/quantbt-engine-packaging`, then PR/merge to `dev` only + after gates pass. + +Scope: + +- Add CI for: + - Python 3.11; + - Python 3.12; + - Python 3.13; + - lint/type smoke where safe; + - full pytest; + - wheel build; + - clean wheel install; + - public import smoke; + - pool_alpha compatibility smoke. +- Add release workflow skeleton for `quantbt-engine`. +- Prefer PyPI Trusted Publishing/OIDC. +- Keep API token only as manual/emergency fallback. +- Document release procedure: + - feature branch -> `dev`; + - release branch -> `main`; + - tag from `main`; + - GitHub Release triggers publish. + +Non-goals: + +- Do not publish to real PyPI without explicit approval. +- Do not tag from `dev`. +- Do not use `main` for package migration experiments. + +Merge gate: + +- CI-equivalent local commands pass. +- Clean install smoke pass. +- No `PYTHONPATH` dependency in package smoke. +- `main` remains stable/releasable. + +Release policy: + +- `quantbt-engine 0.1.x`: packaging, Python behavior unchanged. +- `quantbt-engine 0.2.x`: Python Native Event performance improvements. +- `quantbt-native 0.3.x`: optional experimental Rust accelerator. + +### Phase 43A - Native Event Behavior Freeze And Baseline Benchmarks + +Branch: + +- Only create after Phase 42 is merged into `dev`. +- Create branch: `perf/native-event-python-hotpath`. + +Scope: + +- Tests first; no implementation changes before behavior is frozen. +- Add Native Event callback timing tests: + - initialize at bar 0; + - commands effective next bar; + - same effective bar sequence order; + - finalize commands beyond end of tape. +- Add lifecycle parity tests for: + - PLACE; + - AMEND; + - REPLACE; + - CANCEL; + - CANCEL_ALL; + - market; + - limit; + - stop-market; + - stop-limit; + - GTC; + - GTD; + - IOC; + - FOK; + - reduce-only; + - parent first-fill/full-fill; + - OCO; + - quantity constraints; + - insufficient margin; + - funding; + - intrabar / after-funding / after-order liquidation; + - multi-symbol. +- Add compact deterministic fingerprints instead of DataFrame string hashes. +- Add baseline benchmark scenarios: + - 25k bars / low order count; + - 25k bars / high order churn; + - 100k bars / low order count; + - 100k bars / high order churn; + - parent/OCO-heavy; + - GTD-heavy; + - multi-symbol; + - 100 repeated prepared scores. + +Reference/oracle: + +- `replay_certified` is the canonical domain/accounting oracle. +- Python single-pass must pass before any Rust work. + +Validation target: + +```bash +pytest -q tests/native_event +python benchmarks/native_event/benchmark_reactive_session.py +``` + +Implementation note, 2026-08-01: + +- Status: **completed as behavior-freeze and baseline phase**. +- Branch deviation: the detailed guide suggests `perf/native-event-python-hotpath` + after Phase 42 is merged into `dev`; Phase 42A-C are still on + `feat/quantbt-engine-packaging`, so Phase 43A was implemented on the same + rollout branch to preserve package-layout/CI context. +- Runtime implementation changed: **none**. This phase only added tests, + deterministic fingerprint helpers, and benchmark artifacts. +- Added files: + - `tests/native_event/test_reactive_callback_contract.py`; + - `tests/native_event/test_reactive_lifecycle_parity.py`; + - `tests/native_event/test_reactive_accounting_parity.py`; + - `tests/native_event/test_reactive_memory_lifetime.py`; + - `tests/native_event/test_reactive_backend_matrix.py`; + - `benchmarks/native_event/benchmark_reactive_session.py`; + - `benchmarks/native_event/reactive_session_baseline.json`; + - `benchmarks/native_event/reactive_session_baseline.md`. +- Validation: + - `UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp /root/bobby/pool_alpha/.venv/bin/uv run pytest -q tests/native_event` + -> `20 passed, 2 skipped, 2 xfailed`. + - `UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp /root/bobby/pool_alpha/.venv/bin/uv run python benchmarks/native_event/benchmark_reactive_session.py` + -> completed and wrote baseline artifacts. +- Baseline benchmark summary: + - 25k low orders: `1.4672s` wall, `329.11 MB` peak RSS, 26 fills. + - 25k high churn: `1.3999s` wall, `336.55 MB` peak RSS, 1,250 fills. + - 100k low orders: `4.4641s` wall, `387.48 MB` peak RSS, 26 fills. + - 100k high churn: `5.2720s` wall, `388.85 MB` peak RSS, 1,000 fills. + - parent/OCO-heavy: `1.3920s` wall, `388.85 MB` peak RSS, 626 fills. + - GTD-heavy: `1.3772s` wall, `388.85 MB` peak RSS, 418 fills. + - multi-symbol lifecycle: `6.0748s` wall, `388.85 MB` peak RSS, 400 fills. + - prepared 100 scores: `25.2063s` wall, `388.85 MB` peak RSS. +- Known debts surfaced by freeze tests: + - `xfail`: finalize commands whose effective bar is beyond the market tape + are currently discarded instead of retained as outside-tape audit records. + - `xfail`: reactive `single_pass` replay parity currently fails after + quantity preflight/rounding, with the replay oracle differing in equity. + - `skip`: Rust/native extension parity and version-mismatch fallback remain + Phase 44 work because no native wheel is routed yet. + - Reactive strategy facade is still single-frame oriented; multi-symbol + lifecycle is tested through `NativeEventBackend.run_order_commands(...)` + directly. + +### Phase 43B - Native Event Python Hot Path, RSS, And Prepared Score + +Branch: + +- Continue on `perf/native-event-python-hotpath`. + +Scope: + +- Score retention and result path: + - add internal score requirements; + - avoid pandas materialization in score path; + - keep public `BacktestResultV2` path unchanged. +- Queue/object lifetime: + - pop consumed scheduled commands; + - release fill/event callback payload after callback; + - separate active order state from terminal history; + - score mode should not retain terminal order objects. +- Context allocation: + - cache immutable helpers; + - use read-only OHLCV row views; + - keep positions as snapshots; + - avoid active-order snapshots when no active orders. +- Lifecycle indexes where clearly beneficial: + - active by ID; + - children by parent; + - OCO membership; + - expiry bucket by bar; + - keep `CANCEL_ALL` simple unless benchmark proves it is a hotspot. +- Margin/accounting cache: + - refresh close margin once per bar; + - mark dirty after fill; + - do not change formulas. +- Prepared runner/evaluator: + - immutable market arrays reused; + - mutable session reset per trial; + - evaluator does not retain prior strategy/result/session; + - selected candidate reruns replay-certified audit. + +Performance rules: + +- No `fastmath`. +- No formula simplification. +- No public endpoint/result change. +- No merge if lifecycle/accounting parity fails. + +Merge gate: + +- Lifecycle parity: 100%. +- Accounting parity: 100%. +- RSS repeated-run plateau. +- Score throughput improves or at least no material regression. +- Audit path remains compatible. + +Validation target: + +```bash +pytest -q tests/native_event +pytest -q tests/test_phase34*.py +python benchmarks/native_event/benchmark_reactive_session.py +pytest -q quantbt/tests +``` + +Implementation note, 2026-08-01: + +- Status: **completed as Python hot-path/RSS/prepared-score optimization + phase**. +- Runtime implementation changed: + - `_ReactiveOrderState` now uses `slots=True`. + - Reactive session caches immutable context helpers: + `symbols_tuple`, `size_helper`, empty payload tuples, and active-order + snapshots. + - Prepared market arrays, reactive `opens_arr`, and `volumes_arr` are marked + read-only; context OHLCV now returns row views instead of per-bar copies. + - Scheduled command queues are popped per bar after execution. + - Callback payload dictionaries are released after the callback; full + fills/events are kept in separate compact lifecycle ledgers for reporting. + - Terminal state cleanup is centralized through `_terminalize_state(...)`. + - `id_to_order` is kept active/waiting only; score mode does not retain + terminal order history. + - Parent children, OCO membership, and GTD expiry buckets are indexed without + changing insertion-order priority. + - Close-margin calculation is cached per bar and dirtied after fills or + liquidation; formulas and liquidation priority are unchanged. +- Public API changed: **none**. +- Source mirrored in both packaging paths: + - `src/quantbt/backends/native_event.py`; + - `backends/native_event.py`; + - `src/quantbt/core/preprocessor.py`; + - `core/preprocessor.py`. +- Validation: + - `UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp /root/bobby/pool_alpha/.venv/bin/uv run pytest -q tests/native_event` + -> `20 passed, 2 skipped, 2 xfailed`. + - `UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp /root/bobby/pool_alpha/.venv/bin/uv run pytest -q tests/native_event tests/test_phase30d_native_event_reactive_runner.py tests/test_phase34b_native_event_prepared_score.py tests/test_phase34c_native_event_single_pass.py` + -> `34 passed, 2 skipped, 2 xfailed`. + - `UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp /root/bobby/pool_alpha/.venv/bin/uv run python benchmarks/native_event/benchmark_reactive_session.py` + -> completed and refreshed `benchmarks/native_event/reactive_session_baseline.*`. +- Benchmark change versus Phase 43A warm baseline: + - 25k low orders: `1.4672s -> 1.2510s` (`~14.7%` faster). + - 25k high churn: `1.3999s -> 1.3620s` (`~2.7%` faster). + - 100k low orders: `4.4641s -> 3.5485s` (`~20.5%` faster). + - 100k high churn: `5.2720s -> 3.8159s` (`~27.6%` faster). + - parent/OCO-heavy: `1.3920s -> 1.1372s` (`~18.3%` faster). + - GTD-heavy: `1.3772s -> 1.0886s` (`~21.0%` faster). + - prepared 100 scores: `25.2063s -> 21.6358s` (`~14.2%` faster). + - Multi-symbol benchmark path changed from reactive facade fallback to direct + lifecycle package path after Phase 43A documented the facade limitation; it + is not compared as a like-for-like speedup. +- Known debts still open: + - The two Phase 43A `xfail` items remain open intentionally: finalize + outside-tape audit retention and quantity-preflight replay parity. + - Prepared score still materializes enough accounting arrays to preserve the + existing score result contract; deeper requirements-driven scalar-only + metrics can be a later phase only after metric parity is locked. + - RSS peak is mostly bounded by pandas/result/report artifacts and Numba + compiled code residency; callback payload retention is now cleaned per bar. + +### Phase 44A - PyO3 R0 Scaffold And Backend Fallback + +Branch: + +- Only create after Phase 43B is merged into `dev`. +- Create branch: `feat/native-event-pyo3`. + +Scope: + +- Add `rust/native_event`. +- Add Rust/PyO3 package `quantbt-native`. +- Expose `_quantbt_native` version/capabilities only. +- Add thin Python adapter: + - `quantbt/backends/_native_event_rust.py`. +- Add backend selection internals: + - `auto`; + - `python`; + - `rust`; + - `replay_certified`. +- Initial rollout: + - `auto -> python`; + - `rust` requires explicit opt-in and raises clearly if extension is absent + or version-incompatible. + +Non-goals: + +- No production route through Rust yet. +- No domain logic in the adapter. + +Validation target: + +```bash +cargo fmt --check +cargo clippy -- -D warnings +cargo test +maturin build --release +python -c "import _quantbt_native" +pytest -q tests/native_event +``` + +Status: completed on `feat/quantbt-engine-packaging` (the planned +`feat/native-event-pyo3` split is deferred until the packaging branch is +integrated into `dev`). + +Implemented: + +- `rust/native_event` now contains the isolated `quantbt-native 0.3.0` PyO3 + R0 crate. `_quantbt_native` exports only `version`, `api_version`, and a + capability map; it has no matching, accounting, or execution implementation. +- `src/quantbt/backends/_native_event_rust.py` is the sole optional-import and + compatibility boundary. It validates API `0.3`, never silently enables an + incompatible extension, and contains no domain logic. +- `QUANTBT_NATIVE_BACKEND=auto|python|rust|replay_certified` is internal-only: + `auto` and `python` resolve to the existing Python path, `replay_certified` + forces the canonical replay route, and explicit `rust` fails clearly until a + later Rust slice exposes `reactive_session` capability. +- `NativeEventBackend` now records the selected backend and native capability + state in reactive-result metadata. No endpoint signature or default routing + changed. +- Main package `native` extra intentionally remains empty until a native wheel + is published. Maturin remains isolated to the Rust subpackage and its native + CI workflow, so normal core Python installs and the locked core environment + do not need an unpublished package or a Rust toolchain. + +### Phase 44B - PyO3 R1 Single-Symbol POC + +Branch: + +- Continue on `feat/native-event-pyo3`. + +Scope: + +- Rust POC supports: + - single symbol; + - PLACE; + - CANCEL; + - market; + - limit; + - GTC; + - fee; + - slippage; + - position/equity accounting. +- Python adapter compiles command batches into contiguous numeric buffers. +- Rust returns compact fill/event/state arrays. +- Python materializes callback/audit objects only at boundaries. +- `QUANTBT_NATIVE_BACKEND=rust` explicit opt-in only. +- `auto` remains Python. + +Parity gate: + +- Same command timing. +- Same lifecycle states. +- Same fills. +- Same positions. +- Same fees/slippage. +- Same final equity. + +Benchmark gate: + +- Median end-to-end speedup >= 1.20x. +- High-churn speedup >= 1.50x. +- Peak RSS reduction >= 30%. +- Repeated-run RSS plateau. + +Stop condition: + +- If Rust boundary conversion dominates, parity needs loose tolerance, or RSS + does not improve, keep Rust experimental and do not expand. + +### Phase 44C - PyO3 Feature Expansion And Native Release Gate + +Branch: + +- Continue on `feat/native-event-pyo3`. + +Scope: + + +- Expand only after Phase 44B gate passes. +- Feature slices in order: + - stop orders; + - amend/replace; + - reduce-only; + - quantity constraints; + - parent-child; + - OCO; + - GTD; + - IOC/FOK; + - funding; + - margin/liquidation; + - multi-symbol. +- Each slice gets differential tests against Python/replay oracle. +- Add native wheel CI for Linux x86-64 first. +- Add combined core+native wheel install test. + +Non-goals: + +- Do not enable Rust as default `auto` until full parity, randomized + certification, production soak, wheel coverage, fallback test, and RSS/runtime + gates all pass. +- Do not publish `quantbt-native` unless the matching `quantbt-engine` version + exists and combined parity passes. + +Release gate: + +- Build core wheel. +- Build native wheel. +- Install both in clean environment. +- Run native-event parity suite. +- Run RSS benchmark smoke. +- Publish only from GitHub Release / protected environment. + +Status: R2 implementation and CI gate added; release certification remains +blocked on an actual Rust toolchain/combined-wheel run. + +Implemented R2 slice: + +- Explicit `QUANTBT_NATIVE_BACKEND=rust` now supports, within the existing + single-symbol/no-funding/no-liquidation/GTC boundary: + - `STOP_MARKET` and `STOP_LIMIT` touch rules matching the Python reactive + session; + - `AMEND` and `REPLACE`, including a replacement alias so a later command + that targets the original ID reaches the active replacement; + - reduce-only quantity clipping/cancellation semantics; + - dynamic `qty_step`, `min_qty`, and `min_notional` filtering through the + shared canonical `quantize_signed_quantity` helper. +- The Python/Rust boundary remains fixed-width contiguous primitive arrays; + R2 reuses the R1 buffer layout instead of allocating richer Python objects + in the bar loop. +- `Native PyO3 Gate` CI now builds the core wheel and native wheel from the + same ref, clean-installs both, then runs the explicit Rust parity suite and + Rust RSS benchmark smoke. +- Python-side feature-gate/buffer tests pass locally. Installed-wheel R1/R2 + differential tests are present but skipped locally because this machine has + no `cargo`, `rustc`, or `maturin`. + +Remaining Phase 44C slices and release debt: + +- R3: parent-child, OCO, GTD, IOC/FOK, CANCEL_ALL. +- R4: funding, margin acceptance, intrabar/after-funding/after-order + liquidation. +- R5: deterministic multi-symbol lifecycle ordering. +- Rust format/clippy/test/build, exact differential parity, randomized parity, + and end-to-end speed/RSS gates must pass in the combined native CI before + R2 is called certified or `quantbt-native` is published. + +### Phase 45 - Packaging And Native Event Branch Audit Closure + +Detailed source of truth: + +- `upgrade/quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md` + (especially sections `45` to `57`). + +Execution rule: + +- Read the detailed v3 audit before every Phase 45 subphase. It overrides this + summary if a conflict is discovered. +- Do not leave known P0 parity, source-tree, wheel-install, or certification + debt behind merely to claim a phase complete. +- `src/quantbt` is the canonical implementation during migration; root source + is a verified compatibility mirror until it can be removed safely. +- Rust remains explicit/experimental and `auto` remains Python until every + advertised capability has real installed-wheel parity and RSS evidence. + +#### Phase 45A - Branch Certification And P0 Correctness Lock + +Read first: + +- V3 sections `45.1` to `45.4`, `46.1` to `46.7`, `47.1`, and `55` steps 1-3. + +Scope: + +- Record branch certification evidence through a Draft PR or manual native CI; + never alter publish triggers for a feature branch. +- Remove required native-event `xfail`s by fixing domain logic, including: + - reactive quantity preflight parity; + - finalize commands retained in the immutable audit tape even when their + effective bar lies beyond executable market data. +- Add complete quantity constraint parity cases: `qty_step`, `lot_size`, + `min_qty`, `min_notional`, below-minimum post-quantization, reduce-only + clipping, and floating-point boundary values. +- Make Rust installed-wheel tests use `assert_native_event_full_parity` plus + explicit raw-session fill/event checks. +- Add a root/src SHA256 synchronization guard and CI coverage so neither tree + can silently drift before the migration cleanup phase. + +Exit criteria: + +- No `xfail` in the required native-event domain suite. +- Exact lifecycle/accounting parity with the replay-certified oracle. +- Root and `src` Python trees pass the synchronization guard. +- Core wheel/sdist and native CI commands are ready to run on the feature ref; + remote CI evidence is archived rather than assumed. + +Implementation status (local, 2026-08-01): + +- Complete locally: the two required reactive P0 cases no longer use `xfail`. + Reactive scheduling now applies the same quantity preflight as the static + replay oracle, while preserving the original requested command in the audit + tape and reporting canonical rounding/drop diagnostics from replay. +- Complete locally: callback commands with no executable next bar are retained + as `outside_executable_tape=True` audit intent. They are never scheduled, + replayed, or allowed to create a synthetic final-bar fill. +- Complete locally: quantity/reduce-only boundary tests, root/src SHA256 + mirror guard, full installed-wheel Rust parity assertions, and raw Rust + session fill/event comparison checks are present. +- Local evidence: focused native/PyO3/source-sync suite passed `30 passed, + 2 skipped`; broader native, packaging, and lifecycle suite passed + `90 passed, 4 skipped`. Core wheel and sdist both clean-installed and + imported successfully from isolated environments. +- Required external evidence before branch certification: run the native CI on + this feature ref (or a Draft PR) to execute `cargo fmt`, `clippy`, Rust + tests, built-wheel differential parity, and RSS smoke. This workstation has + no Rust toolchain, so skipped installed-extension tests are not treated as + certification. `twine check` remains Phase 45C release validation. + +#### Phase 45B - Score Memory And PyO3 Boundary Certification + +Read first: + +- V3 sections `47.3` to `47.4`, `51`, `52.3` to `52.7`, and `55` steps 4-5. + +Scope: + +- Make prepared score execution scalar/array-first with conditional path + allocation, online metrics, and no pandas/result materialization per trial. +- Add isolated process RSS benchmarks, warm-up discipline, threshold checks, + and parity locks for score versus audit reruns. +- Replace per-trial Rust market copies with a safe shared immutable + `PreparedMarketCore`; add reusable command/result buffers and compact typed + boundary payloads before any R3+ lifecycle feature expansion. + +Exit criteria: + +- Score path retains no unnecessary audit history or DataFrames. +- Python/Rust/replay parity is exact for each advertised R1/R2 capability. +- RSS plateaus across repeated runs and benchmark gates have recorded evidence. +- If the Rust boundary fails the speed/RSS gate, freeze it as experimental and + do not start R3-R5. + +Implementation status (local, 2026-08-01): + +- Prepared `.score(...)` now calls the internal direct score route. It builds + `NativeAccountingArrays` from the completed reactive session and computes + metrics through the existing array-first performance contract; it does not + build `BacktestResultV2`, pandas Series, or DataFrames first. +- `NativeEventScoreRequirements` controls session path retention internally. + The compatible public score contract retains accounting arrays needed for + exact audit metrics, while fill/event/terminal-order ledgers and endpoint + result retention are disabled by default. Evaluators also stop retaining the + last strategy/result unless `retain_last=True` is explicitly requested. +- Added a capacity-managed Rust command buffer and a `PreparedMarketCore` + PyO3 design. A prepared runner caches that immutable core by exact prepared + array identity, so a capable native wheel copies market arrays once instead + of once per score trial. Older R2 wheels retain their explicit compatible + fallback path; `auto` remains Python. +- Added fresh-process RSS benchmark `run_phase45b_native_event_score_rss.py` + and a CI gate requiring score/audit final-equity parity, score throughput + improvement, and score RSS no higher than audit. Local 1,000-bar/100-run + evidence: score `7.3839s`, `285.23 MB`; audit `10.0324s`, `335.11 MB`; + final-equity parity exact to `1e-12`. +- Local native/lifecycle/PyO3/source-sync regression remains green. Rust code + is intentionally not certified locally because this workstation has no + `cargo`, `rustc`, or `maturin`; the feature-ref CI must compile the new + `PreparedMarketCore`, run installed-wheel parity, and collect native RSS + evidence before the PyO3 boundary can be certified or R3-R5 can begin. + +##### Phase 45B.1 - Native Evidence Gate For This Linux VPS + +Status: **completed: correctness evidence passes; performance gate rejects +Rust default rollout**. + +Purpose: + +- Close the local evidence gap before Phase 45C. This is certification for the + current Linux x86_64 / CPython 3.12 VPS only, not a manylinux release claim. + +Required procedure: + +1. Install a minimal stable Rust toolchain with `rustfmt` and `clippy` outside + either Python virtual environment; pin the repository with + `rust-toolchain.toml`. +2. Install `maturin` only into the QuantBT/Pool Alpha Python tool environment, + never by copying packages between virtual environments. +3. Run `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo test` in + `rust/native_event`. +4. Build a release native wheel, build the core wheel from the same commit, + then clean-install both into an isolated CPython 3.12 virtual environment. +5. Run the installed-wheel Python/Rust full lifecycle parity suite and the + fresh-process score/RSS benchmark. Archive JSON evidence locally. +6. Compare Rust against warmed Python single-pass only; do not compare cold + Numba compilation. If parity or performance gates fail, keep Rust explicit + and fix the boundary before Phase 45C. + +Exit criteria: + +- The new Rust source compiles and passes format, lint, and unit tests on this + VPS. +- Built-wheel installed R1/R2 parity tests no longer skip. +- Prepared-market reuse is observed on the actual extension. +- Python/Rust/replay accounting and lifecycle parity passes for the advertised + R2 capability matrix, with process-RSS and throughput evidence saved. + +Local evidence (Linux x86_64, CPython 3.12, 2026-08-01): + +- Installed Rust stable `1.97.1`, `rustfmt`, `clippy`, Linux C build tools, and + Maturin in the QuantBT virtual environment only. Neither project Python venv + was replaced or removed. +- `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo test` pass. + The first real compiler pass also fixed a missing NumPy trait import in the + PyO3 crate and strict dead-code handling in the prepared market container. +- Built and clean-installed core plus native CPython 3.12 Linux wheel. The + extension imports from `site-packages`, advertises `prepared_market_core`, + and installed R1/R2 capability tests pass `15 passed, 1 skipped`. +- Correctness is therefore evidenced for the advertised R2 subset only. The + full native-event suite must not run under `QUANTBT_NATIVE_BACKEND=rust`: + funding, liquidation, OCO/GTD, and multi-symbol remain explicit unsupported + features and must raise rather than silently fall back. +- Performance gate **fails**: two fresh clean-wheel probes on identical warmed + R1 workloads place Rust at `0.69x-0.83x` Python throughput. The first probe + was `2.5499s` vs `1.9472s` low-order (`0.764x`) and `2.7063s` vs `1.9980s` + high-churn (`0.738x`); the repeat retained the direction and exact final + equity/fill counts. Peak RSS was also not lower (Rust `239.81/250.89 MB` vs + Python `238.41/245.23 MB` in the repeat). `auto` remains Python and + `quantbt-native` remains unpublished/experimental. +- Root cause is now measured rather than speculative: the R2 adapter crosses + PyO3 once per callback bar and materializes a `PyDict` plus Python event and + active-order payload processing on that path. `PreparedMarketCore` removes + the per-trial market copy but cannot compensate for per-bar boundary churn. + Do not start R3-R5 on this architecture. A future Rust effort must first + provide a batched/compiled strategy or a compact typed step protocol and + demonstrate the documented speed/RSS thresholds. + +#### Phase 45C - Core Packaging Track A (Python Only) + +Detailed source of truth: + +- [QuantBT Native Event - Core Packaging, Python Hot Path and Batched Rust + Execution Plan](quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md) +- Read the guide sections `1`, `2`, `2.1` to `2.4`, `13.1`, `14`, `15`, and + `16` before changing packaging or release files. + +Status: **completed locally: `quantbt-engine==0.1.0` core packaging gates pass; +root compatibility source intentionally retained**. + +Scope: + +- Certify the Python core distribution independently from Rust/PyO3. +- Keep `src/quantbt` as the wheel/sdist canonical package source. +- Keep the existing root package mirror temporarily for rollback and editable + compatibility. Do not delete root files in this phase. +- Keep `tests/test_phase45a_source_tree_sync.py` as a SHA256 drift guard while + both source locations exist. +- Validate wheel and sdist metadata with `twine check`. +- Test clean wheel and sdist installs from outside the repository root. +- Test the unchanged public import: + `from quantbt import QuantBTEndpoint`. +- Test Pool Alpha-style editable/path compatibility without requiring + `PYTHONPATH` for installed-package smoke tests. +- Keep `quantbt-engine[native]` empty/unpublished until the separate Rust + batched path passes its performance and RSS gates. + +Required implementation: + +1. README installation uses `quantbt-engine==0.1.0` for the released core and + `uv sync --all-extras --dev` for repository development. +2. CI validates `uv build`, `twine check dist/*`, clean wheel install, and clean + sdist install on Python 3.11, 3.12, and 3.13. +3. Publish workflow repeats metadata and clean artifact checks before any OIDC + publication job. +4. Version gate remains `pyproject.toml 0.1.0` to tag `v0.1.0`. +5. No Rust implementation, endpoint, accounting, or fallback behavior changes + are allowed in this phase. + +Validation commands: + +```bash +uv sync --all-extras --dev +uv run pytest -q tests/test_phase42_packaging_layout.py \ + tests/test_phase42c_ci_release.py tests/test_phase45a_source_tree_sync.py +uv build +uv run twine check dist/* +``` + +Clean artifact gates: + +```bash +python3 -m venv /tmp/quantbt-phase45c-wheel +/tmp/quantbt-phase45c-wheel/bin/python -m pip install dist/quantbt_engine-*.whl +cd /tmp +/tmp/quantbt-phase45c-wheel/bin/python -c \ + "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" + +python3 -m venv /tmp/quantbt-phase45c-sdist +/tmp/quantbt-phase45c-sdist/bin/python -m pip install dist/quantbt_engine-*.tar.gz +cd /tmp +/tmp/quantbt-phase45c-sdist/bin/python -c \ + "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +Exit criteria: + +- `quantbt-engine==0.1.0` builds wheel and sdist from `src/quantbt`. +- `twine check dist/*` passes. +- Clean wheel and sdist imports resolve from `site-packages` outside the repo. +- Root source mirror remains present and SHA256-identical to `src/quantbt`. +- Existing alpha/notebook public imports remain unchanged. +- Core package release readiness is independent of `quantbt-native`. +- Rust remains explicit experimental and is not enabled by `auto`. + +Local implementation evidence (2026-08-01): + +- README now documents `pip install quantbt-engine==0.1.0` and the `uv` + development workflow; obsolete Poetry/PYTHONPATH package instructions were + removed from the installation/development section. +- CI and publish workflows now validate distribution metadata and both wheel + and sdist clean-install smoke paths. +- Root compatibility source was not deleted. The source mirror guard remains + active for safe future migration. +- Native release readiness remains a separate later phase described by the + linked v3 guide; Phase 45B.1 performance evidence still blocks native + publication/default rollout. + +#### Phase 45D - Python Native Event Zero-Object Hot Path + +Detailed source of truth: + +- [`quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md`](quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md) +- Read sections `1`, `3`, `4.1` to `4.9`, `6`, `8`, `10`, `11`, `12`, and + `13.2` to `13.4` before implementation. + +Status: **completed locally on 2026-08-01; Python parity/certification gates +pass.** + +Purpose: + +- Reduce Python Native Event score-path allocations and RSS without changing + endpoint behavior, strategy callbacks, accounting, or replay semantics. +- Establish the fair zero-object Python baseline that Rust must beat. Rust must + not be compared with an unnecessarily heavy Python audit path. + +Implementation plan: + +- `NativeEventScoreRequirements` now has explicit low-retention scalar and + compatibility ndarray contracts. Score paths conditionally allocate + accounting arrays; no dummy full-length arrays are used. +- `NativeEventScalarScoreResult` computes the same array-first metrics online + with stable moments, daily fallback/annualization, drawdown, trade count, + hit-rate, profit-factor, fee/funding/turnover and margin counters. +- Scalar prepared scores do not create pandas results, full fill/event + ledgers, terminal-order history, or emitted command tapes. Scheduled queues + and per-bar callback payloads are released as soon as callbacks consume them. +- `native_context_requirements` lets a strategy disable transient fills, + events, active-order snapshots, positions, and margin payloads explicitly. + Unknown declaration keys fail early. `NativeCommandBatch` is an optional + immutable callback wrapper; legacy list/tuple returns remain unchanged. +- The compatibility `PreparedNativeEventStrategyRunner.score()` call still + returns ndarray `NativeEventScoreResult`; `PreparedNativeEventStrategyEvaluator` + uses the scalar contract by default, so existing direct-path consumers do + not change behavior. +- Immutable prepared market arrays remain shared across trials. No endpoint + rename, default backend change, Rust routing, or root-source deletion was + introduced. + +Acceptance: + +- Optimized scalar Python score equals replay-certified accounting metrics on + single- and multi-day tapes, including edge cases with no daily return + sample. +- Public audit/full-report path remains unchanged; compatibility score tests + remain green. +- Exact parity holds for equity, positions, fees, funding, margin, + liquidation, trade count, and scalar lifecycle counters; lifecycle fill and + event parity remains covered by the existing replay/audit suite. +- Fresh-process benchmark `benchmarks/native_event/benchmark_phase45d_zero_object.py` + records audit, compatibility score, scalar score, CPU, and peak RSS. The + 100k-bar single-symbol probe recorded audit `10.2708s / 443.57 MB`, + compatibility score `7.7606s / 294.30 MB`, and scalar score + `7.3513s / 294.36 MB`, with exact final-equity parity. This is a + lower-retention/object-allocation result, not a claim that HWM RSS is lower + on every machine; the scalar score was faster in this fresh process. Rust + must beat this fair baseline before any native default/release claim. + +Validation completed: + +```text +tests/test_phase45d_native_event_zero_object.py: 5 passed +tests/test_phase34b_native_event_prepared_score.py: 8 passed +tests/test_phase34c_native_event_single_pass.py: 3 passed +tests/native_event: 49 passed, 4 skipped +tests/test_phase45a_source_tree_sync.py: 1 passed +``` + +Remaining follow-up is intentionally narrow: benchmark repeated 100k-bar +OCO/GTD, funding/liquidation, and multi-symbol profiles in isolated CI, and +replace the live Python order state with a fully primitive side-table only if +profiling proves it improves RSS without parity drift. Those are not blockers +for the scalar score correctness contract. + +Non-goals: + +- No Rust routing, no endpoint rename, no default backend change, and no + removal of the root compatibility source. + +#### Phase 45E - Rust Batched Full-Tape Execution + +Detailed source of truth: + +- [`quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md`](quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md) +- Read sections `1`, `3`, `5.1` to `5.3`, `6`, `7`, `8`, `9`, `10`, `11`, + `12`, and `13.5` to `13.8` before implementation. + +Status: **implemented (explicit experimental backend; native rollout still +blocked by the performance gate)**. + +Implementation plan: + +- Add an internal `RustBatchedRunner` beside `PythonReactiveRunner`. +- Keep `auto` on Python for arbitrary Python callbacks. +- Add prepared immutable Rust market ownership with one market preparation per + process/session family, not one copy per trial. +- Implement `run_tape_score(...)` as one PyO3 call for a complete static + command tape, returning scalar/typed score output. +- Implement `run_tape_audit(...)` with contiguous struct-of-arrays buffers for + fills and events; do not return per-bar `PyDict`, nested lists, or Python row + objects. +- Start with the advertised single-symbol R1/R2 scope, then add one feature + slice at a time: stop orders, amend/replace, reduce-only, and quantity + constraints. +- Preserve the replay-certified oracle as the source of truth. + +Implemented surface: + +- `RustBatchedRunner` owns one immutable `PreparedMarketCore` and creates a + fresh Rust session per static tape, so market arrays are not recopied per + trial. +- `compile_rust_batched_tape(...)` converts the canonical + `CompiledOrderCommandArrays` once into contiguous `(command_ptr, codes, + values, expiry)` buffers. +- `run_tape_score(...)` crosses PyO3 once and returns only scalar accounting and + lifecycle counters. +- `run_tape_audit(...)` crosses PyO3 once and returns contiguous SoA arrays for + fills and events; no per-bar Python dictionaries or nested Python rows cross + the boundary. +- `NativeEventBackend.prepare_rust_batched_runner(...)` is an opt-in helper; + public endpoint defaults and `auto` routing remain unchanged. +- The certified initial scope is single-symbol R1/R2 with immediate GTC + market/limit/stop commands, cancel/amend/replace, reduce-only, fee and + slippage. Unsupported funding, liquidation, quantity constraints, package + orders, expiry, non-GTC TIF and multi-symbol inputs raise before execution. +- The static tape contract uses the native-event v2 effective bar timeline; + parity tests intentionally place the first executable command at bar 1. + +Acceptance: + +- Same market, commands, and config produce exact lifecycle/accounting parity. +- Discrete parity has no tolerance: effective bar, order sequence, fill, + rejection, quantity, OCO/expiry state and liquidation decision must match. +- Numeric parity uses exact equality where possible and `atol=1e-12` only when + operation ordering requires it. +- Rust wheel is built and tested in a clean environment, but remains explicit + experimental until end-to-end performance gates pass. + +Validation completed for this phase: + +```text +cargo fmt --all +cargo check +local maturin release build + editable wheel install +tests/native_event/test_rust_batched_full_tape.py: 5 passed +tests/native_event: 47 passed, 2 skipped +tests/test_phase45a_source_tree_sync.py + tests/test_phase45d_native_event_zero_object.py: 6 passed +full regression: 623 passed, 3 skipped, 25 warnings +clean manylinux_2_34 CPython 3.12 wheel import smoke: passed +``` + +The Rust/Python full-tape parity test uses the same prepared market, command +tape and account config. It asserts exact lifecycle counts and `atol=1e-12` +for equity, positions, fees, turnover and fill prices. The audit path also +asserts every returned buffer is C-contiguous. A performance claim is not made +as a release claim yet; Phase 45F owns the isolated multi-scenario benchmark +and release gate. The Phase 45E smoke profile (`100,000` bars, `40` GTC +commands, five warm repetitions) recorded Rust score `0.005314s` versus Python +v2 `0.024851s` median (`4.68x` in this process) with exact final-equity and +fill-count parity. This is evidence for the batched boundary, not a substitute +for the required churn/RSS/multi-symbol gate. + +Non-goals: + +- Do not compile arbitrary Python strategy callbacks. +- Do not add sparse callbacks or native strategy programs in this phase. +- Do not route `auto` to Rust or publish `quantbt-native` from a partial slice. + +#### Phase 45F - Sparse Runner, Certification, And Native Release Gate + +Detailed source of truth: + +- [`quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md`](quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md) +- Read sections `5.4`, `5.5`, `9`, `10`, `11`, `12`, `13.9`, `13.10`, `14`, + `15`, and `16` before implementation. + +Status: **implemented; native release gate not passed**. + +Implementation plan: + +- Add a stateful `RustBatchedSession.run_until(...)` so Rust runs many bars + continuously and Python receives only sparse fill/event wake arrays plus an + end-of-chunk marker. This is intentionally a static command-tape + continuation, not an arbitrary Python callback runner. +- Extend feature slices in guide order: parent/OCO, GTD/IOC/FOK, + funding, margin/liquidation, then multi-symbol. +- Consider a restricted numeric native strategy program only after tape and + sparse paths pass parity; arbitrary Python is never implicitly compiled. +- Add process-isolated profiling for PyO3 calls, callbacks, command/event + buffers, kernel time, decode time, peak RSS, post-run RSS, and repeated-run + plateau. +- Run at least five measured repetitions after warm-up on all guide scenarios. +- Build manylinux CPython 3.11-3.13 wheels and perform combined installed-wheel + parity before any native release. + +Release gate: + +```text +100% lifecycle/accounting parity +median end-to-end speedup >= 1.50x +high-churn speedup >= 2.00x +peak RSS reduction >= 40% +repeated-run RSS plateau +``` + +If any gate fails, Rust stays explicit experimental, `auto` stays Python, and +the failure plus evidence is recorded here. No native extra or PyPI claim is +allowed before the gate passes. + +Implementation and certification evidence: + +- Added `RustBatchedSession` and `RustBatchedChunkResult` to the canonical + `src/quantbt` package and kept the root compatibility mirror synchronized. +- The session keeps one Rust lifecycle/accounting state across consecutive + chunks, caches the compiled command tape, releases the GIL for each long + chunk, and returns only contiguous sparse fill/event/wake arrays and scalar + accounting. It does not materialize dense equity or position paths. +- Added `tests/native_event/test_rust_batched_sparse.py`: chunk boundaries, + cumulative accounting, exact fill/event ledger replay, wake filtering, and + invalid session transitions all pass. +- Added the process-isolated gate + `benchmarks/native_event/benchmark_phase45f_release_gate.py` and evidence + `benchmarks/native_event/phase45f_release_gate.json`. +- Gate run: `100,000` bars, low/high churn, five warm repetitions per backend; + lifecycle smoke parity passed; speedup was `5.09x` low-churn and `79.06x` + high-churn; repeated RSS plateau passed; the lower process peak RSS + reduction was `18.3%`, so the required `40%` RSS gate failed. +- The local installed wheel was rebuilt and exercised on CPython 3.12. The + CPython 3.11/3.13 manylinux matrix remains a release follow-up, not an + unverified claim. + +Scope integrity note: + +- Phase45F adds only the static single-symbol sparse continuation needed by + the linked guide. It does not expand feature semantics, route `auto` to + Rust, or claim portfolio/arbitrage/native-program parity. +- The RSS miss is a release blocker, not a domain fallback: the explicit Rust + backend remains available for the certified feature slice, while unsupported + features continue to raise before execution. + +Non-goals: + +- No silent semantic fallback from an unsupported Rust feature. +- No claim of portfolio/arbitrage/native-program parity until those feature + slices have their own saved evidence bundles. + +### Phase 42-44 Definition Of Done + +This roadmap is complete only when: + +- `pip install quantbt-engine` works independently. +- `from quantbt import QuantBTEndpoint` remains unchanged. +- Existing alphas do not require migration. +- `pool_alpha` can use editable/path dependency and later PyPI dependency. +- Clean wheel install and public import smoke pass. +- GitHub Release can publish through OIDC, not long-lived tokens. +- Missing Rust wheel falls back to Python/Numba. +- Rust version mismatch fails/falls back clearly. +- Rust path passes lifecycle/accounting parity before any default rollout. +- Candidate optimization results can rerun through replay-certified oracle. +- Repeated prepared-score runs reach RSS plateau. +- End-to-end benchmark proves benefit before Rust default. +- `main` remains stable/releasable; no tag is cut from `dev`. + +### Phase 42-44 Agent Execution Addendum + +Purpose: + +- This addendum is the executable checklist for future agents. +- The detailed source of truth remains: + - Phases 42-44: `upgrade/quantbt_engine_packaging_pypi_pyo3_final_plan_v2_expanded.md` + - Phase 45 and later: [`upgrade/quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md`](quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md) +- Agents must read the referenced sections before implementing each phase. +- Do not treat the summary above as enough context to code from. +- If this addendum and the detailed guide conflict, follow the detailed guide + and update this file with the discovered correction. + +#### Global Execution Protocol + +Hard rule: + +- Before starting every Phase 42-44 phase, the agent must first read this + `Phase 42-44 Agent Execution Addendum` and the detailed guide sections listed + under that specific phase. This is mandatory even if the agent read it in a + previous turn. + +Before any phase: + +1. Confirm branch and remote state: + ```bash + git status --short --branch + git log --oneline --decorate --max-count=8 + git fetch --all --prune + ``` +2. Work from `dev`, not `main`. +3. Use feature branches exactly as the guide specifies. +4. Do not rewrite shared history: + - no `commit --amend` after remote push; + - no force-push to `main`; + - prefer follow-up commits. +5. Keep public imports unchanged: + ```python + from quantbt import QuantBTEndpoint + ``` +6. Keep endpoints unchanged unless the guide explicitly allows an internal-only + selector or environment variable. +7. Preserve domain semantics first; optimize only after parity. +8. Every phase must end with: + - tests run; + - exact command output summary; + - implementation note; + - remaining debt note; + - commit. + +#### Phase 42A Detailed Guide - Packaging Baseline + +Read first: + +- Guide sections `1` to `5`. +- Guide section `24`, especially `Phase 1`. +- Guide section `26.1`, `26.2`, `26.3`, `26.4`, `26.5`. +- Guide section `42`. + +Branch: + +```bash +git checkout dev +git pull --ff-only origin dev +git checkout -b feat/quantbt-engine-packaging +``` + +Required artifacts: + +- Baseline tag: + ```bash + git tag pre-quantbt-engine-packaging-20260731 + ``` +- Baseline note in this file containing: + - commit SHA; + - branch; + - Python version; + - dependency versions for NumPy, Pandas, Numba, Optuna if installed; + - full test command and result; + - current Native Event benchmark command and result if benchmark exists; + - current import mode: root package, not `src/quantbt` yet. + +Implementation rules: + +- Do not move source in Phase 42A. +- Do not add `src/quantbt` yet unless Phase 42A baseline is complete. +- Do not edit Native Event implementation. +- Do not edit endpoint behavior. +- Do not publish anything. + +Validation commands: + +```bash +git status --short --branch +python --version +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q quantbt/tests +``` + +Optional if benchmark exists: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python3 \ + quantbt/benchmarks/native_event/benchmark_reactive_session.py +``` + +Exit criteria: + +- Baseline recorded. +- Rollback tag exists locally. +- Full tests pass or failure is documented as pre-existing with exact failing + tests. +- No production code changed. + +Phase 42A baseline captured on 2026-07-31 UTC: + +```text +branch: feat/quantbt-engine-packaging +source branch: dev +baseline commit SHA: 6762cd7ac872e6344fbab13dc23ca790733990ab +origin/dev SHA after fetch/pull: 6762cd7ac872e6344fbab13dc23ca790733990ab +bobby-origin/dev SHA after fetch: 6762cd7ac872e6344fbab13dc23ca790733990ab +rollback/reference tag: pre-quantbt-engine-packaging-20260731 +origin tag verification: refs/tags/pre-quantbt-engine-packaging-20260731 -> 6762cd7ac872e6344fbab13dc23ca790733990ab +current import mode: root package layout, no src/quantbt package layout yet +system python3: Python 3.10.4 +poetry python3: Python 3.12.13 +numpy: 2.2.6 +pandas: 2.3.3 +numba: 0.65.1 +optuna: 4.8.0 +``` + +Baseline protocol commands: + +```bash +git fetch --all --prune +git pull --ff-only origin dev +git tag pre-quantbt-engine-packaging-20260731 +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q quantbt/tests +``` + +Baseline full test result: + +```text +561 passed, 1 skipped, 25 warnings in 54.19s +``` + +Baseline Native Event benchmark commands: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python3 \ + benchmarks/run_phase34a_native_event_memory.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python3 \ + benchmarks/run_phase34b_native_event_prepared_score.py +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run python3 \ + benchmarks/run_phase34c_native_event_single_pass.py +``` + +Baseline Native Event benchmark result: + +```text +Phase 34A artifact memory: +- minimal: 0.334564s, peak RSS 339.957 MB, commands 3100, fills 3000, events 6100 +- standard: 0.501138s, peak RSS 344.855 MB, commands 3100, fills 3000, events 6100 +- audit: 0.638208s, peak RSS 348.371 MB, commands 3100, fills 3000, events 6100 + +Phase 34B prepared score: +- public audit seconds: 2.869046 +- prepared score seconds: 1.846037 +- speedup: 1.554x +- peak RSS: 335.645 MB +- metric parity: True +- prepared endpoint result retained: False + +Phase 34C single-pass: +- replay-certified seconds: 2.352882 +- single-pass seconds: 1.977425 +- speedup: 1.190x +- peak RSS: 347.438 MB +- accounting parity: True +``` + +Phase 42A implementation note: + +- No package source was moved. +- No `src/quantbt` layout was created. +- No Native Event implementation was changed. +- No endpoint behavior was changed. +- Only the implementation roadmap/baseline notes and generated benchmark + baseline artifacts changed. + +#### Phase 42B Detailed Guide - Python Package Layout + +Read first: + +- Guide section `6`: target repo structure. +- Guide section `7`: migration rules into `src/quantbt`. +- Guide section `8`: `pyproject.toml`. +- Guide section `23`: pool_alpha migration. +- Guide section `25`: Definition of Done. + +File-level patch order: + +1. Add packaging metadata: + - `pyproject.toml`; + - package metadata for PyPI distribution `quantbt-engine`; + - Python import module remains `quantbt`; + - exact dependencies must come from current repo/environment, not guessed + major upgrades. +2. Add source layout: + - `src/quantbt/`; + - `src/quantbt/py.typed`; + - copy current package source into `src/quantbt` without rewriting logic. +3. Keep root source during migration: + - do not delete root modules until wheel install, public import smoke, and + pool_alpha smoke pass. +4. Fix only import/path issues that are caused by package layout. +5. Add packaging smoke tests if missing. + +Implementation rules: + +- Copy/move safely; do not manually rewrite modules. +- Do not introduce compatibility shim as a long-term source of truth. +- If a root shim is temporarily needed, document it as temporary and add a + removal gate. +- Do not change domain/accounting/native-event semantics. +- Do not optimize runtime in this branch. +- Do not add Rust. + +Validation commands: + +```bash +uv sync --all-extras --dev +uv run pytest +uv build +python -m pip install dist/quantbt_engine-*.whl +python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +Clean import smoke must run outside repository root: + +```bash +cd /tmp +python -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +Pool Alpha compatibility smoke: + +```bash +cd /root/bobby/pool_alpha +poetry run python3 -c "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +Exit criteria: + +- Wheel builds. +- Wheel installs in a clean environment. +- Public import unchanged. +- Existing tests pass through installed package path. +- pool_alpha can still import QuantBT. +- Backtest fingerprints are unchanged for representative fixtures. + +Phase 42B implementation note captured on 2026-07-31 UTC: + +```text +branch: feat/quantbt-engine-packaging +distribution name: quantbt-engine +public import module: quantbt +package layout: src/quantbt +root source status: retained during migration +py.typed: src/quantbt/py.typed +build backend: setuptools.build_meta +uv version used for validation: uv 0.12.0 +uv cache override: UV_CACHE_DIR=/tmp/uv-cache +native extra status: intentionally empty until Phase 44 creates quantbt-native +``` + +Phase 42B source/layout changes: + +- Added `pyproject.toml` with PEP 621 metadata for the PyPI distribution + `quantbt-engine`. +- Kept the Python import surface unchanged: + ```python + from quantbt import QuantBTEndpoint + ``` +- Copied current runtime source into `src/quantbt` without rewriting domain + logic. +- Added `src/quantbt/benchmarks` because existing tests and certification + helpers currently import `quantbt.benchmarks.*`; this preserves compatibility + with the root package surface during migration. Only benchmark helper Python + files, `README.md`, and `phase7_thresholds.json` are kept in package source; + generated benchmark outputs are not copied. +- Added `src/quantbt/py.typed`. +- Added `tests/test_phase42_packaging_layout.py` to lock: + - distribution name vs import module; + - `src/quantbt` package layout; + - root source retained until migration exit gates pass. +- Adjusted `.gitignore` so root benchmark artifacts remain ignored while + `src/quantbt/benchmarks` can be tracked as package compatibility source. + +Phase 42B dependency policy: + +- Dependency ranges were pinned around the currently validated Poetry baseline + instead of broad major ranges: + - NumPy `>=2.2.6,<2.3`; + - Pandas `>=2.3.3,<2.4`; + - Numba `>=0.65.1,<0.66`; + - Optuna `>=4.8.0,<4.9`; + - Matplotlib `>=3.10.9,<3.11`; + - scikit-learn `>=1.8.0,<1.9`; + - NautilusTrader `>=1.230.0,<1.231`. +- This avoids the package env drifting from the certified baseline, for + example accidentally resolving NumPy `2.4.x`. + +Phase 42B validation commands and results: + +```bash +MPLCONFIGDIR=/tmp PYTHONPATH=/root/bobby/pool_alpha poetry run pytest -q \ + quantbt/tests/test_phase42_packaging_layout.py +``` + +```text +3 passed in 2.96s +``` + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv sync --all-extras --dev +``` + +```text +Resolved 95 packages +Checked 93 packages +``` + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv run pytest -q +``` + +```text +564 passed, 1 skipped, 25 warnings in 48.74s +``` + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv build +``` + +```text +Successfully built dist/quantbt_engine-0.1.0.tar.gz +Successfully built dist/quantbt_engine-0.1.0-py3-none-any.whl +``` + +```bash +MPLCONFIGDIR=/tmp poetry run python3 -m pip install --force-reinstall --no-deps \ + /root/bobby/pool_alpha/quantbt/dist/quantbt_engine-0.1.0-py3-none-any.whl +``` + +```text +Successfully installed quantbt-engine-0.1.0 +``` + +```bash +cd /tmp +MPLCONFIGDIR=/tmp /root/bobby/pool_alpha/.venv/bin/python -c \ + "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +```text + +``` + +```bash +cd /tmp +MPLCONFIGDIR=/tmp /root/bobby/pool_alpha/.venv/bin/python -c \ + "from quantbt.benchmarks.run_phase7 import PROFILES; print(sorted(PROFILES)[:3])" +``` + +```text +['large', 'smoke', 'standard'] +``` + +```bash +cd /root/bobby/pool_alpha +MPLCONFIGDIR=/tmp poetry run python3 -c \ + "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +```text + +``` + +Phase 42B validation caveat: + +- A first `uv run pytest -q` attempt was accidentally launched from the + `pool_alpha` parent directory and collected unrelated MLops/alpha tests. + That failure was unrelated to QuantBT packaging. The accepted gate is the + rerun from `/root/bobby/pool_alpha/quantbt`, where `pyproject.toml` + `testpaths = ["tests"]` is active. + +Phase 42B remaining debt: + +- Root source is still retained intentionally. It should only be removed after + a later migration gate confirms editable install, wheel install, pool_alpha + compatibility, and import-path parity across the service notebooks. +- `native` extra remains empty until Phase 44 creates and publishes the + `quantbt-native` PyO3 package. + +#### Phase 42C Detailed Guide - CI, Release Workflow, PyPI Prep + +Read first: + +- Guide section `16`: versioning. +- Guide section `17`: CI. +- Guide section `18`: PyPI release through Trusted Publishing/OIDC. +- Guide section `19`: publish package workflow. +- Guide section `21`: token fallback rules. +- Guide section `22`: GitHub release procedure. +- Guide section `41`: workflow correction/addendum. +- Guide section `42`: main/dev release policy. + +File-level patch order: + +1. Add or update `.github/workflows/*` for core package: + - Python 3.11; + - Python 3.12; + - Python 3.13; + - `uv sync`; + - tests; + - wheel build; + - clean wheel install; + - public import smoke. +2. Add release workflow skeleton: + - publish only on GitHub Release `published`; + - use protected environment `pypi`; + - use OIDC/trusted publishing, not long-lived token by default. +3. Add manual/TestPyPI token fallback docs only: + - do not put token in repo; + - do not require token for normal release path. +4. Add pool_alpha dependency migration docs: + - local editable/path dependency during development; + - `quantbt-engine` PyPI dependency after release. + +Implementation rules: + +- Do not publish to real PyPI without explicit user approval. +- Do not tag from `dev`. +- Do not make push-to-main publish automatically. +- GitHub Release from `main` is the only intended publish trigger. +- Native package workflow must not assume core wheel exists unless the workflow + builds/downloads/installs it explicitly. + +Validation commands: + +```bash +uv sync --all-extras --dev +uv run pytest +uv build +python -m pip install dist/quantbt_engine-*.whl +cd /tmp && python -c "from quantbt import QuantBTEndpoint" +``` + +Exit criteria: + +- CI workflow is syntactically valid. +- Local CI-equivalent commands pass. +- Release workflow is prepared but not triggered. +- No PyPI publish happened. +- Release policy documented. + +Phase 42C implementation note captured on 2026-08-01 UTC: + +```text +branch: feat/quantbt-engine-packaging +publish status: not published +tag status: no release tag created +workflow added: .github/workflows/publish.yml +workflow updated: .github/workflows/ci.yml +release environment: pypi +publish trigger: GitHub Release published event only +trusted publishing: OIDC / id-token write +token fallback: docs only, no token added +``` + +Phase 42C source/layout changes: + +- Replaced the old `PYTHONPATH`-based CI with package-layout CI: + - Python matrix `3.11`, `3.12`, `3.13`; + - `uv sync --all-extras --dev`; + - `uv run pytest -q`; + - `uv build`; + - clean wheel install smoke; + - public import smoke; + - Pool Alpha style import smoke. +- Added `.github/workflows/publish.yml` for `quantbt-engine`: + - trigger is only `release: published`; + - build/test jobs must pass first; + - artifact upload/download is explicit; + - publish job uses protected environment `pypi`; + - publish job uses PyPI Trusted Publishing/OIDC; + - no long-lived PyPI token is referenced. +- Added `tools/check_release_version.py`: + - checks `GITHUB_REF_NAME == v{pyproject.version}` when running under GitHub; + - allows local execution without `GITHUB_REF_NAME`. +- Added `docs/release_packaging.md` and linked it from `docs/README.md`. +- Updated README Python badge to `3.11+`. +- Updated `pyproject.toml`: + - `requires-python = ">=3.11,<3.14"`; + - moved `matplotlib` and `seaborn` into core dependencies because public + import currently imports `quantbt.viz`; + - kept `nautilus-trader` as optional validation dependency with + `python_version >= "3.12"` marker because NautilusTrader `1.230.0` does + not support Python 3.11. + +Phase 42C validation commands and results: + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv lock +``` + +```text +Resolved 100 packages +``` + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv run pytest -q \ + tests/test_phase42_packaging_layout.py tests/test_phase42c_ci_release.py +``` + +```text +6 passed in 4.94s +``` + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv sync --all-extras --dev +``` + +```text +Resolved 100 packages +Checked 93 packages +``` + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv run pytest -q +``` + +```text +567 passed, 1 skipped, 25 warnings in 49.23s +``` + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv build +``` + +```text +Successfully built dist/quantbt_engine-0.1.0.tar.gz +Successfully built dist/quantbt_engine-0.1.0-py3-none-any.whl +``` + +Clean wheel install smoke: + +```bash +env UV_CACHE_DIR=/tmp/uv-cache \ + /root/bobby/pool_alpha/.venv/bin/uv venv --clear \ + /tmp/quantbt-wheel-smoke-42c \ + --python /root/bobby/pool_alpha/.venv/bin/python +env UV_CACHE_DIR=/tmp/uv-cache \ + /root/bobby/pool_alpha/.venv/bin/uv pip install \ + --python /tmp/quantbt-wheel-smoke-42c/bin/python \ + /root/bobby/pool_alpha/quantbt/dist/quantbt_engine-0.1.0-py3-none-any.whl +cd /tmp +MPLCONFIGDIR=/tmp /tmp/quantbt-wheel-smoke-42c/bin/python -c \ + "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +```text +Installed 18 packages + +``` + +Version gate smoke: + +```bash +env UV_CACHE_DIR=/tmp/uv-cache MPLCONFIGDIR=/tmp \ + /root/bobby/pool_alpha/.venv/bin/uv run python tools/check_release_version.py +``` + +```text +quantbt-engine version check passed: 0.1.0 +``` + +Pool Alpha compatibility smoke: + +```bash +cd /root/bobby/pool_alpha +MPLCONFIGDIR=/tmp poetry run python3 -c \ + "from quantbt import QuantBTEndpoint; print(QuantBTEndpoint)" +``` + +```text + +``` + +Phase 42C remaining debt: + +- No real PyPI/TestPyPI publish has been performed. Publishing still requires + explicit user approval, a protected GitHub `pypi` environment, and a GitHub + Release from `main`. +- `quantbt-native` workflow is intentionally not added yet. Phase 44 must build + and test the core `quantbt-engine` artifact before any native wheel publish. +- Root source remains retained until a later migration/removal gate proves + Pool Alpha notebooks/services are using installed/editable package layout + safely. + +#### Phase 43A Detailed Guide - Native Event Behavior Freeze + +Read first: + +- Guide section `27`: implementation map and public contract. +- Guide section `28`: NE-0 behavior freeze. +- Guide section `34`: lifecycle parity. +- Guide section `39`: required test names. +- Guide section `40`, PR/commit 1: tests only. +- Guide section `43`: Native Event core DoD. + +Branch: + +```bash +git checkout dev +git pull --ff-only origin dev +git checkout -b perf/native-event-python-hotpath +``` + +Required files to add: + +- `tests/native_event/test_reactive_callback_contract.py` +- `tests/native_event/test_reactive_lifecycle_parity.py` +- `tests/native_event/test_reactive_accounting_parity.py` +- `tests/native_event/test_reactive_memory_lifetime.py` +- `tests/native_event/test_reactive_backend_matrix.py` +- `benchmarks/native_event/benchmark_reactive_session.py` + +Required golden cases: + +- market order; +- limit order; +- stop-market; +- stop-limit; +- GTC; +- GTD; +- IOC; +- FOK; +- PLACE; +- AMEND; +- REPLACE; +- CANCEL; +- CANCEL_ALL; +- reduce-only; +- parent first-fill; +- parent full-fill; +- OCO; +- quantity quantization; +- insufficient margin; +- funding; +- intrabar liquidation; +- after-funding liquidation; +- after-order liquidation; +- multi-symbol. + +Required fingerprint fields: + +- command effective bar; +- command sequence; +- event type/status/reject reason; +- fill bar/symbol/side/qty/price/fee; +- position after each bar; +- equity after each bar; +- margin after each bar; +- liquidation result. + +Implementation rules: + +- Tests only first. +- Do not change `native_event.py` implementation in the tests-only commit. +- Do not use DataFrame string representation as fingerprint. +- Randomized tests must use fixed seeds and print the seed on failure. +- Reference oracle is `replay_certified`. +- Python single-pass must pass before Rust is attempted. + +Validation commands: + +```bash +pytest -q tests/native_event +python benchmarks/native_event/benchmark_reactive_session.py +``` + +Exit criteria: + +- Behavior/timing contract locked by tests. +- Baseline benchmark recorded: + - wall time; + - CPU time if available; + - peak RSS; + - post-run RSS; + - command count; + - event count; + - fill count; + - max active orders. +- No implementation changed before baseline tests exist. + +#### Phase 43B Detailed Guide - Native Event Python Hotpath Optimization + +Read first: + +- Guide section `29`: score retention and result path. +- Guide section `30`: queue and object lifetime. +- Guide section `31`: context allocation. +- Guide section `32`: beneficial indexes. +- Guide section `33`: margin/accounting cache. +- Guide section `35`: prepared runner integration. +- Guide section `40`, PR/commit 2 to PR/commit 5. +- Guide section `43`: Native Event core DoD. + +Patch order: + +1. Retention and queue cleanup: + - mostly `quantbt/backends/native_event.py` or + `src/quantbt/backends/native_event.py` after packaging; + - pop consumed scheduled commands; + - release fills/events after callback; + - separate active order state from terminal history; + - add one terminal transition helper. +2. Context and margin cache: + - cache symbols tuple; + - cache size helper; + - use empty tuple constants; + - make prepared market arrays read-only after build; + - use OHLCV row views, not copies; + - keep position snapshot semantics; + - refresh close margin once per bar; + - dirty margin after fill. +3. Parent/OCO/expiry indexes: + - children by parent ID; + - members by OCO group; + - expiry bucket by bar; + - avoid changing order priority. +4. Prepared score integration: + - internal score requirements; + - no pandas materialization in score path; + - mutable session reset per trial; + - evaluator does not retain last strategy/result/session; + - selected candidate reruns replay-certified audit. + +Implementation rules: + +- No `fastmath`. +- No formula simplification. +- Do not change callback timing. +- Do not change command next-bar semantics. +- Do not change same-bar command ordering. +- Do not change public `BacktestResultV2`. +- Do not change public endpoint signatures. +- If a speed optimization changes lifecycle/accounting parity, revert it. +- Add benchmark evidence before claiming performance improvement. + +Required parity checks: + +- lifecycle state exact; +- command count/effective bar/order exact; +- reject codes exact; +- fill side/qty/price/fee exact; +- parent activation exact; +- OCO cancellation exact; +- expiry exact; +- liquidation flag/bar/reason exact; +- positions/equity/fees/funding/turnover/margin exact or `atol <= 1e-12` + only when float operation order is the sole difference. + +Validation commands: + +```bash +pytest -q tests/native_event +pytest -q tests/test_phase34*.py +python benchmarks/native_event/benchmark_reactive_session.py +pytest -q quantbt/tests +``` + +Exit criteria: + +- Lifecycle parity 100%. +- Accounting parity 100%. +- Repeated prepared-score RSS plateaus. +- Score path avoids unnecessary pandas/report materialization. +- Public audit path remains compatible. +- Benchmark report shows runtime/RSS before vs after. + +#### Phase 44A Detailed Guide - PyO3 R0 Scaffold + +Read first: + +- Guide section `9`: Rust/PyO3 subpackage. +- Guide section `10`: Rust scope and boundary. +- Guide section `36.1` and `36.2`: adapter and rollout. +- Guide section `37`, Slice R0. +- Guide section `40`, PR/commit 6. +- Guide section `41`: native workflow correction. +- Guide section `42`: release policy. + +Branch: + +```bash +git checkout dev +git pull --ff-only origin dev +git checkout -b feat/native-event-pyo3 +``` + +Required files: + +- `rust/native_event/Cargo.toml` +- `rust/native_event/pyproject.toml` +- `rust/native_event/src/lib.rs` +- later split candidates: + - `rust/native_event/src/session.rs` + - `rust/native_event/src/types.rs` + - `rust/native_event/src/matching.rs` + - `rust/native_event/src/accounting.rs` +- Python adapter: + - `quantbt/backends/_native_event_rust.py` + - or `src/quantbt/backends/_native_event_rust.py` after packaging. + +Implementation rules: + +- R0 exposes only version/capabilities/import smoke. +- Do not route production runs through Rust in R0. +- Keep `auto -> python`. +- `rust` explicit opt-in must raise clearly if extension is absent or version + incompatible. +- No domain logic in adapter. +- No async runtime, message bus, actor model, Rayon, unsafe optimization, or + fast-math. + +Validation commands: + +```bash +cargo fmt --check +cargo clippy -- -D warnings +cargo test +maturin build --release +python -c "import _quantbt_native" +pytest -q tests/native_event +``` + +Exit criteria: + +- Rust crate builds. +- Python fallback works without extension. +- Explicit Rust mode fails clearly when unavailable. +- Version/capability check exists. +- No production behavior changed. + +#### Phase 44B Detailed Guide - PyO3 R1 POC + +Read first: + +- Guide section `12`: PyO3 POC. +- Guide section `36.3` to `36.11`: Rust session API and boundary. +- Guide section `37`, Slice R1. +- Guide section `38`: benchmark and stop conditions. + +R1 supported scope: + +- single symbol; +- PLACE; +- CANCEL; +- market; +- limit; +- GTC; +- fee; +- slippage; +- position/equity accounting. + +Python/Rust boundary: + +- one Rust call per bar; +- no per-fill/per-fee/per-margin PyO3 calls; +- Python compiles command batches into contiguous numeric buffers; +- Rust returns compact arrays/scalars; +- Python materializes events/context only at callback boundary; +- strategy callbacks remain Python. + +Bar 0 flow must match guide: + +1. `step(0, empty commands)`; +2. build context 0; +3. `initialize(context0)`; +4. `on_bar_close(context0)`; +5. concatenate initialize commands before bar0 commands; +6. execute them at bar 1. + +Opt-in behavior: + +- `QUANTBT_NATIVE_BACKEND=rust` may route R1-supported cases to Rust. +- `auto` remains Python. +- Unsupported Rust feature must raise/fallback according to selected backend, + never silently change semantics. + +Validation commands: + +```bash +pytest -q tests/native_event +pytest -q tests/native_event -k rust +python benchmarks/native_event/benchmark_reactive_session.py --backend python +python benchmarks/native_event/benchmark_reactive_session.py --backend rust +``` + +Exit criteria: + +- Same commands. +- Same fills. +- Same positions. +- Same fee/slippage. +- Same final equity. +- Median end-to-end speedup >= 1.20x. +- High-churn speedup >= 1.50x. +- Peak RSS reduction >= 30%. +- Repeated-run RSS plateau. + +Stop conditions: + +- Boundary conversion dominates runtime. +- Strategy Python time dominates and Rust cannot move needle. +- RSS does not improve. +- Parity requires loose tolerance. +- Maintenance complexity exceeds benefit. + +Status: implemented on `feat/quantbt-engine-packaging`; local native wheel +build/parity remains pending the Rust toolchain and Maturin CI gate. + +Implemented: + +- Rust `ReactiveSessionCore` now owns single-symbol R1 market arrays, active + order state, GTC market/limit matching, PLACE/CANCEL lifecycle, fee, + slippage, PnL, position, equity, and basic post-cost margin acceptance. +- The Python adapter compiles per-bar `OrderCommand` batches into contiguous + `int64` code and `float64` value arrays, preserves command identity through + a session-local interner, and materializes callback objects only at the + boundary. +- Explicit `QUANTBT_NATIVE_BACKEND=rust` routes to R1 only for: one symbol, + no funding, no quantity constraints, immediate non-contingent orders, GTC, + and `maintenance_ratio=0.0`. Unsupported scope raises rather than falling + back silently. `auto` remains Python. +- Rust path is compared with `replay_certified` via an installed-wheel parity + test. The native CI workflow now builds the wheel, installs it into the core + test environment, and runs `tests/native_event -k rust`. + +Remaining R1 certification gate: + +- This local machine has no Rust toolchain/Maturin, therefore only the Python + adapter/buffer/fake-extension boundary tests run locally. The real Rust + compile, Python-Rust differential parity, RSS plateau, and speed gates must + pass in `Native R0` CI before R1 can be called certified or considered for + further Rust expansion. + +#### Phase 44C Detailed Guide - PyO3 Expansion And Release Gate + +Read first: + +- Guide section `13`: Rust expansion order. +- Guide section `37`, Slices R2 to R5. +- Guide section `38`: benchmark gates. +- Guide section `41`: workflow correction. +- Guide section `42`: main/dev release policy. + +Expansion order: + +1. Stop orders. +2. AMEND. +3. REPLACE. +4. Reduce-only. +5. Quantity constraints. +6. Parent-child. +7. OCO. +8. GTD. +9. IOC. +10. FOK. +11. Funding. +12. Margin/liquidation. +13. Multi-symbol. + +Implementation rules: + +- One feature slice at a time. +- Every slice must add differential parity tests first or in the same commit. +- Do not enable Rust as default `auto` after a partial POC. +- Do not publish native wheels until combined core+native parity passes. +- Keep Python/Numba fallback and replay oracle. + +Native wheel CI requirements: + +- Linux x86-64 first. +- Build native wheel. +- Build/install core wheel from same tag/ref. +- Install both wheels. +- Run native parity tests. +- Run RSS benchmark smoke. + +Release gate: + +```text +feature branches -> dev -> release branch -> main -> GitHub Release -> PyPI +``` + +Do not: + +- tag from `dev`; +- publish from uncommitted local tree; +- publish on push to `main`; +- publish native package if core compatible package has not passed combined + wheel install tests. + +Exit criteria: + +- All Rust-supported features have exact lifecycle/accounting parity. +- Unsupported features fallback/raise clearly. +- Native package remains optional. +- Core package installs without Rust. +- `quantbt-engine[native]` installs both packages when wheels are available. + +#### Required Test Name Checklist + +Agents should map the detailed guide section `39` to concrete tests. Minimum +test names: + +- `test_native_event_initialize_and_bar0_ordering` +- `test_native_event_commands_effective_next_bar` +- `test_native_event_same_bar_command_sequence` +- `test_native_event_cancel_replace_amend_parity` +- `test_native_event_parent_activation_parity` +- `test_native_event_oco_parity` +- `test_native_event_gtd_expiry_bar_parity` +- `test_native_event_ioc_fok_parity` +- `test_native_event_reduce_only_parity` +- `test_native_event_quantity_constraint_parity` +- `test_native_event_funding_parity` +- `test_native_event_margin_sequence_parity` +- `test_native_event_liquidation_priority_parity` +- `test_native_event_multisymbol_parity` +- `test_native_event_score_no_pandas_materialization` +- `test_native_event_score_does_not_retain_terminal_orders` +- `test_native_event_consumed_queues_are_released` +- `test_native_event_repeated_score_rss_plateaus` +- `test_native_event_python_vs_replay_randomized` +- `test_native_event_rust_vs_replay_randomized` +- `test_native_event_backend_fallback_without_extension` +- `test_native_event_backend_version_mismatch_falls_back` + +#### Phase 45C Detailed Guide - Core Packaging Track A + +Read first, every time this phase is resumed: + +- [`quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md`](quantbt_engine_packaging_pypi_pyo3_final_plan_v3_branch_audit.md), + sections `1`, `2`, `2.1` to `2.4`, `13.1`, `14`, `15`, and `16`. +- This Phase 45C entry above, including the explicit root-source retention + decision. + +Hard rules: + +- Work on core packaging only; do not modify Rust execution semantics. +- `src/quantbt` is the distribution source. +- Root `quantbt` compatibility files stay in place during this phase. +- Keep the SHA256 root/src mirror guard; do not replace it with a deletion + check. +- Do not publish or enable `quantbt-native`. +- Keep `from quantbt import QuantBTEndpoint` unchanged. + +Required checks: + +```bash +uv sync --all-extras --dev +uv run pytest -q tests/test_phase42_packaging_layout.py \ + tests/test_phase42c_ci_release.py tests/test_phase45a_source_tree_sync.py +uv build +uv run twine check dist/* +``` + +Then install both `dist/quantbt_engine-*.whl` and +`dist/quantbt_engine-*.tar.gz` into separate temporary environments and import +from a directory outside the repository. Record the exact result, source +path, version, and root/src mirror status in this implementation log. + +Phase 45C is complete only when wheel, sdist, CI metadata, public import, +editable/path compatibility, and source-sync checks pass. Python hot-path work +is Phase 45D; Rust batched execution is a separate Phase 45E/45F track. + +#### Final Merge Checklist For This Roadmap + +Before merging each branch into `dev`: + +- update this implementation log with: + - implemented items; + - exact tests; + - exact benchmark numbers; + - known remaining debt; + - commit hashes. +- run branch-specific tests; +- run full tests where feasible; +- verify public import unchanged. + +Before merging any release branch into `main`: + +- clean wheel install passes; +- no `PYTHONPATH` dependency; +- pool_alpha smoke passes; +- release notes/changelog/version are correct; +- PyPI workflow is configured but not accidentally triggered. + +Before enabling Rust by default: + +- full lifecycle parity passes; +- randomized differential tests pass; +- production soak completed; +- wheel coverage is sufficient; +- fallback tests pass; +- runtime/RSS gates pass end-to-end, not just inside Rust kernel. + +## Final Upgrade - Dual Backend, RSS, And PyPI Release + +Status: **Phases 46A-46B implemented locally; Phases 46C-46F remain planned**. + +Detailed source of truth: + +- [`quantbt_final_upgrade_dual_backend_pypi_plan.md`](quantbt_final_upgrade_dual_backend_pypi_plan.md) +- Before implementing each phase, read the linked guide sections named in + that phase. This summary is a tracking plan, not a replacement for the + detailed guide. + +Branch baseline: + +- Work from `feat/quantbt-engine-packaging` after the committed Phase45F + state. +- Phase45F sparse runner is implemented and full regression is green. +- Rust remains explicit experimental because the current process peak-RSS + gate is not passed; `auto` remains Python. + +Global rules for all six phases: + +- Correctness and replay certification precede optimization claims. +- Keep `from quantbt import QuantBTEndpoint` and existing endpoint defaults + compatible. +- Never silently fallback from an explicit unsupported Rust capability or + silently change execution semantics. +- Keep the root compatibility mirror through the intermediate phases. Remove + it only in the final packaging phase after the source-sync and clean-install + gates pass. +- Do not use total-process RSS alone as an engine-memory claim. Record + interpreter, import, prepared, execution-peak, and post-run checkpoints. +- Every phase ends with focused tests, exact benchmark/evidence output, a + technical-debt note, and a commit using the configured contributor identity. +- Do not publish to production PyPI without explicit release approval. Build + and TestPyPI/OIDC validation may be prepared earlier, but credentials and + tokens must never enter the repository. + +### Phase 46A - PyPI Baseline And Correctness Certification + +Status: **implemented locally; focused correctness and mirror gates pass**. + +Detailed guide sections: + +- Guide [`quantbt_final_upgrade_dual_backend_pypi_plan.md`](quantbt_final_upgrade_dual_backend_pypi_plan.md), sections `1`, `2`, + `2.1` to `2.3`, `13.1`, and Patch `F1` in section `16`. + +Objective: + +- Establish one correctness contract before changing the performance path. +- Capture the core PyPI readiness baseline while keeping source layout and + public imports stable. + +Implementation: + +- Add one canonical `assert_native_event_full_parity(candidate, oracle, ...)` + helper used by Python optimized, Rust batched, and replay-certified tests. +- Compare effective bar, command sequence, acceptance/rejection, status + transitions, fills, position/equity/fee/funding/turnover paths, margin, + parent/OCO/TIF/expiry/liquidation state where the capability exists, and + final state. +- Use exact equality for discrete fields; use `rtol=0, atol=1e-12` only for + numeric operation-order differences that cannot change a discrete decision. +- Create the canonical capability matrix consumed by the Python selector, + Rust `capabilities()`, tests, and docs. Rust unsupported requests must fail + clearly before execution. +- Add seeded randomized differential tests and remove any required `xfail` + from the advertised single-symbol R2 scope. +- Verify `pyproject` version, public metadata, core wheel/sdist entry points, + and existing root/src mirror integrity without deleting the mirror yet. + +Required tests/evidence: + +- Full Python/replay lifecycle matrix. +- Rust/replay R2 matrix for the installed wheel. +- Randomized Python-vs-replay and Rust-vs-replay fingerprints. +- Public import and source-sync tests. +- Evidence JSON must include `oracle_fingerprint`, candidate fingerprints, + exact parity status, capability matrix version, and commit hash. + +Acceptance and debt: + +- No performance result is accepted unless full parity passes first. +- Any unsupported capability remains an explicit debt and is not included in + the Rust release claim. +- Evidence is emitted by + [`benchmark_phase46a_certification.py`](../benchmarks/native_event/benchmark_phase46a_certification.py) + and records fingerprints, exact parity, capability version, and source + commit. The root compatibility mirror remains intentionally retained. +- Remaining Phase 46A scope note: the installed Rust audit/replay matrix is + covered by the existing Rust batched full-tape tests and the new parity + contract; full score-path equivalence is deliberately Phase 46B. + +### Phase 46B - Apples-To-Apples Score And RSS Benchmark + +Status: **implemented locally; scalar parity, audit parity, and standard RSS +evidence pass**. + +Detailed guide sections: + +- Guide sections `3`, `3.1` to `3.3`, `4`, `4.1` to `4.2`, and Patch `F2`. + +Objective: + +- Replace the current unfair Rust-scalar versus Python-minimal-result + comparison with equivalent scalar artifacts and staged RSS evidence. + +Implementation: + +- Add an internal Python `run_compiled_tape_score(...)` that avoids pandas, + `BacktestResultV2`, full ledgers, command reports, and nested artifacts. +- Return the same scalar fields as Rust: + `final_equity`, `final_position`, `total_fee`, `total_turnover`, fill/event + counters, rejection/cancellation counters, and margin maxima. +- Run one full audit parity pass before timing and persist its fingerprint. +- Build separate Python, Rust, and replay child fixtures. Do not prepare two + backend representations in one process. +- Record `rss_interpreter`, `rss_after_import_quantbt`, + `rss_after_market_prepare`, `rss_after_command_compile`, + `rss_after_runner_prepare`, `peak_rss_during_run`, and `rss_after_run`. +- Run at least five warm measured repetitions for low churn, high churn, and + repeated prepared-score scenarios; add the 100-run RSS plateau workload. + +Required evidence: + +- JSON fields for fingerprints, scalar parity, timing medians, CPU time, + absolute RSS, incremental prepared RSS, incremental execution peak, and + post-run RSS. +- No claim based only on final equity/fill count or total-process percentage. + +Acceptance and debt: + +- The benchmark is valid only when Python and Rust have the same scalar + artifact contract and the one-time audit fingerprint matches. +- If Python score-path overhead dominates, record it as facade debt instead + of overstating Rust speedup. +- Implementation is in `NativeEventBackend.run_compiled_tape_score(...)` and + the scalar properties on `NativeEventScalarScoreResult`; source mirrors are + kept byte-identical during the packaging transition. +- Evidence runner: + [`benchmark_phase46b_score_rss.py`](../benchmarks/native_event/benchmark_phase46b_score_rss.py). + Standard evidence: + [`phase46b_score_rss.json`](../benchmarks/native_event/phase46b_score_rss.json). + Methodology and runnable command: + [`native_event_score_rss.md`](../docs/native_event_score_rss.md). +- The 2,000-bar, five-sample profile passed full audit parity and scalar + parity for low/high churn; both Python and Rust 100-run score plateaus + passed. Rust was faster on this host, while total process RSS remained + dominated by the shared Python/package import floor. Prepared and execution + deltas are reported separately and are not conflated with that floor. +- Phase 46B does not change public endpoint defaults and does not certify + portfolio, arbitrage, multi-symbol, or unsupported Rust capabilities. + +### Phase 46C - Import Graph, Core Dependencies, And RSS Floor + +Status: **implemented locally; import, public-API, mirror, metadata, and fresh-process RSS gates pass**. + +Detailed guide sections: + +- Guide section `5`, subsections `5.1` to `5.4`, section `13.1`, and Patch + `F3`. + +Objective: + +- Lower the process RSS floor for both backends without removing public names. +- Make the core PyPI distribution usable without visualization, optimization, + Nautilus, or report extras installed. + +Implementation: + +- Refactor `src/quantbt/__init__.py` to keep only minimal core imports eager + and expose non-core public names through safe lazy imports. +- Preserve public export identity for `QuantBTEndpoint`, results, schemas, + `quick_plot`, `tearsheet`, `OptunaOptimizer`, Nautilus helpers, and other + existing names. +- Move matplotlib/seaborn to `viz`, Optuna to `optimization`, Nautilus to + `validation`, and QuantStats/report dependencies to `reports` extras as + specified by the guide. Core import must work without those extras. +- Add import-time and fresh-process RSS tests; use `-X importtime` evidence. +- Keep the root mirror and SHA256 sync guard during this phase. Do not turn + lazy import work into an unreviewed source deletion. + +Implementation completed: + +- Core `quantbt` import now resolves optional public exports through a cached + module-level lazy resolver. `QuantBTEndpoint`, engines, schemas, execution + contracts, metrics, and result types remain eager core imports. +- `matplotlib` and `seaborn` were removed from core `project.dependencies` and + the corresponding `uv.lock` package metadata. They remain in `viz`/`all`; + Optuna, QuantStats, and Nautilus remain owned by their existing extras. +- `walkforward.DuplicatePruner` no longer imports Optuna at module import; + Optuna is loaded only by the optimization execution path. +- Top-level backend/report/viz imports were moved behind the method or lazy + export boundary. Existing root compatibility mirror files were synchronized + from `src/quantbt` and remain protected by the mirror test. +- Added [`import_graph_and_rss_floor.md`](../docs/import_graph_and_rss_floor.md), + [`test_phase46c_import_graph.py`](../tests/test_phase46c_import_graph.py), + and [`benchmark_phase46c_import_rss.py`](../benchmarks/native_event/benchmark_phase46c_import_rss.py). + +Evidence: + +- [`phase46c_import_rss.json`](../benchmarks/native_event/phase46c_import_rss.json) + records a fresh `/tmp` child process with no forbidden optional modules, + `QuantBTEndpoint.__module__ == "quantbt.endpoint"`, 1,007 loaded modules, + and 188,170,240 bytes RSS after core import on the current host for the + saved run. RSS is allocator/environment dependent; the JSON is the exact + evidence for that run. +- The focused Phase 46A/46B/46C and source-mirror suite passed with `23 passed`. +- The complete public `quantbt.__all__` surface (351 names) resolved in the + full development environment. Optional names still require their declared + extra when installed in a core-only environment. +- The pinned build toolchain produced + `quantbt_engine-0.1.0-py3-none-any.whl` and + `quantbt_engine-0.1.0.tar.gz`. The wheel was imported from a target + directory with `--no-deps`; its metadata contains only NumPy/pandas/Numba as + unconditional requirements and keeps optional markers for viz, + optimization, reports, and validation. +- Full regression passed with `648 passed, 3 skipped`. + +Required tests/evidence: + +- `import quantbt` does not import matplotlib, seaborn, Optuna, Nautilus, or + reporting modules. +- All public exports remain accessible and preserve direct-import identity. +- Thread-safety smoke for lazy export access. +- Core-only wheel/sdist install plus each optional extra in isolation. +- Full regression and before/after import RSS report. + +Acceptance and debt: + +- Core package import must not require optional extras. +- Any downstream import that depended on eager side effects must be fixed + explicitly and tested; no hidden fallback import is allowed. +- Phase 46C does not claim prepared-tape, execution, portfolio, or Rust RSS + improvements. Those remain Phase 46D/46E work; the measured value here is + the fresh core import/process floor only. + +### Phase 46D - Market Ownership, Tape Memory, And Rust Hot State + +Status: **implemented locally on `feat/quantbt-engine-packaging`; ownership, +hot-state, score-boundary, reset, and bounded-cache gates pass.** The Rust +extension remains explicit/experimental under the Phase 46 release policy; +this phase does not silently change the endpoint default. + +Detailed guide sections: + +- Guide sections `6`, `6.1` to `6.4`, `7`, `7.1` to `7.3`, `8`, `8.1` to + `8.4`, `9`, `9.1` to `9.2`, and Patches `F4` and `F5`. + +Objective: + +- Remove avoidable duplicate market/tape ownership and reduce Rust order/ + buffer allocation churn without altering domain semantics. + +Implementation: + +- Split fixtures and prepared containers into explicit Python-owned and + Rust-owned paths. After one safe Rust copy, release DataFrame/Series and + temporary NumPy inputs before timing checkpoints. +- Keep Rust `PreparedMarketCore` immutable and consider `Box<[T]>` or + `Arc<[T]>` only after parity; do not use unsafe NumPy borrows in this + phase. +- Replace linear active-order scans with an order-slot table, O(1) ID lookup, + priority-preserving active sequence, tombstone compaction, and tested alias + path compression. +- Add reusable SoA audit buffers, typed score result boundary where safe, and + reset parity tests before allowing buffer reuse. +- Replace unbounded object/tape retention with stable-fingerprint bounded + cache policy and `clear_tape_cache()` service control. +- Avoid simultaneously retaining original `OrderCommand` objects, compiled + objects, and Rust arrays in score runs unless audit explicitly requests it. + +Implementation completed: + +- Prepared market arrays now cross the PyO3 boundary once into immutable Rust + `Box<[T]>` storage. The runner can release the Python market frame and + temporary arrays after preparation without invalidating execution. +- Reactive Rust state now uses an O(1) order-slot table with an ID index, + priority-preserving active sequence, tombstone compaction, and bounded alias + path compression/cycle protection. Slot reuse is delayed until compaction + so same-bar replacement cannot duplicate priority entries. +- Score execution uses a typed `BatchedScoreResultCore` and scalar counters; + fill/event/order snapshots are materialized only by audit or sparse paths. + The static tape adapter explicitly translates canonical compiler + `REPLACE/AMEND` codes to the stable reactive ABI, preserving the existing + R2 behavior. +- Static tapes use a stable primitive-array fingerprint and one runner-local + byte-bounded cache. `RustBatchedRunner.clear_tape_cache()` gives services a + deterministic release control. Sparse sessions expose `reset()` and retain + Rust buffer capacity while resetting accounting/lifecycle state. +- Evidence and operational notes are recorded in + [`docs/native_event_rust_ownership_r2.md`](../docs/native_event_rust_ownership_r2.md). + +Required tests/evidence: + +- Exact lifecycle/accounting parity after each Rust state change. +- Replacement-chain and alias-cycle tests. +- Audit/score reset parity and 100-run memory plateau. +- Rust-only prepared RSS checkpoints with Python inputs released. +- Low/high order churn benchmarks and command-cache byte limits. + +Evidence: + +- `cargo fmt --check` and `cargo check --manifest-path + rust/native_event/Cargo.toml` pass after the ownership/order-table changes. +- Focused ownership, replacement-chain/cycle, typed-score, bounded-cache, and + sparse-reset tests pass with the installed local extension: `60 passed, + 2 skipped`. The full repository regression is `654 passed, 3 skipped`. + The JSON evidence file is at + `benchmarks/native_event/phase46d_ownership_r2.json`. +- The benchmark reports low/high order churn, first/repeated score timing, + Rust-owned incremental RSS, cache bytes before/after clear, and 100-run + sparse-session reset parity. Its RSS numbers are incremental Rust-path + measurements, not a claim about the Phase 46C fresh-process import floor. + On the 2,000-bar/100-run profile it passed with 40 low-churn orders and + 3,999 high-churn orders; repeated score RSS growth was 0 bytes in both + profiles, and reset/cache gates passed. + +Acceptance and debt: + +- All discrete decisions and accounting must remain exact. +- If memory is not reduced after ownership separation, record allocator/import + floor separately; do not loosen domain parity or gate thresholds. + +Residual scope is intentionally unchanged: the later Phase 46E Python hot +state and dual-backend release gate remain open, and the Rust backend is not +auto-enabled until its complete parity/RSS/release gates pass. + +### Phase 46D.1 - Fast Score Cache And RSS Refinement + +Status: **implemented locally on `feat/quantbt-engine-packaging`; benchmark +parity and score/RSS plateau gates pass.** The optional prepared-RSS reduction +target was measured but not claimed; execution remains explicit Rust and +`auto` remains Python. + +Guide link: + +- [`quantbt_final_upgrade_dual_backend_pypi_plan.md`](quantbt_final_upgrade_dual_backend_pypi_plan.md), + sections `8.2`, `8.4`, `9.1` to `9.2`, `10.1`, and gate section `12`. + +Objective: + +- Remove per-score tape fingerprint work from the measured Rust path while + preserving a stable, complete cache identity and avoiding retention of the + original command object. +- Reduce avoidable sparse-result allocation when the caller does not request + fill/order-event wake payloads, without changing scalar accounting or the + default audit path. +- Re-run the exact Phase 46B benchmark and target a return toward the earlier + `~167x` to `~180x` Rust/Python score ratio where the workload supports it; + report failure honestly if the state-table or ABI boundary remains the + limiting factor. + +Implementation: + +- Compute the complete primitive command-tape fingerprint at compile time, + including all fields that affect Rust validation or execution. Treat the + compiled tape arrays as an immutable internal contract so cache identity + cannot become stale through post-compile mutation. +- Make `RustBatchedRunner._tape_arrays()` use the stored fingerprint in the + hot score loop; retain only bounded primitive arrays and the digest. +- Keep the explicit `clear_tape_cache()` and byte-limit behavior unchanged. +- Add a sparse fast path that retains scalar counters but does not materialize + fill/event arrays when both wake payload flags are disabled. Keep the + default wake/audit behavior byte-for-byte compatible. +- Add focused cache-invalidation, immutable-tape, sparse-fast-path, parity, + and repeated-run RSS tests. Re-run the Phase 46B low/high benchmark in a + fresh subprocess and retain before/after JSON evidence. + +Acceptance: + +- Exact Python/Rust audit and scalar parity remains 100%. +- Existing full regression remains green. +- Rust score median returns toward the Phase 46B range, or the measured + residual cause is documented with no false speed claim. +- Prepared/score RSS does not regress; repeated score RSS remains plateaued. +- No new endpoint argument is required and `auto` remains Python until the + Phase 46E release gate passes. + +Implementation completed and evidence: + +- `CompiledOrderCommandArrays` now carries a complete compile-time primitive + fingerprint covering execution and validation fields. Its arrays are + read-only after compilation, preventing stale cache identity through + mutation. Rust score cache hits use the stored digest and avoid rehashing + the tape. +- `OrderTable` uses a bounded small-book sequence lookup and early tombstone + compaction; larger live books retain the numeric O(1) ID map. Sparse calls + with both wake payload flags disabled keep scalar accounting without + materializing fill/event arrays. +- Focused native tests pass: `16 passed`. The final apples-to-apples evidence + is [`phase46d1_score_rss.json`](../benchmarks/native_event/phase46d1_score_rss.json): + `270.3x` low-churn and `175.3x` high-churn Rust/Python score speedup in the + saved run; scalar/full parity and repeated RSS plateau pass. +- Ownership evidence is in + [`phase46d1_ownership_r2.json`](../benchmarks/native_event/phase46d1_ownership_r2.json): + both low/high sparse reset RSS deltas are zero and cache/reset gates pass. + Prepared incremental RSS was approximately `2.79 MB`/`2.98 MB` in the + staged score benchmark, so no 20% prepared-RSS reduction is claimed. + +### Phase 46E - Python Hot State, Dual Backend Contract, And Release Gate + +Status: **implemented on `feat/quantbt-engine-packaging`; dual-backend +behavior, common-result adaptation, parity tests, and the fresh release-gate +evidence are complete.** The native PyPI extra remains intentionally closed +because the explicit prepared-RSS reduction threshold is not met; `auto` +therefore remains Python by policy. + +Detailed guide sections: + +- Guide sections `10`, `10.1` to `10.3`, `11`, `11.1` to `11.3`, `12`, and + Patch `F6` plus the first part of Patch `F7`. + +Objective: + +- Keep Python the full-featured canonical backend while making its static tape + fallback fair, compact, and explicit. +- Re-run certification under the final dual-backend contract. + +Implementation: + +- Use primitive active-order state and optional metadata side tables in Python + score mode, without changing public `OrderCommand` or event types. +- Make context fields such as active orders, event ledgers, fills, margin, and + positions lazy by score requirements; preserve the full compatibility + default. +- Define the public/internal selection contract exactly as `python`, `rust`, + `auto`, and `replay_certified`. +- Keep `python` full reactive/default, `rust` explicit capability-gated, + `auto` Python for this release, and `replay_certified` the audit oracle. +- Keep the per-bar Rust adapter for debug/correctness only; do not call it a + performance route. +- Re-run fresh-process benchmarks with identical scalar artifacts and at + least five repetitions plus 100-run plateau. + +Release gate: + +```text +full lifecycle/accounting parity = 100% +low-churn speedup >= 1.50x +high-churn speedup >= 2.00x +incremental prepared RSS reduction >= 40% +incremental execution peak reduction >= 40% +absolute peak RSS below declared budget +100-run RSS plateau +``` + +Acceptance and debt: + +- A failed RSS gate keeps Rust experimental and leaves `auto` on Python. +- Native feature claims must be generated from the canonical capability + matrix; no package/docs drift is accepted. + +Execution checklist for this phase: + +- Preserve `BacktestResultV2` and existing endpoint behavior for `python` and + public audit/report calls. +- Add explicit backend selection and capability errors for direct Rust use; + never silently downgrade an explicit `rust` request. +- Certify Rust audit-to-common-result conversion and Python/Rust scalar, + lifecycle, fills, fees, margin, and report parity. +- Add score-requirement/lazy-state tests and fresh-process dual-backend + benchmark evidence. Record every release-gate result, including failed RSS + thresholds, without changing the declared policy. + +Implementation completed and evidence: + +- Added `native_backend` to `NativeEventConfig`, `EndpointConfig`, and + `BacktestEngineV2`. The selector is exactly `python`, `rust`, `auto`, or + `replay_certified`; explicit Rust requests fail fast for unsupported + multi-symbol, funding, liquidation, and quantity-constraint semantics. +- Added `RustBatchedAuditResult.to_backtest_result(...)`. Rust SoA audit output + now reaches the common `BacktestResultV2` contract with equity, positions, + fees, margins, `fills_report`, `order_report`, `Fill` objects, and the normal + metrics/report/plot helpers. The adapter is outside the scalar score path. +- Python scalar score state now drops non-execution strategy metadata when the + declared context requirements disable all related payloads. Full audit and + compatibility defaults retain their existing objects and metadata. +- Added [`docs/native_event_dual_backend_phase46e.md`](../docs/native_event_dual_backend_phase46e.md), + the endpoint selector documentation, and + [`tests/native_event/test_phase46e_dual_backend_contract.py`](../tests/native_event/test_phase46e_dual_backend_contract.py). +- The reproducible gate is + [`benchmarks/native_event/benchmark_phase46e_release_gate.py`](../benchmarks/native_event/benchmark_phase46e_release_gate.py), + with evidence in + [`benchmarks/native_event/phase46e_release_gate.json`](../benchmarks/native_event/phase46e_release_gate.json). + The fresh run passed full parity, low/high speed thresholds (`155.6x` and + `218.4x`), absolute peak RSS budget (`183.14 MB < 512 MB`), and the 100-run + RSS plateau. The prepared-RSS reduction was `-28.5%` low churn and `-17.8%` + high churn, so the required `40%` prepared-RSS gate is honestly recorded as + failed; no native extra or automatic Rust selection is claimed. +- Focused Phase 46E and prior Rust/Python parity tests pass: `26 passed`. + +### Phase 46F - Core PyPI Finalization And Native Release Decision + +Status: implemented on `feat/quantbt-engine-packaging`; core release gate +passed locally, native release gate remains intentionally closed. + +Detailed guide sections: + +- Guide sections `13`, `13.1` to `13.2`, `14`, `14.1` to `14.4`, `15`, + `15.1` to `15.4`, `16` Patches `F7` to `F9`, and `17`. + +Objective: + +- Finish the independently installable `quantbt-engine` core release first. +- Only publish `quantbt-native` and expose a non-empty native extra if its + full parity, RSS, wheel, and fallback gates genuinely pass. + +Core PyPI implementation: + +- `src/quantbt` remains the distribution source of truth. The root mirror is + deliberately retained because the repository owner approved a staged + migration; it is byte-locked by `tests/test_phase45a_source_tree_sync.py` + and is not included as a second package source in the wheel. +- Aligned `__version__`, `pyproject` version, wheel metadata, and release + notes at `1.0.7`; added Python 3.11/3.12/3.13 classifiers, + Documentation/Changelog URLs, and [`CHANGELOG.md`](../CHANGELOG.md). +- Added local package-gate commands to + [`docs/release_packaging.md`](../docs/release_packaging.md): isolated + wheel/sdist build, metadata inspection, `twine check`, clean import, and + dependency-complete `pip check`. +- Added manual `.github/workflows/publish-testpypi.yml` for RC tags with a + protected `testpypi` environment and OIDC. The production workflow now + refuses prerelease/draft GitHub Releases and retains the protected `pypi` + OIDC gate. +- Added package metadata, workflow contract, native-extra, and release-note + tests in `tests/test_phase46f_packaging_release.py`. +- The root mirror was not deleted; removing it remains a separate, explicitly + approved migration and is outside this release-finalization scope. + +Native release decision: + +- Build `quantbt-native` for CPython 3.11, 3.12, and 3.13 on Linux + manylinux-compatible x86-64 runners, install the wheel with the matching + core wheel, and run combined parity/fallback/RSS smoke. +- Complete native metadata, README, license inclusion, Cargo.lock, API + compatibility documentation, and separate distribution/API versioning. +- If every gate passes: publish `quantbt-native` first, verify installation, + then add `quantbt-engine[native]` and publish the compatible core release. +- If any gate fails: publish only `quantbt-engine`, keep Rust explicit + experimental, keep `auto=Python`, and leave the native extra empty/absent. + +Phase 46E evidence and the Phase 46F fresh rerun confirm the second branch: +Python/Rust parity and score speed thresholds pass. The fresh run measured +`182.2x` low churn and `251.3x` high churn, with absolute peak RSS +`184.11 MB < 512 MB` and a passing 100-run plateau. The prepared RSS +reduction gate fails (`-26.1%` low churn, `-7.6%` high churn), so +`quantbt-native` is not published and `project.optional-dependencies +["native"]` remains empty. This is a deliberate release decision, not an +unresolved correctness claim. + +Final definition of done: + +- Core `quantbt-engine` clean wheel/sdist install works without optional + dependencies and `from quantbt import QuantBTEndpoint` is unchanged. +- Pool Alpha compatibility, full tests, source/import checks, and TestPyPI + RC smoke pass. +- Python remains canonical/full-featured; replay remains the certification + oracle. +- Rust claims, capabilities, wheel matrix, RSS evidence, and fallback policy + agree with one source of truth. +- No production release is declared from a failed parity or RSS gate. + +Phase 46F local evidence: + +- Packaging metadata and workflow tests: pass. +- Core package build toolchain: `build 1.5.0`, `twine 6.2.0`. +- Full regression on the Phase 46F commit: `664 passed, 3 skipped`. +- Root/source parity: pass for the complete mirrored Python tree. +- Native release: intentionally not ready because the prepared RSS gate is + measured and failed; no automatic Rust selection or non-empty native extra + is claimed. +- Fresh Phase 46F gate artifact: + [`benchmarks/native_event/phase46f_release_gate.json`](../benchmarks/native_event/phase46f_release_gate.json). + +### Final Upgrade Tracking Rules + +- This section is the only active plan for the final dual-backend/PyPI + upgrade; older Phase42-45 notes remain historical evidence. +- Each agent must first read the linked detailed guide and this section before + starting a phase, then update the phase status with commit, tests, evidence, + and remaining debt. +- The scope deliberately stops at the guide's dual-backend/static-tape and + PyPI release goals. It does not add arbitrary Python-to-Rust compilation, + portfolio/arbitrage Rust parity, or silent default routing. + +## Final Grid Python/Rust Full-Contract Upgrade + +Status: **Phases 47A-47C implemented locally; Phase 47D remains planned.** + +Detailed source of truth: + +- [`quantbt_final_grid_python_rust_full_contract_guide.md`](quantbt_final_grid_python_rust_full_contract_guide.md) + +This plan condenses the complete Grid guide into four implementation phases. +The linked guide remains normative; this section is only the execution tracker +and must not replace the detailed code snippets, contracts, or acceptance rules +in that guide. + +### Scope and non-negotiable rules + +- Work only with the existing Grid module at + `/root/bobby/pool_alpha/alphas_storage/TA/dynamic_grid_quantbt_native_event.py`. + Do not copy its source into the QuantBT repository. +- Keep the public endpoints unchanged: + `QuantBTEndpoint.native_event_strategy(...)` and + `QuantBTEndpoint.prepare_native_event_strategy(...)`. +- Add only the Grid-side `native_backend` selector and scalar/prepared helpers + described by the guide. Do not create a Grid-specific endpoint family. +- Preserve the full Grid domain contract: `PLACE`, `AMEND`, `CANCEL`, + `CANCEL_ALL`, `MARKET`, `LIMIT`, `GTC`, `reduce_only`, OCO entry/exit + batches, active-order snapshots, per-bar fills, funding, initial and + maintenance margin, liquidation, and single-symbol lifecycle semantics. +- Do not disable funding, OCO, maintenance margin, liquidation, or lifecycle + fields to make Rust run. An explicit unsupported Rust capability must raise a + clear capability error; it must never silently fallback or change semantics. +- Keep Python/replay as the correctness reference until the Rust contract has + passed the shared conformance suite and both Grid parity workloads. +- Keep the root compatibility mirror during all intermediate phases. Its + removal is not part of this Grid contract upgrade and requires a separately + approved packaging migration after clean-install/import verification. +- Do not claim Rust production support, publish a native extra, or route + `native_backend="auto"` to Rust before all release gates pass. +- Every completed phase must include focused tests, evidence/benchmark output, + explicit remaining debt, and an immediate commit using the configured + contributor identity. Do not modify `main`. + +### Phase 47A - Grid Adapter, Python Scalar Baseline, And Diagnostic Lock + +Status: **implemented locally; Python scalar/public/replay gates pass.** + +Detailed guide sections: + +- Sections `1` to `7` of + [`quantbt_final_grid_python_rust_full_contract_guide.md`](quantbt_final_grid_python_rust_full_contract_guide.md): + source of truth, current Python/Rust status, endpoint policy, Grid config + forwarding, public-result versus scalar-score separation, notebook import, + and the three canonical Python paths. +- Sections `16` to `16.2` for the required Python-versus-replay diagnostic + before any Rust parity claim. + +Objective: + +- Freeze the actual Grid contract through the existing Python implementation + and replay-certified oracle before expanding Rust. +- Make the existing Grid alpha selectable through the current endpoint without + changing its strategy callback, command generation, or accounting behavior. +- Separate public result materialization from the prepared scalar score path. + +Implementation: + +- Add `native_backend` to the end of the existing `GridExecutionConfig` with + exactly `python`, `rust`, `auto`, and `replay_certified` validation. +- Forward the selector and the existing reactive/report/audit settings once + through `build_grid_endpoint`; do not add a new endpoint or alter defaults. +- Add `prepare_grid_score_runner(...)` and `score_grid_params(...)` using + `NativeEventScoreRequirements.scalar_score_contract()` and a fresh strategy + instance per evaluation. +- Add the notebook import/version guard from guide section `6`, without + changing the source module or copying it into QuantBT. +- Define and run the three Python paths exactly as specified: + replay-certified audit, Python public minimal, and Python scalar v2. +- Add the diagnostic comparison that separates position transitions, fill + count, entry/exit/flatten fills, fees, funding, and `num_trades`; identify + the exact first divergent bar/transition before treating any result change + as an engine bug. + +Tests and evidence: + +- Python replay-certified versus Python single-pass full lifecycle parity. +- Python public minimal versus replay position/fill/accounting parity. +- Python scalar totals/fingerprint versus the same Python audit run. +- Config forwarding, allowed selector values, default compatibility, fresh + strategy instances, and no `endpoint.result` materialization in score mode. +- Diagnostic evidence explaining every `num_trades +2` or proving the metric + counting semantics are the only difference. + +Acceptance and possible debt: + +- Python must remain correct and unchanged for existing Grid users before Rust + work begins. +- Scalar mode must not call `full_report()` or retain public ledgers. +- Any unexplained command/fill/position/equity divergence blocks Phase 47B. +- Expected residual debt is Rust capability incompleteness; it must be listed, + not hidden by disabling Grid features. + +Implementation and evidence: + +- Updated the existing Grid module only at + `/root/bobby/pool_alpha/alphas_storage/TA/dynamic_grid_quantbt_native_event.py`; + the source was imported directly and was not copied into QuantBT. +- `GridExecutionConfig.native_backend` now validates and normalizes exactly + `python`, `rust`, `auto`, and `replay_certified`, while the existing endpoint + forwarding remains the only routing change. +- Added `prepare_grid_score_runner(...)` and `score_grid_params(...)`. Each + score creates a fresh mutable Grid strategy, reuses the prepared market tape, + uses `NativeEventScoreRequirements.scalar_score_contract()`, and leaves + `endpoint.result` untouched. +- Added the scalar retention evidence flag + `score_full_ledgers_materialized=False` to both canonical `src/quantbt` and + the compatibility mirror; this is metadata only and does not change fills, + accounting, or execution order. +- Added [`test_phase47a_grid_adapter.py`](../tests/test_phase47a_grid_adapter.py) + covering selector forwarding, public-result/scalar separation, repeated + score determinism, reportability, and Python single-pass/replay parity. +- Focused Phase 47A suite: **5 passed**. Related native-event regression: + **20 passed**. Full repository regression: **669 passed, 3 skipped**. +- Syntax compile and the complete mirrored Python-tree check pass with no + `src/quantbt` to root-mirror content differences. + +Phase 47A completion boundary: + +- Python/replay baseline is locked and safe to use as the Phase 47B oracle. +- No Rust Grid claim, no 2,000-bar Grid production parity claim, no RSS + benchmark claim, and no optimizer speedup claim is made by this phase. +- Existing dirty notebook changes in the external TA repository were left + untouched; only the Grid module was changed for this phase. + +### Phase 47B - Rust Native Event V2 Full Contract And Conformance Suite + +Status: **implemented locally; full-contract conformance and focused Rust +regressions pass. Grid workload certification remains Phase 47C.** + +Detailed guide sections: + +- Sections `8` to `10` of + [`quantbt_final_grid_python_rust_full_contract_guide.md`](quantbt_final_grid_python_rust_full_contract_guide.md): + full Rust domain contract, file-level adapter/core design, order table, + exact bar execution order, and shared conformance tests. + +Objective: + +- Upgrade Rust from the currently narrower/static scope to the same advertised + Native Event V2 domain contract used by Python and Grid. +- Make the replay-certified execution order the single lifecycle ordering + reference; Rust must reproduce it rather than infer a new ordering. + +Implementation: + +- Extend the Python Rust adapter command ABI for `PLACE`, `CANCEL`, + `CANCEL_ALL`, `AMEND`, `REPLACE`, order type, TIF, expiry, activation, + parent/group/OCO IDs, and symbol index. +- Remove hardcoded unsupported behavior only after the Rust core implements + the corresponding semantics; pass real funding arrays/masks, maintenance + ratio, quantity constraints, liquidation state, and active-order metadata. +- Split Rust internals into the guide's `types`, `session`, `commands`, + `order_table`, `matching`, `lifecycle`, `accounting`, and `buffers` roles. +- Implement priority-preserving order slots, ID lookup, parent/group/OCO and + expiry indexes without `Vec.remove()` priority shifts. +- Copy the oracle's exact bar sequence for mark/PnL, intrabar liquidation, + funding, after-funding liquidation, expiry, commands, matching, parent/OCO + lifecycle, after-order liquidation, and state recording. +- Use compact primitive/SoA state at the Rust boundary; preserve public result + semantics and avoid per-bar Python object materialization in the score path. + +Tests and evidence: + +- Add the shared `tests/native_event/contract/` matrix and run every fixture + through replay-certified, Python, and Rust. +- Cover command timing, all command kinds, MARKET/LIMIT/STOP variants, + GTC/GTD/IOC/FOK, reduce-only, quantity constraints, parent activation, + group/OCO, funding, margin, liquidation, and multi-symbol behavior declared + by the capability matrix. +- Compare command tape, effective bars, statuses/reject reasons, fills, + positions, equity, fee, funding, turnover, margin, liquidation, and final + state. Discrete fields must be exact; numeric tolerance is only + `rtol=0, atol=1e-12` where operation order cannot change a decision. +- Add explicit Rust capability/version mismatch tests proving fail-fast + behavior and no silent fallback. + +Acceptance and possible debt: + +- No Grid Rust integration is accepted if any full-contract lifecycle or + accounting field is missing from parity. +- If multi-symbol or another capability is not implemented safely, capability + metadata must report it as unsupported and Phase 47C must not claim it. +- Rust remains explicit/experimental until the conformance suite is green; + this phase does not change `auto` routing. + +Implementation and evidence: + +- Added the versioned Rust API `0.4` full-contract ABI and capability gate. + The existing R1/R2 API remains readable for compatibility, while explicit + full execution requires every `native_event_v2_*` capability listed above. +- Added `rust/native_event/src/full.rs` with the compact full session, + flattened multi-symbol market tape, lifecycle/order table, matching, + funding, margin, liquidation, quantity-preflight boundary, and SoA audit + output. Its execution ordering is locked to the Python replay oracle. +- Extended `src/quantbt/backends/_native_event_rust.py` and the compatibility + mirror for full command compilation, per-symbol reactive batches, funding, + liquidation, full active-order relationship metadata, event reject codes, + and `RustFullAuditResult` adaptation to `BacktestResultV2`. +- Corrected two parity defects found by the conformance suite: `REPLACE` + target aliases now resolve subsequent CANCEL/AMEND commands to the newest + slot, and replacement no longer emits a spurious cancellation event. + Quantity preflight also selects constraints by the command's symbol rather + than always using symbol column zero. +- Added [`test_phase47b_full_contract.py`](../tests/native_event/contract/test_phase47b_full_contract.py). + It covers multi-symbol funding, parent activation, OCO, TIF/expiry, + CANCEL_ALL, liquidation, replace aliasing, amend, stop order types, + reduce-only, per-symbol quantity constraints, active metadata, event + status, and reject-code parity. Focused result after a release rebuild: + **9 passed**. +- Updated [`native_event_rust_full_contract.md`](../docs/native_event_rust_full_contract.md) + and the endpoint/backend documentation. Public endpoint names and defaults + remain unchanged; `native_backend="rust"` is still explicit and fail-fast, + while `auto` remains Python. +- Verification after the final Rust rebuild: `cargo check` passed, focused + Phase 47B/native-event regressions passed **41 tests**, and the complete + repository regression passed **678 passed, 3 skipped** with the existing + warning set only. + +Phase 47B completion boundary and remaining debt: + +- Rust and Python now execute the same tested Native Event V2 contract on the + synthetic conformance matrix, including full accounting and lifecycle + metadata. This is a domain-contract lock, not a production performance or + Grid result claim. +- Phase 47C still must run Grid 2,000-bar long-only and long-short parity, + scalar-to-audit fingerprint checks, isolated runtime/RSS benchmarks, and + repeated-run leak checks before any Rust promotion policy can change. +- The full Rust score call currently returns typed Rust equity/position paths + so common metrics can be computed correctly; it avoids pandas/report-frame + construction but is not yet the final scalar-only memory optimization. + +### Phase 47C - Grid 2,000-Bar Parity, Backend Policy, And RSS Benchmark + +Status: **implemented locally; 2,000-bar parity, scalar retention, backend +policy, and isolated RSS/runtime gates pass.** + +Detailed guide sections: + +- Sections `11` to `15` of + [`quantbt_final_grid_python_rust_full_contract_guide.md`](quantbt_final_grid_python_rust_full_contract_guide.md): + 2,000-bar data/configuration, parity gate, isolated benchmark process, + backend policy, and primary Definition of Done. + +Objective: + +- Prove that Grid itself, not merely synthetic micro-fixtures, produces the + same lifecycle/accounting result on Python and Rust. +- Establish a fair runtime/RSS evidence bundle without mixing backend-owned + market representations in one process. + +Implementation: + +- Run the last 2,000 monotonic, unique bars for both + `best_params_long_only` and `best_params_long_short`. +- Execute in this order: replay audit, Python audit/minimal, Python scalar v2, + Rust audit, Rust scalar. Never reuse a strategy instance between runs. +- Compare full command/fill/position/equity/fee/funding/margin/liquidation + parity; certify scalar paths using audit fingerprints plus scalar totals, + never only Sharpe, final equity, or fill count. +- Add `benchmarks/native_event/benchmark_grid_2000.py` with isolated child + processes, one warm-up, five measured runs, median runtime, CPU time, + peak/post-run RSS, and parity fingerprint. +- Add repeated-run RSS plateau evidence and keep the accepted approximately + 180 MB baseline rule: no regression beyond the guide's 10–15% allowance, + no linear leak, and no false 40% reduction requirement. +- Make backend selection policy explicit: Python full/default, Rust explicit + capability-gated, replay oracle, and `auto` Rust only after all certification + and wheel/version checks pass. + +Tests and evidence: + +- Long-only and long-short Grid 2,000-bar parity tests. +- Python/Rust scalar-to-audit fingerprint and totals parity. +- Fresh-process low/high churn and repeated-run memory tests. +- Explicit Rust unsupported capability and no-silent-fallback tests. +- Benchmark JSON must record commit, module version, backend, fixture, + fingerprints, parity status, runtime medians, RSS checkpoints, and gate + results. + +Implementation and evidence: + +- Added [`test_phase47c_grid_parity.py`](../tests/test_phase47c_grid_parity.py). + It imports the external Grid module read-only, generates a deterministic + sorted/unique 2,000-bar OHLCV fixture, and runs both `long_only` and + `long_short` through replay-certified, Python, and explicit Rust audit paths. + It compares command tape, event ledger, fill ledger, positions, equity, + fees, funding, margin, liquidation, and lifecycle counters. Result: + **3 passed** after the Rust scalar retention patch. +- Completed Rust reactive scalar retention: when the prepared runner receives + `scalar_score_contract()`, the Rust adapter uses the same online score state + as Python and does not allocate dense equity/position/fee/funding/margin + paths or retain full ledgers. Both Python and Rust now return + `NativeEventScalarScoreResult`; the public audit path remains unchanged. +- Added [`benchmark_grid_2000.py`](../benchmarks/native_event/benchmark_grid_2000.py). + It accepts optional OHLCV CSV/CSV.GZ input, otherwise uses the deterministic + fixture, runs one warm-up plus five measurements in one backend-owned + process, records median/p95 wall time, CPU time, peak/post RSS, repeated-run + RSS slope, and a SHA-256 audit fingerprint. `gc.collect()` is performed + between retained runs so Python allocator high-water behavior is not falsely + classified as a live-object leak. +- Added the runbook [`grid_native_event_phase47c.md`](../docs/grid_native_event_phase47c.md) + and linked it from the documentation map and endpoint guide. It records the + public endpoint contract, scalar/audit separation, policy, fingerprint + evidence, and the exact benchmark commands. +- Full audit runs produced identical fingerprints for all three backends in + both Grid modes. Long-only terminal equity is `28972.788456089613` with + `839` fills; long-short terminal equity is `20457.971765918566` with `107` + fills. Scalar totals match the same-backend audit for equity, positions, + fees, funding, fills, rejects, cancels, and liquidation. +- The final five-run benchmark evidence on commit `54525d3` (synthetic 2,000 + bars) shows Python scalar medians of `1.138s` long-only and `1.846s` + long-short; Rust scalar medians of `1.245s` and `1.985s`. Rust remains a correctness + and explicit experimental backend here; this workload does not claim Rust + is faster than the Python reactive score facade. +- Audit process RSS stayed bounded under the repeated-run tail-slope gate + after explicit collection. Rust and Python retained different + allocator/high-water profiles, so RSS is reported as evidence, not a + universal hardware claim. The observed full Grid facade peaks are about + `265.6-293.4 MB`; the guide's approximately `180 MB` reference is from a + different native-event process profile, so this phase does not claim an + apples-to-apples absolute no-regression result against that number. + +Acceptance and possible debt: + +- Rust is not promoted or selected by `auto` unless every required gate passes. +- Any RSS failure is reported separately from correctness; it cannot relax + accounting or lifecycle parity. +- If a real Grid workload exposes a contract gap, freeze the result as a + reproducible failing fixture and keep Rust explicit until repaired. + +Phase 47C completion boundary and remaining debt: + +- The Grid integration now has an executable 2,000-bar correctness gate for + both supported modes, a low-retention Python/Rust score contract, and a + reproducible process-isolated RSS/runtime benchmark. The repeated-run + plateau gate passes, while an apples-to-apples pre-Phase47C Grid RSS + baseline remains required before claiming an absolute RSS regression + improvement. `native_backend="rust"` is explicit and fail-fast; `auto` + still resolves to Python. +- The canonical parity surface intentionally excludes the diagnostic + `filled_command_count` aggregate because replay counts filled command + states while reactive sessions count fill records. The exact command/event/ + fill ledgers and accounting paths are compared instead; this naming + difference is documented and not used to hide a lifecycle mismatch. +- Phase 47D remains open for optimizer root-cause profiling, optional Grid + alpha preparation caching, and safe diagnostics-off patches. This phase + does not claim Rust promotion, portfolio/arbitrage/options parity, L2 depth, + or venue-specific cross-margin certification. + +### Phase 47D - Optimizer Root-Cause, Safe Hot-Path Patches, And Final Certification + +Status: **implemented locally; optimizer gate, parity, and RSS certification pass.** + +Detailed guide sections: + +- Sections `17` to `22` of + [`quantbt_final_grid_python_rust_full_contract_guide.md`](quantbt_final_grid_python_rust_full_contract_guide.md): + optimizer bottleneck analysis, scalar-path gate, single-trial profiling, + safe Grid optimizer patches, performance acceptance, and supplemental + Definition of Done. + +Objective: + +- Improve optimizer throughput only after proving that it uses the prepared + scalar evaluator and that every optimization change preserves domain + behavior. +- Explain whether remaining wall time is alpha preparation, strategy callback, + engine score, objective/reporting, or Optuna overhead rather than blaming the + backend generically. + +Implementation: + +- Added `benchmarks/native_event/profile_grid_optimizer_trial.py` to separate + alpha preparation, strategy construction, prepared engine score, public + objective/report work, fill count, and `num_trades`. The apples-to-apples + prepared scalar path measured `0.813s` on the local 2,000-bar five-repeat + profile after the patch. +- The scalar gate is enforced by the external Grid helper: `scores` increments + exactly once, `runs` does not increment, `endpoint.result is None`, and the + score path materializes no public result. +- Added the minimal Grid context declaration and changed + `score_grid_params(...)` to derive `NativeEventScoreRequirements` with + `from_strategy(...)`; fills, active orders, and positions remain enabled. +- Added optional `GridExecutionConfig.collect_diagnostics=True`. The score + helper forces a fresh diagnostics-off policy, avoids all `_diag_*` arrays, + and keeps public/audit behavior unchanged. +- Made `long_entry_*`, `long_exit_*`, `short_entry_*`, and `short_exit_*` + aliases optional. Canonical execution columns are parity-tested and remain + present in scalar mode. +- Did not add `PreparedGridAlphaFactory`: profiling showed alpha preparation + was only about `2.2%`, while the reactive engine callback was about `97.9%`; + a bounded indicator cache would add state complexity + without addressing the measured bottleneck. +- Updated the Grid endpoint/docs and recorded the external adapter patch as + commit `fda46c3` in the separate `alphas_storage` repository. + +Tests and evidence: + +- Added `tests/test_phase47d_grid_optimizer.py` for context requirements, + alias parity, diagnostics retention, scalar gate, public-accounting parity, + and the explicit diagnostics-off report guard. +- Focused Grid suite passes **13 tests** when combined with Phase 47A/47C + (Phase 47C Rust tests remain environment-gated if the extension is absent). +- Re-ran the 2,000-bar Python/Rust scalar benchmark in isolated processes: + long-only `0.850s`/`1.086s`, long-short `1.412s`/`1.831s`; fingerprint, + terminal accounting, and repeated RSS tail gates pass. Peak RSS was + `265.4/271.2 MB` long-only and `291.0/293.6 MB` long-short. +- The public/audit default remains diagnostic-enabled; no public lifecycle, + command, fill, fee, funding, margin, liquidation, or report contract was + relaxed. The detailed evidence is in + [`docs/grid_native_event_phase47c.md`](../docs/grid_native_event_phase47c.md). + +Final acceptance and explicit non-goals: + +- Python single-pass matches replay-certified lifecycle. +- The `num_trades +2` discrepancy is explained by exact transitions/fills or + corrected metric semantics; it is never hidden with tolerance. +- Prepared scalar evaluator is actually used and does not materialize public + results. +- Score-mode diagnostics are optional and do not alter domain decisions. +- Rust full contract, both Grid 2,000-bar modes, scalar paths, RSS plateau, + explicit failure policy, and benchmark evidence all pass. +- This phase does not add a new endpoint, copy the Grid source into QuantBT, + claim portfolio/arbitrage/options Rust parity, or delete the root mirror. + +### Final Grid Upgrade Tracking Rules + +- Before every Phase 47 implementation, read this section and the linked + detailed guide in full; the guide's code snippets and exact contracts take + precedence over a shortened summary here. +- Mark each phase only after its focused tests and the full regression pass, + record the commit and evidence paths, then state remaining debt explicitly. +- The phrase “Rust Grid supported” is reserved for a pass of the complete + Native Event V2 conformance suite plus both 2,000-bar parity workloads. +- Until that point, Python remains canonical, replay remains the oracle, Rust + remains explicit experimental, and `auto` remains Python. + +## Final Release Audit Upgrade: Six-Phase Plan + +Status: **planned; implementation awaits approval.** + +This release pass follows the complete guide: + +[`quantbt_final_release_native_event_endpoint_packaging_audit.md`](quantbt_final_release_native_event_endpoint_packaging_audit.md) + +The guide is the detailed source of truth. The phase summaries below are +tracking boundaries only; every implementation must read the linked sections +and execute the exact contracts, examples, and gates described there. + +### Release baseline and non-negotiable policy + +Current baseline before this plan: + +```text +core distribution: quantbt-engine 1.0.7 +import package: quantbt +native API: 0.4 +Python/replay/Rust: Phase 47 domain evidence available +backend="auto": Python +native extra: empty until public native wheels are certified +src/quantbt: wheel source of truth +root mirror: intentionally retained for Pool Alpha/local development +``` + +Release priority remains: + +```text +domain correctness +→ replay-certified parity +→ stable public endpoint +→ no runtime/RSS regression +→ clean artifacts and TestPyPI +``` + +No phase may: + +- change command timing, fill priority, funding, margin, liquidation, or + accounting semantics to obtain a benchmark result; +- silently fallback when `backend="rust"` is explicit; +- remove the root compatibility mirror before two-way parity and migration + evidence pass; +- claim a public dual backend while `quantbt-engine[native]` is empty or + `quantbt-native` wheels do not install from a clean public index; +- publish to TestPyPI/PyPI without the exact-SHA release gate and user approval. + +The final acceptance target is not a fixed speedup ratio. It is exact lifecycle +parity, no unexplained runtime regression, no RSS regression above the guide's +10–15% tolerance, no positive repeated-run RSS slope, and no trial-proportional +retention. The accepted benchmark scope remains separate for static/batched +Rust and arbitrary Python reactive strategies. + +### Phase 48A - P0 Release Surfaces, API 0.4 CI, And Stale Documentation + +Status: **implemented and locally certified**. + +Detailed guide sections: + +- Sections `1`, `2.1`, `2.2`, `2.3`, `8.1` to `8.4`. +- Patch `1` and the native workflow examples in the guide. + +Objective: + +Close the blockers that would make CI or documentation contradict the actual +Native Event API 0.4 implementation before touching optimization or release +publishing. + +Implementation scope: + +- Update native CI assertions from API `0.3` to API `0.4`. +- Assert the complete required capability set: + `native_event_v2_full_contract`, multisymbol, funding, liquidation, + cancel-all/OCO, TIF expiry, relationships, and quantity preflight. +- Rename stale R0 workflow/job terminology to the current Native Event API + 0.4 terminology; no compatibility redirect is needed for workflow names. +- Add the clean combined core/native install smoke specified in Section 2.1, + but keep the public native wheel matrix gate in Phase 48E. +- Update `docs/release_packaging.md` from the obsolete R1/R2 restrictions to + the API 0.4 contract, explicit Rust fail-fast policy, and `auto=Python` + policy. Keep historical R0/R1/R2 material only under a clearly labelled + history section. +- Align native package metadata, API version wording, project URLs, and + distribution-version/API-version distinction. Never reuse an uploaded + version. + +Tests and evidence: + +- Native workflow API/capability smoke on the exact commit. +- Existing full Native Event conformance suite and Grid long-only/long-short + integration tests. +- Documentation consistency scan for stale API `0.3`, R0/R1/R2 restrictions, + and claims that Rust is the default backend. +- Record the exact workflow file, job names, capability keys, and release + metadata in the phase report. + +Exit gate: + +```text +CI checks API 0.4 +required capabilities are present +release docs match implementation +no execution logic changed +``` + +Phase 48A evidence: + +- `.github/workflows/native.yml` is now the Native Event API 0.4 workflow. Its + smoke gate asserts `_quantbt_native.api_version() == "0.4"` and all eight + required capability keys, including full contract, multisymbol, funding, + liquidation, cancel-all/OCO, TIF expiry, relationships, and quantity + preflight. +- Native metadata is aligned at distribution version `0.4.0` in Cargo, + maturin metadata, and the exported native version constant. The Python + distribution remains `quantbt-engine 1.0.7`; the distribution version and + native API version remain separate contracts. +- `docs/release_packaging.md` now describes the current API 0.4 contract, + explicit Rust failure policy, and `auto=Python` policy. R0/R1/R2 text is + retained only as historical scaffold material. +- The core wheel and native wheel were built and installed together into an + isolated target directory. The smoke imported `quantbt` from that target, + verified native version `0.4.0`, API `0.4`, and all required capabilities. + Both wheels passed `twine check`. +- Focused regression: `87 passed, 2 skipped`. Rust checks passed with + `cargo fmt --check`, `cargo clippy --all-targets --all-features -- + -D warnings`, and `cargo test --release`. +- The host does not provide `uv`, `python3-venv`, or a network-independent + clean virtualenv bootstrap. The combined wheel smoke therefore used the + repository Poetry Python with a fresh `pip --target` install; the CI clean + install workflow remains the authoritative isolated-environment gate. +- No execution semantics changed. The Rust source adjustments outside version + metadata are formatting and explicit dead-code annotations required by the + strict lint gate. + +### Phase 48B - Two-Way Mirror, Git Hygiene, Secret Safety, And Artifact Allowlist + +Status: **implemented and locally certified**. + +Detailed guide sections: + +- Sections `2.4`, `7.1` to `7.7`, and the mirror code block in Section 2.4. +- Patch `2` and the artifact inspection commands in Section 7.7. + +Objective: + +Make the open-source repository auditable without deleting the root mirror or +mistaking private/local artifacts for package source. + +Implementation scope: + +- Add the explicit mirror manifest and two-way byte/hash test. It must detect + both missing files in the root mirror and extra root-only Python files. +- Add `tools/sync_source_mirror.py` with explicit, non-automatic directions: + `--src-to-root`, `--root-to-src`, and `--check`. Never merge both trees + automatically. +- Keep `src/quantbt` as wheel source of truth and the root mirror as a + compatibility source until migration is explicitly completed. +- Replace blanket `.gitignore` rules for `upgrade/` and `benchmarks/` with + selective private/local/cache/build rules from Section 7.3. +- Keep tracked implementation plans, tests, docs, deterministic fixtures, + benchmark scripts, accepted summaries, and small JSON evidence visible. +- Add the `implement.md` presence/non-ignored CI gate. +- Add the release secret scan and review documented false positives. +- Add explicit wheel/sdist artifact inspection and an allowlist/denylist gate; + secrets must never be protected only by `MANIFEST.in` after entering Git. +- Add or align `MANIFEST.in` only for sdist content control, with private data, + credentials, profiler output, and local artifacts excluded. + +Tests and evidence: + +- Two-way mirror test and sync-tool check mode. +- `git ls-files --error-unmatch upgrade/implement.md` and check-ignore gate. +- Secret-path scan and manual review record. +- Wheel/sdist listing plus suspicious-path rejection fixture. +- Full regression after `.gitignore`, manifest, and tooling changes. + +Exit gate: + +```text +src/root trees are byte-identical over the explicit manifest +implement.md remains visible +private files remain ignored +accepted benchmark evidence remains trackable +wheel/sdist contain no suspicious private paths +``` + +Phase 48B evidence: + +- `tools/source_mirror_manifest.py` defines the allowlisted compatibility + surface. The current manifest-listed root/source Python mirror is byte-identical; + `src/quantbt/benchmarks` and root benchmark scripts are intentionally not + mirror entries, so benchmark/tool files cannot be confused with package + compatibility source. +- `tools/sync_source_mirror.py` supports only explicit `--src-to-root`, + `--root-to-src`, or `--check` directions. It never merges both trees and + never deletes an unknown root-only file. Extra, missing, or drifted files + stop the command with a reviewable report. +- `.gitignore` no longer blankets `upgrade/` or `benchmarks/`. Public plans, + tests, docs, tools, benchmark scripts, and accepted evidence remain visible; + only private planning, local benchmark output, caches, credentials, local + data, and build/profiling artifacts are ignored. +- CI, TestPyPI, and PyPI workflows now require tracked/non-ignored + `upgrade/implement.md`, run `tools/scan_public_secrets.py`, and inspect + built artifacts with `tools/check_release_artifacts.py` before upload. + Generic documentation terms are excluded from the high-confidence scanner; + actual credential-shaped matches still fail for manual review. +- `MANIFEST.in` controls sdist content and excludes private/local paths and + credential extensions. The core wheel allowlist is `quantbt/**` plus its + own dist-info metadata; a suspicious-path fixture is rejected by tests. +- Focused Phase 48B hygiene checks: **8 passed**; the compatibility/release + bundle with prior source-tree and CI packaging locks passed **15 passed**. + Coverage includes mirror parity, extra/missing/drift detection, check mode, + visibility, secret scanning, workflow gates, and artifact rejection. Built + `quantbt-engine 1.0.7` wheel/sdist passed `twine check` and the artifact gate. + +### Phase 48C - Stable Event-Driven Facade And Strategy Protocol + +Status: **implemented and locally certified**. + +Detailed guide sections: + +- Sections `3.1` to `3.6` and `9`. +- Patch `3` and all stable usage examples in Section 3.4. + +Objective: + +Stop endpoint surface drift while preserving every existing constructor and +execution behavior. The new facade is a configuration resolver, not a second +execution engine. + +Implementation scope: + +- Add `NativeEventProfile` values `research`, `optimize`, and `audit`. +- Add canonical `QuantBTEndpoint.event_driven(...)` with the small public + surface: + + ```python + event_driven( + input_mode="strategy", # strategy | orders + profile="research", # research | optimize | audit + backend="auto", # auto | python | rust + ..., + ) + ``` + +- Delegate `input_mode="strategy"` to the existing + `native_event_strategy(...)` path and `input_mode="orders"` to the existing + `native_event_lifecycle(...)` path. Do not duplicate matching/accounting. +- Resolve profiles exactly as the guide specifies: + `research=fast/single_pass/minimal/none`, + `optimize=fast/single_pass/score/none`, + `audit=audit/replay_certified/audit/memory`. +- Map public `backend` to internal `native_backend`; do not expose + `replay_certified` as a language backend in this facade. +- Raise on contradictory profile-controlled low-level options instead of + silently overriding them. Keep the advanced legacy constructors available + for custom combinations and backward compatibility. +- Document one `NativeEventStrategy` protocol for stateful reactive alphas: + `initialize`, `on_bar_close`, `finalize`, and declared context requirements. +- Document the three input levels: target/signal, explicit order tape, and + stateful reactive strategy. Grid remains a strategy-level integration, not a + Grid-specific endpoint. +- Add a concise README quick start and move low-level flags into advanced docs. + +Tests and evidence: + +- Profile mapping tests for research/optimize/audit. +- Strategy and explicit-order delegation parity against existing endpoints. +- Conflict validation tests. +- Backward compatibility tests for + `native_event_strategy`, `native_event_lifecycle`, and `orders`. +- Grid 2,000-bar fingerprint/accounting parity through the new facade. +- Public result API smoke: `simulate`, `show_metrics`, `full_report`, + `quick_plot`/tearsheet where applicable. + +Exit gate: + +```text +new facade changes configuration only +existing endpoint snippets remain valid +no domain behavior changes +new users need profile/backend, not internal lifecycle flags +``` + +Phase 48C evidence: + +- `QuantBTEndpoint.event_driven(...)` is the stable public resolver for both + `input_mode="strategy"` and `input_mode="orders"`. It delegates to the + existing `native_event_strategy(...)` and `native_event_lifecycle(...)` + constructors, so no second matcher, fill engine, or accounting path was + introduced. +- `NativeEventProfile` exposes only `research`, `optimize`, and `audit`. Their + exact mappings are `fast/single_pass/minimal/none`, + `fast/single_pass/score/none`, and `audit/replay_certified/audit/memory`. + The public `backend` selector is limited to `auto`, `python`, and `rust`; + `replay_certified` remains an advanced internal kernel selector. +- Profile-controlled low-level values raise an explicit conflict error rather + than being silently overwritten. Existing low-level constructors remain + available for advanced combinations and backward compatibility. +- `NativeEventStrategy` is exported as a runtime-checkable structural protocol + for `initialize`, `on_bar_close`, and `finalize`; the existing duck-typed + `NativeEventStrategyProtocol` remains compatible with older strategies. +- Focused facade/profile/delegation tests pass, including accounting equality + against direct native-event strategy and lifecycle endpoints. README and + `docs/endpoint.md` now document the stable declaration, profiles, input + modes, strategy responsibilities, backend release policy, and escape hatch. +- Added `benchmarks/benchmark_phase48c_event_driven.py`, which runs direct and + facade routes in fresh processes on the fixed 2,000-bar baseline and reports + the external reactive Grid separately. The latest five-run evidence records + common throughput of `12,407` versus `12,942` bars/s and Grid throughput of + `1,410` versus `1,430` bars/s; facade/direct fingerprints and accounting are + identical in both cases. +- The source mirror was synchronized with `tools/sync_source_mirror.py` and + `--check` passes. Focused Phase 48C and compatibility tests pass **22/22**; + full regression passes **704 passed, 3 skipped** with no failures. + +### Phase 48D - Rust Full-Session Ownership, Output Requirements, And Indexed Lifecycle + +Detailed guide sections: + +- Sections `5.1` to `5.8`, including P1–P6. +- Optimization order `O1` to `O3` in Section 5.17. + +Objective: + +Reduce full-contract Rust allocation/RSS overhead without changing the +replay-certified lifecycle. This is the main native performance phase and must +be implemented as individually testable patches, not one broad rewrite. + +Implementation scope, in order: + +1. Share immutable prepared market data with `Arc`; sessions + own only mutable account/lifecycle state and never clone OHLCV/funding tape. +2. Replace the growing historical order vector with a stable-priority arena, + free list, generation-safe slot references, and bounded tombstone + compaction. Preserve active insertion priority and relationship references. +3. Add relationship/expiry indexes for parent activation, OCO cancellation, + GTD expiry, group filters, and active-only `CANCEL_ALL`. Index lookup must + preserve replay event order. +4. Add internal `FullOutputRequirements` for score, reactive-context, and audit + output. Keep the old full `step()` behavior as a compatibility wrapper. +5. Replace nested per-step vectors with reusable SoA buffers; clear without + shrinking on every bar and expose explicit excess-capacity release. +6. Add typed frozen PyO3 step/sparse chunk result classes while retaining + dictionary conversion only at backward-compatible public boundaries. + +Every subpatch must preserve: + +```text +command effective bar and priority +accept/reject and reason +parent/group/OCO/expiry lifecycle +fills and prices +funding +margin/liquidation ordering +positions/equity/fees/turnover +``` + +Tests and evidence after each subpatch: + +- Replay-certified → Python single-pass → Rust exact conformance. +- All actions/order types/TIF/quantity constraints/reduce-only/relationships. +- Single- and multi-symbol, funding, margin and liquidation. +- Stable priority after arena slot reuse and compaction. +- 100k terminal-order retention fixture and active-only scan evidence. +- Prepared market shared by two sessions; reset cannot mutate the tape. +- Output requirement combinations and old `step()` compatibility. +- SoA capacity/release counters and typed result field parity. +- Grid long-only/long-short parity after every patch. +- Repeated 100-run RSS plateau and high-churn benchmark. + +Exit gate: + +```text +exact discrete lifecycle parity +numeric parity at documented tolerance +no prepared-market duplication in full sessions +no historical-order retention proportional to terminal orders +RSS/runtime improvement or neutral result +no Rust fallback or API drift +``` + +### Pre-Phase 48E - Apples-To-Apples Native Event Performance Pass + +Detailed guide: [`quantbt_final_grid_python_rust_full_contract_guide.md`](./quantbt_final_grid_python_rust_full_contract_guide.md), sections `# QuantBT pre-48E`, `pre-48E.A` through `pre-48E.F`, and acceptance sections `8` and `9`. + +Status: **complete**. The accepted evidence is frozen before Phase 48E. + +Objective: establish a current, reproducible performance baseline and apply only +domain-preserving zero-work optimizations to the native event Python/Rust paths. +Historical Phase 43 numbers are reference-only; all accepted numbers must use +the same commit, machine, tape, contract, and process-isolated runner. + +Scope and execution order: + +1. **Baseline freeze (`pre-48E.A`)** + - Add `benchmarks/native_event/benchmark_pre48e.py`. + - Use one deterministic `2,000`-bar single-symbol tape for comparable + native-event/common reporting and the same command tape for Python/Rust. + - Measure explicit lifecycle orders and generic `native_event_strategy` in + separate cases; run `score` and `audit` separately. + - Separate cold preparation/first execution from warm execution. Use a fresh + subprocess per route, `7` measured warm runs, median, p95, CPU time, + `VmHWM`/peak RSS, post-prepare RSS, and post-run RSS. + - Record bars, commands, events, fills, active-order peak, commit SHA, + Python/NumPy/Numba/Rust API versions, backend resolution and contract. + - Save JSON/Markdown under `benchmarks/native_event/results/pre48e/`. + +2. **Python safe fast paths (`pre-48E.B`)** + - Cache quantity-constraint enablement at session construction. + - Skip retime, quantization and schedule allocation for empty command batches. + - Preserve all preflight behavior for `PLACE`/`REPLACE` when constraints are + enabled; do not change timestamp, next-bar, rejection, fill or accounting + semantics. + - Expose execution counters for bars, commands, retime/quantize calls, + contexts, snapshots and constraint preflight so speed claims are auditable. + +3. **Score/audit separation (`pre-48E.C`)** + - Keep score output scalar/minimal and audit output full. Do not create audit + ledger objects in score mode merely to discard them later. + - Preserve the existing public result surface and undeclared-strategy + compatibility. Any strategy context requirement remains explicit. + +4. **Rust bridge/allocation evidence (`pre-48E.D`)** + - Benchmark the existing prepared Rust full-tape score/audit contract with + the identical compiled tape. Do not claim Rust parity where the extension + capability gate rejects a feature. + - Report PyO3 call count, prepared-market reuse, command-buffer reuse and + allocation/copy counters where available. No silent Python fallback for an + explicit Rust route. + +5. **Lifecycle and Grid evidence (`pre-48E.E`)** + - Run high-churn explicit lifecycle and Grid smoke/parity separately. + - Grid/reactive results are written to this plan only; they are not merged + into the README native-event throughput headline. + +6. **Freeze accepted result (`pre-48E.F`)** + - Save before/after artifacts, exact fingerprints, parity tolerances and + remaining hotspots. Required parity covers effective commands, lifecycle + status/rejection, fills, positions, fees, funding, turnover, margin, + liquidation and final equity. + +Acceptance gates: + +```text +Python/replay/Rust exact lifecycle parity on the supported contract +score/audit parity and prepared/non-prepared parity +no changed fill, rejection, fee, funding, margin or liquidation behavior +no RSS regression >10-15%; repeated-run RSS remains bounded +same 2,000-bar contract and s/ms formatting in the README benchmark table +explicit Rust remains fail-fast when its capability contract is unavailable +``` + +Deliverables: + +- benchmark script, JSON and Markdown evidence; +- focused parity/counter tests; +- README native-event benchmark table only for the common native-event routes; +- Grid/reactive evidence and remaining hotspots in this implementation plan; +- a committed pre-48E result before entering Phase 48E. + +#### Pre-48E evidence and close-out + +The gate was executed with `benchmarks/native_event/benchmark_pre48e.py` using +the required deterministic 2,000-bar, one-symbol tape, fresh subprocesses, +seven warm runs, separate score/audit routes, and the same compiled command +tape for Python and Rust. The complete before/after evidence is in +[`benchmarks/native_event/results/pre48e/report.md`](../benchmarks/native_event/results/pre48e/report.md); +the machine-readable artifacts are `baseline.json` and `after.json` in the +same directory. + +All eight required parity groups passed: + +```text +common_low/high_churn x score/audit PASS +explicit_low/high_churn x score/audit PASS +numeric accounting atol <= 1e-12 PASS +discrete lifecycle fields exact PASS +``` + +The fingerprint covers effective accounting outputs, positions, fees, +funding, margin, fill rows, final equity, and fill/event/rejection/cancellation +counters. The Python safe-path patch removed empty-batch retime/quantize work +and retained quantity preflight whenever constraints are enabled. The common +Python score route improved from `0.148483s` to `0.087736s` on the frozen +workload (`13,470` to `22,796` bars/s); common Python audit improved from +`0.166945s` to `0.087327s` (`11,980` to `22,902` bars/s). Rust used the prepared +full-tape bridge and stayed parity-locked; its common score route moved from +`0.232064s` to `0.188448s`. Explicit Rust score remained the fastest measured +route at `0.000964s` (`2,075,635` bars/s) for the low-churn tape. Short explicit +high-churn score runs varied slightly and are intentionally not presented as a +universal speed claim. + +Peak RSS is reported alongside every route. The Python common audit path fell +from `316.3MB` in the old warm baseline to `240.8MB` after the patch; other RSS +changes remain within normal process/import noise and no route exceeded the +accepted regression envelope. No accounting, preflight, fill, rejection, +funding, margin, or liquidation work was removed for the speed result. + +Reactive evidence was measured separately with the existing +`benchmarks/benchmark_phase48c_event_driven.py`, also on 2,000 bars. Direct +Grid and `event_driven(profile="audit")` both produced `839` fills and final +equity `28,972.788456`, with parity **PASS**. The measured Grid facade overhead +was `+2.04%` (`1.146060s` direct vs `1.169473s` facade); peak RSS was about +`274.5MB` for both routes. This result stays in the plan and is deliberately +excluded from the README common native-event throughput headline. + +Pre-48E remaining hotspots, carried into Phase 48E, are Python context/timestamp +boxing and higher-level WFO/service loops, PyO3/context bridge cost on generic +callbacks, audit report construction, and deeper RSS retention analysis. These +are optimization candidates only after the same parity contract continues to +pass. The pre-phase does not certify Rust as the default reactive Grid backend. + +### Phase 48E - Python Context/Command Reuse, Dual Backend Wheels, And Native Certification + +Detailed guide sections: + +- Sections `5.9` to `5.16`, `8.4` to `8.5`, and `6.3` to `6.4`. +- Optimization `O4` and `O5` in Section 5.17. +- Native wheel matrix in Section 2.2. +- The deeper implementation and release evidence is also governed by + [`quantbt_final_release_native_event_endpoint_packaging_audit.md`](./quantbt_final_release_native_event_endpoint_packaging_audit.md), + sections `5.2` to `5.18` and `O1` to `O5`. That guide is normative for + ownership, output profiles, cache/reset behavior, RSS evidence, and the + PyO3/maturin release boundary. + +Objective: + +Finish the Python↔Rust boundary and certify a real public native distribution +before considering a non-empty `[native]` extra. + +Status: **implemented; local gates pass, public wheel matrix remains a CI/release gate**. +The source contract, local CPython 3.12 extension, parity suite, cache/reset +suite, and 2,000-bar benchmark pass. CPython 3.11/3.13 manylinux wheels are +generated by the committed workflow and still require a successful CI run +before `quantbt-native` can be advertised or added to `[native]`. + +Implementation scope: + +- Add a reusable full-contract Python command buffer with one canonical ABI + layout, capacity growth counters, and no per-bar `zeros/full` allocation. +- Reuse the Python context container and materialize fills, events, + active-order snapshots, positions, margin, and metadata only when required. +- Thread the same context requirements into the Rust full-contract projection + mask. Accounting and live positions remain mandatory; fills, lifecycle + events, and active-order snapshots are omitted from the PyO3 payload when + neither the strategy nor the audit contract requests them. +- Compact terminal Rust order records conservatively after lifecycle work, + preserving insertion order and all `REPLACE` aliases. This bounds long Grid + tapes without changing fill, cancellation, OCO, or replacement semantics. +- Add active-order generation caching and bounded metadata behavior while + preserving full compatibility for undeclared strategies and audit profiles. +- Remove duplicate Python retention through separate prepared Python/Rust + market ownership; `backend="rust"` must release temporary normalized arrays + when safe, while `auto` must not eagerly prepare both backends. +- Add exact session reset, `clear_caches()`, `cache_info()`, capacity counters, + and 100-run reset/fresh-session parity. +- Apply GIL policy from the guide: detach long Rust-only tape/chunk calls; + benchmark, but do not automatically detach very short per-bar reactive + callbacks. +- Add portable Rust release profile (`opt-level=3`, thin LTO, one codegen + unit, stripped symbols, no `target-cpu=native`, no panic-abort shortcut). +- Split the large Python adapter only after behavior/performance stabilizes, + preserving all re-exports and isolating legacy API 0.3 compatibility from + API 0.4 full/ batched modules. +- Add observability counters for bars, commands, fills/events, active peaks, + slots/compactions, snapshots, copies, GIL calls, cache bytes/entries. +- Build native wheels for CPython `3.11`, `3.12`, `3.13`, Linux x86_64 + manylinux2014/`manylinux_2_17` using maturin/PyO3 CI. Do not publish a + locally built Ubuntu-only wheel as public artifact. +- Clean-install each native wheel together with the core wheel, run API and + capability smoke, full Rust contract, Python/replay/Rust parity, Grid + integration, and `pip check`. +- Align native package metadata (`quantbt-native`, preferred `0.4.0`) with API + version and project URLs. If no native wheel is published, keep + `quantbt-engine[native]` empty and label Rust local/experimental. + +Tests and evidence: + +- Python context/command buffer parity and memory counter tests. +- Fresh-vs-reset session exact fingerprint parity. +- 100 repeated runs plateau with bounded capacities and no retained trial + result/strategy. +- Cargo fmt, clippy `-D warnings`, release cargo tests. +- CPython 3.11/3.12/3.13 manylinux wheel install matrix. +- Combined core+native clean install, API `0.4`, required capability keys, + contract suite, Grid smoke, and `pip check` for every wheel. +- Static tape speed evidence remains separate from reactive facade evidence; + no universal Rust speed claim is made. + +#### Phase 48E close-out evidence + +Focused Phase 48E tests are in +[`tests/native_event/test_phase48e_reuse.py`](../tests/native_event/test_phase48e_reuse.py) +and cover reusable command storage, exact reset/cache reuse, context projection +masking, and long-tape terminal compaction parity. The native-event suite is +`75 passed, 2 skipped` with the local API `0.4` extension; the Phase 48E +focused suite is `4 passed`. Cargo `fmt`, Clippy with `-D warnings`, release +tests, and release build pass. + +The apples-to-apples 2,000-bar evidence is frozen in +[`benchmarks/native_event/results/phase48e/after.md`](../benchmarks/native_event/results/phase48e/after.md) +and `after.json`. All eight Python/Rust score/audit groups pass exact lifecycle +fingerprints and numeric accounting at `atol <= 1e-12`. Common callback Rust +remains slower than the optimized Python callback path on this workload; the +Rust advantage is confined to the explicit prepared full-tape route. This is +why `auto` remains Python. The benchmark also records bounded command-tape +cache bytes, one PyO3 static call, output requirements, and RSS separately. + +Local PyO3 execution used the repository Rust toolchain and a CPython 3.12 +extension built with the portable release profile. The committed +`.github/workflows/native.yml` is the authoritative CPython `3.11/3.12/3.13` +manylinux/maturin matrix. Until that matrix and clean combined-wheel install +pass in CI, the native extra stays empty and the native package remains +experimental rather than a public performance/certification claim. + +Exit gate: + +```text +native wheels install on every supported Python target +API/capabilities are 0.4 and complete +full parity and RSS plateau pass per wheel +explicit Rust is fail-fast +auto remains Python for 1.0.7 +[native] is populated only if the public install is real +``` + +### Phase 48E.1 - Native Production Closure Before 48F + +**Status: implemented locally; CI wheel gate pending.** This is a required closure phase between Phase 48E and +Phase 48F. The normative implementation guide is +[`quantbt_final_grid_python_rust_full_contract_guide.md`](quantbt_final_grid_python_rust_full_contract_guide.md), +section `QuantBT Phase 48E.1 - Native Production Closure Before 48F`, including +P0-P7, patch order `48E.1-A` through `48E.1-G`, the mandatory test matrix, and +the acceptance gate. That guide is authoritative; this section tracks the +actual repository work and evidence. + +#### Scope and non-regression contract + +- Complete Rust conditional output allocation, not only PyO3 payload omission. +- Use one lifecycle implementation with count-only and collecting sinks. +- Replace nested per-row hot-path projections with reusable Rust-owned SoA + buffers. Score must not materialize audit rows; audit converts once at the + boundary. No unsafe borrowed NumPy views. +- Preserve the public command ABI (`i64/f64`, 16/3 full command arrays), public + endpoint, timing, fee, funding, margin, liquidation, parent/OCO/TIF and + quantity semantics. +- Add validated compact internal enums/flags, immutable market storage and a + typed PyO3 scalar step result without changing the legacy `step()` surface. +- Make `command_report`, `order_report`, `fills_report` and `order_events` + distinct, with command metadata enrichment performed once at the Python + audit boundary. Rust score/research/audit profiles must have explicit + retention semantics. +- Harden existing compaction/reset relationships and isolate API 0.4 full + capability routing from legacy adapters. Explicit `backend="rust"` must + fail fast when its capability contract is unavailable; no silent fallback. + +#### Tracked implementation order + +1. `48E.1-A`: freeze current parity, report schema, RSS/runtime and counters. +2. `48E.1-B`: implement `StepCounters`/`DetailSink` and prove score allocation + suppression with exact Python/oracle parity. +3. `48E.1-C`: implement reusable `FillBuffer`, `EventBuffer`, + `ActiveOrderBuffer`, typed step payload and adapter projection tests. +4. `48E.1-D`: compact validated internal types/flags and fixed market arrays; + run Rust format, clippy and release tests without ABI changes. +5. `48E.1-E`: close report semantics, command metadata, full-report parity and + export bundle tests. +6. `48E.1-F`: cover replacement aliases, waiting parent/child, OCO, GTD, + priority, multi-symbol and fresh/reset parity; run 100-run memory plateau. +7. `48E.1-G`: build/install CPython 3.11/3.12/3.13 manylinux wheels in CI, + clean-install core plus native, run capability/full-contract/Grid/report/ + `pip check` and RSS gates. Local Ubuntu wheels are not public evidence. + +#### Required tests and evidence + +- All command actions, order types, GTC/GTD/IOC/FOK, quantity constraints, + reduce-only, parent/group/OCO, funding, margin and liquidation paths. +- Every valid output-mask combination: counts, positions, fills, events, + active orders, mixed projections and full audit; accounting/lifecycle must + remain identical. +- Python/Rust/oracle parity at `atol <= 1e-12` for accounting and exact + discrete lifecycle parity, including report schema/value parity. +- Score buffers do not grow, audit buffers reuse capacity, reset is equivalent + to a fresh session, prepared market is shared, and 100 runs have bounded RSS. +- Isolated low/high-churn explicit, generic callback and Grid benchmarks with + CPU time, median/p95, VmHWM, RSS, capacity growth, PyO3 calls, returned bytes, + compactions and margin recomputes. No speed claim may hide missing domain + work, and no unexplained regression over 10-15% is accepted. + +#### Exit gate + +Phase 48E.1 is complete only when R3/R4 allocation counters, typed/result and +report contracts, parity, compaction/reset, bounded RSS and the installed-wheel +matrix pass. Any unavailable wheel target or report/correctness blocker keeps +this phase open; Phase 48F remains limited to artifact/TestPyPI/release work. + +#### Phase 48E.1 implementation and local evidence + +Implemented in the Rust full-contract core and both Python mirrors: + +- `StepCounters`/`DetailSink` is the single lifecycle output path. Score uses + count-only mode, so fills/events/active rows are not materialized or allocated + before the PyO3 boundary. +- Reusable `FillBuffer`, `EventBuffer`, `ActiveOrderBuffer` SoA storage is + cleared without shrinking. Static audit consumes those columns directly; + compatibility/reactive projections materialize rows only when requested. +- API 0.4 `FullStepResultCore` provides typed scalar fields and optional + projection fields. The old dictionary `step()` method remains intact. +- Rust internal order state now validates side/order-type/TIF at the boundary, + stores symbol/side/type/TIF/activation in compact representations and packs + reduce-only into a flag. Public command IDs and the 16/3 ABI remain `i64/f64`. +- Market and fixed account arrays use boxed immutable storage behind the shared + `Arc` ownership. Existing compaction/reset behavior is kept; + relationship coverage includes replacement aliases, parent/OCO/GTD paths. +- Per-bar margin valuation is cached safely. The first close-margin lookup scans + the symbol set once; accepted fills update only the changed symbol's initial + and maintenance contribution in O(1), while liquidation invalidates the + cache. `margin_recompute_count` is observable through `cache_info()` and is + covered by parity/plateau tests; formulas and post-cost margin gates are + unchanged. +- Rust audit now exposes independent command-intent, lifecycle order and fill + reports. Fill metadata is enriched from the immutable command side table; + `command_report` is never an alias of `order_report`. +- Explicit Rust capability selection includes quantity-preflight capability and + continues to fail fast; no silent Python fallback was introduced. + +Focused evidence: + +```text +tests/native_event/test_phase48e1_closure.py 4 passed +tests/native_event suite 79 passed, 2 skipped +cargo fmt / clippy -D warnings / cargo test --release PASS +margin recomputes are bounded to at most one per bar on the 100-run +score/reset fixture +``` + +The isolated 2,000-bar rerun is in +[`benchmarks/native_event/results/phase48e1/after.md`](../benchmarks/native_event/results/phase48e1/after.md) +and `after.json`. All eight score/audit Python/Rust parity groups pass exact +fingerprints and `atol <= 1e-12`. Common callback measurements remain a +separate facade result (Python is faster on this tape); explicit prepared Rust +score reaches `6.92M bars/s` low churn and `5.46M bars/s` high churn, while +explicit Rust audit reaches `459K` and `309K bars/s`. Explicit Rust audit RSS +is about `182-183 MB`, versus Python audit about `238-240 MB`; common score RSS +is about `182-186 MB` and common audit about `239-242 MB`. The benchmark also +records one margin recompute per bar for the explicit score/audit sessions, +with fill updates handled by the O(1) cache delta path. + +The local clean wheel smoke was run on CPython 3.12 with API `0.4` and +`pip check`. The committed `.github/workflows/native.yml` is the authoritative +CPython 3.11/3.12/3.13 manylinux/maturin gate and now runs the Phase 48E.1 +closure tests. Since this host does not contain CPython 3.11/3.13, those two +installed-wheel jobs remain CI evidence rather than being claimed as local +passes. The native extra therefore remains empty and `auto` remains Python +until the public matrix passes. + +### Phase 48F - TestPyPI Artifact Gate, Release Workflow, And Final Handoff + +**Status: `1.0.7rc1` local release gate complete; feature branch is waiting for +maintainer merge into `dev` before the RC tag/TestPyPI step.** The +implementation follows the packaging/release sections linked from the guide; +no tag, merge, or publish action was triggered from this branch. + +Detailed guide sections: + +- Sections `8.2`, `8.3`, `7.7`, `9`, `10`, `11`, and `12`. +- Patches `6` and `7`. + +Objective: + +Prove that the exact release artifacts install and behave correctly in clean +environments, then prepare a controlled TestPyPI RC. Public PyPI release is a +separate user-approved action after the RC is inspected. + +Implementation scope: + +- Add clean wheel and sdist install steps to `publish-testpypi.yml` before + upload. Install the exact built artifacts, run isolated import smoke, and + run `pip check` for both paths. +- Keep production publishing release-only: exact tag/version gate, GitHub + Release trigger, protected PyPI environment, OIDC trusted publishing, and + no normal `dev`/`main` push upload. +- Build to a clean directory and run `twine check`. +- Inspect wheel/sdist contents against the allowlist; fail on credentials, + private data, profiler output, `.env`, `.pypirc`, key material, or private + planning paths. +- Run the complete local gate from Section 11: clean tree/diff check, `uv + sync`, full pytest, native tests, cargo fmt/clippy/test, build, wheel/sdist + smoke, and artifact scan. +- Update README/docs so the quick start uses stable `event_driven(profile, + backend)` and phase details remain in engineering evidence docs. +- Verify Pool Alpha/local editable-path usage and a clean wheel import in + separate environments; ensure package import resolves from `site-packages`. +- Produce the TestPyPI RC checklist containing exact SHA, version, artifact + hashes, test results, wheel matrix, parity fingerprints, RSS results, and + known policy (`auto=Python`, native extra state). +- Add `tools/create_release_manifest.py` for deterministic artifact SHA256, + commit/ref, version, benchmark-evidence and backend-policy recording. The + manifest is uploaded separately from the publishable wheel/sdist files. +- Extend `tools/check_release_artifacts.py` to inspect archive members and + small file contents, rejecting secret-like content, private/local data, + profiler/compiler output and unsafe paths while allowing the public + `quantbt/benchmarks` Python package. +- Keep the source mirror and local editable workflow unchanged; the wheel is + still built only from `src/quantbt` and the root mirror is not copied into + the distribution. +- Do not publish PyPI or merge branches in this implementation phase without + explicit approval. The guide's public order remains: native first if real, + then populate `[native]`, then core release, otherwise release Python-first + with native clearly experimental. + +Tests and evidence: + +- `uv run pytest -q`, Native Event tests, source mirror tests. +- `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test --release`. +- `uv build`, `twine check`, wheel clean install, sdist clean install, and + `pip check`. +- TestPyPI workflow dry-run/build validation and exact artifact install. +- Secret scan, package-path allowlist, version/tag/ref consistency, and + `quantbt.__file__` site-packages check. +- Final report must classify: + `domain correctness`, `Python performance/RSS`, `Rust performance/RSS`, + `endpoint usability`, `core PyPI`, and `public dual-backend installation` + separately, exactly as Section 12 does. + +Local Phase 48F evidence: + +```text +tests/test_phase48f_release_gate.py and release regressions 20 passed +full repository regression 720 passed, 3 skipped +native-event regression 79 passed, 2 skipped +twine check wheel + sdist PASS +archive allowlist/secret scan PASS +wheel target import from /tmp PASS +sdist target import from /tmp PASS +manifest version/hash/backend policy PASS +``` + +The reproducible local artifact manifest records the exact `1.0.7` wheel and +sdist hashes, the release commit SHA/ref, and the current policy `auto=Python`, +`native extra=empty`, explicit Rust experimental. The GitHub workflows recreate +this manifest after checkout so its SHA always identifies the exact release +artifact commit. They additionally run the dependency-complete fresh-venv +`pip check` and CPython 3.11/3.12/3.13 matrix; those hosted jobs are the final +multi-interpreter evidence because this VPS has only CPython 3.12 and no +system `python3-venv` package. + +Exit gate: + +```text +exact release SHA is green +wheel and sdist are clean-installable +artifact contents are safe +TestPyPI RC is reproducible once the workflow is run with the matching RC tag +endpoint quick start is stable +native extra claim matches actual public wheels +``` + +#### Final release decision boundary + +The six phases are complete only when Phase 48F has produced a reproducible +TestPyPI-ready artifact bundle. At that point: + +```text +core Python package: publishable after user approval +Rust reactive correctness: certified for Native Event V2 tested matrix +Rust static/batched performance: report separately +backend="auto": Python for 1.0.7 unless policy is explicitly changed +native extra: empty unless quantbt-native wheels passed public clean install +``` + +The plan deliberately does not promise further raw benchmark gains before +TestPyPI. Any future native DSL, portfolio/arbitrage/options native backend, +or deeper reactive callback optimization must be a new parity-first upgrade +after this release gate rather than being mixed into the packaging release. diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..90bbfe9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2193 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.14" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "arch" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "statsmodels" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/50/f8be4b21db5eb0490aef82b592d105baac957f601805ee7fe5b9182405b2/arch-8.0.0.tar.gz", hash = "sha256:5e9895c2354b9475aff50797ff2191dc64dc5f79602baf0c9321310fb864b637", size = 872623, upload-time = "2025-10-21T08:55:52.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/8f04a871c2e0f94430c15d313f88fe7808d80c4752b0ebdeadfec21dec8e/arch-8.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94262bef94dda3f72182a8dfc21cab1a8a79750cf168f3cf2aec02d7217bee55", size = 940443, upload-time = "2025-10-21T08:46:46.648Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/7f1b880857839ea0841304586715c7d2a477552d04bcde32d1d55d8ccaa0/arch-8.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1d9cb343f15e71e9cee2415bffa1e3458aeb674a538118de71f1124b6c5b755a", size = 929795, upload-time = "2025-10-21T08:39:58.066Z" }, + { url = "https://files.pythonhosted.org/packages/94/d8/44724b06cff6f51b977e8b947403c846be4645c9333d2cad350101b917ca/arch-8.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b72818d66e3ba1f5fcf2a7af4d81a1da7c70e72edf9437144a013173e11b901d", size = 974063, upload-time = "2025-10-21T09:11:39.331Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/7cc035e2a08b9186cfbd0b5d3dd3967481f64722c3af69416edc8a182fd7/arch-8.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975ec3bdf7926335742ac362251fafe32b448b8f194dead062f22a00beef772d", size = 990702, upload-time = "2025-10-21T09:11:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4e2dad5b4b88d872a9afd29916ced89116cb31a7c81ed4cbfb2de972cae5/arch-8.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c9d0c8b26f49e3f8b7ae4ade15fac74555c95701a3e22463d991ce4ae7cea966", size = 993284, upload-time = "2025-10-21T09:11:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4b/abfe066b00a5f1f0ab80dc5b7424f9fc1008116546fefcc1d17def0be9b6/arch-8.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:2aa5e631f91283733592b44e0c0640da5f690f5895738ad0d26a007325e3d0fc", size = 937932, upload-time = "2025-10-21T08:43:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/84/6e/b4379d1dee984f4a51afad9bfb49a3079ae196faf0bb834b7b5ad8e5ec6a/arch-8.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:268dfe386f8c64a1973374bc0425bdf0c7c2250c2bfd7238d98bae701827ec2b", size = 942557, upload-time = "2025-10-21T08:45:19.825Z" }, + { url = "https://files.pythonhosted.org/packages/8d/54/ab79d924327497fddb462ce51216d193e374ad2295b1003542802ed9a021/arch-8.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f4341b22279d82d0300ebd54d1d5f80324f31fc017c8138f47e810bdb81d753", size = 932106, upload-time = "2025-10-21T08:42:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1d/82a772cbc8d64a804438a618f766574d3c87c888342240465761fdba9dec/arch-8.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e551820a0640736c9e9b8fa10ce50e7ae4f31e570ec229c308a3b46aaf8242a7", size = 964602, upload-time = "2025-10-21T09:13:26.715Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d3/da7d55f51bb31a10d1b4a01a22ec0180265a5afeed0d99bd4d0c7b3a61e1/arch-8.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13cbf04d45ecbee7578704a232f897cd02794d845f877158fb2838e6fb637887", size = 981331, upload-time = "2025-10-21T09:13:28.013Z" }, + { url = "https://files.pythonhosted.org/packages/db/be/b44592be8f7926e04f2646206ef83cd68f40e948465fff651b739412146a/arch-8.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fab6e25763e1ef516d8b6c932ef1d0aac3ec812d6b501fc57d8269333d02ce86", size = 983205, upload-time = "2025-10-21T09:13:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/ef/86/612d45473d0865d41934b0580fa05e6aa48167b502d0136e8bd9dd5aa581/arch-8.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b13d261e0a681b3a8a2f9c588ab37a35500bca9f3bbcc6ca1ce2d999322651d", size = 930370, upload-time = "2025-10-21T08:42:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/78f84f9e486e173356931b2bfaf0c2a6d6923f1e8975045e3416ac388215/arch-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4320a9b3707e819a97f0b0a10847e529e2f765158c617a455987a34305018617", size = 940530, upload-time = "2025-10-21T08:46:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b8/73910773efffc2d35d2739be1bdc70dfcc58a83cff35c4d62e14acceca2b/arch-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b75bc7f4af4da5aca6cbcc52284564fcce8c974cf7d89d8b9777d8c16a228b0", size = 930359, upload-time = "2025-10-21T08:40:11.285Z" }, + { url = "https://files.pythonhosted.org/packages/1c/04/bdd65c773f6ce60cae50cb4f85bcf15dcbe687df75998966e5a236125182/arch-8.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aaefeb2b23276fe286fe554e7e69fea80daff4185ecdf9fc891ba1b2c1e49ad4", size = 964843, upload-time = "2025-10-21T09:13:48.538Z" }, + { url = "https://files.pythonhosted.org/packages/d6/40/7b7ac152c35c32da2a00ba3523ea84c358478b12ee7b3b2b6892e5b9d81b/arch-8.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c1f1d8abefab2f69f7fdbef08cc18c8377667d3b8d197a1f301d97f0e686cd2", size = 982864, upload-time = "2025-10-21T09:13:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/80/40/d99c7d3e0a471d5e0f3e6b3ff1145db789e5a1c4e8fed25e8c22629e87fc/arch-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a985abc5367d225a6b346782dee9e7d84381c2af5ab795a6234aa1491c96f0bb", size = 985288, upload-time = "2025-10-21T09:13:52.243Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e7/2d15374129c03b6f97321f837190cb19863204dbcff289e23cc37f035c96/arch-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:bd73bd2d811bcf0551443b6e0a10bc25af002e9eb146aff164897c70aac35e85", size = 929688, upload-time = "2025-10-21T08:42:06.529Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "colorlog" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.164.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/ac/7b76103bd74d8457e4de0c6a6c3a26ac6327016438bde125e0a3de83a5b8/hypothesis-6.164.0.tar.gz", hash = "sha256:5d63d263d8c71b571638c18d9591f6e34b836c60a12469e9d9105c1c785f00f1", size = 492022, upload-time = "2026-07-30T12:39:49.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/fe/d5b75a55892b33e72945f82efc71f645d29c0bfdb9f00727f7535a52edcc/hypothesis-6.164.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:14b861ac3353f8643b82a3ba76b8a0a54d2a06160c32b9a1f64a8ab41b179089", size = 771561, upload-time = "2026-07-30T12:39:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/2f01e9efc7267446bad0e2a68f7472daa174a72553d213b16aefe44b2bda/hypothesis-6.164.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3d8c8bb00a4b86ae90b9ad41f3e1c99d016ec3e64c0ff9d676a4bb7be4f56948", size = 767079, upload-time = "2026-07-30T12:39:23.123Z" }, + { url = "https://files.pythonhosted.org/packages/c3/26/d7bcd26b58e1df2bd39116b924b2a72676215d9650e68cbff9a629c3ce30/hypothesis-6.164.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e80e3ba8eaf37664eaa0f2625cef120b330b128a7df570210cf8be4f5ae65aaa", size = 1096364, upload-time = "2026-07-30T12:38:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/9d/17/99fe7ea866935da83444c3ef7885a14fc7349d96ff61c6faebd37ef4edf2/hypothesis-6.164.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8cdf70f821e2d2f3a0bccaab29830aea8aefb63a77806e7e91246fb65a10c8d3", size = 1124963, upload-time = "2026-07-30T12:39:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/38/e8/df08be6296cbc1271d44e81f8ff9dcd6267a07552fb768e0fdc166e93d40/hypothesis-6.164.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc3743e22b3cffa7267b4bc74d03628606e4a115495728e986a7be220987315", size = 1145886, upload-time = "2026-07-30T12:39:45.612Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/d5cf6fbfac40891d4281f630e16a6eb217ff56f97e350a06e0fd9322aa6a/hypothesis-6.164.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:730f09d4afcd8a918b3d589bfb6421e3b41c057aa57652a773ef4f512cc60836", size = 1101181, upload-time = "2026-07-30T12:39:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ff/7ceb002329febffb678b65835ca6e9479a916325d088aadb0210d07f8252/hypothesis-6.164.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9651cb48cb5a995295b442138d15d381547b935dcb0066fca7148a7955347400", size = 1137970, upload-time = "2026-07-30T12:39:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/c12c697b73ca9ca24d8a913879e3e0a9db86479754c7221554247c701565/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:51d161d2655dd86143b370c577267b5b7b4c2e8fcb8a3f22c1a787572aad707c", size = 1270184, upload-time = "2026-07-30T12:38:54.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2f/93f1c850c794fc9c80f5e61b3b20652126b865e6f57b348ae530446aadc7/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e8a250552390128b57e3afe55035ce2c2cb1f6f0919817657854244f071bc5be", size = 1397987, upload-time = "2026-07-30T12:38:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/bab2546325e15e87c8518dfbca263c81dbc35d566c516d66c9da98a38b77/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:570cd51944e1cc3443847d8afa3d17fcf8aac475a1f744c9e7318a5ad7ef5c9f", size = 1270755, upload-time = "2026-07-30T12:38:51.571Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4e/ea97dd39678a42dc5a24e3e2a64d3b950fad9fb1dcce8d7be5afb52a0335/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3a423e543055b3de5af7a7624c4285422541658367211fa293a3a57dd0ad01ba", size = 1312888, upload-time = "2026-07-30T12:38:30.847Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/a6f2d5b12b23d65f16eb398750e430065f9d1f40f4418569e3b87ef58d23/hypothesis-6.164.0-cp310-abi3-win32.whl", hash = "sha256:f5e51490b2ce64c66138f24477d83c71b6224ab0ef65700da10187c464b54e94", size = 657401, upload-time = "2026-07-30T12:39:11.581Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d3/c5ee410daa594cac2d3fe1fbe5473f2390e35f4369e168a817e43341ce2f/hypothesis-6.164.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9059dfbb039342b6590bbce207f90e0f9a80fdf45a404c68c2d3e598be78ab3", size = 663566, upload-time = "2026-07-30T12:39:30.27Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/eb86342b486f6392e884f3974ff144ae978e4646787250199f11b9d0931b/hypothesis-6.164.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e0cffc2228f9185e02eb48cfbd8e30ddf9968ccaac55ab1fb70cbda9aad97507", size = 772036, upload-time = "2026-07-30T12:38:22.166Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/15415bd08b9ae221381ae01d6f453055744112b730c4a670b1642fce4b83/hypothesis-6.164.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f19befdb5e2d8ebe42edd0d2150681a97f599af478ed4e5c86f43762d068b760", size = 767811, upload-time = "2026-07-30T12:39:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/59624a5c3194c2cabe0ceeb0d14294b093d8e2d712720bc09320ff73d72a/hypothesis-6.164.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef7115077451e893cb2b96cc30066fc1033ee0788f2d85557fc8479f26b4e6eb", size = 1096709, upload-time = "2026-07-30T12:39:08.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/3a/09d6c06bb24e6099bdfa7bf74d0f22fbb050f02709563d744d705e44f24a/hypothesis-6.164.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfa97ba4c59014260c07c35fb3447c3c47eb2faedcffcdd76a9a7967fcac4208", size = 1146181, upload-time = "2026-07-30T12:39:06.668Z" }, + { url = "https://files.pythonhosted.org/packages/59/11/27390692c2529fed8ebd337127b42299f0f5670f66decedb3cc4c1721d3e/hypothesis-6.164.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:900bb366b2be38c01753345cfdec3b7f30b1b274b9ffa0b0c0bfb3fd085278db", size = 1270536, upload-time = "2026-07-30T12:39:03.321Z" }, + { url = "https://files.pythonhosted.org/packages/43/2a/7021041460601e2711dbb9dc8c5efbefbbbeffb56b52407ae674d02666e5/hypothesis-6.164.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:def37fdb4d4edb9b423e5ae0625f36074063a6ba82a7d3149dd74f59a291ed3b", size = 1313128, upload-time = "2026-07-30T12:38:44.311Z" }, + { url = "https://files.pythonhosted.org/packages/30/1f/40cf7209663d4a1d760ead952c58dfba7aca168455dfd436b4c1764d935c/hypothesis-6.164.0-cp311-cp311-win_amd64.whl", hash = "sha256:14633b36b646e9a2a611834ac0a26cde245440469708f59668327eb54f202692", size = 663260, upload-time = "2026-07-30T12:39:38.707Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/4942fe3f2f08b920368ed5a2937346259e843e382205513b4a0e70d2de9d/hypothesis-6.164.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6bc3373fe550cf4d7cadb94ceaeb91e431e1418a96b7baa330487366eaa67d3c", size = 773152, upload-time = "2026-07-30T12:38:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/eb/df/e66d052386a2b6c3e2f3eab32a02d7de3c9c59cd21d5dd58c08ecfa715f0/hypothesis-6.164.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2780297ca68929b153eff7effb2ebe67e9487d2fd9f49fa961007f8f2d236c9e", size = 764713, upload-time = "2026-07-30T12:38:48.59Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d5/5a50d14b8f04809e973c4dea884b367fef3663ff253c1205fa9e96229ef9/hypothesis-6.164.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b400bb4eb5a4a1e19cd5af3cc63817909e6b54b4603e04022bdba46860913d7", size = 1095160, upload-time = "2026-07-30T12:38:58.925Z" }, + { url = "https://files.pythonhosted.org/packages/58/01/781b19ce4382ec239c4dc6ec3bd9f195e69e5570f2814bbf04b5781ecb18/hypothesis-6.164.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fca6632933fc506dd96926d9383483e4c0066c7ff62c748d059a3276da761e7", size = 1145199, upload-time = "2026-07-30T12:39:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/e9/64/30e016863515ca01c1c738b05dd50491353d3ccae6432362e56e0c15d0da/hypothesis-6.164.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9e1f6e89e5ec34735b727f3ce41d12e7f3b8efc162c91c8a225e10b54b504b4", size = 1267980, upload-time = "2026-07-30T12:38:18.733Z" }, + { url = "https://files.pythonhosted.org/packages/84/23/17eb8d67d59ecd3a820c905fbdf514e371dd7d01631e62a304cdd5793abe/hypothesis-6.164.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:51b0f967f608707b24ed37a298174ae6eec7899bfe3f271d1c3062c39ad66c06", size = 1312181, upload-time = "2026-07-30T12:38:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/cff9f3cd9524252adda7c8e0e129dfc176e72f64fdf0bf1552d1ea43d78d/hypothesis-6.164.0-cp312-cp312-win_amd64.whl", hash = "sha256:5770df7d518bf867a9379e9081abd9e44db1d15473430e26a0946438c08c5926", size = 660690, upload-time = "2026-07-30T12:38:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/729697380a22dc2ce8feae3c64b08bf3bd3c27e99c3706cb9bdac40c6fc8/hypothesis-6.164.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:29e7cb48974cb9fd87602e20625c890385793c6b56c18a957085a9c291f56ef8", size = 773046, upload-time = "2026-07-30T12:39:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/72374f02d90dfda198afd8aac6b1e7d1184506f97e62ebcf3d2c1e5bf761/hypothesis-6.164.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1ff8c3819345be8dd15ee6588ee9383869a54c9a3d2232cce5e26b456424135d", size = 764659, upload-time = "2026-07-30T12:38:55.896Z" }, + { url = "https://files.pythonhosted.org/packages/6e/75/fb26388915d71e5949b98ccd0c9d95edcbe6b45d0370f177d43633d81ae2/hypothesis-6.164.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33e88be13fac3ff7cb789a0b4cc43d99fb297db085f529fbb363188141c7d5bf", size = 1095078, upload-time = "2026-07-30T12:38:34.677Z" }, + { url = "https://files.pythonhosted.org/packages/be/63/f6da6e39667d39a1e44c5df82fbe6cff070c29aaffa9beb62a5322e7d8ae/hypothesis-6.164.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2e296d03a77355ce2e1c32e85a636b555edf0ddaaef277f98f1b84fe38a4595", size = 1145015, upload-time = "2026-07-30T12:39:26.487Z" }, + { url = "https://files.pythonhosted.org/packages/88/c7/55ba09727da3d9a60628c50e31e6083a36f403cb230f5e1a7bd1749a5c39/hypothesis-6.164.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:53698a1b246714539dd0ecc2d556cde613d74e9f7385ec4109e0651ab2d382d6", size = 1268027, upload-time = "2026-07-30T12:38:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4789cade332f799b0e8f2f7ea0fe2aae6157a85e60f74497e316dd17a7e3/hypothesis-6.164.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:004c92c4b869f8e258f0641101b7743cae8420436f4465383f681c086ef95c9d", size = 1311895, upload-time = "2026-07-30T12:39:14.621Z" }, + { url = "https://files.pythonhosted.org/packages/12/8a/18d85e624f8631aec42daa8a2f07c6edcedb7385b2c0f375ba8a30cbd065/hypothesis-6.164.0-cp313-cp313-win_amd64.whl", hash = "sha256:4878f81fa92a580d3e16b53e64e01a9d9fe1dca5973783558493a003138dbd36", size = 660656, upload-time = "2026-07-30T12:38:37.696Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/7f41c25a4aa977ddeb79b61df2dd70ab19986356a344ea2b4cf1fc6a85d2/hypothesis-6.164.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:eb43a07578e04c5a66d86b5a9dd6e5eb81280a8c20b14ff456dd57936fecde15", size = 772964, upload-time = "2026-07-30T12:38:41.559Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/d09a3b2af2a817e9c91769ac04ea1083f25e177b54311e8389d2d5cb2bf4/hypothesis-6.164.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c46cd09811f28c286821863565cf07679294221c40aab3c7a593685fb9c725ed", size = 768787, upload-time = "2026-07-30T12:38:29.533Z" }, + { url = "https://files.pythonhosted.org/packages/60/91/f073c582c8746efae8b7c2218129d335f13f98cd59d70c04a1ac03baa8e1/hypothesis-6.164.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79c209968acd4d6992c6b8d659f27d160d1368656796781a6fe46471dd9383e4", size = 1097680, upload-time = "2026-07-30T12:39:42.144Z" }, + { url = "https://files.pythonhosted.org/packages/45/6e/fb1a4e43975b811b40eadd55505fa58d04604e8951dfdcad53903913c8bf/hypothesis-6.164.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a28c8c86a6d3dfb4471687e7b274b6159241a1e56cbe88203521bce635c6f084", size = 1147457, upload-time = "2026-07-30T12:39:01.894Z" }, + { url = "https://files.pythonhosted.org/packages/32/47/340074ec647f799fdc1b17b2b857e8d0ac0192459980717b7d45910133c8/hypothesis-6.164.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04d2bd698fb58ec697f020ebe7b1f8779fc5bf0c910f3dc934c78542790563fa", size = 664358, upload-time = "2026-07-30T12:38:16.722Z" }, +] + +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, +] + +[[package]] +name = "multitasking" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/ac2cc9307fb15cc28ed6d4a9266b216c83ee7fe64299f0264047982bce88/multitasking-0.0.13.tar.gz", hash = "sha256:d896b5df877c9ca5eeddbf0e5994124694d6cb535aba698fb23344c7025155a1", size = 20585, upload-time = "2026-04-23T12:14:15.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/1c/24dbf69b247f287401c904a396233a43c89fd4fb9b7cd2e50e430e9cd57c/multitasking-0.0.13-py3-none-any.whl", hash = "sha256:ec9243af140c67bfe52dc98d7173c294512735a88e8425c458b250db99dc2b48", size = 16380, upload-time = "2026-04-23T12:14:13.776Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nautilus-trader" +version = "1.230.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "fsspec" }, + { name = "msgspec" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "portion" }, + { name = "pyarrow" }, + { name = "pytz" }, + { name = "tqdm" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/33/af01e6a9d26b9594228a903eb00b7175d82777955cc331aab0faffbc614e/nautilus_trader-1.230.0.tar.gz", hash = "sha256:cc7eaa247e640e46588094fcca44f34f4b6330eeec41e9247068f2500921757d", size = 9519123, upload-time = "2026-06-29T12:04:09.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/85/01ebade671867357059aa876d9d0da0ae7dbac63d9fc019092a1e2599bd5/nautilus_trader-1.230.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:033f6207d1c52095d64a7644f43b90cab939c2038044db70a4165f2acef3d079", size = 156035900, upload-time = "2026-06-29T11:59:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/34c51e170086e11aad482067b7ad81bcf3633b43cbe0a04ae35e9314c2f6/nautilus_trader-1.230.0-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:de69df04918ad52095dca2e462f2e1e7755a53e74df13f35de9631ef71513beb", size = 168348884, upload-time = "2026-06-29T11:59:12.31Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ce/f5d1ebe03896d5dc5c0e77a99bea3959d7cc78338e6b575981835dbd82ed/nautilus_trader-1.230.0-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:8f4ff2e8af10e93ed643496eb62999b00558085f0e0bff17fc76e08809ba91b9", size = 182608916, upload-time = "2026-06-29T11:59:20.916Z" }, + { url = "https://files.pythonhosted.org/packages/7a/eb/10cd2e6dfaaa235de0a195a0ae95f5803d0fe706f122a12ef27ec21a6982/nautilus_trader-1.230.0-cp312-cp312-win_amd64.whl", hash = "sha256:9420b151e92a504841b7fc768ba937dc7f3bffe387096215f39b83c6fcda27e8", size = 109597078, upload-time = "2026-06-29T11:59:27.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0a/bc9da3ab8f5eb74875f3b846807dfaf79b1b97aff43de70d6f298020c743/nautilus_trader-1.230.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:af8a83f7cabb91460dcf5b966259eab1f4edee7f5bba965119e70c06a876e997", size = 156113454, upload-time = "2026-06-29T11:59:35.256Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/3506c5c995ad3107484df8035fb1a9c3006f98861f5c64e15aaa4b74624a/nautilus_trader-1.230.0-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:8143907c667fda91bb6466c02ff2ec9f6389aa2fcc35fb0843e75a2ab4e4bfa8", size = 168414885, upload-time = "2026-06-29T11:59:43.575Z" }, + { url = "https://files.pythonhosted.org/packages/8e/aa/0bcade1efbe58e6877be9d43e4535ceba52c2c71de71b582ed5b03bbd17e/nautilus_trader-1.230.0-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:b5755ae5057b260827404aef23c3051969107d6724dd8c525f17301c9c5b24c0", size = 182538533, upload-time = "2026-06-29T11:59:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/a8463ccad00e2b75013c75714d4fb872c22b4a116575d5491fed460ca501/nautilus_trader-1.230.0-cp313-cp313-win_amd64.whl", hash = "sha256:8817c46dc34e0aafc606948aacf1dd0fbbe1a31273c8a2f20983cf4ab2ddeef1", size = 109566360, upload-time = "2026-06-29T11:59:59.683Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", size = 24684, upload-time = "2026-06-22T00:47:02.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25", size = 1443564, upload-time = "2026-06-22T00:46:36.66Z" }, + { url = "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", size = 838002, upload-time = "2026-06-22T00:46:38.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", size = 823045, upload-time = "2026-06-22T00:46:39.495Z" }, + { url = "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", size = 1093171, upload-time = "2026-06-22T00:46:41.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", size = 1049217, upload-time = "2026-06-22T00:46:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", size = 917372, upload-time = "2026-06-22T00:46:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", size = 806699, upload-time = "2026-06-22T00:46:45.99Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", size = 835165, upload-time = "2026-06-22T00:46:47.617Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", size = 858282, upload-time = "2026-06-22T00:46:49.276Z" }, + { url = "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", size = 1014328, upload-time = "2026-06-22T00:46:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", size = 1098207, upload-time = "2026-06-22T00:46:52.674Z" }, + { url = "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", size = 1056961, upload-time = "2026-06-22T00:46:54.335Z" }, + { url = "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", size = 1033829, upload-time = "2026-06-22T00:46:56.258Z" }, + { url = "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", size = 609081, upload-time = "2026-06-22T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", size = 624461, upload-time = "2026-06-22T00:46:59.163Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", size = 603060, upload-time = "2026-06-22T00:47:00.596Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, +] + +[[package]] +name = "optuna" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "colorlog" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/9b/62f120fb2ecbc4338bee70c5a3671c8e561714f3aa1a046b897ff142050e/optuna-4.8.0.tar.gz", hash = "sha256:6f7043e9f8ecb5e607af86a7eb00fb5ec2be26c3b08c201209a73d36aff37a38", size = 482603, upload-time = "2026-03-16T04:59:58.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/24/7c731839566d30dc70556d9824ef17692d896c15e3df627bce8c16f753e1/optuna-4.8.0-py3-none-any.whl", hash = "sha256:c57a7682679c36bfc9bca0da430698179e513874074b71bebedb0334964ab930", size = 419456, upload-time = "2026-03-16T04:59:56.977Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "patsy" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" }, +] + +[[package]] +name = "peewee" +version = "4.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/45b139fba589e185dcee447414c2e0efaebb0a247c06329fdc9bd84ba4aa/peewee-4.2.6.tar.gz", hash = "sha256:f40655c64a62eaa447af228e4b84a5e80d3c812d2de34c7b9c621a3ef0c6555c", size = 779493, upload-time = "2026-07-17T18:11:21.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/63/26ec789e68e994d64ed957dd6ee5a08bb78c2f401c40e46fc142669cd8b0/peewee-4.2.6-py3-none-any.whl", hash = "sha256:b54c0f6e09c987465f8268bb2b4c1cba2e1b2788fcc0974c8e2233af1fd5af8f", size = 173774, upload-time = "2026-07-17T18:11:19.787Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portion" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/5e/571d6f0ae29cf3dcec34519d7f8a4265e3348ef58f247548e9306087163b/portion-2.6.2.tar.gz", hash = "sha256:fbf334143dbac5d07ffa411784e2b29e4e1f21203385019a93e2c1a8f443da16", size = 123371, upload-time = "2026-06-14T14:16:22.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/26/669f1f68c5741bce383c3bb6afd1dda3495813118024a6a0a27d47b9d284/portion-2.6.2-py3-none-any.whl", hash = "sha256:86be115afafa776174dc5eac82afb6496c9fa3684f5b3a844c3139535c51085e", size = 28218, upload-time = "2026-06-14T14:16:21.169Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/98/ae2b5acf9876dbeffa6f320776242c52caab062df55c8ac5501ed2679e74/pyarrow-25.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2e3b6544e26e393fe2cd530f523e36c1c8d3c345bbbb60cca3fd866be8322517", size = 35939080, upload-time = "2026-07-10T08:26:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/3de2a968edbd496c86cb8b932cdbee2d4b08c4a28e9884a15e5c705a646b/pyarrow-25.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:b724d127783b4c19f088fcdfc844cbc318809246a30307bcabd5ed02045e890e", size = 37633420, upload-time = "2026-07-10T08:26:10.354Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/8399243a4ce080426ec37db18d5e29148b7ec960a8a8c7f9059a7bf6ef0a/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:244f98a595f70fa4fd35faa7508c4ae67e14a173397a4b3b49d2b3c360fb0062", size = 46861050, upload-time = "2026-07-10T08:26:16.397Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/72d704b02bc5fc6d06954d76a0208c1e79cad3ab370f6d6a91ffe5078870/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0222f0071d13313962a88d21bf28b80d355ac39d81bfa6ff3fe00eeaf748e4be", size = 50056458, upload-time = "2026-07-10T08:26:23.271Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/3c31a60b6403d63cad2e0f829096f5fc5763a129ead4207a5d4690b96448/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b58726f118c079f9d4ed7e904975d4f15fd69d0741ba511a4e2dcaa4ef16354f", size = 49957793, upload-time = "2026-07-10T08:26:30.232Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/8f8a019061f9863a831915329264372a87ed25eaf9109ce56eb0e84012c5/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38a2c887cb3883e241b70201688db34133b6dfadd04f03c8f9213df53770c18e", size = 53100544, upload-time = "2026-07-10T08:26:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/738071e95c5ddad7b3dfc12f569ffa992db89d7d7b4a95258fd184191249/pyarrow-25.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:161649d60a7a46c613a19fd795763ea8a88c36ba997dd99d9bc66e6794ee36e8", size = 27848311, upload-time = "2026-07-10T08:26:41.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, + { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, + { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "quantbt-engine" +version = "1.0.7" +source = { editable = "." } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, +] + +[package.optional-dependencies] +all = [ + { name = "arch" }, + { name = "matplotlib" }, + { name = "nautilus-trader", marker = "python_full_version >= '3.12'" }, + { name = "optuna" }, + { name = "quantstats" }, + { name = "scikit-learn" }, + { name = "seaborn" }, +] +optimization = [ + { name = "arch" }, + { name = "optuna" }, + { name = "scikit-learn" }, +] +reports = [ + { name = "quantstats" }, +] +validation = [ + { name = "nautilus-trader", marker = "python_full_version >= '3.12'" }, +] +viz = [ + { name = "matplotlib" }, + { name = "seaborn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "build" }, + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "twine" }, +] + +[package.metadata] +requires-dist = [ + { name = "arch", marker = "extra == 'all'", specifier = ">=8.0.0,<8.1" }, + { name = "arch", marker = "extra == 'optimization'", specifier = ">=8.0.0,<8.1" }, + { name = "matplotlib", marker = "extra == 'all'", specifier = ">=3.10.9,<3.11" }, + { name = "matplotlib", marker = "extra == 'viz'", specifier = ">=3.10.9,<3.11" }, + { name = "nautilus-trader", marker = "python_full_version >= '3.12' and extra == 'all'", specifier = ">=1.230.0,<1.231" }, + { name = "nautilus-trader", marker = "python_full_version >= '3.12' and extra == 'validation'", specifier = ">=1.230.0,<1.231" }, + { name = "numba", specifier = ">=0.65.1,<0.66" }, + { name = "numpy", specifier = ">=2.2.6,<2.3" }, + { name = "optuna", marker = "extra == 'all'", specifier = ">=4.8.0,<4.9" }, + { name = "optuna", marker = "extra == 'optimization'", specifier = ">=4.8.0,<4.9" }, + { name = "pandas", specifier = ">=2.3.3,<2.4" }, + { name = "quantstats", marker = "extra == 'all'", specifier = "==0.0.81" }, + { name = "quantstats", marker = "extra == 'reports'", specifier = "==0.0.81" }, + { name = "scikit-learn", marker = "extra == 'all'", specifier = ">=1.8.0,<1.9" }, + { name = "scikit-learn", marker = "extra == 'optimization'", specifier = ">=1.8.0,<1.9" }, + { name = "seaborn", marker = "extra == 'all'", specifier = ">=0.13.2,<0.14" }, + { name = "seaborn", marker = "extra == 'viz'", specifier = ">=0.13.2,<0.14" }, +] +provides-extras = ["optimization", "reports", "viz", "validation", "native", "all"] + +[package.metadata.requires-dev] +dev = [ + { name = "build", specifier = ">=1.3,<2.0" }, + { name = "hypothesis", specifier = ">=6.148,<7.0" }, + { name = "mypy", specifier = ">=1.19,<2.0" }, + { name = "pytest", specifier = ">=9.1,<10.0" }, + { name = "pytest-cov", specifier = ">=7.0,<8.0" }, + { name = "ruff", specifier = ">=0.14,<0.15" }, + { name = "twine", specifier = ">=6.2,<7.0" }, +] + +[[package]] +name = "quantstats" +version = "0.0.81" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "python-dateutil" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "seaborn" }, + { name = "tabulate" }, + { name = "yfinance" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/a8/33f31a0d179b6c4ffefa1a4318a78075ea96f7ace7292663f1a99acebdd6/quantstats-0.0.81.tar.gz", hash = "sha256:91f44895e4481167255384c2297193233255b427e3a09a3fa111a5ce77e9b44a", size = 87569, upload-time = "2026-01-13T18:18:20.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/d4/484041d5c5a5d3ec8df5c74fef3054fec004dab554f6c3c00187888f8cc1/quantstats-0.0.81-py3-none-any.whl", hash = "sha256:6af2b501f61917c8c960faaf8007eb858d970ab02a3cf0d7dc19f048953e15f3", size = 90067, upload-time = "2026-01-13T18:18:18.451Z" }, +] + +[[package]] +name = "readme-renderer" +version = "45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/51/d3a6ea424652c60f05600d8c2e01a55c913755e7cdad64afabbd1aa16f44/readme_renderer-45.0.tar.gz", hash = "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1", size = 36172, upload-time = "2026-06-09T21:05:17.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl", hash = "sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f", size = 14134, upload-time = "2026-06-09T21:05:15.85Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "statsmodels" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, + { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, + { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + +[[package]] +name = "websockets" +version = "17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/ea/c0f7924f7ccf005d6ad1f829971762ae751727497d6db1977ba5a635314f/websockets-17.0.tar.gz", hash = "sha256:6bbe83c4ef52a7533d2d8c6a3512b93722fd0db6bc6bc638d45edd49ef201444", size = 183456, upload-time = "2026-07-29T18:07:16.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/66/1fa9cd9c0e2e77f74c5b9391f5e154b939efbf9695eb5e5bb72e1d993669/websockets-17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ddd0444e942d1f42ea2ab5c38f6f9dddfd6782a5bda0a29e210b414dda7e3636", size = 212719, upload-time = "2026-07-29T18:04:23.164Z" }, + { url = "https://files.pythonhosted.org/packages/1b/63/43d85076ba399257685c79726309c1367c9d6a133ef620b8fe1d166d7324/websockets-17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1edeb9d17bbd4e5bb45c230fc77cd140e4b445d6daaf395910c72aa703e3606", size = 210403, upload-time = "2026-07-29T18:04:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/7f/75/b98ec2482ac7f82c6a098d0350ed6d206032944230d8a18284c700fb2455/websockets-17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37f79808bf93a97c040ccb4dbee77ea1527d0fc3656077001428409866a06784", size = 210681, upload-time = "2026-07-29T18:04:26.675Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8d/6d37513adec534af9ed1f3f990be3e42aab2ec062d4730b24f01dc85d8f9/websockets-17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd1b0bdb6f6692baad8dbc366886c9ecd167862ccfce4d227cd05f6ef26698d7", size = 219745, upload-time = "2026-07-29T18:04:28.085Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/a083d572986f8532369e8a376452bfdbb403899e7ac18c4982a05ee8123b/websockets-17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cf609755e58e3eee3f105dac839d5a57687d67ade20752b4459402a96fe1c216", size = 220018, upload-time = "2026-07-29T18:04:29.489Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fc/399ff59d88a6378f1f6a291676c0c0b0bd287617584ab49968ed91c05f90/websockets-17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65fc0f621c801762ad16f95f6728c2498b4a2a9244938635d79e72234887cd1c", size = 221252, upload-time = "2026-07-29T18:04:31.04Z" }, + { url = "https://files.pythonhosted.org/packages/e8/67/eb0c001332545a7616c6f32110c11a46185e3df305e507cdc3970f1a3807/websockets-17.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cf2a17a24719b3666130cc42f4c22c5f067c94d78981a2895b5782687ac91978", size = 224544, upload-time = "2026-07-29T18:04:32.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/348ead1b20ddac653797f7a3681395189e8d2d6815844d6ef845e1d46dd8/websockets-17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90aba12b1e2e9b79c6f7a56fbd16bcbbeab23ef51c11122b346f5cc4cfd9b10d", size = 221814, upload-time = "2026-07-29T18:04:34.239Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/22a9185f219cd21583ad1d7292061a867af03f9c3cb76b24ff8532efacb9/websockets-17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ef569c690e1a7de6b218c1a8fba5a5b8560d6d141fa76e0e865e1c98fa4b140", size = 220586, upload-time = "2026-07-29T18:04:35.625Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ce/fbf20ff14a52e03ec76a706d2e768d9b0e6dd5f20bccafc214df854b89eb/websockets-17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb6a5c404a3982c1ea834a758558c0b13f4917c78658a6e87eb728fb268b0f4c", size = 217880, upload-time = "2026-07-29T18:04:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2b/8663a96e9765074a9d76fb3dc336d7d3d51eef19866248b374f01fd24a49/websockets-17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b3c20b64398f0a0ce4a8b7caf6988e738de3eda2d7049e42ce655c137cc987d9", size = 220741, upload-time = "2026-07-29T18:04:38.588Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/6e37383539995c3cb2924af89541c771b85158930e6ce5fd059b0bf37a39/websockets-17.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7018d5c1a0e161237aa52e282aaf2364daf45f0b792b212f6d3c1bc85a03ae36", size = 219332, upload-time = "2026-07-29T18:04:40.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/d5d42031a3ee438018ad3874f52104ea1144caa9edc455871d90fc3d9a1e/websockets-17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e8e4545866fe949e932e0a895471b06d2784c6e0fcd35b3c7da02d7600d766f7", size = 220100, upload-time = "2026-07-29T18:04:41.495Z" }, + { url = "https://files.pythonhosted.org/packages/da/71/4763704b3b80757ed926d8d0cc06542e90a9e41aebd379324c950fcafc4f/websockets-17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9273bc1a7441ffd7a0bb63cf21cbe56bc046744cc4df24df060fe6806fb1c81", size = 221145, upload-time = "2026-07-29T18:04:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/96/12bd7d70842c2a4f4894d2905ddcd7078509e468a78c8efeada2836db0d8/websockets-17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:95143a62308b1d2b81157ea8ebce502a8b07087f6c47226175f23a5e2358c09e", size = 218724, upload-time = "2026-07-29T18:04:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/14/6b/d8ff625ac0c6fdba6cf1eb0d884aa618db864aacba992fceaafd977c9a53/websockets-17.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6ad3fad2a03731b788d7003e2f7603772a1cbe701a840a6acaa8305b7605bfbf", size = 219757, upload-time = "2026-07-29T18:04:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/c4fb9895b1e57e548b60905a40b9e8dba4098b32bfae54c3a415512ad777/websockets-17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0aec4d4fc61ce7a24912026be07a6329a5d7b8c9012c45b573ab78878fe4e41", size = 219993, upload-time = "2026-07-29T18:04:47.947Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a9/8cb56af6c9d123a7f1b61694d1c5405a3742bb89108bbdfb3255fd0d9b11/websockets-17.0-cp311-cp311-win32.whl", hash = "sha256:577be42e4cbe01cfbaf322b7a4998c0a0124d11582d34774f7226911a35c32bd", size = 213202, upload-time = "2026-07-29T18:04:49.495Z" }, + { url = "https://files.pythonhosted.org/packages/0f/42/0987257ab1ffce8492800c409106a3c2b4d247d6f93023a0f5de9f33680a/websockets-17.0-cp311-cp311-win_amd64.whl", hash = "sha256:d2f9829d91acf2863c1fb97e39095f5423b5f704fb1e478379ccc27a0c58df0c", size = 213499, upload-time = "2026-07-29T18:04:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/fa/70/3a62e87a178317739dba28f870c329e1cd34ee6ba051f3c936f7582d5c9b/websockets-17.0-cp311-cp311-win_arm64.whl", hash = "sha256:525488db5030b4c9bb03328269ab803a6f43a2232fc12e67c3a6b5c422ea96e3", size = 213430, upload-time = "2026-07-29T18:04:52.297Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e3/e4f27930a556ea4039487415ed7100ce96d607b29dfc65ac309168695ba4/websockets-17.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6312d9926196483550c0ad83459595dd02dd816fa0523ec91dac5601b35de2da", size = 212744, upload-time = "2026-07-29T18:04:54.041Z" }, + { url = "https://files.pythonhosted.org/packages/e6/14/2bcbc1805f1b42b94fa6fc81e7a0d1ffc1029d938cf9ce4b8e3a48875116/websockets-17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12a21ef5e185f9e0c1c9ad23649aca411b04e49e030287f0a47b889d9e1724a9", size = 210425, upload-time = "2026-07-29T18:04:55.613Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/a88e66b7b8581f433b990f20738045093bfc15dd3b8b939980daf793121d/websockets-17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e219be64a9dff86d33b3314ecc6c42289a2d8a447821931012f874b2cc3c70a9", size = 210692, upload-time = "2026-07-29T18:04:56.944Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/f8565de07cb99b9e9f21a6932ce87d28cd65e06bf8b9e6cfc795d7fb12ea/websockets-17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98e4882f2f37b4efa7e1c41eb97db1e86384b6252135ab8f5794656cb3bec1ae", size = 220018, upload-time = "2026-07-29T18:04:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/be/7c/883fddde356c9366bbb1abc9a16d02e20515aadb89de3364c5dd7b9cc360/websockets-17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:abfa93514d5d7fe50988c4b6092585da0e9a737c1063530cf62fecfe93f7acf0", size = 220295, upload-time = "2026-07-29T18:04:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/9a/18/2b2c71d158206b759e79a2e606ad057a3e3f01e05353a676081417ea9bc2/websockets-17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aacbf208ef605c463e5cc888d26e25b68732baa171990339c1b4e2880f7b60dd", size = 221533, upload-time = "2026-07-29T18:05:01.734Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/d58c3f516dcfed9d98804fa25c679958df32286bfabd6029dabeec5f1ce7/websockets-17.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fdea04f18e814a15ef115356392624f8a694f29bb6b8ed65828a6d53eeb96654", size = 224312, upload-time = "2026-07-29T18:05:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/77/49/33946a85a09638f046c2db6506fe53aee35f71fcef9347d343ce668c9cb5/websockets-17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7c3b3c1fda46b2d40d57503278755f3ad47f09eec57c4f6145cd80f1c8beecf", size = 222169, upload-time = "2026-07-29T18:05:04.635Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/e2441326cd2132b4861ff1a0b03671dedacdca6e7996e913137ec1b4ad26/websockets-17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da8b74ac47a129bcb82f40aab234ead2d31ed20566e6e75d1929ac4d61f22a55", size = 220924, upload-time = "2026-07-29T18:05:06.252Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ee/ae47d5aace0b71c7e038d00f1651086cd32fa44190f179182c58a6c5b795/websockets-17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e43040c1f6b0e0fced4a3020693f32914e4d57605be63da30c197bfa118c6d7", size = 218171, upload-time = "2026-07-29T18:05:07.655Z" }, + { url = "https://files.pythonhosted.org/packages/6a/99/2872777a8d96c4bc546bc79a22acd7db57aa2acddcbd3527c83515c7d789/websockets-17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddfc7ae598004778e8e092580aafec16ae9f8f16ebf0c178bb76292db6e8dd", size = 220970, upload-time = "2026-07-29T18:05:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8e/64472cc08da2e6ed2ee40c372abfe090e7d368965aa861dc32382aba051d/websockets-17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:180837e1f4f82fb4779fe4561d246a55028d01f7f41c4a00b24117804d382f14", size = 219572, upload-time = "2026-07-29T18:05:10.548Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/61c12777165b02a578e4a0055ccbcb48bad92f3ae4373b2bb449a28ceebf/websockets-17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4736675b7079a09b04558f1e5613dacb71165ff9868b7dd01c2488159ca5c089", size = 220342, upload-time = "2026-07-29T18:05:12.006Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/e6e60c01b6100ac9f9a1afd3391a5f3e0c72eee536429d001c4be3af7004/websockets-17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1922e2124f7eb7ca7ba203973a0b8b3f598447efe6937feaf63fbb1775341eb8", size = 221450, upload-time = "2026-07-29T18:05:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d3/64cb3002bbb6ee592591f668a2c802deccc183fbf5a41071145bdb133d57/websockets-17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a3cfb0ea471e325b596e9259d2f35f3040ecd1896e2d608649f25748929febc0", size = 219002, upload-time = "2026-07-29T18:05:14.894Z" }, + { url = "https://files.pythonhosted.org/packages/55/08/0877015b5b252d83c7f441023e11293fd0d0be9dc05c792c5f91712c8eec/websockets-17.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1a44cbbf2ab144f1ce5268c1dc4a541e9ed0cd35a892d38a9a52e3d01456cbf7", size = 219983, upload-time = "2026-07-29T18:05:16.539Z" }, + { url = "https://files.pythonhosted.org/packages/57/f8/271327f8fa4c07326ba9c79c9daea81e4c043029c6df48bbddfb0bf46649/websockets-17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c59f7a03967dcdb490098a7e684b1e691f8032835f8176d9cb3cbc654773381", size = 220259, upload-time = "2026-07-29T18:05:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8b/31e77872bc730124acd9e0af977667b9805c4450519e9bd220e4450f4749/websockets-17.0-cp312-cp312-win32.whl", hash = "sha256:67e3de3a5abbea437cd73505a2220a3fa37b3e38b68c7dd410de6fadb9492dc5", size = 213205, upload-time = "2026-07-29T18:05:19.575Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/5a5706da118fe90038a529ca43557092c1f5665876b00570d777bd19cfff/websockets-17.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f7cef3e552397fc4313b1caf4fe1fabf53dfde4e4153aa1a74d73b5a246794b", size = 213502, upload-time = "2026-07-29T18:05:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/fd6d3c80f548dbae84687f9c50b26407707e63d624ba2edc6736c0aa68fc/websockets-17.0-cp312-cp312-win_arm64.whl", hash = "sha256:499e8536471f07de659bc3f003f1fcef60da953de8ffc26d01253828f6b0a003", size = 213430, upload-time = "2026-07-29T18:05:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/ad36c2cd987b89447e2216d19355306eb9a66a9ce4fbcfb22924ade347a1/websockets-17.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:29a24b93f223c701053db3e07416769f64ac69bc2204131d286ca9e309f78012", size = 212738, upload-time = "2026-07-29T18:05:23.902Z" }, + { url = "https://files.pythonhosted.org/packages/26/03/c89dc12a6fd49948b2aa0cda77765859c1310f6ec2ad50fc43d15851fa7a/websockets-17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:005d06fe6af0071625a41c231848342da013709738cae9c22031d396b85fa875", size = 210420, upload-time = "2026-07-29T18:05:25.362Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/9fdf5fd50ab0b9db8fdd4037d54064703f5f99a8c34c995b3d25a8099c65/websockets-17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1feba08ed3370fad0efc1295b5b314115b920b8014d1fc20d3535dada44c155", size = 210682, upload-time = "2026-07-29T18:05:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/6e/71/e56676f18dc9b906018aa8e9e106080edb81240df12672a71b2a0273677f/websockets-17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8d8b6160b46996d2821659ae6fcf9aa20b2641bc7a08972b15308c65b0764295", size = 220067, upload-time = "2026-07-29T18:05:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a5/0d742c23f1ba6e60c5cb0fd402f89a5faeeee3c23c8dffcc3308125b124c/websockets-17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce75f71335f3d682d37ff7464d1e1c20a065794108087ddcf3404aa03ba91295", size = 220352, upload-time = "2026-07-29T18:05:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c9/43201b9fbc5c58f89e0bee12c14a67d847a453449d8ba95f29adab128855/websockets-17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d5721fc96349667b623d6e1209f3c111667946d346715023013b11681d8d37b", size = 221589, upload-time = "2026-07-29T18:05:31.394Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/c226a8bf87376165fe15e0fa2ab1557433463ed279a9e17e899c77cb307e/websockets-17.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dd09cacb19f2e6d7e01c9e8d870ab40e4d4b1d59508646e74cdb963bbb73730a", size = 223030, upload-time = "2026-07-29T18:05:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/96/e8/b7b7cad3d1bfff2c60c51bd64a3e29f48c988b1e7f1731fe9e89b09dcfa5/websockets-17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d599bf4fab7e1bc1c009a966c8ded26c97cb8983410ab6d404f21b2e750557c9", size = 222216, upload-time = "2026-07-29T18:05:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/ca/6b1dab07811b26bd79b85788aaf1d14acdeb2bc0252d2e18999e46e9f834/websockets-17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:46a13ca29de8d60ef9cc6cba58e9c4e65a19a0cf25140576285f561f23827044", size = 220971, upload-time = "2026-07-29T18:05:36.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/7943eeb82ba8f323f36c0b52f471ea012b563af1e50bfe15230fd973ac7d/websockets-17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c153840709258daef58a13a0e4cf78b5d838d5b15261de0d49f6ec1fd2538d44", size = 218227, upload-time = "2026-07-29T18:05:37.582Z" }, + { url = "https://files.pythonhosted.org/packages/7c/39/a88e72a5b8ff80e4f7c1c5ddb335d64432650252a5856935fc6fe3065869/websockets-17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63609c513bc5f8757e8ecb0eb788afc54825807cf151216ce7d3359576899b70", size = 221034, upload-time = "2026-07-29T18:05:39.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/14/a8bfd634a5dad970a946aca76de7c9e8e717b8f9960e290d20b6f21d5931/websockets-17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:e2b977c946503cd3182a7f7cf3d18255d682580400cf4ecdeeccad435b5d2bfe", size = 219632, upload-time = "2026-07-29T18:05:41.066Z" }, + { url = "https://files.pythonhosted.org/packages/19/c9/9cfca56b5a216b001c9d3dd2351f2e3af7b967473b89df7aae656d61e048/websockets-17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5668320cde66fa7737a26e894fda39e0ad76d4edf96832650cab84370c561ad0", size = 220401, upload-time = "2026-07-29T18:05:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2f75906e489049cd3420c46511054f95fb063a54dcf99483cc063e14a713/websockets-17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2e5e26e649b0786b8e696c41a8a3147a4c68c79fe6e0b1f07bbefeba054d56", size = 221503, upload-time = "2026-07-29T18:05:45.1Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/8d95434937e1fbaa0fee8bcf764867e9ccf8d42abb8a159c2681dd68a112/websockets-17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42fec6309ac1c20e45982460321468858f2b2cbc66d1919cfa04663e0aaaefcb", size = 219063, upload-time = "2026-07-29T18:05:46.57Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ee/3217cee93eaccf717c291d678a0594a5388555b024b9f46b0555fc25a812/websockets-17.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d648a61bfd3e2f3be8643a27eded0c7fe4e178670ee1534061f1235f2c857be1", size = 220017, upload-time = "2026-07-29T18:05:48.074Z" }, + { url = "https://files.pythonhosted.org/packages/3f/9d/bf0c9c0905b3b6e4eaf9cdf37361d38c2707815baf6c0bbf69fc873ddb76/websockets-17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d802fd1ff5d1e1773d815c5fee634b9e94e9829afb4fdbcfc8dab39c648095d", size = 220299, upload-time = "2026-07-29T18:05:49.549Z" }, + { url = "https://files.pythonhosted.org/packages/b9/03/33fe4e800d3bc72101cff3c148de55ac73eb51bbae142e6aafaf835901cf/websockets-17.0-cp313-cp313-win32.whl", hash = "sha256:c2786b3cc77a84afa612c2c60fc20c22b576ec46e7ae1e79cc14ad43cd1ed05a", size = 213194, upload-time = "2026-07-29T18:05:51.085Z" }, + { url = "https://files.pythonhosted.org/packages/bd/18/6c358b4611ce7a1c438bcb6cf7dbe9be32993c1c785d1a9cef495ab34e6e/websockets-17.0-cp313-cp313-win_amd64.whl", hash = "sha256:aa9b082460c6775f98179aa78d9186ff68ad69eca8edd30c816e689190e1bf6b", size = 213503, upload-time = "2026-07-29T18:05:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/e51d30d7a9b1ecb3135871b4faece90bee14cf0c754881583e3a5b9a30a1/websockets-17.0-cp313-cp313-win_arm64.whl", hash = "sha256:169412f60a48be88350dc5e89a446de89c11d2c6f6a9c62b6ab796e1b490d7d8", size = 213435, upload-time = "2026-07-29T18:05:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/28/d8/7879b3a9d00343f9574ffdf5b854419a33b5bae8a96a20b2583ef502e892/websockets-17.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cad3963bc9664468223b9e75734a04b1092e5e6947783d9162877c7be68091d2", size = 210337, upload-time = "2026-07-29T18:07:03.635Z" }, + { url = "https://files.pythonhosted.org/packages/44/aa/e38fe356c3cb92af10894e7e3affed5bd831af5d4ed7fbe64fe1b00213c4/websockets-17.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:162188a53ffb58b175dc41bc9aee1232b87205e46591cb327be71315f8630bec", size = 210610, upload-time = "2026-07-29T18:07:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2f/0681ddc3a07af06e1be2b2954b6cb07f9caf67c69ada44a81e029573cfd4/websockets-17.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4e95999b19cd99b01d401937f2adebc515b815fa2c7cfb043fc64cd0cdf2d3", size = 211560, upload-time = "2026-07-29T18:07:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6f/8630e03816889034aed3765a4de67839b36f04acdca52648ced6b690f89e/websockets-17.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2314ae31ab4a629cac708ece44e28d88fae9fbb1bd4bb5b21718b7ac4ec7e91", size = 211454, upload-time = "2026-07-29T18:07:09.347Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/a8d02a7a9f92804daba7f861ba539cf6c25765d8b2a7d7c8ea355c79deb8/websockets-17.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60ed4a3b760ed8db9a0c2c01ad65b2c253603b0edd7236ef24dbe363e417f31b", size = 212348, upload-time = "2026-07-29T18:07:11.29Z" }, + { url = "https://files.pythonhosted.org/packages/6e/14/ac6da556d66c5f5fcf21e2f8468cd303262ae46a7f460bb481425d77ed42/websockets-17.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:69852d81e27f53bb69db752c55ecbbb73a0988692c654bafd1651d3e51441476", size = 213586, upload-time = "2026-07-29T18:07:13.442Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b4/9b5bd8ad82a7ace4e4a497aed083b6a9bf9076b1ea1a0bf5831686b4af71/websockets-17.0-py3-none-any.whl", hash = "sha256:0c24d62cafaca7dc1631e9f3bf0672fa83f010e66a2aeff4d00727b18addcd8e", size = 206871, upload-time = "2026-07-29T18:07:15.156Z" }, +] + +[[package]] +name = "yfinance" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/25/66da4c0063e7ca96f145861b6194b2b5244b488b997112869929422dc178/yfinance-1.5.2.tar.gz", hash = "sha256:5935d457fc62cf2f7e9bf1b2d019a8fec8fb0072f58a095eb53740b70a6a06ed", size = 167938, upload-time = "2026-07-23T19:16:31.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/77/9549bf914e0de5b5ca15086cd3f31bf35d5a1e6bfb793196eb9c4c41baff/yfinance-1.5.2-py2.py3-none-any.whl", hash = "sha256:197fc03485c246547a5a9184956c60150ea33b6f740d877e02a97f123d5cd2b9", size = 144062, upload-time = "2026-07-23T19:16:30.201Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/walkforward.py b/walkforward.py index 4be680c..5654311 100644 --- a/walkforward.py +++ b/walkforward.py @@ -31,12 +31,6 @@ _NUMBA_AVAILABLE = njit is not None -try: # optional at import time; required only when optimization runs - import optuna as _optuna -except Exception: # pragma: no cover - optional dependency guard - _optuna = None - - StrategyOutput = Union[pd.Series, pd.DataFrame, Dict[str, pd.Series]] @@ -379,12 +373,10 @@ def __init__(self, early_stopping_rounds: int, direction: str = "maximize"): self.early_stopping_rounds = int(early_stopping_rounds) -class DuplicatePruner(_optuna.pruners.BasePruner if _optuna is not None else object): +class DuplicatePruner: """Optuna pruner that avoids running duplicate parameter sets.""" def __init__(self): - if _optuna is None: # pragma: no cover - dependency guard - raise ImportError("DuplicatePruner requires optuna") self.trial_params = set() def prune(self, study, trial) -> bool: