diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32d5a4ee..06102bf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -358,11 +358,19 @@ jobs: --python "$RUNNER_TEMP/candidate-venv/bin/python" \ dist/*.whl + - name: Check pinned book companion links + run: uv run python validation/check_book_links.py + - name: Execute the documented quick starts against the wheel run: >- "$RUNNER_TEMP/candidate-venv/bin/python" validation/check_documentation_examples.py + - name: Exercise the optional diagnostic handoff against the wheel + run: | + uv pip install --python "$RUNNER_TEMP/candidate-venv/bin/python" "ml4t-diagnostic==0.1.4" + "$RUNNER_TEMP/candidate-venv/bin/python" validation/check_documentation_examples.py docs/tutorials/diagnostic-handoff.md + - name: Verify source provenance run: | test "$(git rev-parse HEAD)" = "$GITHUB_SHA" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0d5a14a1..ef87a5ab 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -37,6 +37,9 @@ jobs: echo "ML4T_DOCS_VERSION=$(uv run python -c 'import ml4t.backtest as package; print(package.__version__)')" >> "$GITHUB_ENV" echo "ML4T_DOCS_COMMIT=$GITHUB_SHA" >> "$GITHUB_ENV" + - name: Check pinned book companion links + run: uv run python validation/check_book_links.py + - name: Build docs run: uv run mkdocs build --strict diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48ba5d3b..9634ea9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -269,6 +269,9 @@ jobs: "$RUNNER_TEMP/published-venv/bin/python" -I -c "import ml4t.backtest as package; assert package.__version__ == '${{ needs.preflight.outputs.version }}'" "$RUNNER_TEMP/published-venv/bin/python" validation/check_documentation_examples.py + uv pip install --python "$RUNNER_TEMP/published-venv/bin/python" "ml4t-diagnostic==0.1.4" + "$RUNNER_TEMP/published-venv/bin/python" validation/check_documentation_examples.py docs/tutorials/diagnostic-handoff.md + - name: Download and verify GitHub release assets env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 6fbed871..cb3785df 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ for supported setup paths. ## Quick Start +The [first backtest](https://www.ml4trading.io/docs/backtest/getting-started/quickstart/) +uses bundled synthetic data and explains every fill. The [tutorial sequence](https://www.ml4trading.io/docs/backtest/) +then covers orders, multi-asset portfolios, costs, account rules, risk, and results. + ```python from datetime import datetime @@ -336,25 +340,25 @@ The table reports engine-call wall time for all 17 correctness-passing pairs. Th | Real strategy | Pinned framework | Framework median (95% CI), s | ML4T median (95% CI), s | Framework / ML4T median | |---|---|---:|---:|---:| -| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.287 (0.286-0.289) | 0.415 (0.414-0.419) | 0.692x | -| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.171 (0.171-0.174) | 0.415 (0.413-0.417) | 0.412x | -| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.355 (9.303-9.424) | 0.430 (0.426-0.432) | 21.762x | -| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.872 (3.867-3.910) | 0.632 (0.628-0.638) | 6.127x | -| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.564 (2.487-2.599) | 0.750 (0.749-0.757) | 3.418x | -| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.286 (0.284-0.289) | 0.432 (0.429-0.435) | 0.663x | -| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 2.495 (2.488-2.526) | 0.429 (0.427-0.433) | 5.812x | -| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.801 (2.709-2.891) | 0.657 (0.655-0.661) | 4.266x | -| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.283 (0.281-0.286) | 0.146 (0.145-0.147) | 1.939x | -| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.139 (0.138-0.141) | 0.146 (0.145-0.147) | 0.952x | -| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.425 (0.420-0.432) | 0.146 (0.145-0.147) | 2.920x | -| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.913 (0.877-0.953) | 0.153 (0.152-0.157) | 5.952x | -| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.795 (0.787-0.838) | 20.555 (20.486-20.600) | 0.039x | -| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 16.995 (16.960-17.024) | 20.495 (20.453-20.534) | 0.829x | -| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 495.553 (494.227-510.273) | 21.470 (21.386-21.543) | 23.082x | -| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 107.609 (106.834-109.150) | 22.132 (22.082-22.234) | 4.862x | -| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 47.683 (47.333-48.015) | 26.368 (26.222-26.473) | 1.808x | - -Measured 2026-09-03 on `Linux-6.8.0-138-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). +| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.293 (0.290-0.298) | 0.424 (0.421-0.434) | 0.691x | +| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.192 (0.176-0.493) | 0.414 (0.413-0.417) | 0.462x | +| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.631 (9.428-9.710) | 0.439 (0.434-0.444) | 21.954x | +| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.888 (3.878-3.959) | 0.621 (0.618-0.626) | 6.256x | +| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.638 (2.516-2.707) | 0.750 (0.743-0.757) | 3.520x | +| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.290 (0.289-0.294) | 0.410 (0.406-0.958) | 0.708x | +| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 6.639 (5.831-8.332) | 0.428 (0.422-1.235) | 15.504x | +| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.828 (2.737-2.952) | 1.327 (0.657-2.183) | 2.131x | +| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.288 (0.286-0.294) | 0.148 (0.147-0.149) | 1.947x | +| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.144 (0.142-0.146) | 0.145 (0.145-0.146) | 0.988x | +| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.434 (0.430-0.440) | 0.147 (0.146-0.149) | 2.947x | +| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.953 (0.936-1.164) | 0.158 (0.155-0.164) | 6.033x | +| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 3.806 (3.336-4.341) | 23.306 (21.906-25.833) | 0.163x | +| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 17.116 (17.069-17.633) | 25.738 (24.983-27.163) | 0.665x | +| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 668.611 (642.993-718.202) | 26.887 (26.406-29.406) | 24.868x | +| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 117.445 (116.308-118.391) | 26.821 (25.473-27.644) | 4.379x | +| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 48.436 (48.221-48.986) | 27.688 (27.575-27.796) | 1.749x | + +Measured 2026-09-24 on `Linux-6.8.0-139-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). ### Synthetic diagnostic scenarios diff --git a/docs/api/index.md b/docs/api/index.md index ed525ef9..28ca74b3 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -63,6 +63,7 @@ Auto-generated from source docstrings. - sell - close_position - reduce_position + - reduce_all_positions - flatten_all_positions - order_target_percent - order_target_value @@ -169,6 +170,7 @@ Auto-generated from source docstrings. - from_parquet - to_trades_dataframe - to_fills_dataframe + - to_funding_dataframe - to_rejected_orders_dataframe - to_portfolio_state_dataframe - to_predictions_dataframe diff --git a/docs/book-guide/index.md b/docs/book-guide/index.md index c4385ded..015c10da 100644 --- a/docs/book-guide/index.md +++ b/docs/book-guide/index.md @@ -1,131 +1,50 @@ # Book Guide -Use this guide to move between *Machine Learning for Trading, Third Edition* and -`ml4t-backtest` without guessing which notebook or case-study file maps to which -production API. - -The book teaches the concepts step by step. `ml4t-backtest` packages those ideas into -reusable execution, reporting, and portfolio-simulation workflows. - -## How To Use This Guide - -- start in the book when you want intuition, derivations, and side-by-side experiments -- start in the library docs when you want stable APIs and configurable workflows -- use this page to jump from a chapter or case study to the corresponding guide page - -The most common reader journey is: - -1. read the notebook that explains the trading or execution idea -2. use this guide to find the matching library workflow -3. read the user-guide page for the reusable configuration pattern -4. use the [API Reference](../api/index.md) for exact classes and signatures - -## Chapter Map - -### Chapter 16: Strategy Simulation - -| Book path | What the book teaches | Library workflow | Docs page | -|-----------|-----------------------|------------------|-----------| -| `16_strategy_simulation/02_order_types.py` | order semantics, stop/limit behavior, and timing choices | order types and trigger behavior | [Order Types](../user-guide/orders.md) | -| `16_strategy_simulation/03_execution_semantics.py` | next-bar vs same-bar timing, stop handling, and execution assumptions | execution-mode, trigger, and stop configuration | [Execution Semantics](../user-guide/execution-semantics.md) | -| `16_strategy_simulation/04_configuration_profiles.py` | framework-style presets and reproducible backtest behavior | named config profiles and `BacktestConfig` | [Configuration](../user-guide/configuration.md), [Profiles](../user-guide/profiles.md) | -| `16_strategy_simulation/05_performance_reporting.py` | trade analytics, tearsheets, and gross-vs-net reporting | `BacktestResult`, trade/fill/equity export, reporting helpers | [Results & Analysis](../user-guide/results.md) | -| `16_strategy_simulation/06_framework_parity.py` | why configurable semantics matter for reproducibility | strict profiles and parity validation philosophy | [Profiles](../user-guide/profiles.md) | -| `16_strategy_simulation/08_rebalancing.py` | weight-based portfolio management | target weights and rebalancing executors | [Rebalancing](../user-guide/rebalancing.md) | - -What changes when you move to the library: - -- the book shows concepts one execution assumption at a time -- the library exposes those assumptions as named configuration knobs -- results become exportable, replayable, and comparable across frameworks - -### Chapter 17: Portfolio Construction - -| Book path | What the book teaches | Library workflow | Docs page | -|-----------|-----------------------|------------------|-----------| -| `17_portfolio_construction/01_portfolio_metrics.py` | allocator comparison and portfolio-level behavior | portfolio accounting and result metrics | [Results & Analysis](../user-guide/results.md), [Account Policies](../user-guide/accounts.md) | -| `17_portfolio_construction/08_library_comparison.py` | comparing portfolio construction workflows | feed preparation, rebalancing, and configuration | [Data Feed](../user-guide/data-feed.md), [Rebalancing](../user-guide/rebalancing.md) | -| `17_portfolio_construction/09_allocation_horse_race.py` | realistic allocator comparison under costs | cost-aware execution and turnover analysis | [Results & Analysis](../user-guide/results.md), [Market Impact](../user-guide/market-impact.md) | - -What changes when you move to the library: - -- allocators become reusable strategies or weight targets -- fills, turnover, and portfolio state become first-class reporting surfaces -- account policy becomes explicit instead of hidden in notebook assumptions - -### Chapter 18: Costs - -| Book path | What the book teaches | Library workflow | Docs page | -|-----------|-----------------------|------------------|-----------| -| `18_costs/01_transaction_costs.py` | commissions, slippage, and cost decomposition | commission and slippage configuration | [Configuration](../user-guide/configuration.md), [Market Impact](../user-guide/market-impact.md) | -| `18_costs/02_market_impact.py` | realistic impact assumptions and sensitivity to size | market-impact models and execution drag | [Market Impact](../user-guide/market-impact.md) | -| `18_costs/03_quote_aware_execution.py` | bid/ask-aware execution and quote-side marking | quote-aware fills, bid/ask mark prices, quote-side execution | [Execution Semantics](../user-guide/execution-semantics.md), [Data Feed](../user-guide/data-feed.md) | - -What changes when you move to the library: - -- manual cost adjustments become configuration-backed execution models -- quote-aware assumptions become explicit and reproducible -- fills and trades carry enough context to audit cost drag directly - -### Chapter 19: Risk Management - -| Book path | What the book teaches | Library workflow | Docs page | -|-----------|-----------------------|------------------|-----------| -| `19_risk_management/02_exit_strategies.py` | stop-loss, take-profit, and trailing-stop design | position-rule composition | [Risk Management](../user-guide/risk-management.md) | -| `19_risk_management/03_position_sizing.py` | position sizing and constraints | account policy, sizing, and exposure limits | [Account Policies](../user-guide/accounts.md), [Risk Management](../user-guide/risk-management.md) | -| `19_risk_management/06_portfolio_limits.py` | portfolio-level guardrails | portfolio limits and manager coordination | [Risk Management](../user-guide/risk-management.md) | - -What changes when you move to the library: - -- notebook exits become reusable rule objects -- portfolio-wide guardrails become configuration, not ad hoc if-statements -- risk and execution interact through explicit event ordering - -## Case-Study Pipeline Map - -Most case studies use `ml4t-backtest` in the final simulation and reporting stages. - -| Case-study step | Typical file | Library workflow | Docs page | -|-----------------|--------------|------------------|-----------| -| Backtest setup | `case_studies//code/14_backtest.py` | `BacktestConfig`, `DataFeed`, `Engine`, strategy bridge | [Quickstart](../getting-started/quickstart.md), [Configuration](../user-guide/configuration.md), [Data Feed](../user-guide/data-feed.md) | -| Performance reporting | `case_studies//code/15_evaluation.py` or equivalent | `BacktestResult`, trade/fill/equity export, downstream diagnostics | [Results & Analysis](../user-guide/results.md) | -| Portfolio/risk comparison | allocator or risk notebooks in Ch17-Ch19 | account policy, turnover, limits, and rebalancing behavior | [Account Policies](../user-guide/accounts.md), [Rebalancing](../user-guide/rebalancing.md), [Risk Management](../user-guide/risk-management.md) | - -Particularly strong bridges in the current materials: - -- Ch16 case studies use `setup.yaml` and `get_backtest_config()` to turn notebook assumptions - into reusable configuration -- NASDAQ-100 microstructure work demonstrates quote-aware execution and richer reporting -- portfolio-construction studies rely on rebalancing and turnover-aware result surfaces -- live-trading materials depend on keeping the same strategy shape before migrating to `ml4t-live` - -## From Notebook Code to Library API - -Use this translation when moving from the book to reusable code: - -| Book pattern | Library equivalent | -|--------------|--------------------| -| inline execution assumptions | `BacktestConfig(...)` or named profile | -| notebook-specific fill logic | `ExecutionMode`, fill-ordering, stop-fill configuration | -| manual portfolio loops | rebalancers and weight-target executors | -| ad hoc reporting frames | `BacktestResult` exports for trades, fills, equity, and portfolio state | -| cost tweaks in separate analysis cells | slippage, commission, and market-impact models in config | -| one-off state carried across notebook cells | stateful strategy patterns in reusable `Strategy` subclasses | - -## What To Prioritize - -These are the workflows most readers should prioritize: - -- execution semantics and configuration profiles -- data-feed preparation and signal alignment -- result export and performance reporting -- risk-management rules and rebalancing -- quote-aware execution and market-impact modeling when realism matters - -## Related Docs - -- [Quickstart](../getting-started/quickstart.md) -- [How It Works](../concepts/how-it-works.md) -- [Execution Semantics](../user-guide/execution-semantics.md) -- [Results & Analysis](../user-guide/results.md) -- [API Reference](../api/index.md) +This map connects selected *Machine Learning for Trading, Third Edition* +companion notebooks to the library workflows that implement the corresponding +backtest operations. The [tutorials](../getting-started/quickstart.md) work with +bundled synthetic data, so the book and its datasets are optional. All book +links below point to one checked companion revision. + +## Chapters and workflows + +| Book section and notebook | What it adds | Library workflow | +|---|---|---| +| [16.3 Vectorized and event-driven backtesting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/04_single_asset_ml4t_backtest.ipynb) | Run one strategy, reconcile fills and trades | [First backtest](../getting-started/quickstart.md) | +| [16.3 Stateful strategies](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/05_stateful_strategies.ipynb) | Carry realized state into later decisions | [Risk and state](../tutorials/risk-and-state.md) | +| [16.5 Understanding performance metrics](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/09_performance_reporting.ipynb) | Read returns and drawdowns | [Result exports](../tutorials/results-and-analysis.md) | +| [16.3 Vectorized and event-driven backtesting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/07_engine_divergence_anatomy.ipynb) | Change one execution assumption at a time | [Profiles and parity](../tutorials/profiles-and-parity.md) | +| [17.4 Defining Baseline Allocators](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/07_conformal_position_sizing.ipynb) | Turn uncertainty into position sizes | [Accounts and constraints](../tutorials/accounts-and-constraints.md) | +| [17.7 Comparing Allocator Performance](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/08_library_comparison.ipynb) | Compare allocators with matched inputs | [Multi-asset rebalancing](../tutorials/multiasset-rebalancing.md) | +| [18.7 Transaction Cost Analysis and Model Validation](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/18_transaction_costs/10_gross_vs_net_performance.ipynb) | Reconcile gross and net performance | [Costs and funding](../tutorials/costs-and-funding.md) | +| [19.4 Drawdowns, Path Risk, and Time-to-Recovery](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/19_risk_management/02_exit_strategies.ipynb) | Compare fixed and trailing exits | [Risk and state](../tutorials/risk-and-state.md) | +| [19.4 Drawdowns, Path Risk, and Time-to-Recovery](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/19_risk_management/10_ml4t_backtest_risk_demo.ipynb) | Use library position rules and portfolio limits | [Risk management](../user-guide/risk-management.md) | + +The book develops research questions, statistical interpretation, and +larger datasets. The library pages specify feed contracts, order timing, +account behavior, executable examples, and result schemas. Follow the library +reference when a notebook and the current API differ. + +## Case studies + +| Companion example | What it adds | Library workflow | +|---|---|---| +| [ETF backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/etfs/14_backtest.ipynb) | Weight targets from a prediction stream | [Multi-asset rebalancing](../tutorials/multiasset-rebalancing.md) | +| [CME futures backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/cme_futures/13_backtest.ipynb) | Contract multipliers and futures sessions | [Example data](../tutorials/data.md) | +| [FX pairs backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/fx_pairs/13_backtest.ipynb) | USD-quoted pairs and signal alignment | [Data Feed](../user-guide/data-feed.md) | +| [Crypto perpetual funding](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/crypto_perps_funding/16_costs.ipynb) | Funding and transaction-cost assumptions | [Costs and funding](../tutorials/costs-and-funding.md) | +| [ETF risk controls](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/etfs/16_risk_management.ipynb) | Position exits in a full strategy | [Risk management](../tutorials/risk-and-state.md) | + +## Move from a notebook to a reusable run + +1. Start with the [first backtest](../getting-started/quickstart.md) and the + [bundled price panels](../tutorials/data.md). +2. Reproduce the notebook's decision and fill timing with the + [orders tutorial](../tutorials/orders-and-timing.md). +3. Specify capital, share precision, and exposure limits with the + [account tutorial](../tutorials/accounts-and-constraints.md). +4. Add costs or risk rules only after checking the baseline fills. +5. Export the result and join it to the original timestamps with the + [result tutorial](../tutorials/results-and-analysis.md). + +The [API reference](../api/index.md) gives current signatures for each step. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 6ba6ad60..064b3a40 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,272 +1,87 @@ -# Quickstart +# Run your first completed backtest -Build and run your first backtest in 5 minutes. - -## Minimal Example +Install the package as described in [Installation](installation.md), then run the +following Python script. It loads nine synthetic daily AAPL bars from the wheel, +submits a buy order, submits a close order four asset bars later, and inspects +the resulting fills, trade, and equity. The bundled data is described in +[Example Data](../tutorials/data.md). ```python -import polars as pl -from ml4t.backtest import Engine, DataFeed, Strategy - -class BuyAndHold(Strategy): - def on_data(self, timestamp, data, context, broker): - for asset, bar in data.items(): - if broker.get_position(asset) is None: - broker.submit_order(asset, 100) - -# Create sample price data -prices = pl.DataFrame({ - "timestamp": pl.datetime_range( - start=pl.datetime(2023, 1, 2), - end=pl.datetime(2023, 6, 30), - interval="1d", - eager=True, - ), - "asset": "AAPL", - "open": [150.0 + i * 0.1 for i in range(180)], - "high": [151.0 + i * 0.1 for i in range(180)], - "low": [149.0 + i * 0.1 for i in range(180)], - "close": [150.5 + i * 0.1 for i in range(180)], - "volume": [1_000_000] * 180, -}) - -feed = DataFeed(prices_df=prices) -engine = Engine(feed=feed, strategy=BuyAndHold()) -result = engine.run() - -print(f"Final Value: ${result.metrics['final_value']:,.2f}") -print(f"Total Return: {result.metrics['total_return_pct']:.1f}%") -print(f"Sharpe Ratio: {result.metrics['sharpe']:.2f}") -print(f"Trades: {result.metrics['num_trades']}") -``` - -An `Engine` instance can run once. Create a new instance for another run. Result collection fields -are snapshots, so changing their lists does not change Engine or Broker state. - -## Data Format - -With the default feed mapping, `DataFeed` requires `timestamp`, a recognized entity -column such as `asset`, and `close`: - -| Column | Type | Required | -|--------|------|----------| -| `timestamp` | Datetime | Yes | -| `asset` | String | Yes | -| `open` | Float | No | -| `high` | Float | No | -| `low` | Float | No | -| `close` | Float | Yes | -| `volume` | Float | No | - -For multi-asset backtests, stack all assets in a single DataFrame -- the engine -handles partitioning by timestamp automatically. - -Missing open, high, and low values fall back to `close`; missing volume becomes -zero. This makes close-only examples runnable, but stop, limit, and volume-limited -fills require the corresponding input columns for useful simulation. - -`bar["price"]` is always populated from the configured reference-price column. By -default it follows `close`, but it switches to `FeedSpec.price_col` or the -`price_col=` override when you provide one. Construction fails if that resolved -column is absent. - -## Strategy Callbacks - -Every strategy subclasses `Strategy` and implements `on_data`: - -```python -class MyStrategy(Strategy): - def on_start(self, broker): - """Called once before the backtest starts. Set up risk rules here.""" - pass - - def on_data(self, timestamp, data, context, broker): - """Called on every bar. Generate orders here. - - Args: - timestamp: Current bar's datetime - data: Dict of {asset: {price, open, high, low, close, volume, signals, ...}} - context: Dict of context data (if provided) - broker: Broker for submitting orders and querying positions - """ - pass - - def on_end(self, broker): - """Called once after the backtest ends.""" - pass -``` - -## Signal-Based Strategy - -Pass pre-computed signals alongside prices: - -```python -from ml4t.backtest import run_backtest - -class SignalStrategy(Strategy): - def on_data(self, timestamp, data, context, broker): - for asset, bar in data.items(): - signal = bar.get("signals", {}).get("prediction", 0) - position = broker.get_position(asset) - - if signal > 0.7 and position is None: - # Buy 10% of portfolio value - equity = broker.get_account_value() - shares = int(equity * 0.10 / bar["price"]) - if shares > 0: - broker.submit_order(asset, shares) - - elif signal < 0.3 and position is not None: - broker.close_position(asset) - -# Signals DataFrame has same timestamp/asset columns plus your signal columns -result = run_backtest(prices, SignalStrategy(), signals=signals_df) -``` - -## Quote-Aware Feeds - -If you have quotes, add them without changing your strategy interface: - -```python -from ml4t.backtest import BacktestConfig, DataFeed -from ml4t.backtest.config import ExecutionPrice - -feed = DataFeed( - prices_df=quotes_df, - price_col="mid_price", - bid_col="bid", - ask_col="ask", - bid_size_col="bid_size", - ask_size_col="ask_size", -) - -config = BacktestConfig( - execution_price=ExecutionPrice.QUOTE_SIDE, - mark_price=ExecutionPrice.QUOTE_SIDE, -) -``` - -Buys then fill from the ask, sells fill from the bid, and `bar["price"]` still gives your configured reference price. - -## Adding Transaction Costs - -```python -from ml4t.backtest import BacktestConfig -from ml4t.backtest.config import CommissionType, SlippageType - -config = BacktestConfig( - initial_cash=100_000, - commission_type=CommissionType.PERCENTAGE, - commission_rate=0.001, # 0.1% per trade - slippage_type=SlippageType.PERCENTAGE, - slippage_rate=0.0005, # 0.05% slippage -) - -result = run_backtest(prices, strategy, config=config) -``` - -## Using Framework Profiles - -Match the exact behavior of another backtesting framework: - -```python -# Backtrader-compatible: next-bar execution, integer shares, default cash checks -result = run_backtest(prices, strategy, config="backtrader") - -# VectorBT-compatible: same-bar execution, fractional shares, no costs -result = run_backtest(prices, strategy, config="vectorbt") - -# Zipline comparison protocol: next-bar open execution, no default costs -result = run_backtest(prices, strategy, config="zipline") - -# Conservative production settings -result = run_backtest(prices, strategy, config="realistic") -``` +from importlib.metadata import version -See [Profiles](../user-guide/profiles.md) for all available presets and their settings. +import polars as pl -## Adding Risk Management +from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy +from ml4t.backtest.example_data import load_example_prices -Set position rules in `on_start` to automatically manage exits: -```python -from ml4t.backtest import StopLoss, TakeProfit, TrailingStop, RuleChain - -class ProtectedStrategy(Strategy): - def on_start(self, broker): - # Rules evaluate in order; first trigger wins - broker.set_position_rules(RuleChain([ - StopLoss(pct=0.05), # Exit at -5% loss - TakeProfit(pct=0.15), # Exit at +15% profit - TrailingStop(pct=0.03), # Trail 3% from high water mark - ])) +class FirstRoundTrip(Strategy): + def __init__(self): + self.asset_bars = 0 def on_data(self, timestamp, data, context, broker): - for asset, bar in data.items(): - if broker.get_position(asset) is None: - equity = broker.get_account_value() - shares = int(equity * 0.10 / bar["price"]) - if shares > 0: - broker.submit_order(asset, shares) -``` + if "AAPL" not in data: + return + self.asset_bars += 1 + if self.asset_bars == 1: + broker.submit_order("AAPL", 10) + elif self.asset_bars == 5: + broker.close_position("AAPL") -## Analyzing Results -`engine.run()` returns a `BacktestResult` with metrics, trades, and export methods: - -```python +prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL") +feed = DataFeed(prices_df=prices) +engine = Engine(feed, FirstRoundTrip(), BacktestConfig(initial_cash=100_000)) result = engine.run() -# Key metrics (dict) -print(result.metrics["sharpe"]) -print(result.metrics["max_drawdown_pct"]) -print(result.metrics["win_rate"]) -print(result.metrics["profit_factor"]) - -# Trades as Polars DataFrame -trades_df = result.to_trades_dataframe() -print(trades_df.head()) - -# Equity curve as Polars DataFrame -equity_df = result.to_equity_dataframe() -print(equity_df.head()) - -# Fills as Polars DataFrame -fills_df = result.to_fills_dataframe() -print(fills_df.head()) - -# Portfolio state snapshots -portfolio_df = result.to_portfolio_state_dataframe() -print(portfolio_df.head()) - -# Export to Parquet for analysis with ml4t-diagnostic -result.to_parquet("./results/my_backtest") +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"bars: {len(feed)}") +for fill in result.fills: + print(f"{fill.timestamp:%Y-%m-%d} {fill.side.value} {fill.quantity:g} @ ${fill.price:.2f}") +print(f"closed trades: {len(result.to_trades_dataframe())}") +print(f"trade P&L: ${result.trades[0].pnl:.2f}") +equity = result.to_equity_dataframe() +print(f"equity points: {equity.height}") +print(f"final equity: ${result.metrics['final_value']:.2f}") ``` -For quote-aware backtests, `fills_df` and `trades_df` preserve the quote context -used for execution, while `portfolio_df` shows the effect of the configured -marking source over time. - -## Convenience Function - -For quick experiments, `run_backtest` combines DataFeed + Engine in one call: - -```python -from ml4t.backtest import run_backtest - -# Accepts DataFrames or file paths -result = run_backtest( - prices="data/prices.parquet", - strategy=MyStrategy(), - signals="data/signals.parquet", - config="backtrader", -) + +```text +ml4t-backtest {package_version} +bars: 9 +2024-01-03 buy 10 @ $188.37 +2024-01-09 sell 10 @ $191.37 +closed trades: 1 +trade P&L: $30.00 +equity points: 9 +final equity: $100030.00 ``` -## Next Steps - -- [How It Works](../concepts/how-it-works.md) -- understand the execution model -- [Execution Semantics](../user-guide/execution-semantics.md) -- fill ordering, stops, timing -- [Configuration](../user-guide/configuration.md) -- all 40+ behavioral knobs -- [Risk Management](../user-guide/risk-management.md) -- stops, trails, portfolio limits +The feed provides `timestamp`, `asset`, and OHLCV columns. `DataFeed` delivers +the AAPL bar to `on_data` at each timestamp. The first callback queues a buy; +the fifth queues a close. The default `NEXT_BAR` execution mode fills both +market orders at the next AAPL bar's open. The orders were submitted on January +2 and January 8, and the fills occurred on January 3 and January 9. The run +starts with $100,000, charges no commission or slippage, and ends with one +closed trade worth $30. The nine equity points include cash and the marked +position after each bar. These synthetic prices illustrate accounting, not a +claim about AAPL's historical return. + +`result.to_fills_dataframe()` gives one row per execution. `result.to_trades_dataframe()` +gives the completed position round trip, including entry, exit, and P&L. +`result.to_equity_dataframe()` gives the dated account value. Inspect these +three frames before interpreting a summary metric; [Results & Analysis](../user-guide/results.md) +covers export and additional measures. + +An `Engine` instance runs once. Create a new instance to test another strategy +or setting. Continue with [Order Types](../user-guide/orders.md) and +[Execution Semantics](../user-guide/execution-semantics.md) to see how order +choice and timing change fills. + +## In the book + +Chapter 16, Section 16.3, [Vectorized and event-driven backtesting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/README.md), +and [notebook 04, Single Asset Backtest with ml4t-backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/04_single_asset_ml4t_backtest.ipynb) +extend this first round trip to a stateful RSI rule, explicit costs, and a matched +comparison with a vectorized backtest. diff --git a/docs/index.md b/docs/index.md index 17cd3441..030587b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,22 +46,32 @@ deployment: ## Quick Example -```python -import polars as pl -from ml4t.backtest import Engine, DataFeed, Strategy, BacktestConfig +This run uses a bundled synthetic AAPL panel, so it needs no API key or data +download. The [first backtest](getting-started/quickstart.md) walks through the +orders and result records. -class BuyAndHold(Strategy): - def on_data(self, timestamp, data, context, broker): - for asset, bar in data.items(): - if broker.get_position(asset) is None: - broker.submit_order(asset, 100) + +```python +from importlib.metadata import version -feed = DataFeed(prices_df=prices) -engine = Engine(feed=feed, strategy=BuyAndHold()) -result = engine.run() +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices + +prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL") +result = Engine( + DataFeed(prices_df=prices), + ExampleRoundTrip("AAPL", 100), + BacktestConfig(initial_cash=100_000), +).run() +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"fills={len(result.fills)} final=${result.metrics['final_value']:.2f}") +``` -print(f"Total Return: {result.metrics['total_return_pct']:.1f}%") -print(f"Sharpe Ratio: {result.metrics['sharpe']:.2f}") + +```text +ml4t-backtest {package_version} +fills=2 final=$100300.00 ``` Each `Engine` instance is single-use. Create a new instance for every independent run. @@ -136,25 +146,25 @@ The table reports engine-call wall time for all 17 correctness-passing pairs. Th | Real strategy | Pinned framework | Framework median (95% CI), s | ML4T median (95% CI), s | Framework / ML4T median | |---|---|---:|---:|---:| -| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.287 (0.286-0.289) | 0.415 (0.414-0.419) | 0.692x | -| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.171 (0.171-0.174) | 0.415 (0.413-0.417) | 0.412x | -| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.355 (9.303-9.424) | 0.430 (0.426-0.432) | 21.762x | -| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.872 (3.867-3.910) | 0.632 (0.628-0.638) | 6.127x | -| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.564 (2.487-2.599) | 0.750 (0.749-0.757) | 3.418x | -| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.286 (0.284-0.289) | 0.432 (0.429-0.435) | 0.663x | -| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 2.495 (2.488-2.526) | 0.429 (0.427-0.433) | 5.812x | -| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.801 (2.709-2.891) | 0.657 (0.655-0.661) | 4.266x | -| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.283 (0.281-0.286) | 0.146 (0.145-0.147) | 1.939x | -| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.139 (0.138-0.141) | 0.146 (0.145-0.147) | 0.952x | -| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.425 (0.420-0.432) | 0.146 (0.145-0.147) | 2.920x | -| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.913 (0.877-0.953) | 0.153 (0.152-0.157) | 5.952x | -| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.795 (0.787-0.838) | 20.555 (20.486-20.600) | 0.039x | -| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 16.995 (16.960-17.024) | 20.495 (20.453-20.534) | 0.829x | -| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 495.553 (494.227-510.273) | 21.470 (21.386-21.543) | 23.082x | -| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 107.609 (106.834-109.150) | 22.132 (22.082-22.234) | 4.862x | -| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 47.683 (47.333-48.015) | 26.368 (26.222-26.473) | 1.808x | - -Measured 2026-09-03 on `Linux-6.8.0-138-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). +| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.293 (0.290-0.298) | 0.424 (0.421-0.434) | 0.691x | +| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.192 (0.176-0.493) | 0.414 (0.413-0.417) | 0.462x | +| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.631 (9.428-9.710) | 0.439 (0.434-0.444) | 21.954x | +| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.888 (3.878-3.959) | 0.621 (0.618-0.626) | 6.256x | +| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.638 (2.516-2.707) | 0.750 (0.743-0.757) | 3.520x | +| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.290 (0.289-0.294) | 0.410 (0.406-0.958) | 0.708x | +| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 6.639 (5.831-8.332) | 0.428 (0.422-1.235) | 15.504x | +| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.828 (2.737-2.952) | 1.327 (0.657-2.183) | 2.131x | +| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.288 (0.286-0.294) | 0.148 (0.147-0.149) | 1.947x | +| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.144 (0.142-0.146) | 0.145 (0.145-0.146) | 0.988x | +| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.434 (0.430-0.440) | 0.147 (0.146-0.149) | 2.947x | +| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.953 (0.936-1.164) | 0.158 (0.155-0.164) | 6.033x | +| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 3.806 (3.336-4.341) | 23.306 (21.906-25.833) | 0.163x | +| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 17.116 (17.069-17.633) | 25.738 (24.983-27.163) | 0.665x | +| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 668.611 (642.993-718.202) | 26.887 (26.406-29.406) | 24.868x | +| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 117.445 (116.308-118.391) | 26.821 (25.473-27.644) | 4.379x | +| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 48.436 (48.221-48.986) | 27.688 (27.575-27.796) | 1.749x | + +Measured 2026-09-24 on `Linux-6.8.0-139-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). ### Synthetic diagnostic scenarios diff --git a/docs/tutorials/accounts-and-constraints.md b/docs/tutorials/accounts-and-constraints.md new file mode 100644 index 00000000..474f41c6 --- /dev/null +++ b/docs/tutorials/accounts-and-constraints.md @@ -0,0 +1,149 @@ +# Compare accounts, share sizing, and exposure caps + +This tutorial holds prices and orders fixed while changing one account or sizing +setting at a time. The declared USD prices are $100 on each of three synthetic +daily bars. AAPL and MSFT are treated as equities with a unit multiplier, zero +fees, and next-bar market fills. The fractional-share case assumes a venue that +supports decimal equity quantities. The margin case uses the default 50% initial +margin requirement; it is an example of buying power, not a claim that every +broker offers the same terms. The [account policy](../user-guide/accounts.md) +and [rebalancing](../user-guide/rebalancing.md) guides explain the settings in +full. + +## Run the controlled cases + +The first six runs submit one AAPL order at the January 2 close and inspect the +January 3 fill or rejection. `rejected_orders` supplies a structured code when +a gatekeeper declines an order. The last three runs request 80% each in AAPL +and MSFT. Each cap is applied before sizing the next-bar orders. The final +portfolio-state row records cash and signed market exposure after the fills. + + +```python +from datetime import datetime +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine, OrderSide, Strategy +from ml4t.backtest import RebalanceConfig, TargetWeightExecutor +from ml4t.backtest.config import ShareType + + +prices = pl.DataFrame({ + "timestamp": [datetime(2024, 1, day) for day in (2, 3, 4) for _ in range(2)], + "asset": [asset for _ in range(3) for asset in ("AAPL", "MSFT")], + "open": [100.0] * 6, + "high": [100.0] * 6, + "low": [100.0] * 6, + "close": [100.0] * 6, + "volume": [10_000.0] * 6, +}) + + +class OneOrder(Strategy): + def __init__(self, quantity, side): + self.quantity = quantity + self.side = side + self.submitted = False + + def on_data(self, timestamp, data, context, broker): + if not self.submitted: + broker.submit_order("AAPL", self.quantity, self.side) + self.submitted = True + + +def report(label, result): + state = result.to_portfolio_state_dataframe().to_dicts()[-1] + fills = [(fill.asset, fill.side.value, fill.quantity) for fill in result.fills] + codes = [row["rejection_code"] for row in result.to_rejected_orders_dataframe().to_dicts()] + print(f"{label}: fills={fills} cash={state['cash']:.0f} " + f"net={state['net_exposure']:.0f} rejected={codes}") + + +print("ml4t-backtest " + version("ml4t-backtest")) +aapl = prices.filter(pl.col("asset") == "AAPL") +account_cases = [ + ("cash buy", 150, OrderSide.BUY, {}), + ("margin buy", 150, OrderSide.BUY, {"allow_leverage": True}), + ("cash short", 10, OrderSide.SELL, {}), + ("short enabled", 10, OrderSide.SELL, {"allow_short_selling": True}), + ("integer", 10.5, OrderSide.BUY, {"share_type": ShareType.INTEGER}), + ("fractional", 10.5, OrderSide.BUY, {"share_type": ShareType.FRACTIONAL}), +] +for label, quantity, side, options in account_cases: + result = Engine(DataFeed(prices_df=aapl), OneOrder(quantity, side), + BacktestConfig(initial_cash=10_000, **options)).run() + report(label, result) + + +class TwoTargets(Strategy): + def __init__(self, single_cap, gross_cap): + self.executor = TargetWeightExecutor(RebalanceConfig( + max_single_weight=single_cap, max_gross_leverage=gross_cap, + allow_fractional=True, + )) + self.submitted = False + + def on_data(self, timestamp, data, context, broker): + if not self.submitted: + self.executor.execute({"AAPL": 0.8, "MSFT": 0.8}, data, broker, + timestamp=timestamp) + self.submitted = True + + +for label, single_cap, gross_cap in [ + ("uncapped targets", 1.0, None), + ("50% position cap", 0.5, None), + ("100% gross cap", 1.0, 1.0), +]: + result = Engine(DataFeed(prices_df=prices), TwoTargets(single_cap, gross_cap), + BacktestConfig(initial_cash=10_000, allow_leverage=True, + share_type=ShareType.FRACTIONAL)).run() + report(label, result) +``` + + +```text +ml4t-backtest {package_version} +cash buy: fills=[] cash=10000 net=0 rejected=['insufficient_cash'] +margin buy: fills=[('AAPL', 'buy', 150.0)] cash=-5000 net=15000 rejected=[] +cash short: fills=[] cash=10000 net=0 rejected=['account_restriction'] +short enabled: fills=[('AAPL', 'sell', 10.0)] cash=11000 net=-1000 rejected=[] +integer: fills=[('AAPL', 'buy', 10.0)] cash=9000 net=1000 rejected=[] +fractional: fills=[('AAPL', 'buy', 10.5)] cash=8950 net=1050 rejected=[] +uncapped targets: fills=[('AAPL', 'buy', 80.0), ('MSFT', 'buy', 80.0)] cash=-6000 net=16000 rejected=[] +50% position cap: fills=[('AAPL', 'buy', 50.0), ('MSFT', 'buy', 50.0)] cash=0 net=10000 rejected=[] +100% gross cap: fills=[('AAPL', 'buy', 50.0), ('MSFT', 'buy', 50.0)] cash=0 net=10000 rejected=[] +``` + +## Read the account results + +A cash account rejects the $15,000 purchase because it has $10,000. With +leverage enabled, the identical 150-share order fills and leaves $5,000 of +negative cash against $15,000 of long exposure. A cash account rejects a new +short with `account_restriction`; enabling short selling admits the same +10-share sell, leaves $11,000 cash including sale proceeds, and records +-$1,000 signed exposure. Cash alone is therefore not the account's equity or +spendable buying power. The two share-type runs submit the same 10.5-share +request: integer execution fills 10 shares, while fractional execution fills +10.5. Check the venue's actual share and lot rules before using the latter. + +## Read the exposure results + +Without a rebalance cap, two 80% targets buy 80 shares each and borrow $6,000. +`max_single_weight=0.5` cuts each target to 50%; `max_gross_leverage=1.0` +scales their combined 160% target down to 100%. Both changes produce two +50-share fills and no borrowing in this symmetric example. The caps cause +constrained sizing, not a rejected order; the account gatekeeper still checks +cash and margin when the resulting orders execute. Compare `fills`, +`to_rejected_orders_dataframe()`, and `to_portfolio_state_dataframe()` together +instead of inferring acceptance from requested weights. + +## In the book + +Chapter 17, Section 17.1, [Defining the allocation problem](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/README.md), +sets out the role of constraints and leverage. [Notebook 07, Conformal position +sizing](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/07_conformal_position_sizing.ipynb) +uses registered ETF and futures predictions to study a larger sizing problem. +The controlled runs here make the account and execution layer inspectable +before applying a book allocator. diff --git a/docs/tutorials/costs-and-funding.md b/docs/tutorials/costs-and-funding.md new file mode 100644 index 00000000..adf25dc7 --- /dev/null +++ b/docs/tutorials/costs-and-funding.md @@ -0,0 +1,195 @@ +# Reconcile trading costs and funding + +The [first backtest](../getting-started/quickstart.md) charged no trading costs. +These examples keep the same synthetic inputs fixed while adding commission, +slippage, market impact, and perpetual funding. Each printed amount is +recomputed by the installed-wheel documentation check. + +## Compare gross and net equity + +The AAPL strategy buys 1,000 shares and closes after its fifth bar. All three +runs use the same orders and price panel. The `gross` run has no costs. The +`moderate` assumptions leave a positive $2,392.44 change in equity; the `high` +assumptions turn the same $3,000 before-cost trade into a $2,771.76 loss. +These labels describe only the declared synthetic scenarios. + + +```python +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import BacktestConfig, CommissionType, DataFeed, Engine +from ml4t.backtest.config import SlippageType +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices +from ml4t.backtest.execution import LinearImpact + + +prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL") +scenarios = [ + ("gross", 0.0, 0.0, 0.0), + ("moderate", 0.001, 0.0005, 0.1), + ("high", 0.01, 0.005, 0.2), +] +print("ml4t-backtest " + version("ml4t-backtest")) +baseline_fills = None +baseline_gross = None +for name, fee_rate, slip_rate, impact in scenarios: + config = BacktestConfig( + initial_cash=1_000_000, + commission_type=CommissionType.PERCENTAGE, + commission_rate=fee_rate, + slippage_type=SlippageType.PERCENTAGE, + slippage_rate=slip_rate, + ) + result = Engine( + DataFeed(prices_df=prices), ExampleRoundTrip("AAPL", 1000), config, + market_impact_model=LinearImpact(coefficient=impact), + ).run() + trade = result.trades[0] + if baseline_fills is None: + baseline_fills = result.fills + baseline_gross = trade.gross_pnl + assert baseline_gross is not None + fees = sum(fill.commission for fill in result.fills) + slip = trade.total_slippage_cost + price_drag = sum( + (fill.price - baseline.price) * fill.quantity + if fill.side.value == "buy" else (baseline.price - fill.price) * fill.quantity + for baseline, fill in zip(baseline_fills, result.fills, strict=True) + ) + impact_cost = price_drag - slip + net = result.metrics["final_value"] - 1_000_000 + assert abs(baseline_gross - fees - slip - impact_cost - net) < 0.01 + print(f"{name}: gross={baseline_gross:.2f} fees={fees:.2f} " + f"slip={slip:.2f} impact={impact_cost:.2f} net={net:.2f}") +``` + + +```text +ml4t-backtest {package_version} +gross: gross=3000.00 fees=0.00 slip=0.00 impact=0.00 net=3000.00 +moderate: gross=3000.00 fees=379.73 slip=189.87 impact=37.96 net=2392.44 +high: gross=3000.00 fees=3797.18 slip=1898.66 impact=75.92 net=-2771.76 +``` + +The `gross` column is the no-cost run's completed trade P&L. Commission comes +from the fill records. The trade's `total_slippage_cost` reports the configured +slippage model. The script infers impact as the remaining change in execution +prices relative to the no-cost fills. For each row, gross minus commission, +slippage, and impact equals the change from the $1,000,000 starting equity. +`trade.gross_pnl` in a costed run already reflects its actual fill prices, so +subtracting slippage from that field again would double count it. See +[Market Impact](../user-guide/market-impact.md) for the individual models. + +## Add perpetual funding + +A long BTC perpetual receives two funding events while it is held. A positive +rate debits the position and a negative rate credits it. The event is processed +before orders at the same timestamp; the January 7 payment uses the position +held just before its exit. The example uses a synthetic price panel and declared +funding rates, with no trading fees. + + +```python +from datetime import datetime +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import AssetClass, BacktestConfig, ContractSpec, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices + + +prices = load_example_prices("crypto_perp").filter(pl.col("asset") == "BTC-PERP") +funding = pl.DataFrame({ + "timestamp": [datetime(2024, 1, 4), datetime(2024, 1, 7)], + "asset": ["BTC-PERP", "BTC-PERP"], + "rate": [0.001, -0.0005], +}) +config = BacktestConfig(initial_cash=100_000, allow_leverage=True) +spec = ContractSpec("BTC-PERP", AssetClass.FUTURE, margin=4000) +base = Engine(DataFeed(prices_df=prices), ExampleRoundTrip("BTC-PERP", 1), + config, contract_specs={"BTC-PERP": spec}).run() +funded = Engine(DataFeed(prices_df=prices), ExampleRoundTrip("BTC-PERP", 1), + config, contract_specs={"BTC-PERP": spec}, funding_df=funding).run() +print("ml4t-backtest " + version("ml4t-backtest")) +for row in funded.to_funding_dataframe().iter_rows(named=True): + print(f"{row['timestamp']:%Y-%m-%d} rate={row['rate']:.4f} cash={row['cash_delta']:.2f}") +print(f"trading P&L: ${base.trades[0].pnl:.2f}") +print(f"funding: -${abs(funded.metrics['total_funding']):.2f}") +print(f"net change: ${funded.metrics['final_value'] - 100_000:.2f}") +assert abs(base.trades[0].pnl + funded.metrics['total_funding'] + - (funded.metrics['final_value'] - 100_000)) < 0.01 +``` + + +```text +ml4t-backtest {package_version} +2024-01-04 rate=0.0010 cash=-42.20 +2024-01-07 rate=-0.0005 cash=21.75 +trading P&L: $700.00 +funding: -$20.45 +net change: $679.55 +``` + +`to_funding_dataframe()` keeps funding separate from fills and closed trades. +The $700 trading gain minus $20.45 funding equals the $679.55 change in equity. +The rate is applied to the held quantity, current causal mark, and contract +multiplier; see [Funding payments](../user-guide/market-impact.md#perpetual-futures-funding) +for the complete input and event-time contract. + +## Charge a flip once + +A position flip closes a long position and opens a short position with one +sell order. With a $5 per-trade commission, the 20-share flip creates one fill +and one $5 charge. The earlier buy is a separate $5 order. + + +```python +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import BacktestConfig, CommissionType, DataFeed, Engine, OrderSide, Strategy +from ml4t.backtest.example_data import load_example_prices + + +class FlipOnce(Strategy): + def __init__(self): + self.asset_bars = 0 + + def on_data(self, timestamp, data, context, broker): + self.asset_bars += 1 + if self.asset_bars == 1: + broker.submit_order("AAPL", 10, OrderSide.BUY) + elif self.asset_bars == 3: + broker.submit_order("AAPL", 20, OrderSide.SELL) + + +prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL") +result = Engine( + DataFeed(prices_df=prices), FlipOnce(), + BacktestConfig( + initial_cash=100_000, allow_short_selling=True, + commission_type=CommissionType.PER_TRADE, commission_per_trade=5, + ), +).run() +print("ml4t-backtest " + version("ml4t-backtest")) +for fill in result.fills: + print(f"{fill.timestamp:%Y-%m-%d} {fill.side.value} {fill.quantity:g} commission=${fill.commission:.2f}") +print(f"total commission: ${result.metrics['total_commission']:.2f}") +``` + + +```text +ml4t-backtest {package_version} +2024-01-03 buy 10 commission=$5.00 +2024-01-05 sell 20 commission=$5.00 +total commission: $10.00 +``` + +## In the book + +Chapter 18, Section 18.7, [Transaction cost analysis and model validation](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/18_transaction_costs/README.md), +and [notebook 10, Gross versus net performance](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/18_transaction_costs/10_gross_vs_net_performance.ipynb) +extend the cost decomposition to larger strategy runs. The [crypto-perpetual +cost notebook](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/crypto_perps_funding/16_costs.ipynb) +adds case-study funding and fee assumptions. diff --git a/docs/tutorials/data.md b/docs/tutorials/data.md new file mode 100644 index 00000000..3888b8c1 --- /dev/null +++ b/docs/tutorials/data.md @@ -0,0 +1,176 @@ +# Bundled example data + +The five price panels shipped with `ml4t-backtest` are synthetic. Their symbols, dates, +prices, volumes, and futures close times were written for these examples; they are not +historical market observations. Each panel has nine dates and two assets. The files +ship in the wheel, so the examples run offline without credentials. Every snippet +below runs on its own and submits one buy and one close order for its named asset. + +The `timestamp` column is the bar time; `asset` identifies the instrument. +`open`, `high`, `low`, `close`, and `volume` provide the execution and mark inputs. +The futures file also has a `session_date` column because its two contracts close +at 21:00 and 21:15 in this synthetic calendar. `ExampleRoundTrip` buys after the +first asset bar and closes after its fifth; default next-bar execution fills at +the following bar's open. These examples exercise data loading and accounting, +not a profitable trading rule. + +The output includes the installed distribution version. The documentation check +substitutes that exact version and compares the remaining lines byte for byte. + +## Equities + + +```python +from importlib.metadata import version +from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices + +prices = load_example_prices("equity") +feed = DataFeed(prices_df=prices) +config = BacktestConfig(initial_cash=100000) +engine = Engine(feed, ExampleRoundTrip("AAPL", 10), config) +result = engine.run() +assert len(result.fills) == 2 and len(result.trades) == 1 +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"equity: fills={len(result.fills)}, trades={len(result.trades)}") +print(f"final equity: ${result.metrics['final_value']:.2f}") +``` + + +```text +ml4t-backtest {package_version} +equity: fills=2, trades=1 +final equity: $100030.00 +``` + +## ETFs + + +```python +from importlib.metadata import version +from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices + +prices = load_example_prices("etf") +feed = DataFeed(prices_df=prices) +config = BacktestConfig(initial_cash=100000) +engine = Engine(feed, ExampleRoundTrip("SPY", 10), config) +result = engine.run() +assert len(result.fills) == 2 and len(result.trades) == 1 +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"etf: fills={len(result.fills)}, trades={len(result.trades)}") +print(f"final equity: ${result.metrics['final_value']:.2f}") +``` + + +```text +ml4t-backtest {package_version} +etf: fills=2, trades=1 +final equity: $100030.00 +``` + +## Futures + +ES uses a $50 point multiplier and an explicit 21:00 bar close. The decision waits for the later ZC close. + + +```python +from importlib.metadata import version +from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices +from ml4t.backtest import AssetClass, ContractSpec + +prices = load_example_prices("future") +feed = DataFeed(prices_df=prices, session_col="session_date") +config = BacktestConfig(initial_cash=200000, allow_leverage=True) +spec = ContractSpec(symbol="ES", asset_class=AssetClass.FUTURE, multiplier=50, margin=12000) +engine = Engine(feed, ExampleRoundTrip("ES", 1), config, contract_specs={"ES": spec}) +result = engine.run() +assert len(result.fills) == 2 and len(result.trades) == 1 +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"future: fills={len(result.fills)}, trades={len(result.trades)}") +print(f"final equity: ${result.metrics['final_value']:.2f}") +``` + + +```text +ml4t-backtest {package_version} +future: fills=2, trades=1 +final equity: $201000.00 +``` + +## FX + +The pair is USD quoted, so this example does not need a currency conversion feed. + + +```python +from importlib.metadata import version +from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices +from ml4t.backtest import AssetClass, ContractSpec + +prices = load_example_prices("fx") +feed = DataFeed(prices_df=prices) +config = BacktestConfig(initial_cash=100000) +spec = ContractSpec(symbol="EURUSD", asset_class=AssetClass.FOREX) +engine = Engine(feed, ExampleRoundTrip("EURUSD", 10000), config, contract_specs={"EURUSD": spec}) +result = engine.run() +assert len(result.fills) == 2 and len(result.trades) == 1 +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"fx: fills={len(result.fills)}, trades={len(result.trades)}") +print(f"final equity: ${result.metrics['final_value']:.2f}") +``` + + +```text +ml4t-backtest {package_version} +fx: fills=2, trades=1 +final equity: $100050.00 +``` + +## Crypto perpetuals + +The contract uses the futures accounting convention; a later tutorial adds funding payments. + + +```python +from importlib.metadata import version +from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices +from ml4t.backtest import AssetClass, ContractSpec + +prices = load_example_prices("crypto_perp") +feed = DataFeed(prices_df=prices) +config = BacktestConfig(initial_cash=100000, allow_leverage=True) +spec = ContractSpec(symbol="BTC-PERP", asset_class=AssetClass.FUTURE, margin=4000) +engine = Engine(feed, ExampleRoundTrip("BTC-PERP", 1), config, contract_specs={"BTC-PERP": spec}) +result = engine.run() +assert len(result.fills) == 2 and len(result.trades) == 1 +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"crypto_perp: fills={len(result.fills)}, trades={len(result.trades)}") +print(f"final equity: ${result.metrics['final_value']:.2f}") +``` + + +```text +ml4t-backtest {package_version} +crypto_perp: fills=2, trades=1 +final equity: $100700.00 +``` + +## Use longer data + +For US equities and ETFs, [Alpha Vantage's daily API](https://www.alphavantage.co/documentation/) +offers a free compact history after registering for an API key. The offline examples above +need no key. The [ECB exchange-rate series](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.et.html) +provides public FX reference rates, not executable OHLC bars. [Binance's public archive](https://github.com/binance/binance-public-data/blob/master/README.md) +provides spot and futures klines for crypto research. [CME daily settlements](https://www.cmegroup.com/market-data/daily-settlements.html) +show contract marks; check CME's data access terms before building a historical +futures panel. Normalize vendor timestamps, corporate actions, currency units, +contract multipliers, and missing sessions before substituting any of these +sources for the synthetic panels. + +## In the book + +Chapter 16, Section 16.3, [Futures backtesting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/02_futures_backtesting.ipynb) extends the small synthetic futures panel to a longer contract history. [FX pairs backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/fx_pairs/13_backtest.ipynb) shows how a research prediction stream becomes aligned feed input. diff --git a/docs/tutorials/diagnostic-handoff.md b/docs/tutorials/diagnostic-handoff.md new file mode 100644 index 00000000..aec0aa74 --- /dev/null +++ b/docs/tutorials/diagnostic-handoff.md @@ -0,0 +1,51 @@ +# Send a backtest to ml4t-diagnostic + +`ml4t-diagnostic` is an optional dependency for post-backtest analysis. This +example was verified with `ml4t-diagnostic==0.1.4` in addition to the installed +`ml4t-backtest` wheel. Install that version in an analysis environment before +running the code. The core [result export tutorial](results-and-analysis.md) +runs without it. + +The helper `portfolio_analysis_from_result()` reads daily returns from the +backtest result. `calendar="crypto"` selects the crypto annualization calendar. +`compute_summary_stats()` returns a metrics object; the displayed total +return and maximum drawdown come from that object. This example has no funding +or trading costs, so its figures differ from the funded run in the core +result export tutorial. + + +```python +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import AssetClass, BacktestConfig, ContractSpec, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices +from ml4t.diagnostic.integration import portfolio_analysis_from_result + +prices = load_example_prices("crypto_perp").filter(pl.col("asset") == "BTC-PERP") +result = Engine( + DataFeed(prices_df=prices), ExampleRoundTrip("BTC-PERP", 1), + BacktestConfig(initial_cash=100_000, allow_leverage=True), + contract_specs={"BTC-PERP": ContractSpec("BTC-PERP", AssetClass.FUTURE, margin=4000)}, +).run() +analysis = portfolio_analysis_from_result(result, calendar="crypto") +stats = analysis.compute_summary_stats() +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"daily returns={len(analysis.returns)} total={stats.total_return:.6f} " + f"max drawdown={stats.max_drawdown:.6f}") +``` + + +```text +ml4t-backtest {package_version} +daily returns=9 total=0.007000 max drawdown=-0.002986 +``` + +The example reports nine daily-return observations, not nine closed trades. +For a larger analysis, pass a benchmark to the helper and inspect the +[Results & Analysis](../user-guide/results.md#integration-with-ml4t-diagnostic) +reference for trade, fill, and portfolio-state handoffs. + +## In the book + +Chapter 17, Section 17.3, [Portfolio metrics](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/01_portfolio_metrics.ipynb) applies `ml4t-diagnostic` to a larger ETF allocation. The small example above tests the bridge before adding benchmark and rolling analyses. diff --git a/docs/tutorials/multiasset-rebalancing.md b/docs/tutorials/multiasset-rebalancing.md new file mode 100644 index 00000000..ded14bcf --- /dev/null +++ b/docs/tutorials/multiasset-rebalancing.md @@ -0,0 +1,235 @@ +# Turn signals into portfolio weights + +The [first backtest](../getting-started/quickstart.md) held one stock. This +tutorial uses the same [bundled synthetic panels](data.md) to rebalance two +assets. A `signals_df` row contains one declared `prediction` for each +`(timestamp, asset)` pair. The values 0.7 and 0.3 are fixed teaching inputs, +not fitted forecasts. The strategy puts 80% of capital to work, so its first +targets are 56% and 24%, with 20% left in cash. It evaluates its schedule on +every callback and rebalances every second session. + +## Equities and ETFs + +Both assets in each of these panels share the same daily bar timestamp. The +NYSE calendar and New York timezone identify the synthetic 16:00 local closes. +Orders submitted at the January 2 decision fill on January 3. + + +```python +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine, RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor +from ml4t.backtest.example_data import load_example_prices + + +class RankedRebalance(Strategy): + def __init__(self, calendar=None): + self.executor = TargetWeightExecutor(RebalanceConfig( + schedule=RebalanceSchedule.fixed_n_sessions(2), + calendar=calendar, + data_frequency="daily", + max_single_weight=0.6, + )) + self.decisions = [] + + def on_data(self, timestamp, data, context, broker): + targets = { + asset: round(bar["signals"]["prediction"] * 0.8, 2) + for asset, bar in data.items() + } + orders = self.executor.execute(targets, data, broker, timestamp=timestamp) + if orders: + self.decisions.append((timestamp, targets, len(orders))) + + +def run(category, first_asset, calendar=None): + prices = load_example_prices(category) + signals = prices.select("timestamp", "asset").with_columns( + pl.when(pl.col("asset") == first_asset) + .then(0.7).otherwise(0.3).alias("prediction") + ) + strategy = RankedRebalance(calendar) + result = Engine(DataFeed(prices_df=prices, signals_df=signals), strategy, + BacktestConfig(initial_cash=100_000, calendar=calendar, timezone="America/New_York", enforce_sessions=True)).run() + print(f"{category}: decisions={len(strategy.decisions)}, fills={len(result.fills)}") + first = strategy.decisions[0] + print(f" first target: {first[0]:%Y-%m-%d} {first[1]}") + print(f" first fills: {[f.asset for f in result.fills[:2]]} at {[f.timestamp.strftime('%Y-%m-%d') for f in result.fills[:2]]}") + +print("ml4t-backtest " + version("ml4t-backtest")) +run("equity", "AAPL", "NYSE") +run("etf", "SPY", "NYSE") +``` + + +```text +ml4t-backtest {package_version} +equity: decisions=5, fills=6 + first target: 2024-01-02 {'AAPL': 0.56, 'MSFT': 0.24} + first fills: ['AAPL', 'MSFT'] at ['2024-01-03', '2024-01-03'] +etf: decisions=5, fills=5 + first target: 2024-01-02 {'SPY': 0.56, 'TLT': 0.24} + first fills: ['SPY', 'TLT'] at ['2024-01-03', '2024-01-03'] +``` + +`TargetWeightExecutor.execute()` turns the target dictionary into orders and +keeps the schedule state. `result.to_fills_dataframe()` records actual fills; +`strategy.decisions` records the targets used when orders were submitted. The +number of fills differs across the two panels because later price changes +produce different rounded share adjustments. + +## Futures with different close times + +The synthetic ES bar closes at 21:00 UTC and ZC at 21:15 UTC. Exact-timestamp +iteration therefore gives two partial callbacks per date. The observer below +records that default behavior without submitting orders. The rebalance run +uses the explicit `session_date` column, so both bars enter one decision at +21:15. The first ES and ZC orders fill on their own bars the next session. +The example specifies a $50 point multiplier and per-contract margin for each +synthetic contract. It does not claim that one exchange calendar covers both +products; the supplied session labels define this example's grouping. + + +```python +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import AssetClass, BacktestConfig, ContractSpec, DataFeed, Engine +from ml4t.backtest import RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor +from ml4t.backtest.example_data import load_example_prices + + +class Observe(Strategy): + def __init__(self): + self.events = [] + + def on_data(self, timestamp, data, context, broker): + self.events.append((timestamp, sorted(data))) + + +class FuturesRebalance(Strategy): + def __init__(self): + self.executor = TargetWeightExecutor(RebalanceConfig( + schedule=RebalanceSchedule.fixed_n_sessions(2), + data_frequency="daily", timezone="UTC", max_single_weight=0.6, + )) + self.decisions = [] + + def on_data(self, timestamp, data, context, broker): + targets = {asset: round(bar["signals"]["prediction"] * 0.8, 2) + for asset, bar in data.items()} + orders = self.executor.execute(targets, data, broker, timestamp=timestamp) + if orders: + self.decisions.append((timestamp, targets)) + + +prices = load_example_prices("future") +observer = Observe() +Engine(DataFeed(prices_df=prices), observer, BacktestConfig(initial_cash=1_000_000)).run() +signals = prices.select("timestamp", "asset").with_columns( + pl.when(pl.col("asset") == "ES").then(0.7).otherwise(0.3).alias("prediction") +) +strategy = FuturesRebalance() +result = Engine( + DataFeed(prices_df=prices, signals_df=signals, session_col="session_date"), + strategy, + BacktestConfig(initial_cash=1_000_000, allow_leverage=True, timezone="UTC"), + contract_specs={ + "ES": ContractSpec("ES", AssetClass.FUTURE, multiplier=50, margin=12000), + "ZC": ContractSpec("ZC", AssetClass.FUTURE, multiplier=50, margin=10000), + }, +).run() +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"exact events on first date: {[assets for ts, assets in observer.events if ts.day == 2]}") +print(f"session decision: {strategy.decisions[0][0]:%Y-%m-%d %H:%M} {strategy.decisions[0][1]}") +print(f"first fills: {[(f.asset, f.timestamp.strftime('%Y-%m-%d %H:%M')) for f in result.fills[:2]]}") +print(f"filled orders: {len(result.fills)}") +``` + + +```text +ml4t-backtest {package_version} +exact events on first date: [['ES'], ['ZC']] +session decision: 2024-01-02 21:15 {'ES': 0.56, 'ZC': 0.24} +first fills: [('ES', '2024-01-03 21:00'), ('ZC', '2024-01-03 21:15')] +filled orders: 2 +``` + +A session decision cannot fill against the earlier 21:00 bar. The feed also +rejects a session missing ES or ZC before the run starts. With the default +exact-timestamp feed, do not infer a zero target from an asset absent at the +current timestamp. See [Data Feed](../user-guide/data-feed.md#daily-decisions-across-different-close-times) +for the session input contract. + +## USD-quoted FX pairs + +The EURUSD and GBPUSD panels use UTC daily timestamps. Both prices are USD per +unit of base currency, and the contract specifications declare USD settlement. +No spot FX exchange calendar is enforced in this example. The schedule counts +the observed daily sessions and still sends orders for next-bar execution. + + +```python +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import AssetClass, BacktestConfig, ContractSpec, DataFeed, Engine +from ml4t.backtest import RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor +from ml4t.backtest.example_data import load_example_prices + + +class FXRebalance(Strategy): + def __init__(self): + self.executor = TargetWeightExecutor(RebalanceConfig( + schedule=RebalanceSchedule.fixed_n_sessions(2), + data_frequency="daily", timezone="UTC", max_single_weight=0.6, + )) + self.decisions = [] + + def on_data(self, timestamp, data, context, broker): + targets = {asset: round(bar["signals"]["prediction"] * 0.8, 2) + for asset, bar in data.items()} + orders = self.executor.execute(targets, data, broker, timestamp=timestamp) + if orders: + self.decisions.append((timestamp, targets)) + + +prices = load_example_prices("fx") +signals = prices.select("timestamp", "asset").with_columns( + pl.when(pl.col("asset") == "EURUSD").then(0.7).otherwise(0.3).alias("prediction") +) +strategy = FXRebalance() +result = Engine( + DataFeed(prices_df=prices, signals_df=signals), strategy, + BacktestConfig(initial_cash=100_000, timezone="UTC"), + contract_specs={ + asset: ContractSpec(asset, AssetClass.FOREX, currency="USD") + for asset in ("EURUSD", "GBPUSD") + }, +).run() +print("ml4t-backtest " + version("ml4t-backtest")) +print(f"first target: {strategy.decisions[0][0]:%Y-%m-%d} {strategy.decisions[0][1]}") +print(f"first fills: {[(f.asset, f.timestamp.strftime('%Y-%m-%d')) for f in result.fills[:2]]}") +print(f"filled orders: {len(result.fills)}") +``` + + +```text +ml4t-backtest {package_version} +first target: 2024-01-02 {'EURUSD': 0.56, 'GBPUSD': 0.24} +first fills: [('EURUSD', '2024-01-03'), ('GBPUSD', '2024-01-03')] +filled orders: 8 +``` + +The [Rebalancing](../user-guide/rebalancing.md) guide covers target sizing, +missing-price policies, and schedule metadata. The [Account Policies](../user-guide/accounts.md) +guide covers cash and margin settings used by these variants. + +## In the book + +Chapter 17, Section 17.7, [Comparing allocator performance](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/README.md), +and [notebook 08, Library comparison](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/08_library_comparison.ipynb) +compare portfolio construction workflows. [Chapter 16's futures notebook](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/02_futures_backtesting.ipynb) +adds contract and overnight-session assumptions; the [FX case-study backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/fx_pairs/13_backtest.ipynb) +applies targets to a larger prediction stream. diff --git a/docs/tutorials/orders-and-timing.md b/docs/tutorials/orders-and-timing.md new file mode 100644 index 00000000..d51ba08b --- /dev/null +++ b/docs/tutorials/orders-and-timing.md @@ -0,0 +1,88 @@ +# Change the order, change the fill + +Start with the [completed AAPL backtest](../getting-started/quickstart.md). +Here, the strategy makes the same fixed decision on the first bar in every run: +submit one buy order for one share. Only the order type or execution setting +changes. The prices come from the bundled synthetic equity panel. + + +```python +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine, ExecutionMode, OrderType, Strategy +from ml4t.backtest.config import ExecutionPrice +from ml4t.backtest.example_data import load_example_prices + + +class SingleOrder(Strategy): + def __init__(self, order_type, *, limit_price=None, stop_price=None): + self.order_type = order_type + self.limit_price = limit_price + self.stop_price = stop_price + self.submitted = False + + def on_data(self, timestamp, data, context, broker): + if self.submitted: + return + broker.submit_order( + "AAPL", 1, order_type=self.order_type, + limit_price=self.limit_price, stop_price=self.stop_price, + ) + self.submitted = True + + +prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL") +scenarios = [ + ("next market", ExecutionMode.NEXT_BAR, OrderType.MARKET, None, None), + ("same market", ExecutionMode.SAME_BAR, OrderType.MARKET, None, None), + ("next limit", ExecutionMode.NEXT_BAR, OrderType.LIMIT, 188.50, None), + ("next stop", ExecutionMode.NEXT_BAR, OrderType.STOP, None, 191.00), + ("unfilled limit", ExecutionMode.NEXT_BAR, OrderType.LIMIT, 170.00, None), +] +print("ml4t-backtest " + version("ml4t-backtest")) +for name, timing, kind, limit, stop in scenarios: + strategy = SingleOrder(kind, limit_price=limit, stop_price=stop) + engine = Engine(DataFeed(prices_df=prices), strategy, BacktestConfig( + execution_mode=timing, + execution_price=ExecutionPrice.CLOSE if timing is ExecutionMode.SAME_BAR else ExecutionPrice.OPEN, + )) + result = engine.run() + fills = result.to_fills_dataframe() + if fills.height: + fill = result.fills[0] + print(f"{name}: {fill.timestamp:%Y-%m-%d} @ ${fill.price:.2f}") + else: + print(f"{name}: no fill; pending={len(engine.broker.get_pending_orders())}") +``` + + +```text +ml4t-backtest {package_version} +next market: 2024-01-03 @ $188.37 +same market: 2024-01-02 @ $188.00 +next limit: 2024-01-03 @ $188.50 +next stop: 2024-01-03 @ $191.00 +unfilled limit: no fill; pending=1 +``` + +The next-bar market order was submitted on January 2 and filled at January 3's +open. The same-bar run uses `ExecutionPrice.CLOSE`, so its fill is at January +2's close. The strategy does not inspect the bar price before submitting; the +order is fixed in advance. If a decision depends on the completed bar's close, +use next-bar execution. A close-conditioned signal cannot trade at an earlier +open or assume it joined the already completed closing auction. + +The buy limit at $188.50 and buy stop at $191.00 both reached their trigger +on the next bar and filled at their declared levels. The $170 limit never +traded and remains pending at the end of the input. `result.to_fills_dataframe()` +contains only executed orders; `engine.broker.get_pending_orders()` shows the +unfilled instruction. The [Order Types](../user-guide/orders.md) guide lists +all supported order fields, and [Execution Semantics](../user-guide/execution-semantics.md) +describes processing order and price selection. + +## In the book + +Chapter 16, Section 16.3, [Vectorized and event-driven backtesting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/README.md), +and [notebook 04, Single Asset Backtest with ml4t-backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/04_single_asset_ml4t_backtest.ipynb) +apply order timing to a stateful RSI example with explicit costs. diff --git a/docs/tutorials/profiles-and-parity.md b/docs/tutorials/profiles-and-parity.md new file mode 100644 index 00000000..9458d89d --- /dev/null +++ b/docs/tutorials/profiles-and-parity.md @@ -0,0 +1,131 @@ +# Compare profile settings and bounded parity evidence + +A profile fills many configuration fields at once. Inspect it before comparing +results or changing settings. The first example reads five named profiles from +the installed package. It reports configuration only; it does not execute an +external backtester. `zipline_strict` names the library's documented comparison +protocol, which differs from Zipline Reloaded's native daily fill and cost +defaults. The [Profiles](../user-guide/profiles.md) reference lists the other +fields and the retained evidence for supported workloads. + +## Inspect the profiles + + +```python +from importlib.metadata import version + +from ml4t.backtest import BacktestConfig + +print("ml4t-backtest " + version("ml4t-backtest")) +for name in ("default", "vectorbt_strict", "backtrader_strict", "zipline_strict", "lean"): + cfg = BacktestConfig.from_preset(name) + print(f"{name}: {cfg.execution_mode.value}/{cfg.execution_price.value} " + f"shares={cfg.share_type.value} leverage={cfg.allow_leverage}") +``` + + +```text +ml4t-backtest {package_version} +default: next_bar/open shares=integer leverage=False +vectorbt_strict: same_bar/close shares=fractional leverage=False +backtrader_strict: next_bar/open shares=integer leverage=False +zipline_strict: next_bar/open shares=integer leverage=False +lean: next_bar/open shares=integer leverage=True +``` + +The table shows why comparing two unmodified profiles does not isolate an +execution rule: timing, share precision, account policy, costs, and other +fields may differ. It also does not establish native framework equivalence. + +## Change one fill assumption + +The next run holds the bundled synthetic AAPL bars, strategy, share quantity, +capital, and same-bar timing fixed. It changes only `execution_price`: open +versus close. The January 2 open is $187.725 and the close is $188. The order +fills on that same bar in both runs. Same-bar execution may use information +from the bar that is unavailable at the chosen fill time; use it here only to +measure the setting. The [orders tutorial](orders-and-timing.md) shows the +causal next-bar alternative. + + +```python +from dataclasses import replace +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine, ExecutionMode, Strategy +from ml4t.backtest.config import ExecutionPrice +from ml4t.backtest.example_data import load_example_prices + + +class BuyOnce(Strategy): + def __init__(self): + self.submitted = False + + def on_data(self, timestamp, data, context, broker): + if not self.submitted: + broker.submit_order("AAPL", 10) + self.submitted = True + + +prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL") +base = BacktestConfig(initial_cash=100_000, execution_mode=ExecutionMode.SAME_BAR, + execution_price=ExecutionPrice.OPEN) +close_fill = replace(base, execution_price=ExecutionPrice.CLOSE) +print("ml4t-backtest " + version("ml4t-backtest")) +for label, config in (("same-bar open", base), ("same-bar close", close_fill)): + result = Engine(DataFeed(prices_df=prices), BuyOnce(), config).run() + fill = result.fills[0] + print(f"{label}: {fill.timestamp:%Y-%m-%d} 10 at {fill.price:.5f} " + f"final={result.metrics['final_value']:.2f}") +``` + + +```text +ml4t-backtest {package_version} +same-bar open: 2024-01-02 10 at 187.72500 final=100082.75 +same-bar close: 2024-01-02 10 at 188.00000 final=100080.00 +``` + +The fill-price change is $0.275 per share. Ten shares cost $2.75 more in the +close-fill run, which accounts for its $2.75 lower final equity. No framework +is run here, so these two lines support only a local setting comparison. + +## Read the retained comparison correctly + +The [retained real-strategy evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_RESULTS.json) +used five frozen workloads: ETF allocation, CME futures, crypto perpetual +funding, USD-quoted FX allocation, and a US equity panel. Its +[generated report](../user-guide/profiles.md#real-strategy-audit) recorded +17 passing required framework-workload pairs and eight unsupported pairs +for the engine source digest stored with that evidence. Earlier engine changes +in this development branch made that digest stale; the parity-claim gate must +pass again before these rows can support a new release claim. The shared +targets were frozen before either engine ran. Those comparisons covered +ordered fills, supported valuation timestamps, and terminal values under +their declared profiles and canonical precision. They disabled transaction +costs and position rules, so even refreshed results will not establish +equivalence for those production overlays or for every asset class. + +The [synthetic scenario results](https://github.com/ml4t/backtest/blob/main/validation/CORRECTNESS_RESULTS.json) +check isolated conventions for pinned VectorBT, Backtrader, and Zipline +versions. The [large-scale results](https://github.com/ml4t/backtest/blob/main/validation/LARGE_SCALE_RESULTS.json) +check a 250-asset, 5,040-session workload and state which surfaces are native or +reconstructed. Neither expands the real-strategy claim to arbitrary inputs. +LEAN's supported daily US-equity protocol and separate case-study evidence +are described in the [Profiles](../user-guide/profiles.md) reference. + +For a new framework comparison, freeze one input bundle and target stream, +select the matching profile and native framework configuration, then compare +only fields both sides expose. Record framework versions, data digests, +profile overrides, supported result surfaces, and the first differing record. +The [validation methodology](https://github.com/ml4t/backtest/blob/main/validation/METHODOLOGY.md) +describes the comparison process and canonical precision. + +## In the book + +Chapter 16, [Framework parity and engine divergence](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/README.md), +includes [notebook 07, Engine divergence anatomy](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/07_engine_divergence_anatomy.ipynb), +which changes one configuration field at a time, and [notebook 16, Case-study +LEAN parity](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/16_case_study_lean_parity.ipynb), +which reports the bounded cross-framework audit. diff --git a/docs/tutorials/results-and-analysis.md b/docs/tutorials/results-and-analysis.md new file mode 100644 index 00000000..2bbd9766 --- /dev/null +++ b/docs/tutorials/results-and-analysis.md @@ -0,0 +1,127 @@ +# Inspect, join, and export results + +This example reuses the bundled synthetic BTC perpetual panel. It declares +`America/New_York` timestamps on the price bars, a fixed prediction value, and +two funding rates. The strategy buys one contract and later closes it. No +commission or slippage is charged. The resulting equity curve includes trading +P&L and funding cash flows, while fills, trades, and funding remain separate +records. See [Costs and Funding](costs-and-funding.md) for the event math. + +## Run and reconcile the exports + +The code compares Polars timestamp dtypes before joining results to inputs. +The joins use `(timestamp, asset)` for fills and funding, and `timestamp` for +equity and predictions. There is no user-side zone cast. A temporary artifact +is written with `to_parquet()` and read with `BacktestResult.from_parquet()`; +frame equality then checks every exported surface used in this example. + + +```python +from datetime import datetime +from importlib.metadata import version +from tempfile import TemporaryDirectory +from zoneinfo import ZoneInfo + +import polars as pl +from polars.testing import assert_frame_equal +from ml4t.backtest import AssetClass, BacktestConfig, BacktestResult, ContractSpec, DataFeed, Engine +from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices + + +zone = "America/New_York" +prices = load_example_prices("crypto_perp").filter(pl.col("asset") == "BTC-PERP").with_columns( + pl.col("timestamp").dt.replace_time_zone(zone) +) +signals = prices.select("timestamp", "asset").with_columns(pl.lit(0.4).alias("prediction")) +funding = pl.DataFrame({ + "timestamp": [datetime(2024, 1, day, tzinfo=ZoneInfo(zone)) for day in (4, 7)], + "asset": ["BTC-PERP", "BTC-PERP"], + "rate": [0.001, -0.0005], +}) +result = Engine( + DataFeed(prices_df=prices, signals_df=signals), ExampleRoundTrip("BTC-PERP", 1), + BacktestConfig(initial_cash=100_000, allow_leverage=True, timezone=zone), + contract_specs={"BTC-PERP": ContractSpec("BTC-PERP", AssetClass.FUTURE, margin=4000)}, + funding_df=funding, +).run() +equity = result.to_equity_dataframe() +fills = result.to_fills_dataframe() +trades = result.to_trades_dataframe() +state = result.to_portfolio_state_dataframe() +payments = result.to_funding_dataframe() +predictions = result.to_predictions_dataframe() +print("ml4t-backtest " + version("ml4t-backtest")) +frames = { + "prices": prices, + "predictions": predictions, + "equity": equity, + "fills": fills, + "state": state, + "funding": payments, +} +for name, frame in frames.items(): + assert frame.schema["timestamp"] == prices.schema["timestamp"] +assert trades.schema["entry_time"] == prices.schema["timestamp"] +assert trades.schema["exit_time"] == prices.schema["timestamp"] +print(f"timestamp zone: {prices.schema['timestamp'].time_zone}") +fill_match = fills.join(prices.select("timestamp", "asset", "close"), + on=["timestamp", "asset"], how="inner") +equity_match = equity.join(predictions.select("timestamp").unique(), + on="timestamp", how="inner") +funding_match = payments.join(prices.select("timestamp", "asset"), + on=["timestamp", "asset"], how="inner") +print(f"joined: fills {fill_match.height}/{fills.height}, " + f"equity {equity_match.height}/{equity.height}, " + f"funding {funding_match.height}/{payments.height}") +trading_pnl = trades.select(pl.col("pnl").sum()).item() +funding_cash = payments.select(pl.col("cash_delta").sum()).item() +net_change = result.metrics["final_value"] - 100_000 +assert abs(trading_pnl + funding_cash - net_change) < 0.01 +print(f"trading={trading_pnl:.2f} funding={funding_cash:.2f} " + f"net={net_change:.2f}") +print(f"returns={equity.height} portfolio states={state.height}") +with TemporaryDirectory() as directory: + result.to_parquet(directory) + restored = BacktestResult.from_parquet(directory) + for method in ( + "to_fills_dataframe", "to_trades_dataframe", "to_equity_dataframe", + "to_portfolio_state_dataframe", "to_funding_dataframe", + "to_predictions_dataframe", + ): + original = getattr(result, method)() + loaded = getattr(restored, method)() + assert_frame_equal(original, loaded) + print("artifact round trip: fills, trades, equity, state, funding, predictions equal") +``` + + +```text +ml4t-backtest {package_version} +timestamp zone: America/New_York +joined: fills 2/2, equity 9/9, funding 2/2 +trading=700.00 funding=-20.45 net=679.55 +returns=9 portfolio states=9 +artifact round trip: fills, trades, equity, state, funding, predictions equal +``` + +The `return` column in the equity frame is the bar-to-bar equity return; its +first row is zero because there is no preceding bar. Portfolio state records +end-of-bar cash, equity, and exposure. A fill is an execution event, while a +trade is a realized exit leg. Funding payments are cash flows outside the fill +stream. Thus the $700 realized trading P&L plus -$20.45 funding equals the +$679.55 net equity change. Nine prediction and equity timestamps, two fills, +and two funding events join to the original input timestamps exactly. + +The default artifact includes a manifest, config and run spec, metrics, and +Parquet components for trades, fills, rejected orders, predictions, equity, +portfolio state, daily P&L, and funding. The manifest is checked on load; see +[Results & Analysis](../user-guide/results.md#parquet-export) for the full +format and recovery rules. The separate [diagnostic handoff](diagnostic-handoff.md) +shows an optional, tested post-backtest analysis step. + +## In the book + +Chapter 16, [Strategy simulation and reporting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/README.md), +uses fill and trade reconciliation in [notebook 04, Single asset ml4t-backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/04_single_asset_ml4t_backtest.ipynb). +Chapter 17's [portfolio metrics notebook](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/01_portfolio_metrics.ipynb) +extends the reporting workflow to allocation diagnostics. diff --git a/docs/tutorials/risk-and-state.md b/docs/tutorials/risk-and-state.md new file mode 100644 index 00000000..cde27154 --- /dev/null +++ b/docs/tutorials/risk-and-state.md @@ -0,0 +1,135 @@ +# Follow stops and portfolio risk through a backtest + +This run uses two synthetic unit-multiplier assets, A and B, with $2,500 +starting cash and no fees. Both orders are placed on January 2 and filled at +$100 on January 3. B has a 5% stop loss. A has no position stop, so it remains +available for a portfolio drawdown rule that halves exposure after a 10% +drawdown. All bars are daily; the declared open, high, low, and close are equal, +so the $94 B exit is an observable gap below its $95 stop level. The run uses +default next-bar order timing. + +## Run the strategy + +`ManagedRisk` retains its entry flag, risk manager, and callback log between +bars. Its `on_start` installs B's stop and initializes the portfolio's high +water mark. On each callback it passes the broker's current marked equity and +positions to `RiskManager.update`. That call may queue a reduction for the next +bar. The log prints the manager's reported action and the broker's pending +orders separately: a repeated breach can report `reduce` without submitting +another order. + + +```python +from datetime import datetime +from importlib.metadata import version + +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine, StopLoss, Strategy +from ml4t.backtest.risk.portfolio import MaxDrawdownLimit, RiskManager + + +days = (2, 3, 4, 5, 6, 7, 8) +marks = {"A": (100, 100, 80, 80, 120, 80, 80), "B": (100, 100, 94, 94, 94, 94, 94)} +prices = pl.DataFrame({ + "timestamp": [datetime(2024, 1, day) for day in days for _ in ("A", "B")], + "asset": [asset for _ in days for asset in ("A", "B")], + "open": [marks[asset][i] for i in range(len(days)) for asset in ("A", "B")], + "high": [marks[asset][i] for i in range(len(days)) for asset in ("A", "B")], + "low": [marks[asset][i] for i in range(len(days)) for asset in ("A", "B")], + "close": [marks[asset][i] for i in range(len(days)) for asset in ("A", "B")], + "volume": [1000.0] * (len(days) * 2), +}) + +class ManagedRisk(Strategy): + def on_start(self, broker): + broker.set_position_rules(StopLoss(pct=0.05), asset="B") + self.manager = RiskManager(limits=[MaxDrawdownLimit( + max_drawdown=0.10, action="reduce", reduction_pct=0.5, + )]) + self.manager.initialize(initial_equity=broker.get_account_value()) + self.events = [] + self.entered = False + + def on_data(self, timestamp, data, context, broker): + positions = {asset: pos.market_value for asset, pos in broker.positions.items()} + results = self.manager.update( + equity=broker.get_account_value(), positions=positions, + timestamp=timestamp, broker=broker, + ) + self.events.append((timestamp, round(broker.get_account_value(), 2), + {a: p.quantity for a, p in broker.positions.items()}, + [r.action for r in results], + [(o.asset, o.quantity) for o in broker.get_pending_orders()])) + if not self.entered: + broker.submit_order("A", 16) + broker.submit_order("B", 4) + self.entered = True + +strategy = ManagedRisk() +result = Engine(DataFeed(prices_df=prices), strategy, + BacktestConfig(initial_cash=2500)).run() +print("ml4t-backtest " + version("ml4t-backtest")) +for timestamp, equity, positions, actions, pending in strategy.events: + print(f"{timestamp:%Y-%m-%d}: equity={equity:.0f} positions={positions} " + f"risk={actions} pending={pending}") +for fill in result.fills: + if fill.side.value == "sell": + print(f"exit {fill.timestamp:%Y-%m-%d}: {fill.asset} {fill.quantity:g} " + f"at {fill.price:.0f} reason={fill.exit_reason_detail}") +for state in result.to_portfolio_state_dataframe().to_dicts(): + if state["timestamp"].day in (4, 5, 6, 7, 8): + print(f"exposure {state['timestamp']:%Y-%m-%d}: " + f"gross={state['gross_exposure']:.0f} cash={state['cash']:.0f}") +``` + + +```text +ml4t-backtest {package_version} +2024-01-02: equity=2500 positions={} risk=[] pending=[] +2024-01-03: equity=2500 positions={'A': 16.0, 'B': 4.0} risk=[] pending=[] +2024-01-04: equity=2156 positions={'A': 16.0} risk=['reduce'] pending=[('A', 8.0)] +2024-01-05: equity=2156 positions={'A': 8.0} risk=['reduce'] pending=[] +2024-01-06: equity=2476 positions={'A': 8.0} risk=[] pending=[] +2024-01-07: equity=2156 positions={'A': 8.0} risk=['reduce'] pending=[('A', 4.0)] +2024-01-08: equity=2156 positions={'A': 4.0} risk=['reduce'] pending=[] +exit 2024-01-04: B 4 at 94 reason=stop_loss_5.0% +exit 2024-01-05: A 8 at 80 reason=risk reduction: drawdown 13.8% >= 10.0% +exit 2024-01-08: A 4 at 80 reason=risk reduction: drawdown 13.8% >= 10.0% +exposure 2024-01-04: gross=1280 cash=876 +exposure 2024-01-05: gross=640 cash=1516 +exposure 2024-01-06: gross=960 cash=1516 +exposure 2024-01-07: gross=640 cash=1516 +exposure 2024-01-08: gross=320 cash=1836 +``` + +## Read the event order + +1. On January 3, the queued entries fill and the strategy observes 16 A and 4 B. +2. On January 4, the new $94 B bar triggers its stop. The engine fills that + stop before calling the strategy. The callback therefore sees only A and + $2,156 equity, a 13.8% drawdown from $2,500. It queues a sell for half of + A's 16 shares. +3. On January 5, the queued reduction fills at $80 before the callback. Gross + exposure drops from $1,280 to $640. The drawdown remains above 10%, so the + manager reports a breach but does not queue a duplicate reduction. +4. On January 6, A reaches $120 and equity rises to $2,476. This clears the + drawdown breach and allows a later risk event. +5. On January 7, A returns to $80. The new breach queues a four-share sell, + half of the eight shares then held. The January 8 fill leaves four shares + and $320 gross exposure. + +The risk manager stores whether a continuous breach has already caused a +reduction. The strategy retains whether it has submitted the initial orders. +Neither decision can be reconstructed safely from a precomputed vector of +entry signals alone. Inspect fill `exit_reason_detail` and portfolio-state +rows to separate the stop exit, queued reductions, and changes in exposure. +The [Risk Management](../user-guide/risk-management.md) guide covers the rules +and the [stateful strategy examples](https://github.com/ml4t/backtest/blob/main/examples/stateful_strategies.py) +show other uses of callback state. + +## In the book + +Chapter 19, Section 19.4, [Drawdowns, path risk, and time to recovery](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/19_risk_management/README.md), +puts this event trace in a broader risk framework. [Notebook 10, ml4t-backtest +risk demo](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/19_risk_management/10_ml4t_backtest_risk_demo.ipynb) +uses the library's position rules and portfolio controls on larger examples. diff --git a/docs/user-guide/accounts.md b/docs/user-guide/accounts.md index 0410660b..82f8d254 100644 --- a/docs/user-guide/accounts.md +++ b/docs/user-guide/accounts.md @@ -1,5 +1,8 @@ # Account Policies +The [accounts and constraints tutorial](../tutorials/accounts-and-constraints.md) compares +accepted orders, structured rejections, and resulting portfolio state under each setting. + Account policy determines what the broker is allowed to do with cash, leverage, and short sale proceeds. Use this page when you need to decide whether your strategy should behave like a long-only cash account, a short-enabled crypto-style account, @@ -224,20 +227,9 @@ The reviewed 0.1 compatibility snapshot is `tests/compatibility/snapshots/v0.1.j `uv run python validation/generate_compatibility_snapshot.py` to check it. An intentional API or schema change requires `--write` and review of the resulting snapshot diff. -## See It in Action - -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) -materials use account policy most clearly in these workflows: - -- **Ch16 case studies** — reusable `BacktestConfig` objects control cash use, leverage, and - portfolio behavior across equities, futures, crypto, and options examples -- **Ch17** (`portfolio_construction`) — allocator comparisons depend on explicit account and - turnover assumptions instead of hidden notebook defaults -- **Ch19** (`risk_management`) — leverage, shorting, and maintenance rules interact directly - with position sizing and portfolio limits +## In the book -Use the [Book Guide](../book-guide/index.md) when you want to jump from a notebook or -case-study path to the production account-policy workflow. +Chapter 17, Section 17.4, [Conformal position sizing](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/07_conformal_position_sizing.ipynb) turns prediction uncertainty into position sizes. The account tutorial here shows how buying power and share precision affect those sizes. ## Next Steps diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 6fd09fe0..b9eb4bc1 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -514,15 +514,9 @@ config = BacktestConfig( Use this with a `DataFeed` whose `FeedSpec` maps `price_col`, `bid_col`, `ask_col`, and optionally quote sizes. -## See It in Action +## In the book -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses BacktestConfig across all case studies: - -- **Ch16 case studies** — each case study loads config from `setup.yaml` via `get_backtest_config()`, setting initial_cash, commission_rate, slippage_rate, and execution_mode -- **Ch16 / NB13** (`futures_backtesting`) — ContractSpec with CommissionType.PER_CONTRACT for CME futures -- **Ch19 case studies** — risk management config (stop fill modes, trailing stop timing) - -The book pattern: `BacktestConfig()` with 4 overrides (initial_cash, commission_rate, slippage_rate, execution_mode), loaded from YAML. Costs come from `setup.yaml` via a utility function. This covers the vast majority of use cases. +Chapter 16, Section 16.3, [Engine divergence anatomy](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/07_engine_divergence_anatomy.ipynb) changes one execution assumption at a time. Use this reference to identify and record the corresponding `BacktestConfig` fields. ## Next Steps diff --git a/docs/user-guide/data-feed.md b/docs/user-guide/data-feed.md index a12ec972..4fb58088 100644 --- a/docs/user-guide/data-feed.md +++ b/docs/user-guide/data-feed.md @@ -1,5 +1,7 @@ # Data Feed +The [multi-asset rebalancing tutorial](../tutorials/multiasset-rebalancing.md) runs equities, ETFs, futures, and FX from bundled inputs. + `DataFeed` converts a Polars DataFrame into per-bar data for the engine. It handles partitioning by timestamp, multi-asset iteration, optional signals/context data, and additive quote caches for execution-aware workloads. ## Required Columns @@ -110,6 +112,49 @@ prices = pl.DataFrame({ }) ``` +## Daily decisions across different close times + +By default, the feed emits one event per exact timestamp. If ES closes at 21:00 UTC +and ZC closes at 21:15 UTC, the strategy sees two callbacks, each with one asset. +The first callback has no ZC bar. A cross-sectional rebalance built from that +partial mapping can submit an unintended close for a held asset. + +Add a Polars `Date` session column to each price row and pass its name to +`DataFeed` to request one decision per completed session: + +```python +from datetime import date, datetime +import polars as pl +from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.types import ExecutionMode + +prices = pl.DataFrame({ + "timestamp": [datetime(2024, 1, 2, 21), datetime(2024, 1, 2, 21, 15)], + "session_date": [date(2024, 1, 2), date(2024, 1, 2)], + "asset": ["ES", "ZC"], + "open": [100.0, 100.0], + "close": [100.0, 100.0], +}) +feed = DataFeed(prices_df=prices, session_col="session_date") +engine = Engine(feed, strategy, BacktestConfig(execution_mode=ExecutionMode.NEXT_BAR)) +``` + +The engine registers and marks each real bar at its own timestamp. At the final +bar of a session, it calls `on_data` once with both assets and the timestamp of +that final bar. Each asset's `signals` come from its own bar. Context values from +earlier events in the session remain available, with later values taking +precedence for duplicate keys. Market orders from this decision can fill only +on a later bar for that asset. Market-on-close orders also wait for a later +matching close. Same-bar execution is rejected for session decisions. + +A decision session must contain exactly one price bar for every asset in the +feed's asset set. Missing or duplicate asset bars, mixed session dates at one +timestamp, and signal-only or context-only timestamps raise before the run. +A holiday with no rows produces no callback; a partial holiday session raises +rather than silently rebalancing an incomplete portfolio. Supply exchange-local +session dates explicitly when bars cross midnight or daylight-saving boundaries. +The timestamp remains the actual bar close in its original timezone. + ## Signals Pass pre-computed signals (ML predictions, indicators, etc.) as a separate DataFrame: @@ -233,13 +278,9 @@ result = run_backtest("data/prices.parquet", strategy, signals="data/signals.par DataFeed pre-partitions data by timestamp at initialization and pre-extracts column indices for O(1) per-bar access. Quote columns are cached additively, so the OHLCV path stays unchanged unless you provide quote data. The release benchmark records setup separately from engine runtime and measures memory over the complete child process. -## See It in Action - -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book prepares DataFeed inputs in every Engine case study: +## In the book -- **Ch16 case studies** — each case study loads OHLCV from Parquet, constructs a signals DataFrame from ML predictions, and passes both to DataFeed -- **Ch16 / NB13** (`futures_backtesting`) — multi-contract futures data with session boundaries and overnight gaps -- The common pattern: `prices_df` is a stacked multi-asset OHLCV DataFrame, `signals_df` contains prediction columns aligned by (timestamp, asset) +Chapter 16, Section 16.3, [Futures backtesting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/02_futures_backtesting.ipynb) works through contract and session inputs. The [FX pairs backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/case_studies/fx_pairs/13_backtest.ipynb) extends feed alignment to a prediction stream. ## Next Steps diff --git a/docs/user-guide/execution-semantics.md b/docs/user-guide/execution-semantics.md index 7358f0d1..42601d78 100644 --- a/docs/user-guide/execution-semantics.md +++ b/docs/user-guide/execution-semantics.md @@ -2,6 +2,8 @@ This is the reference for how orders execute in ml4t-backtest. Every behavioral detail described here corresponds to a named config parameter, so you can tune or override it. +Run the [order and timing tutorial](../tutorials/orders-and-timing.md) to compare fills on one fixed price panel. + ## Execution Timing ### NEXT_BAR (Default) @@ -23,7 +25,7 @@ config = BacktestConfig(execution_mode=ExecutionMode.NEXT_BAR) # default ``` `OrderType.MOC` is the exception. In `NEXT_BAR` mode, `MOC` orders submitted during -`on_data()` still fill on the current bar, at the close, after strategy logic runs. +`on_data()` still fill on the current bar, at the close, after strategy logic runs. For session-level decisions with `DataFeed(session_col=...)`, an `MOC` order instead waits for the next eligible bar close for that asset. ### Causal Strategy Lifecycle @@ -226,7 +228,7 @@ target-intent payload alone. Unprocessed targets can be restored without order s ### SAME_BAR -Orders fill at the **current bar's close** price, in the same bar they are submitted. +Orders can fill in the bar where they are submitted, using the configured `execution_price`. The default source is `OPEN`; set `ExecutionPrice.CLOSE` to model a current-close fill. ``` Bar N: Strategy sees close=$100, submits buy order, fills at close=$100 @@ -235,7 +237,13 @@ Bar N: Strategy sees close=$100, submits buy order, fills at close=$100 This mode is useful for comparing against vectorized frameworks (VectorBT) where signals and fills happen simultaneously. It carries look-ahead risk for production strategies because the strategy can "see" the close before deciding to trade at the close. ```python -config = BacktestConfig(execution_mode=ExecutionMode.SAME_BAR) +from ml4t.backtest import BacktestConfig, ExecutionMode +from ml4t.backtest.config import ExecutionPrice + +config = BacktestConfig( + execution_mode=ExecutionMode.SAME_BAR, + execution_price=ExecutionPrice.CLOSE, +) ``` ### Execution Price @@ -244,8 +252,8 @@ The `execution_price` parameter controls which price source is used for market o | Value | Fill Price | Typical Use | |-------|-----------|-------------| -| `OPEN` | Next bar's open | Default, realistic | -| `CLOSE` | Current bar's close | VectorBT comparison | +| `OPEN` | Eligible bar open | Default next-bar fill | +| `CLOSE` | Eligible bar close | Same-bar close comparison | | `VWAP` | Volume-weighted average | Requires volume data | | `MID` | (high + low) / 2 | Simple approximation | | `PRICE` | `FeedSpec.price_col` / `bar["price"]` | Custom reference price, derived bars | @@ -256,8 +264,7 @@ The `execution_price` parameter controls which price source is used for market o `PRICE` is the default mark source and follows your feed schema. If you map `price_col="mid_price"`, then both `bar["price"]` and `ExecutionPrice.PRICE` use that midpoint. -`OrderType.MOC` does not use `execution_price`; it always fills at the current bar's -close. +`OrderType.MOC` does not use `execution_price`; it fills at the eligible bar's close. In session decision mode, that eligible bar is later than the decision. ### Mark Price @@ -536,13 +543,9 @@ config = BacktestConfig(share_type=ShareType.FRACTIONAL) config = BacktestConfig(share_type=ShareType.INTEGER) ``` -## See It in Action - -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates execution semantics across chapters: +## In the book -- **Ch16 / NB11** (`engine_divergence_anatomy`) - detailed analysis of how SAME_BAR vs NEXT_BAR and fill ordering affect backtest results -- **Ch18** (`portfolio_construction`) - LinearImpact and SquareRootImpact market impact models with VolumeParticipationLimit -- **Ch16 case studies** - each case study uses setup.yaml to configure commission_rate, slippage_rate, and execution_mode +Chapter 16, Section 16.3, [Engine divergence anatomy](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/07_engine_divergence_anatomy.ipynb) isolates fill timing and ordering differences. This page specifies the current engine behavior behind those comparisons. ## Next Steps diff --git a/docs/user-guide/market-impact.md b/docs/user-guide/market-impact.md index f1be5995..9090c5d0 100644 --- a/docs/user-guide/market-impact.md +++ b/docs/user-guide/market-impact.md @@ -1,5 +1,7 @@ # Market Impact & Execution Costs +The [costs and funding tutorial](../tutorials/costs-and-funding.md) reconciles executed examples across all four cash-flow sources. + Realistic backtesting requires modeling the costs of executing trades. ml4t-backtest provides three layers of cost modeling: commission, slippage, and market impact. ## Cost Layers @@ -68,6 +70,18 @@ combined = CombinedCommission( ) ``` +A custom commission model implements `calculate(asset, quantity, price)` and +returns the fee for that quantity. The engine may call it before execution to +estimate cash or margin requirements, then call it at the actual fill price. +Only the fill-time value is charged. Estimates use a deep copy of the model, +so a model that advances an internal volume tier on an executed fill does not +advance it for a rejected or unfilled estimate. Custom models must support +`deepcopy`, and `calculate` must not have external effects such as writing to a +database or shared counter. A single fill that closes a position and opens +its opposite is charged once for its full quantity. The fee is allocated between the closing +and opening trade records in proportion to their quantities. Partial fills are +charged separately when they execute. + ## Slippage Models Slippage models the bid-ask spread you cross when executing. A buy order fills slightly above the mid-price; a sell order fills slightly below. @@ -204,6 +218,48 @@ engine = Engine( Orders exceeding 10% of bar volume are partially filled (the remainder stays pending). +## Perpetual Futures Funding + +Pass a Polars frame to `Engine(..., funding_df=funding)`. Each row names a feed +timestamp and asset, with either `rate` or `amount_per_unit`: + +```python +from datetime import datetime +import polars as pl + +funding = pl.DataFrame({ + "timestamp": [datetime(2024, 1, 2, 8)], + "asset": ["BTC-PERP"], + "rate": [0.0001], +}) +result = Engine(feed=feed, strategy=strategy, config=config, + funding_df=funding).run() +payments = result.to_funding_dataframe() +print(payments.select("timestamp", "asset", "cash_delta")) +print(result.metrics["total_funding"]) +``` + +A positive rate debits a long and credits a short. For a held position, the +cash transfer is `-quantity * latest_price * contract_multiplier * rate`. +Alternatively, `amount_per_unit` gives an account-currency amount per unit of +underlying, multiplied by position quantity and contract multiplier. Negative +values reverse the direction. Each row must provide exactly one of the two. + +Funding is applied after the bar's reference price becomes available and before +orders eligible at that timestamp or the strategy callback run. A position +opened at that timestamp does not pay that event. If the asset has no bar at +the event, the latest earlier positive reference price is used. Events must +match feed timestamps and known assets; duplicate, missing, or nonfinite +values raise before the run. A rate event for a held position without a causal +price raises before any payment at that timestamp changes cash. + +Funding is a separate cash flow, not a fill or trading fee. The result includes +`funding.parquet`, `to_funding_dataframe()`, `total_funding`, and +`num_funding_events`. Trading P&L +and costs retain their existing definitions; terminal equity includes funding +in addition to trading P&L. A scheduled event for a flat asset records zero +cash transfer. + ## Cost Impact Analysis To measure cost impact, run the same strategy with and without costs: @@ -228,13 +284,9 @@ cost_drag = result_zero.metrics['total_return_pct'] - result_real.metrics['total print(f"Cost drag: {cost_drag:.2f}%") ``` -## See It in Action - -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates market impact in Ch18: +## In the book -- **Cost notebooks** — LinearImpact and SquareRootImpact models applied to multi-asset portfolios -- **VolumeParticipationLimit** — preventing oversized orders in illiquid assets -- **Cost drag analysis** — comparing gross vs net returns across case studies +Chapter 18, Section 18.4, [Market impact calibration](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/18_transaction_costs/03_market_impact_calibration.ipynb) examines how execution size changes impact. [Gross versus net performance](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/18_transaction_costs/10_gross_vs_net_performance.ipynb) shows the portfolio effect of those costs. ## Next Steps diff --git a/docs/user-guide/orders.md b/docs/user-guide/orders.md index 6fb49ba4..32db9d74 100644 --- a/docs/user-guide/orders.md +++ b/docs/user-guide/orders.md @@ -2,6 +2,8 @@ Orders are submitted from a strategy via `broker.submit_order()`. The default order type is `MARKET`. +Run the [order and timing tutorial](../tutorials/orders-and-timing.md) to compare fills on one fixed price panel. + ## Market Orders Execute at the configured execution price (open or close, depending on profile): @@ -14,7 +16,7 @@ broker.submit_order("AAPL", 100) broker.submit_order("AAPL", -100) ``` -In NEXT_BAR mode (default), market orders fill at the next bar's open. In SAME_BAR mode, they fill at the current bar's close. +In `NEXT_BAR` mode (default), ordinary market orders fill at the next eligible asset bar's open. In `SAME_BAR` mode, they use the current bar's configured `execution_price`; choose `ExecutionPrice.CLOSE` for a current-close comparison. ## Market-On-Close Orders @@ -35,7 +37,7 @@ mode, orders submitted during `on_data()` normally fill at the next bar's open, `MOC` orders fill at the current bar's close after strategy logic finishes. In `SAME_BAR` mode, `MOC` also fills at the current bar's close. -For daily bars, this models a market-on-close fill at the session close price. +With an exact-timestamp feed, this models a market-on-close fill at the current daily bar. With `DataFeed(session_col=...)`, a session decision occurs after every component bar has closed; a new `MOC` order waits for that asset's next bar close. ## Limit Orders @@ -150,6 +152,10 @@ broker.close_position("AAPL", order_type=OrderType.MOC) broker.cancel_order(order.id) ``` +## In the book + +Chapter 16, Section 16.3, [Single asset ml4t-backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/04_single_asset_ml4t_backtest.ipynb) reconciles submitted decisions with completed fills. The [engine divergence notebook](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/07_engine_divergence_anatomy.ipynb) in the companion varies execution assumptions. + ## Next Steps - [Execution Semantics](execution-semantics.md) -- how orders fill and at what price diff --git a/docs/user-guide/profiles.md b/docs/user-guide/profiles.md index cea5a254..cd3b665d 100644 --- a/docs/user-guide/profiles.md +++ b/docs/user-guide/profiles.md @@ -1,5 +1,8 @@ # Profiles +The [profiles and parity tutorial](../tutorials/profiles-and-parity.md) runs a +one-setting comparison and explains the supported evidence boundaries. + Profiles are pre-configured `BacktestConfig` settings for framework-specific execution semantics. The retained evidence in this page states which pinned framework scenarios currently match exactly and which remain release-blocking. @@ -194,25 +197,25 @@ The table reports engine-call wall time for all 17 correctness-passing pairs. Th | Real strategy | Pinned framework | Framework median (95% CI), s | ML4T median (95% CI), s | Framework / ML4T median | |---|---|---:|---:|---:| -| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.287 (0.286-0.289) | 0.415 (0.414-0.419) | 0.692x | -| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.171 (0.171-0.174) | 0.415 (0.413-0.417) | 0.412x | -| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.355 (9.303-9.424) | 0.430 (0.426-0.432) | 21.762x | -| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.872 (3.867-3.910) | 0.632 (0.628-0.638) | 6.127x | -| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.564 (2.487-2.599) | 0.750 (0.749-0.757) | 3.418x | -| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.286 (0.284-0.289) | 0.432 (0.429-0.435) | 0.663x | -| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 2.495 (2.488-2.526) | 0.429 (0.427-0.433) | 5.812x | -| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.801 (2.709-2.891) | 0.657 (0.655-0.661) | 4.266x | -| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.283 (0.281-0.286) | 0.146 (0.145-0.147) | 1.939x | -| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.139 (0.138-0.141) | 0.146 (0.145-0.147) | 0.952x | -| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.425 (0.420-0.432) | 0.146 (0.145-0.147) | 2.920x | -| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.913 (0.877-0.953) | 0.153 (0.152-0.157) | 5.952x | -| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.795 (0.787-0.838) | 20.555 (20.486-20.600) | 0.039x | -| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 16.995 (16.960-17.024) | 20.495 (20.453-20.534) | 0.829x | -| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 495.553 (494.227-510.273) | 21.470 (21.386-21.543) | 23.082x | -| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 107.609 (106.834-109.150) | 22.132 (22.082-22.234) | 4.862x | -| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 47.683 (47.333-48.015) | 26.368 (26.222-26.473) | 1.808x | - -Measured 2026-09-03 on `Linux-6.8.0-138-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). +| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.293 (0.290-0.298) | 0.424 (0.421-0.434) | 0.691x | +| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.192 (0.176-0.493) | 0.414 (0.413-0.417) | 0.462x | +| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.631 (9.428-9.710) | 0.439 (0.434-0.444) | 21.954x | +| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.888 (3.878-3.959) | 0.621 (0.618-0.626) | 6.256x | +| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.638 (2.516-2.707) | 0.750 (0.743-0.757) | 3.520x | +| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.290 (0.289-0.294) | 0.410 (0.406-0.958) | 0.708x | +| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 6.639 (5.831-8.332) | 0.428 (0.422-1.235) | 15.504x | +| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.828 (2.737-2.952) | 1.327 (0.657-2.183) | 2.131x | +| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.288 (0.286-0.294) | 0.148 (0.147-0.149) | 1.947x | +| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.144 (0.142-0.146) | 0.145 (0.145-0.146) | 0.988x | +| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.434 (0.430-0.440) | 0.147 (0.146-0.149) | 2.947x | +| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.953 (0.936-1.164) | 0.158 (0.155-0.164) | 6.033x | +| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 3.806 (3.336-4.341) | 23.306 (21.906-25.833) | 0.163x | +| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 17.116 (17.069-17.633) | 25.738 (24.983-27.163) | 0.665x | +| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 668.611 (642.993-718.202) | 26.887 (26.406-29.406) | 24.868x | +| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 117.445 (116.308-118.391) | 26.821 (25.473-27.644) | 4.379x | +| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 48.436 (48.221-48.986) | 27.688 (27.575-27.796) | 1.749x | + +Measured 2026-09-24 on `Linux-6.8.0-139-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). ### Synthetic diagnostic scenarios @@ -257,6 +260,10 @@ print(list_profiles()) # ['backtrader', 'default', 'lean', 'realistic', 'vectorbt', 'zipline'] ``` +## In the book + +Chapter 16, Section 16.3, [Engine divergence anatomy](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/07_engine_divergence_anatomy.ipynb) changes one setting at a time; [Case-study LEAN parity](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/16_case_study_lean_parity.ipynb) reports the bounded comparison audit. The profile tables here identify the configuration used for each retained library workload. + ## Next Steps - [Configuration](configuration.md) -- understand each parameter diff --git a/docs/user-guide/rebalancing.md b/docs/user-guide/rebalancing.md index f67170e8..91f2d41d 100644 --- a/docs/user-guide/rebalancing.md +++ b/docs/user-guide/rebalancing.md @@ -1,5 +1,7 @@ # Rebalancing +The [multi-asset rebalancing tutorial](../tutorials/multiasset-rebalancing.md) runs equities, ETFs, futures, and FX from bundled inputs. + For multi-asset strategies that target portfolio weights, the broker provides `rebalance_to_weights()` and the execution module provides a `TargetWeightExecutor` for advanced control. ## Simple Rebalancing @@ -67,6 +69,18 @@ config = BacktestConfig( ) ``` +## Rebalance after all daily closes + +When daily bars have different close times, exact-timestamp callbacks contain +only the assets closing at that instant. Building a target dictionary from +`data` at each callback may leave out held assets, and `TargetWeightExecutor` +submits closes for holdings missing from the target. Use the feed's explicit +`session_col` option for a complete cross-sectional decision. The callback +runs after the last close in the session and receives one bar per asset; orders +then wait for each asset's next bar. See [Daily decisions across different close +times](data-feed.md#daily-decisions-across-different-close-times) for the input +contract and example. Keep `execution_mode=NEXT_BAR` for this workflow. + ## Advanced: TargetWeightExecutor For more control, use the `TargetWeightExecutor` with a `RebalanceConfig`: @@ -161,14 +175,9 @@ silently restarting session counters. The engine validates `LongShortStrategy` schedule alignment after `on_end`, independently of any callback override. -## See It in Action - -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses TargetWeightExecutor extensively: - -- **Ch16 case studies** — all 6 Engine-based cases (ETFs, FX, equities, crypto, futures, options) use TargetWeightExecutor for ML prediction → portfolio weight → rebalance -- **Ch17** (`portfolio_construction`) — portfolio optimization with weight constraints +## In the book -The common pattern: ML model generates predictions, predictions are converted to portfolio weights, TargetWeightExecutor handles the order generation and execution. +Chapter 17, Section 17.7, [Library comparison](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/17_portfolio_construction/08_library_comparison.ipynb) compares allocation methods on matched inputs. This page covers how those target weights become timed, sized orders. ## Next Steps diff --git a/docs/user-guide/results.md b/docs/user-guide/results.md index 64c3eb2a..91b9aaee 100644 --- a/docs/user-guide/results.md +++ b/docs/user-guide/results.md @@ -1,5 +1,10 @@ # Results & Analysis +The [result export tutorial](../tutorials/results-and-analysis.md) joins a +time-zone-aware feed to fills, equity, predictions, and funding, then checks a +Parquet round trip. The [diagnostic handoff](../tutorials/diagnostic-handoff.md) +uses the optional analysis package. + `Engine.run()` returns a `BacktestResult` containing trades, equity curve, fills, portfolio state, and computed metrics. Everything is accessible as Python objects, Polars DataFrames, or Parquet files. @@ -456,9 +461,10 @@ result = engine.run() # One-liner bridge to ml4t-diagnostic analysis = portfolio_analysis_from_result(result, calendar="NYSE") -# Now use PortfolioAnalysis methods -print(f"Sharpe: {analysis.sharpe_ratio():.2f}") -print(f"Max DD: {analysis.max_drawdown():.2%}") +# Compute PortfolioAnalysis summary metrics +stats = analysis.compute_summary_stats() +print(f"Sharpe: {stats.sharpe_ratio:.2f}") +print(f"Max DD: {stats.max_drawdown:.2%}") monthly = analysis.compute_monthly_returns() ``` @@ -550,13 +556,9 @@ print(result.config.describe()) print(result.config.preset_name) ``` -## See It in Action - -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses BacktestResult in every case study: +## In the book -- **Ch16 / NB05** (`performance_reporting`) — `portfolio_analysis_from_result()`, MFE/MAE analysis, gross vs net comparison, full 24-section tearsheet -- **Ch16 case studies** — all cases save trade artifacts via `to_parquet()` and pass trades/metrics/equity to tearsheet generation -- **Ch16 / NB06** (`sharpe_ratio_inference`) — statistical inference on backtest results +Chapter 16, Section 16.5, [Performance reporting](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/09_performance_reporting.ipynb) develops return and drawdown interpretation. This page defines the result frames and artifact format used to reproduce those reports. ## Next Steps diff --git a/docs/user-guide/risk-management.md b/docs/user-guide/risk-management.md index 9abace32..bc4dc927 100644 --- a/docs/user-guide/risk-management.md +++ b/docs/user-guide/risk-management.md @@ -1,5 +1,8 @@ # Risk Management +The [risk and state tutorial](../tutorials/risk-and-state.md) runs a stop, two +portfolio reductions, and the recovery between breaches from one declared price panel. + ml4t-backtest has two levels of risk management: **position rules** (per-position exits) and **portfolio limits** (portfolio-wide constraints). Position rules are the primary tool -- they automatically evaluate on every bar and generate exit orders when triggered. ## Position Rules @@ -284,12 +287,22 @@ Each limit check returns a `LimitResult` with an action: |--------|---------| | `none` | No breach | | `warn` | Log warning, continue trading | -| `reduce` | Reduce position sizes by a percentage | +| `reduce` | Reduce open positions by the configured fraction; currently supported by `MaxDrawdownLimit` | | `halt` | Stop opening new positions | | `liquidate` | Flatten open positions and stop new trading | -When using portfolio limits in a live strategy loop, pass the broker into -`RiskManager.update(...)` so liquidation actions are applied immediately: +For a drawdown reduction, set both `action="reduce"` and an explicit +`reduction_pct` in (0, 1]. The manager cancels pending orders and submits one +risk-tagged reduction order per open position. It acts once while that limit +remains breached and can act again after the breach clears. Other built-in +limits reject `action="reduce"` at construction until they have a defined +reduction policy. + +For example, `MaxDrawdownLimit(max_drawdown=0.10, action="reduce", +reduction_pct=0.50)` cuts each open position by half after a 10% drawdown. + +Pass the broker into `RiskManager.update(...)` so reduction and liquidation +actions are applied through normal order execution: ```python results = risk_manager.update( @@ -303,16 +316,9 @@ if risk_manager.is_halted: return ``` -## See It in Action - -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates risk management in Ch19 case studies: - -- **ETFs** — RuleChain with StopLoss + TrailingStop on multi-asset ETF portfolios -- **FX Pairs** — StopLoss + TakeProfit + TrailingStop for currency strategies -- **CME Futures** — Risk rules with ContractSpec and per-contract commission -- **US Equities** — MaxDrawdownLimit and DailyLossLimit portfolio protection +## In the book -The case studies show progressive complexity: basic stop-loss → trailing stops → rule chains → portfolio limits. +Chapter 19, Section 19.4, [ml4t-backtest risk demo](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/19_risk_management/10_ml4t_backtest_risk_demo.ipynb) applies position rules and portfolio limits. The [exit strategies notebook](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/19_risk_management/02_exit_strategies.ipynb) compares stop choices. ## Next Steps diff --git a/docs/user-guide/stateful-strategies.md b/docs/user-guide/stateful-strategies.md index 7cd80fa6..d7e70bba 100644 --- a/docs/user-guide/stateful-strategies.md +++ b/docs/user-guide/stateful-strategies.md @@ -403,15 +403,9 @@ def test_pyramiding_respects_max_levels(): See `examples/test_stateful_strategies.py` for 45+ test methods covering all five patterns. -## See It in Action +## In the book -The book uses `ml4t-backtest` precisely when vectorized shortcuts stop being good enough: - -- **Ch16** (`framework_parity`, execution-analysis notebooks, and Engine-based case studies) shows why fill timing, broker state, and portfolio transitions require an event loop -- **Ch17** (`portfolio_construction`) relies on sequential rebalancing, turnover, and account-state updates rather than one-shot allocation math -- **Ch19** case studies layer stop logic, portfolio limits, and path-dependent risk controls on top of evolving strategy state - -Use the [Book Guide](../book-guide/index.md) to jump from those chapters and case studies to the matching reusable APIs. +Chapter 16, Section 16.3, [Stateful strategies](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/05_stateful_strategies.ipynb) demonstrates decisions that depend on prior fills and account state. The runnable risk tutorial here traces one such path across callbacks. ## Next Steps diff --git a/docs/user-guide/strategies.md b/docs/user-guide/strategies.md index bca53eee..ea6449eb 100644 --- a/docs/user-guide/strategies.md +++ b/docs/user-guide/strategies.md @@ -289,14 +289,9 @@ class AssetSpecificRules(Strategy): 4. **Use NEXT_BAR mode** -- for production strategies, avoid SAME_BAR 5. **Validate with profiles** -- compare results across framework profiles -## See It in Action +## In the book -The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book demonstrates these patterns across multiple case studies: - -- **Ch16 / NB03** (`single_asset_ml4t_backtest`) — RSI mean-reversion Strategy with submit_order/close_position -- **Ch16 / NB04** (`framework_parity`) — same strategy compared across VectorBT and ml4t-backtest -- **Ch16 / NB13** (`futures_backtesting`) — futures strategies with ContractSpec and per-contract costs -- **Ch16 case studies** — 6 Engine-based case studies (ETFs, FX, equities, crypto, futures, options) using TargetWeightExecutor with ML predictions +Chapter 16, Section 16.3, [Single asset ml4t-backtest](https://github.com/stefan-jansen/machine-learning-for-trading/blob/366e1d51ace2d851776499a68da3d6e3c2641b02/16_strategy_simulation/04_single_asset_ml4t_backtest.ipynb) implements an event-driven strategy and reconciles its trade log. This page describes the reusable `Strategy` interface. ## Next Steps diff --git a/mkdocs.yml b/mkdocs.yml index 9c74e7f7..ccf8c73d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,6 +106,15 @@ nav: - Getting Started: - Installation: getting-started/installation.md - Quickstart: getting-started/quickstart.md + - Example Data: tutorials/data.md + - Orders and Timing: tutorials/orders-and-timing.md + - Multi-asset Rebalancing: tutorials/multiasset-rebalancing.md + - Costs and Funding: tutorials/costs-and-funding.md + - Accounts and Constraints: tutorials/accounts-and-constraints.md + - Risk and State: tutorials/risk-and-state.md + - Profiles and Parity: tutorials/profiles-and-parity.md + - Results and Analysis: tutorials/results-and-analysis.md + - Diagnostic Handoff: tutorials/diagnostic-handoff.md - Concepts: - How It Works: concepts/how-it-works.md - User Guide: diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index ada3d992..4de3712d 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -6,6 +6,7 @@ from collections import deque from collections.abc import Callable, Mapping from datetime import datetime +from math import isfinite from typing import TYPE_CHECKING, Any, TypedDict, Unpack from zoneinfo import ZoneInfo @@ -36,6 +37,7 @@ SubmitOrderOptions, ) from .execution.fill_executor import FillExecutor +from .funding import FundingEvent, FundingPayment from .models import CommissionModel, NoCommission, NoSlippage, SlippageModel from .types import ( AssetTradingStats, @@ -45,6 +47,7 @@ Fill, Order, OrderSide, + OrderStatus, OrderType, Position, StopFillMode, @@ -452,6 +455,49 @@ def cash(self, value: float) -> None: self._capture_lifecycle_mutation() self.account.cash = value + def _apply_funding(self, events: list[FundingEvent]) -> list[FundingPayment]: + """Apply one timestamp's validated funding events before any fills at that time.""" + payments: list[FundingPayment] = [] + for event in events: + position = self.positions.get(event.asset) + quantity = position.quantity if position is not None else 0.0 + multiplier = ( + position.multiplier if position is not None else self.get_multiplier(event.asset) + ) + mark_price = self.get_last_price(event.asset) if position is not None else None + if quantity == 0.0: + cash_delta = 0.0 + elif event.rate is not None: + if mark_price is None or not isfinite(mark_price) or mark_price <= 0: + raise ValueError(f"funding mark is unavailable for {event.asset}") + cash_delta = -quantity * mark_price * multiplier * event.rate + else: + assert event.amount_per_unit is not None + cash_delta = -quantity * multiplier * event.amount_per_unit + if not isfinite(cash_delta): + raise ValueError(f"funding cash flow is nonfinite for {event.asset}") + payments.append( + FundingPayment( + timestamp=event.timestamp, + asset=event.asset, + quantity=quantity, + mark_price=mark_price, + multiplier=multiplier, + rate=event.rate, + amount_per_unit=event.amount_per_unit, + cash_delta=cash_delta, + ) + ) + total = sum(payment.cash_delta for payment in payments) + new_cash = self.cash + total + new_free_cash = self.account._lock_notional_free_cash + total + if not isfinite(new_cash) or not isfinite(new_free_cash): + raise ValueError("funding would make the cash ledger nonfinite") + if total: + self.cash = new_cash + self.account._lock_notional_free_cash = new_free_cash + return payments + @property def positions(self) -> dict[str, Position]: """Return the open-position ledger owned by AccountState.""" @@ -1713,6 +1759,54 @@ def flatten_all_positions( return liquidations + def reduce_all_positions( + self, + fraction: float, + reason: str, + order_type: OrderType = OrderType.MARKET, + ) -> list[Order]: + """Cancel pending orders and reduce each open position atomically.""" + if not isfinite(fraction) or not 0 < fraction <= 1: + raise ValueError(f"fraction must be a finite value in (0, 1], got {fraction!r}") + + active = [(asset, pos.quantity) for asset, pos in self.positions.items() if pos.quantity] + if not active: + return [] + for asset, quantity in active: + exit_qty = abs(quantity) * fraction + if not isfinite(exit_qty) or exit_qty <= self._order_book._MIN_ORDER_SIZE: + raise ValueError(f"reduction quantity for {asset} is not executable") + if self.share_type == ShareType.INTEGER and int(exit_qty) == 0: + raise ValueError(f"reduction quantity for {asset} rounds to zero") + + state = self._snapshot_lifecycle_state( + all_positions=True, all_pending_orders=True, all_asset_stats=True + ) + try: + for order in list(self.pending_orders): + self.cancel_order(order.order_id) + + reductions: list[Order] = [] + for asset, quantity in active: + exit_side = OrderSide.SELL if quantity > 0 else OrderSide.BUY + order = self.submit_order( + asset, + abs(quantity) * fraction, + exit_side, + order_type, + _options=SubmitOrderOptions( + risk_exit_reason=f"risk reduction: {reason}", + exit_reason=ExitReason.RISK_LIQUIDATION, + ), + ) + if order is None or order.status is OrderStatus.REJECTED: + raise RuntimeError(f"risk reduction order was not accepted for {asset}") + reductions.append(order) + return reductions + except Exception: + self._restore_lifecycle_state(state) + raise + # === Position Modification (P1 Features) === def reduce_position( diff --git a/src/ml4t/backtest/core/execution_engine.py b/src/ml4t/backtest/core/execution_engine.py index 07c8adf3..66a97cf8 100644 --- a/src/ml4t/backtest/core/execution_engine.py +++ b/src/ml4t/backtest/core/execution_engine.py @@ -6,7 +6,7 @@ import math from typing import TYPE_CHECKING, TypeGuard -from ..models import calculate_commission +from ..models import estimate_commission from ..types import ExecutionMode, Order, OrderSide, OrderStatus, OrderType, Position from .shared import is_exit_order, quantity_zero_tolerance from .state import MarketState, OrderState @@ -298,7 +298,7 @@ def _validate_shadow_queue_order( and abs(new_qty) > quantity_zero_tolerance(current_qty, qty_delta) and ((current_qty > 0 and new_qty < 0) or (current_qty < 0 and new_qty > 0)) ) - commission = calculate_commission( + commission = estimate_commission( broker.commission_model, order.asset, order.quantity, validation_price ) multiplier = broker.get_multiplier(order.asset) @@ -355,7 +355,7 @@ def _commit_shadow_queue_fill( shadow_positions[order.asset].quantity if order.asset in shadow_positions else 0.0 ) new_qty = current_qty + qty_delta - commission = calculate_commission( + commission = estimate_commission( broker.commission_model, order.asset, order.quantity, fill_price ) shadow_cash += -qty_delta * fill_price * broker.get_multiplier(order.asset) - commission @@ -689,7 +689,7 @@ def _passes_simple_cash_check(self, order, fill_price: float) -> bool: return True signed_qty = order.quantity if order.side is OrderSide.BUY else -order.quantity - commission = calculate_commission( + commission = estimate_commission( broker.commission_model, order.asset, order.quantity, fill_price ) projected_cash = broker.cash - signed_qty * fill_price - commission @@ -769,7 +769,7 @@ def _estimated_free_cash_use(self, order, use_open: bool) -> float: quantity = abs(float(order.quantity)) unit_cost = float(price) * broker.get_multiplier(order.asset) notional = quantity * unit_cost - commission = calculate_commission( + commission = estimate_commission( broker.commission_model, order.asset, quantity, diff --git a/src/ml4t/backtest/core/fill_engine.py b/src/ml4t/backtest/core/fill_engine.py index e05c8418..849efc5b 100644 --- a/src/ml4t/backtest/core/fill_engine.py +++ b/src/ml4t/backtest/core/fill_engine.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from ..config import ExecutionPrice, LockNotionalUpdateMode, ShareType -from ..models import calculate_commission +from ..models import estimate_commission from ..types import OrderSide, OrderType from .shared import CASH_TOLERANCE, add_with_zero_cancellation from .state import MarketState, OrderState @@ -108,7 +108,7 @@ def _get_lock_notional_zero_cost_limit(self, order, fill_price: float) -> float broker.short_cash_policy.value != "lock_notional" or broker.account.policy.allow_leverage or broker.cash_buffer_pct != 0.0 - or calculate_commission( + or estimate_commission( broker.commission_model, order.asset, order.quantity, diff --git a/src/ml4t/backtest/core/order_book.py b/src/ml4t/backtest/core/order_book.py index c9459b4a..08bc3d37 100644 --- a/src/ml4t/backtest/core/order_book.py +++ b/src/ml4t/backtest/core/order_book.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from ..config import MissingPricePolicy -from ..models import calculate_commission +from ..models import estimate_commission from ..types import ExecutionMode, Order, OrderSide, OrderStatus, OrderType, Position from .shared import SubmitOrderOptions, is_exit_order, quantity_zero_tolerance from .state import MarketState, OrderState, RiskState @@ -389,20 +389,12 @@ def _passes_submission_precheck(self, order: Order) -> bool: shadow_cash = self._submission_shadow_cash if closed != 0.0: - close_cash = (-closed) * signal_price - shadow_cash += close_cash - closed_commission = calculate_commission( - broker.commission_model, order.asset, abs(closed), signal_price - ) - shadow_cash -= closed_commission - + shadow_cash += (-closed) * signal_price if opened != 0.0: - open_cash = opened * signal_price - shadow_cash -= open_cash - opened_commission = calculate_commission( - broker.commission_model, order.asset, abs(opened), signal_price - ) - shadow_cash -= opened_commission + shadow_cash -= opened * signal_price + shadow_cash -= estimate_commission( + broker.commission_model, order.asset, order.quantity, signal_price + ) # Keep shadow effects even for rejected orders to mirror Backtrader's # sequential submitted-queue pseudo-execution behavior. @@ -470,23 +462,13 @@ def _passes_buying_power_check(self, order: Order) -> bool: shadow_cash = self._submission_shadow_cash if closed != 0.0: - closed_value = (-closed) * signal_price - shadow_cash += closed_value - closed_commission = calculate_commission( - broker.commission_model, order.asset, abs(closed), signal_price - ) - shadow_cash -= closed_commission - + shadow_cash += (-closed) * signal_price if opened != 0.0: - # LEAN semantics: both longs and shorts consume buying power. - # Longs cost notional; shorts also require notional (not credit). - # This prevents credit-model inflation where short proceeds - # artificially inflate shadow cash. + # LEAN semantics: shorts consume buying power rather than crediting cash. shadow_cash -= abs(opened) * signal_price - opened_commission = calculate_commission( - broker.commission_model, order.asset, abs(opened), signal_price - ) - shadow_cash -= opened_commission + shadow_cash -= estimate_commission( + broker.commission_model, order.asset, order.quantity, signal_price + ) if shadow_cash < 0.0: # Rejected — do NOT update shadow state (LEAN semantics) @@ -520,7 +502,7 @@ def _passes_margin_submission_precheck( old_qty, old_price, size, signal_price ) - commission = calculate_commission( + commission = estimate_commission( broker.commission_model, order.asset, order.quantity, signal_price ) shadow_positions = self._build_shadow_policy_positions() diff --git a/src/ml4t/backtest/datafeed.py b/src/ml4t/backtest/datafeed.py index 8b5c24fa..461c1576 100644 --- a/src/ml4t/backtest/datafeed.py +++ b/src/ml4t/backtest/datafeed.py @@ -5,7 +5,7 @@ iteration time. """ -from datetime import datetime +from datetime import date, datetime from typing import Any import polars as pl @@ -104,6 +104,7 @@ def __init__( mid_col: str | None = None, bid_size_col: str | None = None, ask_size_col: str | None = None, + session_col: str | None = None, ): if feed_spec is not None and contract is not None: raise ValueError("Pass either feed_spec or contract, not both") @@ -202,6 +203,11 @@ def __init__( self._context_ranges_by_ts = {} self._timestamps = self._get_timestamps() + self.session_col = session_col + self._session_by_timestamp: dict[datetime, date] = {} + self._session_decision_timestamps: set[datetime] = set() + if session_col is not None: + self._index_decision_sessions(session_col) self._idx = 0 self._signal_columns = ( [c for c in self.signals.columns if c not in (self._timestamp_col, self._entity_col)] @@ -338,6 +344,47 @@ def _get_timestamps(self) -> list[datetime]: all_ts.update(self._context_ranges_by_ts.keys()) return sorted(all_ts) + def _index_decision_sessions(self, session_col: str) -> None: + """Validate complete daily cross sections and record each final close.""" + prices = self.prices + assert prices is not None + if session_col not in prices.columns: + raise ValueError(f"session_col={session_col!r} not found in price columns") + if prices.schema[session_col] != pl.Date: + raise ValueError("session_col must contain Polars Date values") + if set(self._timestamps) != set(self._price_ranges_by_ts): + raise ValueError("session decisions require a price row at every feed timestamp") + + assets_by_session: dict[date, set[str]] = {} + last_timestamp_by_session: dict[date, datetime] = {} + previous_session: date | None = None + for timestamp, asset, session in prices.select( + self._timestamp_col, self._entity_col, session_col + ).iter_rows(): + if session is None: + raise ValueError("session_col contains a null session date") + if previous_session is not None and session < previous_session: + raise ValueError("session dates must increase with bar timestamps") + if ( + timestamp in self._session_by_timestamp + and self._session_by_timestamp[timestamp] != session + ): + raise ValueError("one timestamp cannot contain bars from different sessions") + self._session_by_timestamp[timestamp] = session + session_assets = assets_by_session.setdefault(session, set()) + if asset in session_assets: + raise ValueError(f"duplicate bar for {asset!r} in session {session}") + session_assets.add(asset) + last_timestamp_by_session[session] = timestamp + previous_session = session + + expected_assets = set().union(*assets_by_session.values()) if assets_by_session else set() + for session, assets in assets_by_session.items(): + missing = expected_assets - assets + if missing: + raise ValueError(f"session {session} is missing bars for {sorted(missing)}") + self._session_decision_timestamps = set(last_timestamp_by_session.values()) + def __iter__(self): self._idx = 0 return self diff --git a/src/ml4t/backtest/engine.py b/src/ml4t/backtest/engine.py index 6ea4ad5d..52ff1c86 100644 --- a/src/ml4t/backtest/engine.py +++ b/src/ml4t/backtest/engine.py @@ -23,6 +23,7 @@ from .broker import Broker from .config import DataFrequency, ExecutionPrice from .datafeed import DataFeed +from .funding import FundingPayment, index_funding_events from .lifecycle import LifecycleDispatcher from .preopen import default_execution_policy from .strategy import Strategy @@ -89,6 +90,7 @@ def __init__( contract_specs: dict[str, Any] | None = None, market_impact_model: Any | None = None, execution_limits: Any | None = None, + funding_df: pl.DataFrame | None = None, lifecycle_version: LifecycleVersion | str = LifecycleVersion.V1, execution_policy: ExecutionPolicy | None = None, target_intent_state: dict[str, Any] | None = None, @@ -104,6 +106,11 @@ def __init__( self.strategy = strategy self.config = config.merge_feed_spec(getattr(feed, "feed_spec", None)) self.execution_mode = self.config.execution_mode + if ( + getattr(feed, "session_col", None) is not None + and self.execution_mode != ExecutionMode.NEXT_BAR + ): + raise ValueError("session decisions require NEXT_BAR execution") self.lifecycle_version = negotiated_version self.execution_policy = execution_policy or default_execution_policy(self.config) self.broker = Broker.from_config( @@ -112,6 +119,17 @@ def __init__( market_impact_model=market_impact_model, execution_limits=execution_limits, ) + if funding_df is None: + self._funding_events = {} + else: + prices_frame = feed.prices + assert prices_frame is not None + self._funding_events = index_funding_events( + funding_df, + list(feed.timestamps), + set(prices_frame[feed._entity_col].unique().to_list()), + ) + self.funding_payments: list[FundingPayment] = [] if self.broker.execution_price is ExecutionPrice.VWAP and not getattr( self.feed.feed_spec, "vwap_col", None ): @@ -304,6 +322,28 @@ def _run_once(self) -> BacktestResult: for index in filtered["__feed_bar_index"]: valid_intraday_bar_mask[index] = 1 + if self._funding_events and self.config.enforce_sessions and self._calendar: + if self.config.resolved_data_frequency == DataFrequency.DAILY: + assert is_trading_day_fn is not None + calendar_id = self.config.resolved_calendar + assert calendar_id is not None + accepted = { + ts + for ts in timestamps + if is_trading_day_fn( + calendar_id, self.feed._session_by_timestamp.get(ts, ts.date()) + ) + } + else: + accepted = { + ts + for index, ts in enumerate(timestamps) + if valid_intraday_bar_mask is not None and valid_intraday_bar_mask[index] + } + filtered_events = self._funding_events.keys() - accepted + if filtered_events: + raise ValueError("funding event timestamp is excluded by session filtering") + self.lifecycle_dispatcher.dispatch( LifecyclePhase.RUN_START, self.broker, @@ -316,6 +356,9 @@ def _run_once(self) -> BacktestResult: self.config, ) + session_mode = getattr(self.feed, "session_col", None) is not None + session_assets: dict[str, dict[str, Any]] = {} + session_context: dict[str, Any] = {} for feed_bar_index, (timestamp, assets_data, context) in enumerate(self.feed): # Calendar session enforcement calendar_id = self.config.resolved_calendar if self.config else None @@ -328,7 +371,12 @@ def _run_once(self) -> BacktestResult: ): # Daily bars use valid dates. Intraday bars use precomputed session intervals. if self.config.resolved_data_frequency == DataFrequency.DAILY: - if not is_trading_day_fn(calendar_id, timestamp.date()): + session_date = ( + self.feed._session_by_timestamp[timestamp] + if session_mode + else timestamp.date() + ) + if not is_trading_day_fn(calendar_id, session_date): self._skipped_bars += 1 continue elif valid_intraday_bar_mask is None or not valid_intraday_bar_mask[feed_bar_index]: @@ -420,7 +468,12 @@ def _run_once(self) -> BacktestResult: ask_sizes, signals, ) - self._accepted_market_event_count += 1 + if session_mode: + session_assets.update(assets_data) + session_context.update(context) + funding_events = self._funding_events.get(timestamp) + if funding_events: + self.funding_payments.extend(self.broker._apply_funding(funding_events)) # Process pending exits from NEXT_BAR_OPEN mode (fills at open) # before opening targets are sized against the resulting positions. @@ -440,12 +493,19 @@ def _run_once(self) -> BacktestResult: # Process same-cycle risk exits before ordinary strategy decisions. self.broker._process_orders(use_open=True) # Strategy generates new orders - self._dispatch_market_event(timestamp, assets_data, context) + if not session_mode or timestamp in self.feed._session_decision_timestamps: + self._dispatch_market_event( + timestamp, + session_assets if session_mode else assets_data, + session_context if session_mode else context, + ) + session_assets = {} + session_context = {} # MOC orders are the one next-bar exception: they execute on the # current session close after strategy logic runs. self.broker._process_orders( order_types={OrderType.MOC}, - include_orders_this_bar=True, + include_orders_this_bar=not session_mode, ) else: # Same-bar mode: process before and after strategy @@ -497,6 +557,7 @@ def _dispatch_market_event( assets_data: Any, context: dict[str, Any], ) -> None: + self._accepted_market_event_count += 1 self.lifecycle_dispatcher.dispatch( LifecyclePhase.MARKET_EVENT, self.broker, @@ -677,12 +738,15 @@ def _generate_results(self) -> BacktestResult: equity_curve=[], fills=[], rejected_orders=self.broker.get_rejected_orders(), + funding_payments=list(self.funding_payments), predictions=self.feed.signals, portfolio_state=[], metrics={ "skipped_bars": self._skipped_bars, "num_orders": len(self.broker.orders), "num_rejected_orders": len(self.broker.get_rejected_orders()), + "total_funding": 0.0, + "num_funding_events": 0, **contract_evidence, }, config=self.config, @@ -772,6 +836,8 @@ def _generate_results(self) -> BacktestResult: # Commission/slippage from fills (includes open positions) "total_commission": sum(f.commission for f in self.broker.fills), "total_slippage": sum(t.total_slippage_cost for t in all_trades), + "total_funding": sum(payment.cash_delta for payment in self.funding_payments), + "num_funding_events": len(self.funding_payments), # Additional metrics "sharpe": equity.sharpe, "sortino": equity.sortino, @@ -804,6 +870,7 @@ def _generate_results(self) -> BacktestResult: equity_curve=list(self.equity_curve), fills=list(self.broker.fills), rejected_orders=self.broker.get_rejected_orders(), + funding_payments=list(self.funding_payments), predictions=self.feed.signals, portfolio_state=list(self.portfolio_state), metrics=metrics, @@ -822,6 +889,7 @@ def from_config( contract_specs: dict[str, Any] | None = None, market_impact_model: Any | None = None, execution_limits: Any | None = None, + funding_df: pl.DataFrame | None = None, lifecycle_version: LifecycleVersion | str = LifecycleVersion.V1, execution_policy: ExecutionPolicy | None = None, target_intent_state: dict[str, Any] | None = None, @@ -838,6 +906,7 @@ def from_config( contract_specs: Per-asset contract specifications (futures multipliers, etc.) market_impact_model: Market impact model for fill simulation execution_limits: Execution limits (max order size, etc.) + funding_df: Timestamped funding rates or amounts for named assets Returns: Configured Engine instance @@ -849,6 +918,7 @@ def from_config( contract_specs=contract_specs, market_impact_model=market_impact_model, execution_limits=execution_limits, + funding_df=funding_df, lifecycle_version=lifecycle_version, execution_policy=execution_policy, target_intent_state=target_intent_state, @@ -870,6 +940,7 @@ def run_backtest( contract_specs: dict[str, Any] | None = None, market_impact_model: Any | None = None, execution_limits: Any | None = None, + funding_df: pl.DataFrame | None = None, lifecycle_version: LifecycleVersion | str = LifecycleVersion.V1, execution_policy: ExecutionPolicy | None = None, target_intent_state: dict[str, Any] | None = None, @@ -887,6 +958,7 @@ def run_backtest( contract_specs: Per-asset contract specifications (futures multipliers, etc.) market_impact_model: Market impact model for fill simulation execution_limits: Execution limits (max order size, etc.) + funding_df: Timestamped funding rates or amounts for named assets Returns: BacktestResult with metrics, trades, equity curve, and export methods. @@ -929,6 +1001,7 @@ def run_backtest( contract_specs=contract_specs, market_impact_model=market_impact_model, execution_limits=execution_limits, + funding_df=funding_df, lifecycle_version=lifecycle_version, execution_policy=execution_policy, target_intent_state=target_intent_state, diff --git a/src/ml4t/backtest/example_data/__init__.py b/src/ml4t/backtest/example_data/__init__.py new file mode 100644 index 00000000..c4bb58f8 --- /dev/null +++ b/src/ml4t/backtest/example_data/__init__.py @@ -0,0 +1,43 @@ +"""Small synthetic price panels used by the runnable documentation.""" + +from datetime import datetime +from importlib.resources import files +from typing import Any + +import polars as pl + +from ..strategy import Strategy + +_PANELS = frozenset({"equity", "etf", "future", "fx", "crypto_perp"}) + + +def load_example_prices(category: str) -> pl.DataFrame: + """Load one bundled synthetic OHLCV panel for a documented asset category.""" + if category not in _PANELS: + raise ValueError(f"Unknown example category {category!r}; choose from {sorted(_PANELS)}") + path = files(__package__).joinpath(f"{category}.csv") + return pl.read_csv(str(path), try_parse_dates=True) + + +class ExampleRoundTrip(Strategy): + """Buy on the first asset bar and close after its fifth bar.""" + + def __init__(self, asset: str, quantity: float) -> None: + self.asset = asset + self.quantity = quantity + self.asset_bars = 0 + + def on_data( + self, + timestamp: datetime, + data: dict[str, dict[str, Any]], + context: dict[str, Any], + broker: Any, + ) -> None: + if self.asset not in data: + return + self.asset_bars += 1 + if self.asset_bars == 1: + broker.submit_order(self.asset, self.quantity) + elif self.asset_bars == 5: + broker.close_position(self.asset) diff --git a/src/ml4t/backtest/example_data/crypto_perp.csv b/src/ml4t/backtest/example_data/crypto_perp.csv new file mode 100644 index 00000000..1552d3fd --- /dev/null +++ b/src/ml4t/backtest/example_data/crypto_perp.csv @@ -0,0 +1,19 @@ +timestamp,asset,open,high,low,close,volume +2024-01-02T00:00:00,BTC-PERP,41980.0,42080,41900.0,42000,5000 +2024-01-02T00:00:00,ETH-PERP,2180.0,2280,2100.0,2200,5000 +2024-01-03T00:00:00,BTC-PERP,42026.66667,42580,41946.66667,42500,5137 +2024-01-03T00:00:00,ETH-PERP,2226.66667,2310,2146.66667,2230,5137 +2024-01-04T00:00:00,BTC-PERP,42526.66667,42606.66667,42120,42200,5274 +2024-01-04T00:00:00,ETH-PERP,2256.66667,2336.66667,2135,2215,5274 +2024-01-05T00:00:00,BTC-PERP,42226.66667,43080,42146.66667,43000,5411 +2024-01-05T00:00:00,ETH-PERP,2241.66667,2330,2161.66667,2250,5411 +2024-01-06T00:00:00,BTC-PERP,43026.66667,43106.66667,42620,42700,5548 +2024-01-06T00:00:00,ETH-PERP,2276.66667,2356.66667,2160,2240,5548 +2024-01-07T00:00:00,BTC-PERP,42726.66667,43580,42646.66667,43500,5685 +2024-01-07T00:00:00,ETH-PERP,2266.66667,2360,2186.66667,2280,5685 +2024-01-08T00:00:00,BTC-PERP,43526.66667,43606.66667,43020,43100,5822 +2024-01-08T00:00:00,ETH-PERP,2306.66667,2386.66667,2180,2260,5822 +2024-01-09T00:00:00,BTC-PERP,43126.66667,44080,43046.66667,44000,5959 +2024-01-09T00:00:00,ETH-PERP,2286.66667,2380,2206.66667,2300,5959 +2024-01-10T00:00:00,BTC-PERP,44026.66667,44106.66667,43720,43800,6096 +2024-01-10T00:00:00,ETH-PERP,2326.66667,2406.66667,2210,2290,6096 diff --git a/src/ml4t/backtest/example_data/equity.csv b/src/ml4t/backtest/example_data/equity.csv new file mode 100644 index 00000000..cf8295dd --- /dev/null +++ b/src/ml4t/backtest/example_data/equity.csv @@ -0,0 +1,19 @@ +timestamp,asset,open,high,low,close,volume +2024-01-02T16:00:00,AAPL,187.725,189.1,186.625,188,1000000 +2024-01-02T16:00:00,MSFT,369.725,371.1,368.625,370,1000000 +2024-01-03T16:00:00,AAPL,188.36667,191.1,187.26667,190,1000137 +2024-01-03T16:00:00,MSFT,370.36667,373.1,369.26667,372,1000137 +2024-01-04T16:00:00,AAPL,190.36667,191.46667,187.9,189,1000274 +2024-01-04T16:00:00,MSFT,372.36667,373.46667,367.9,369,1000274 +2024-01-05T16:00:00,AAPL,189.36667,194.1,188.26667,193,1000411 +2024-01-05T16:00:00,MSFT,369.36667,375.1,368.26667,374,1000411 +2024-01-08T16:00:00,AAPL,193.36667,194.46667,189.9,191,1000548 +2024-01-08T16:00:00,MSFT,374.36667,378.1,373.26667,377,1000548 +2024-01-09T16:00:00,AAPL,191.36667,196.1,190.26667,195,1000685 +2024-01-09T16:00:00,MSFT,377.36667,378.46667,373.9,375,1000685 +2024-01-10T16:00:00,AAPL,195.36667,196.46667,192.9,194,1000822 +2024-01-10T16:00:00,MSFT,375.36667,380.1,374.26667,379,1000822 +2024-01-11T16:00:00,AAPL,194.36667,199.1,193.26667,198,1000959 +2024-01-11T16:00:00,MSFT,379.36667,382.1,378.26667,381,1000959 +2024-01-12T16:00:00,AAPL,198.36667,199.46667,194.9,196,1001096 +2024-01-12T16:00:00,MSFT,381.36667,382.46667,376.9,378,1001096 diff --git a/src/ml4t/backtest/example_data/etf.csv b/src/ml4t/backtest/example_data/etf.csv new file mode 100644 index 00000000..02076485 --- /dev/null +++ b/src/ml4t/backtest/example_data/etf.csv @@ -0,0 +1,19 @@ +timestamp,asset,open,high,low,close,volume +2024-01-02T16:00:00,SPY,469.8,470.8,469.0,470,750000 +2024-01-02T16:00:00,TLT,97.8,98.8,97.0,98,750000 +2024-01-03T16:00:00,SPY,470.26667,472.8,469.46667,472,750137 +2024-01-03T16:00:00,TLT,98.26667,99.06667,96.2,97,750137 +2024-01-04T16:00:00,SPY,472.26667,473.06667,470.2,471,750274 +2024-01-04T16:00:00,TLT,97.26667,99.8,96.46667,99,750274 +2024-01-05T16:00:00,SPY,471.26667,475.8,470.46667,475,750411 +2024-01-05T16:00:00,TLT,99.26667,100.8,98.46667,100,750411 +2024-01-08T16:00:00,SPY,475.26667,476.06667,472.2,473,750548 +2024-01-08T16:00:00,TLT,100.26667,101.06667,97.2,98,750548 +2024-01-09T16:00:00,SPY,473.26667,477.8,472.46667,477,750685 +2024-01-09T16:00:00,TLT,98.26667,101.8,97.46667,101,750685 +2024-01-10T16:00:00,SPY,477.26667,478.06667,475.2,476,750822 +2024-01-10T16:00:00,TLT,101.26667,102.06667,99.2,100,750822 +2024-01-11T16:00:00,SPY,476.26667,480.8,475.46667,480,750959 +2024-01-11T16:00:00,TLT,100.26667,102.8,99.46667,102,750959 +2024-01-12T16:00:00,SPY,480.26667,481.06667,477.2,478,751096 +2024-01-12T16:00:00,TLT,102.26667,103.06667,100.2,101,751096 diff --git a/src/ml4t/backtest/example_data/future.csv b/src/ml4t/backtest/example_data/future.csv new file mode 100644 index 00000000..e99877bb --- /dev/null +++ b/src/ml4t/backtest/example_data/future.csv @@ -0,0 +1,19 @@ +timestamp,asset,open,high,low,close,volume,session_date +2024-01-02T21:00:00,ES,4799.25,4803.0,4796.25,4800,25000,2024-01-02 +2024-01-02T21:15:00,ZC,459.25,463.0,456.25,460,25000,2024-01-02 +2024-01-03T21:00:00,ES,4801.0,4818.0,4798.0,4815,25137,2024-01-03 +2024-01-03T21:15:00,ZC,461.0,465.0,458.0,462,25137,2024-01-03 +2024-01-04T21:00:00,ES,4816.0,4819.0,4805.0,4808,25274,2024-01-04 +2024-01-04T21:15:00,ZC,463.0,466.0,456.0,459,25274,2024-01-04 +2024-01-05T21:00:00,ES,4809.0,4833.0,4806.0,4830,25411,2024-01-05 +2024-01-05T21:15:00,ZC,460.0,468.0,457.0,465,25411,2024-01-05 +2024-01-08T21:00:00,ES,4831.0,4834.0,4817.0,4820,25548,2024-01-08 +2024-01-08T21:15:00,ZC,466.0,469.0,460.0,463,25548,2024-01-08 +2024-01-09T21:00:00,ES,4821.0,4845.0,4818.0,4842,25685,2024-01-09 +2024-01-09T21:15:00,ZC,464.0,470.0,461.0,467,25685,2024-01-09 +2024-01-10T21:00:00,ES,4843.0,4846.0,4832.0,4835,25822,2024-01-10 +2024-01-10T21:15:00,ZC,468.0,471.0,461.0,464,25822,2024-01-10 +2024-01-11T21:00:00,ES,4836.0,4863.0,4833.0,4860,25959,2024-01-11 +2024-01-11T21:15:00,ZC,465.0,473.0,462.0,470,25959,2024-01-11 +2024-01-12T21:00:00,ES,4861.0,4864.0,4847.0,4850,26096,2024-01-12 +2024-01-12T21:15:00,ZC,471.0,474.0,465.0,468,26096,2024-01-12 diff --git a/src/ml4t/backtest/example_data/fx.csv b/src/ml4t/backtest/example_data/fx.csv new file mode 100644 index 00000000..ce0ff800 --- /dev/null +++ b/src/ml4t/backtest/example_data/fx.csv @@ -0,0 +1,19 @@ +timestamp,asset,open,high,low,close,volume +2024-01-02T17:00:00,EURUSD,1.0915,1.094,1.0895,1.092,1000000 +2024-01-02T17:00:00,GBPUSD,1.2635,1.266,1.2615,1.264,1000000 +2024-01-03T17:00:00,EURUSD,1.09267,1.097,1.09067,1.095,1000137 +2024-01-03T17:00:00,GBPUSD,1.26467,1.27,1.26267,1.268,1000137 +2024-01-04T17:00:00,EURUSD,1.09567,1.09767,1.089,1.091,1000274 +2024-01-04T17:00:00,GBPUSD,1.26867,1.27067,1.263,1.265,1000274 +2024-01-05T17:00:00,EURUSD,1.09167,1.101,1.08967,1.099,1000411 +2024-01-05T17:00:00,GBPUSD,1.26567,1.273,1.26367,1.271,1000411 +2024-01-08T17:00:00,EURUSD,1.09967,1.10167,1.095,1.097,1000548 +2024-01-08T17:00:00,GBPUSD,1.27167,1.27367,1.267,1.269,1000548 +2024-01-09T17:00:00,EURUSD,1.09767,1.104,1.09567,1.102,1000685 +2024-01-09T17:00:00,GBPUSD,1.26967,1.276,1.26767,1.274,1000685 +2024-01-10T17:00:00,EURUSD,1.10267,1.10467,1.098,1.1,1000822 +2024-01-10T17:00:00,GBPUSD,1.27467,1.27667,1.27,1.272,1000822 +2024-01-11T17:00:00,EURUSD,1.10067,1.107,1.09867,1.105,1000959 +2024-01-11T17:00:00,GBPUSD,1.27267,1.279,1.27067,1.277,1000959 +2024-01-12T17:00:00,EURUSD,1.10567,1.10767,1.101,1.103,1001096 +2024-01-12T17:00:00,GBPUSD,1.27767,1.27967,1.273,1.275,1001096 diff --git a/src/ml4t/backtest/execution/fill_executor.py b/src/ml4t/backtest/execution/fill_executor.py index 27d0ebca..3066c25c 100644 --- a/src/ml4t/backtest/execution/fill_executor.py +++ b/src/ml4t/backtest/execution/fill_executor.py @@ -222,18 +222,8 @@ def execute(self, order: Order, base_price: float) -> bool: if position is not None: new_qty = position.quantity + signed_qty if _is_position_flip(position.quantity, new_qty): - close_commission = calculate_commission( - broker.commission_model, - order.asset, - abs(position.quantity), - fill_price, - ) - open_commission = calculate_commission( - broker.commission_model, - order.asset, - abs(new_qty), - fill_price, - ) + close_commission = commission * abs(position.quantity) / fill_quantity + open_commission = commission - close_commission if broker.execution_limits is not None: self.orders.filled_this_bar.add(order.order_id) diff --git a/src/ml4t/backtest/funding.py b/src/ml4t/backtest/funding.py new file mode 100644 index 00000000..8c001f5a --- /dev/null +++ b/src/ml4t/backtest/funding.py @@ -0,0 +1,81 @@ +"""Validated funding events and cash-flow records for perpetual positions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from math import isfinite + +import polars as pl + + +@dataclass(frozen=True) +class FundingEvent: + """One asset's scheduled rate or cash amount at a feed timestamp.""" + + timestamp: datetime + asset: str + rate: float | None = None + amount_per_unit: float | None = None + + +@dataclass(frozen=True) +class FundingPayment: + """Applied cash flow, including zero for an event with no held position.""" + + timestamp: datetime + asset: str + quantity: float + mark_price: float | None + multiplier: float + rate: float | None + amount_per_unit: float | None + cash_delta: float + + +def index_funding_events( + frame: pl.DataFrame | None, + feed_timestamps: list[datetime], + feed_assets: set[str], +) -> dict[datetime, list[FundingEvent]]: + """Reject ambiguous inputs before a backtest can change portfolio state.""" + if frame is None: + return {} + if not isinstance(frame, pl.DataFrame): + raise TypeError("funding input must be a Polars DataFrame") + required = {"timestamp", "asset"} + if not required <= set(frame.columns): + raise ValueError("funding input requires timestamp and asset columns") + if "rate" not in frame.columns and "amount_per_unit" not in frame.columns: + raise ValueError("funding input requires rate or amount_per_unit") + if not isinstance(frame.schema["timestamp"], pl.Datetime): + raise ValueError("funding timestamp must be a datetime column") + + canonical_timestamps = {timestamp: timestamp for timestamp in feed_timestamps} + indexed: dict[datetime, list[FundingEvent]] = {} + seen: set[tuple[datetime, str]] = set() + for row in frame.iter_rows(named=True): + timestamp = canonical_timestamps.get(row["timestamp"]) + if timestamp is None: + raise ValueError("funding timestamp must match a feed event timestamp") + asset = row["asset"] + if not isinstance(asset, str) or not asset or asset not in feed_assets: + raise ValueError(f"funding asset {asset!r} is absent from the feed") + key = (timestamp, asset) + if key in seen: + raise ValueError(f"duplicate funding event for {asset} at {timestamp.isoformat()}") + seen.add(key) + + rate = row.get("rate") + amount = row.get("amount_per_unit") + if (rate is None) == (amount is None): + raise ValueError("funding event requires exactly one of rate or amount_per_unit") + value = rate if rate is not None else amount + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise ValueError("funding rate or amount_per_unit must be finite") + indexed.setdefault(timestamp, []).append( + FundingEvent(timestamp, asset, rate=rate, amount_per_unit=amount) + ) + for events in indexed.values(): + events.sort(key=lambda event: event.asset) + return indexed diff --git a/src/ml4t/backtest/models.py b/src/ml4t/backtest/models.py index ed8aeb4b..a49ced8c 100644 --- a/src/ml4t/backtest/models.py +++ b/src/ml4t/backtest/models.py @@ -1,6 +1,7 @@ """Pluggable commission and slippage models.""" import math +from copy import deepcopy from typing import Protocol, runtime_checkable # === Protocols === @@ -8,7 +9,13 @@ @runtime_checkable class CommissionModel(Protocol): - """Protocol for commission calculation.""" + """Return a non-negative fee for a proposed or executed fill. + + Pre-trade estimates call ``calculate`` on a deep copy. Only the fill-time + result on the original model is charged. Stateful models can advance tiers + on filled executions, but must support deepcopy and avoid external effects + from ``calculate`` because those cannot be isolated by copying. + """ def calculate(self, asset: str, quantity: float, price: float) -> float: ... @@ -32,6 +39,22 @@ def calculate_commission( return _validate_nonnegative_model_output("commission", model, value) +def estimate_commission( + model: CommissionModel, + asset: str, + quantity: float, + price: float, +) -> float: + """Estimate a fee without advancing a stateful model's execution state.""" + try: + estimate_model = deepcopy(model) + except Exception as exc: + raise TypeError("commission model must support deepcopy for pre-trade estimates") from exc + if estimate_model is model: + raise TypeError("commission model deepcopy must return an independent instance") + return calculate_commission(estimate_model, asset, quantity, price) + + def calculate_slippage( model: SlippageModel, asset: str, diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index 9131a729..c5ad2219 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -23,7 +23,7 @@ import json import math -from collections.abc import ItemsView, KeysView +from collections.abc import ItemsView, Iterable, KeysView from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -37,6 +37,7 @@ except ImportError: # pragma: no cover - fallback for local editable edge cases __version__ = "0.0.0.dev0" from .analytics.annualization import should_session_align +from .funding import FundingPayment from .types import Fill, Order, OrderSide, OrderStatus, OrderType, Trade if TYPE_CHECKING: @@ -52,6 +53,7 @@ _COMPONENT_FILES = { "trades": "trades.parquet", "fills": "fills.parquet", + "funding": "funding.parquet", "rejected_orders": "rejected_orders.parquet", "predictions": "predictions.parquet", "equity": "equity.parquet", @@ -177,6 +179,7 @@ class BacktestResult: trades: List of completed Trade objects equity_curve: List of (timestamp, portfolio_value) tuples fills: List of Fill objects (all order fills) + funding_payments: Timestamped cash flows separate from trades and fills rejected_orders: Orders that reached the rejected terminal state. Orders cancelled under permissive insufficient-cash handling are not included. predictions: Raw prediction DataFrame passed into the backtest (optional) @@ -200,6 +203,7 @@ class BacktestResult: default_factory=list ) rejected_orders: list[Order] = field(default_factory=list) + funding_payments: list[FundingPayment] = field(default_factory=list) artifact_diagnostics: tuple[ArtifactDiagnostic, ...] = field(default_factory=tuple) # Cached DataFrames (computed on demand) @@ -223,6 +227,15 @@ def _auto_session_aligned(self, calendar: str | None = None) -> bool: timestamps=timestamps, ) + def _timestamp_dtype(self, timestamps: Iterable[datetime | None]) -> pl.DataType: + """Use the recorded zone, or the equity timeline for an empty export.""" + for timestamp in timestamps: + if timestamp is not None: + return pl.Series([timestamp]).dtype + for timestamp, _ in self.equity_curve: + return pl.Series([timestamp]).dtype + return pl.Datetime() + def to_trades_dataframe(self) -> pl.DataFrame: """Convert trades to Polars DataFrame. @@ -248,8 +261,11 @@ def to_trades_dataframe(self) -> pl.DataFrame: if self._trades_df is not None: return self._trades_df + schema = self._trades_schema() + schema["entry_time"] = self._timestamp_dtype(t.entry_time for t in self.trades) + schema["exit_time"] = self._timestamp_dtype(t.exit_time for t in self.trades) if not self.trades: - return pl.DataFrame(schema=self._trades_schema()) + return pl.DataFrame(schema=schema) records = [] for t in self.trades: @@ -291,7 +307,7 @@ def to_trades_dataframe(self) -> pl.DataFrame: } ) - self._trades_df = pl.DataFrame(records, schema=self._trades_schema()) + self._trades_df = pl.DataFrame(records, schema=schema) return self._trades_df def to_fills_dataframe(self) -> pl.DataFrame: @@ -304,8 +320,10 @@ def to_fills_dataframe(self) -> pl.DataFrame: if self._fills_df is not None: return self._fills_df + schema = self._fills_schema() + schema["timestamp"] = self._timestamp_dtype(fill.timestamp for fill in self.fills) if not self.fills: - return pl.DataFrame(schema=self._fills_schema()) + return pl.DataFrame(schema=schema) records = [] for fill in self.fills: @@ -337,15 +355,48 @@ def to_fills_dataframe(self) -> pl.DataFrame: } ) - self._fills_df = pl.DataFrame(records, schema=self._fills_schema()) + self._fills_df = pl.DataFrame(records, schema=schema) return self._fills_df + def to_funding_dataframe(self) -> pl.DataFrame: + """Return timestamped funding cash flows separately from trades and fills.""" + schema = { + "timestamp": self._timestamp_dtype(p.timestamp for p in self.funding_payments), + "asset": pl.String(), + "quantity": pl.Float64(), + "mark_price": pl.Float64(), + "multiplier": pl.Float64(), + "rate": pl.Float64(), + "amount_per_unit": pl.Float64(), + "cash_delta": pl.Float64(), + } + return pl.DataFrame( + [ + { + "timestamp": payment.timestamp, + "asset": payment.asset, + "quantity": payment.quantity, + "mark_price": payment.mark_price, + "multiplier": payment.multiplier, + "rate": payment.rate, + "amount_per_unit": payment.amount_per_unit, + "cash_delta": payment.cash_delta, + } + for payment in self.funding_payments + ], + schema=schema, + ) + def to_rejected_orders_dataframe(self) -> pl.DataFrame: """Convert rejected orders to a stable, machine-readable DataFrame.""" if self._rejected_orders_df is not None: return self._rejected_orders_df + schema = self._rejected_orders_schema() + schema["timestamp"] = self._timestamp_dtype( + order.created_at for order in self.rejected_orders + ) if not self.rejected_orders: - return pl.DataFrame(schema=self._rejected_orders_schema()) + return pl.DataFrame(schema=schema) records = [ { @@ -370,7 +421,7 @@ def to_rejected_orders_dataframe(self) -> pl.DataFrame: ] self._rejected_orders_df = pl.DataFrame( records, - schema=self._rejected_orders_schema(), + schema=schema, ) return self._rejected_orders_df @@ -439,12 +490,14 @@ def to_portfolio_state_dataframe(self) -> pl.DataFrame: if self._portfolio_state_df is not None: return self._portfolio_state_df + schema = self._portfolio_state_schema() + schema["timestamp"] = self._timestamp_dtype(row[0] for row in self.portfolio_state) if not self.portfolio_state: - return pl.DataFrame(schema=self._portfolio_state_schema()) + return pl.DataFrame(schema=schema) self._portfolio_state_df = pl.DataFrame( self.portfolio_state, - schema=self._portfolio_state_schema(), + schema=schema, orient="row", ).sort("timestamp") return self._portfolio_state_df @@ -615,6 +668,7 @@ def to_dict(self) -> dict[str, Any]: "trades": self.trades, "equity_curve": self.equity_curve, "fills": self.fills, + "funding_payments": self.funding_payments, "portfolio_state": self.portfolio_state, } ) @@ -690,6 +744,7 @@ def to_parquet( {path}/ trades.parquet fills.parquet + funding.parquet rejected_orders.parquet predictions.parquet equity.parquet @@ -703,7 +758,7 @@ def to_parquet( Args: path: Directory path to write files include: Components to include. Default: all. - Options: ["trades", "fills", "rejected_orders", "predictions", "equity", + Options: ["trades", "fills", "funding", "rejected_orders", "predictions", "equity", "portfolio_state", "daily_pnl", "metrics", "config", "spec"] compression: Parquet compression codec (default: "zstd") @@ -825,6 +880,17 @@ def write_component(name: str, writer) -> None: ) written["fills"] = fills_path + if "funding" in selected: + funding_path = path / "funding.parquet" + write_component( + "funding", + lambda: self.to_funding_dataframe().write_parquet( + funding_path, + compression=compression, + ), + ) + written["funding"] = funding_path + if "rejected_orders" in selected: rejected_orders_path = path / "rejected_orders.parquet" write_component( @@ -896,7 +962,8 @@ def write_component(name: str, writer) -> None: "artifact_type": _ARTIFACT_TYPE, "schema_version": _ARTIFACT_SCHEMA_VERSION, "library_version": __version__, - "complete": written.keys() >= _REQUIRED_RESULT_COMPONENTS, + "complete": written.keys() + >= (_REQUIRED_RESULT_COMPONENTS | ({"funding"} if self.funding_payments else set())), "components": { name: _COMPONENT_FILES[name] for name in _COMPONENT_FILES if name in written }, @@ -1081,7 +1148,7 @@ def discover_legacy_components() -> dict[str, str]: f"missing components={missing_required_components}, missing files={missing_files}" ) if recovery: - missing_components = sorted(_COMPONENT_FILES.keys() - components.keys()) + missing_components = sorted((_COMPONENT_FILES.keys() - {"funding"}) - components.keys()) diagnostics.extend( ArtifactDiagnostic( code="component_missing", @@ -1199,6 +1266,21 @@ def read_fills(component_path: Path) -> list[Fill]: ) return result + def read_funding(component_path: Path) -> list[FundingPayment]: + return [ + FundingPayment( + timestamp=row["timestamp"], + asset=row["asset"], + quantity=row["quantity"], + mark_price=row["mark_price"], + multiplier=row["multiplier"], + rate=row["rate"], + amount_per_unit=row["amount_per_unit"], + cash_delta=row["cash_delta"], + ) + for row in pl.read_parquet(component_path).iter_rows(named=True) + ] + def read_rejected_orders(component_path: Path) -> list[Order]: result: list[Order] = [] for row in pl.read_parquet(component_path).iter_rows(named=True): @@ -1281,10 +1363,29 @@ def read_spec_config(component_path: Path): trades = read_component("trades", read_trades, []) fills = read_component("fills", read_fills, []) + funding_payments = read_component("funding", read_funding, []) rejected_orders = read_component("rejected_orders", read_rejected_orders, []) equity_curve = read_component("equity", read_equity, []) portfolio_state = read_component("portfolio_state", read_portfolio_state, []) metrics = read_component("metrics", read_metrics, {}) + if metrics.get("num_funding_events", 0) and not component_read_ok["funding"]: + message = "funding.parquet is required for an artifact with funding events" + if not recovery: + raise ArtifactReadError(message) + diagnostics.append(ArtifactDiagnostic("component_missing", "funding", message)) + if component_read_ok["funding"] and "num_funding_events" in metrics: + reported_count = metrics["num_funding_events"] + reported_total = metrics.get("total_funding") + actual_total = sum(payment.cash_delta for payment in funding_payments) + if ( + reported_count != len(funding_payments) + or not isinstance(reported_total, (int, float)) + or not math.isclose(reported_total, actual_total, abs_tol=1e-9) + ): + message = "funding.parquet is inconsistent with funding metrics" + if not recovery: + raise ArtifactReadError(message) + diagnostics.append(ArtifactDiagnostic("component_inconsistent", "funding", message)) predictions = read_component("predictions", pl.read_parquet, None) daily_pnl = read_component("daily_pnl", pl.read_parquet, None) config = read_component("config", read_config, None) @@ -1327,6 +1428,7 @@ def read_spec_config(component_path: Path): predictions=predictions, portfolio_state=portfolio_state, rejected_orders=rejected_orders, + funding_payments=funding_payments, metrics=metrics, config=config, artifact_diagnostics=tuple(diagnostics), diff --git a/src/ml4t/backtest/risk/portfolio/limits.py b/src/ml4t/backtest/risk/portfolio/limits.py index bdcf7659..72bc32d4 100644 --- a/src/ml4t/backtest/risk/portfolio/limits.py +++ b/src/ml4t/backtest/risk/portfolio/limits.py @@ -3,7 +3,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import date, datetime -from typing import Any +from math import isfinite +from typing import Any, ClassVar import numpy as np @@ -11,6 +12,21 @@ class PortfolioLimit(ABC): """Base class for portfolio-level risk limits.""" + _supports_reduction: ClassVar[bool] = False + + def __post_init__(self) -> None: + action = getattr(self, "action", None) + if action not in {"none", "warn", "reduce", "halt", "liquidate"}: + raise ValueError(f"unsupported portfolio-limit action: {action!r}") + pct = getattr(self, "reduction_pct", 0.0) + if action == "reduce": + if not self._supports_reduction: + raise ValueError(f"{type(self).__name__} does not support action='reduce'") + if not isinstance(pct, (int, float)) or not isfinite(pct) or not 0 < pct <= 1: + raise ValueError("reduction_pct must be a finite fraction in (0, 1]") + elif pct != 0.0: + raise ValueError("reduction_pct requires action='reduce'") + @abstractmethod def check(self, state: "PortfolioState") -> "LimitResult": """Check if limit is breached. @@ -102,23 +118,25 @@ class MaxDrawdownLimit(PortfolioLimit): action: Action when breached ("warn", "reduce", "halt", "liquidate") Default "liquidate" - flatten positions and stop new trades warn_threshold: Optional earlier threshold for warnings + reduction_pct: Required fraction in (0, 1] when action is "reduce" Example: limit = MaxDrawdownLimit(max_drawdown=0.20, warn_threshold=0.15) - # Warns at 15% drawdown, halts at 20% + # Warns at 15% drawdown, liquidates at 20% """ max_drawdown: float = 0.20 action: str = "liquidate" warn_threshold: float | None = None + reduction_pct: float = 0.0 + _supports_reduction: ClassVar[bool] = True def check(self, state: PortfolioState) -> LimitResult: if state.current_drawdown >= self.max_drawdown: - return LimitResult( - breached=True, - action=self.action, - reason=f"drawdown {state.current_drawdown:.1%} >= {self.max_drawdown:.1%}", - ) + reason = f"drawdown {state.current_drawdown:.1%} >= {self.max_drawdown:.1%}" + if self.action == "reduce": + return LimitResult.reduce(reason, self.reduction_pct) + return LimitResult(breached=True, action=self.action, reason=reason) if self.warn_threshold and state.current_drawdown >= self.warn_threshold: return LimitResult.warn( @@ -190,7 +208,7 @@ class DailyLossLimit(PortfolioLimit): Args: max_daily_loss_pct: Maximum daily loss as % of equity (0.0-1.0) Default 0.02 = 2% max daily loss - action: Action when breached ("warn", "reduce", "halt", "liquidate") + action: Action when breached ("warn", "halt", "liquidate") Example: limit = DailyLossLimit(max_daily_loss_pct=0.02) @@ -291,7 +309,7 @@ class VaRLimit(PortfolioLimit): threshold: Maximum acceptable VaR as decimal (0.05 = 5% max loss) confidence_level: Confidence level for VaR (0.95 = 95% confidence) lookback_days: Minimum days of history required (default: 20) - action: Action when breached ("warn", "reduce", "halt") + action: Action when breached ("warn", "halt") returns_key: Key in context for historical returns array Example: @@ -357,7 +375,7 @@ class CVaRLimit(PortfolioLimit): threshold: Maximum acceptable CVaR as decimal (0.08 = 8% expected shortfall) confidence_level: Confidence level for CVaR (0.95 = 95% confidence) lookback_days: Minimum days of history required (default: 20) - action: Action when breached ("warn", "reduce", "halt") + action: Action when breached ("warn", "halt") returns_key: Key in context for historical returns array Example: @@ -424,7 +442,7 @@ class BetaLimit(PortfolioLimit): Args: max_beta: Maximum allowed portfolio beta (default 1.5) min_beta: Minimum allowed portfolio beta (default -0.5) - action: Action when breached ("warn", "reduce", "halt") + action: Action when breached ("warn", "halt") betas_key: Key in context for asset beta dict Example: diff --git a/src/ml4t/backtest/risk/portfolio/manager.py b/src/ml4t/backtest/risk/portfolio/manager.py index bfefe278..22652c78 100644 --- a/src/ml4t/backtest/risk/portfolio/manager.py +++ b/src/ml4t/backtest/risk/portfolio/manager.py @@ -5,6 +5,7 @@ import warnings from dataclasses import dataclass, field from datetime import date, datetime +from math import isfinite from typing import TYPE_CHECKING, Any from .limits import LimitResult, PortfolioLimit, PortfolioState @@ -57,6 +58,7 @@ class RiskManager: _halt_reason: str = "" _warnings: list[str] = field(default_factory=list) _liquidation_applied: bool = False + _reduction_applied: set[int] = field(default_factory=set) def initialize(self, initial_equity: float, timestamp: datetime | None = None) -> None: """Initialize the risk manager with starting equity. @@ -74,6 +76,7 @@ def initialize(self, initial_equity: float, timestamp: datetime | None = None) - self._halt_reason = "" self._warnings = [] self._liquidation_applied = False + self._reduction_applied.clear() def update( self, @@ -83,60 +86,69 @@ def update( context: dict[str, Any] | None = None, broker: Broker | None = None, ) -> list[LimitResult]: - """Update risk state and check all limits. + """Check limits, then apply supported actions through the broker. - Args: - equity: Current portfolio equity - positions: Dict of asset -> position market value - timestamp: Current timestamp - context: Optional context dict with historical data (e.g., returns for VaR) - broker: Optional broker handle. Required to auto-apply - ``action="liquidate"`` by calling - ``broker.flatten_all_positions(reason=...)``. - - Returns: - List of LimitResult for any breached limits + A reduction requires a broker and runs once per continuous breach of + each limit. A new breach after recovery may reduce exposure again. """ - # Update high water mark and track last equity - self._last_equity = equity - if equity > self._high_water_mark: - self._high_water_mark = equity - - # Check for new trading day - if timestamp: + high_water_mark = max(self._high_water_mark, equity) + daily_start_equity = self._daily_start_equity + last_date = self._last_date + if timestamp is not None: current_date = timestamp.date() - if self._last_date and current_date != self._last_date: - # New day - reset daily P&L tracking - self._daily_start_equity = equity - self._warnings = [] # Clear daily warnings - self._last_date = current_date - - # Build portfolio state - state = self._build_state(equity, positions, timestamp, context or {}) - - # Check all limits - results = [] - self._warnings = [] + if last_date is not None and current_date != last_date: + daily_start_equity = equity + last_date = current_date + + state = self._build_state( + equity, + positions, + timestamp, + context or {}, + high_water_mark=high_water_mark, + daily_start_equity=daily_start_equity, + ) + results: list[LimitResult] = [] + reduction_results: dict[int, LimitResult] = {} liquidation_reasons: list[str] = [] + warning_reasons: list[str] = [] + halted = self._halted + halt_reason = self._halt_reason - for limit in self.limits: + for index, limit in enumerate(self.limits): result = limit.check(state) - if result.breached: - results.append(result) - - if result.action in {"halt", "liquidate"}: - self._halted = True - self._halt_reason = result.reason - if result.action == "liquidate": - liquidation_reasons.append(result.reason) - elif result.action == "warn": - self._warnings.append(result.reason) - - if liquidation_reasons and not self._liquidation_applied: - liquidation_reason = "; ".join(dict.fromkeys(liquidation_reasons)) + if not result.breached: + continue + results.append(result) + if result.action in {"halt", "liquidate"}: + halted = True + halt_reason = result.reason + if result.action == "liquidate": + liquidation_reasons.append(result.reason) + elif result.action == "warn": + warning_reasons.append(result.reason) + elif result.action == "reduce": + pct = result.reduction_pct + if not isinstance(pct, (int, float)) or not isfinite(pct) or not 0 < pct <= 1: + raise ValueError("reduce action requires a finite reduction_pct in (0, 1]") + reduction_results[index] = result + elif result.action != "none": + raise ValueError(f"unsupported portfolio-limit action: {result.action!r}") + + new_reductions = set(reduction_results) - self._reduction_applied + has_exposure = bool(positions) or (broker is not None and bool(broker.positions)) + apply_reduction = bool(new_reductions and has_exposure and not liquidation_reasons) + if apply_reduction and broker is None: + raise ValueError("broker is required to apply action='reduce'") + if apply_reduction and broker is not None and not broker.positions: + raise ValueError("broker has no positions to reduce") + + liquidation_applied = self._liquidation_applied + if liquidation_reasons and not liquidation_applied: + reason = "; ".join(dict.fromkeys(liquidation_reasons)) if broker is not None: - broker.flatten_all_positions(reason=liquidation_reason) - self._liquidation_applied = True + broker.flatten_all_positions(reason=reason) + liquidation_applied = True else: warnings.warn( "RiskManager.update() produced action='liquidate' but no broker was " @@ -145,7 +157,25 @@ def update( UserWarning, stacklevel=2, ) + elif apply_reduction: + assert broker is not None + reason = "; ".join( + dict.fromkeys(reduction_results[index].reason for index in sorted(new_reductions)) + ) + fraction = max(reduction_results[index].reduction_pct for index in new_reductions) + broker.reduce_all_positions(fraction=fraction, reason=reason) + self._last_equity = equity + self._high_water_mark = high_water_mark + self._daily_start_equity = daily_start_equity + self._last_date = last_date + self._warnings = warning_reasons + self._halted = halted + self._halt_reason = halt_reason + self._liquidation_applied = liquidation_applied + self._reduction_applied.intersection_update(reduction_results) + if apply_reduction: + self._reduction_applied.update(new_reductions) return results def _build_state( @@ -154,16 +184,20 @@ def _build_state( positions: dict[str, float], timestamp: date | datetime | None, context: dict[str, Any] | None = None, + *, + high_water_mark: float | None = None, + daily_start_equity: float | None = None, ) -> PortfolioState: """Build PortfolioState from current data.""" + high_water_mark = self._high_water_mark if high_water_mark is None else high_water_mark + daily_start_equity = ( + self._daily_start_equity if daily_start_equity is None else daily_start_equity + ) # Calculate drawdown - if self._high_water_mark > 0: - drawdown = (self._high_water_mark - equity) / self._high_water_mark - else: - drawdown = 0.0 + drawdown = (high_water_mark - equity) / high_water_mark if high_water_mark > 0 else 0.0 # Calculate daily P&L - daily_pnl = equity - self._daily_start_equity + daily_pnl = equity - daily_start_equity # Calculate exposures gross_exposure = sum(abs(v) for v in positions.values()) @@ -172,7 +206,7 @@ def _build_state( return PortfolioState( equity=equity, initial_equity=self._initial_equity, - high_water_mark=self._high_water_mark, + high_water_mark=high_water_mark, current_drawdown=max(0, drawdown), num_positions=len(positions), positions=positions, diff --git a/tests/compatibility/snapshots/v0.1.json b/tests/compatibility/snapshots/v0.1.json index 94f01c4f..ec651837 100644 --- a/tests/compatibility/snapshots/v0.1.json +++ b/tests/compatibility/snapshots/v0.1.json @@ -576,20 +576,32 @@ } }, "documented_imports": [ + "from ml4t.backtest import AssetClass, BacktestConfig, BacktestResult, ContractSpec, DataFeed, Engine", + "from ml4t.backtest import AssetClass, BacktestConfig, ContractSpec, DataFeed, Engine", + "from ml4t.backtest import AssetClass, ContractSpec", "from ml4t.backtest import BacktestConfig", "from ml4t.backtest import BacktestConfig, CommissionType", + "from ml4t.backtest import BacktestConfig, CommissionType, DataFeed, Engine", + "from ml4t.backtest import BacktestConfig, CommissionType, DataFeed, Engine, OrderSide, Strategy", "from ml4t.backtest import BacktestConfig, DataFeed", + "from ml4t.backtest import BacktestConfig, DataFeed, Engine", + "from ml4t.backtest import BacktestConfig, DataFeed, Engine, ExecutionMode, OrderType, Strategy", + "from ml4t.backtest import BacktestConfig, DataFeed, Engine, ExecutionMode, Strategy", + "from ml4t.backtest import BacktestConfig, DataFeed, Engine, OrderSide, Strategy", + "from ml4t.backtest import BacktestConfig, DataFeed, Engine, RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor", + "from ml4t.backtest import BacktestConfig, DataFeed, Engine, StopLoss, Strategy", "from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy", "from ml4t.backtest import BacktestConfig, Engine", + "from ml4t.backtest import BacktestConfig, ExecutionMode", "from ml4t.backtest import Broker, BacktestConfig", "from ml4t.backtest import DataFeed", "from ml4t.backtest import Engine", "from ml4t.backtest import Engine, DataFeed", - "from ml4t.backtest import Engine, DataFeed, Strategy", - "from ml4t.backtest import Engine, DataFeed, Strategy, BacktestConfig", "from ml4t.backtest import Engine, Strategy, BacktestConfig, DataFeed", "from ml4t.backtest import Engine, Strategy, DataFeed, BacktestConfig, run_backtest", "from ml4t.backtest import ExecutionMode, StopFillMode", + "from ml4t.backtest import RebalanceConfig, RebalanceSchedule, Strategy, TargetWeightExecutor", + "from ml4t.backtest import RebalanceConfig, TargetWeightExecutor", "from ml4t.backtest import RebalanceSchedule", "from ml4t.backtest import RuleChain, StopLoss, TakeProfit, TrailingStop", "from ml4t.backtest import StopLoss", @@ -614,6 +626,8 @@ "from ml4t.backtest.config import SlippageType", "from ml4t.backtest.config import SlippageType, SpreadConvention", "from ml4t.backtest.config import TrailStopTiming, WaterMarkSource, InitialHwmSource", + "from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices", + "from ml4t.backtest.example_data import load_example_prices", "from ml4t.backtest.execution import LinearImpact", "from ml4t.backtest.execution import LinearImpact, SquareRootImpact, NoImpact", "from ml4t.backtest.execution import RebalanceConfig", @@ -623,6 +637,7 @@ "from ml4t.backtest.models import TieredCommission, CombinedCommission", "from ml4t.backtest.profiles import list_profiles", "from ml4t.backtest.result import BacktestResult", + "from ml4t.backtest.risk.portfolio import MaxDrawdownLimit, RiskManager", "from ml4t.backtest.risk.portfolio.limits import BetaLimit", "from ml4t.backtest.risk.portfolio.limits import DailyLossLimit", "from ml4t.backtest.risk.portfolio.limits import GrossExposureLimit, NetExposureLimit", @@ -1145,6 +1160,7 @@ "max_open_positions", "max_turnover", "num_fills", + "num_funding_events", "num_orders", "num_rebalance_events", "num_rejected_orders", @@ -1161,6 +1177,7 @@ "total_commission", "total_costs", "total_filled_notional", + "total_funding", "total_gross_pnl", "total_return", "total_return_pct", @@ -1863,6 +1880,7 @@ "daily_pnl": "daily_pnl.parquet", "equity": "equity.parquet", "fills": "fills.parquet", + "funding": "funding.parquet", "metrics": "metrics.json", "portfolio_state": "portfolio_state.parquet", "predictions": "predictions.parquet", @@ -2642,6 +2660,24 @@ "qualname": "WaterMarkSource", "signature": "(*values)" }, + "ml4t.backtest.example_data:ExampleRoundTrip": { + "kind": "class", + "members": { + "on_data": { + "kind": "method", + "signature": "(self, timestamp: datetime.datetime, data: dict[str, dict[str, Any]], context: dict[str, Any], broker: Any) -> None" + } + }, + "module": "ml4t.backtest.example_data", + "qualname": "ExampleRoundTrip", + "signature": "(asset: str, quantity: float) -> None" + }, + "ml4t.backtest.example_data:load_example_prices": { + "kind": "function", + "module": "ml4t.backtest.example_data", + "qualname": "load_example_prices", + "signature": "(category: str) -> polars.dataframe.frame.DataFrame" + }, "ml4t.backtest.execution.rebalancer:RebalanceConfig": { "kind": "class", "members": { @@ -2835,6 +2871,10 @@ "kind": "method", "signature": "(self) -> 'pl.DataFrame'" }, + "to_funding_dataframe": { + "kind": "method", + "signature": "(self) -> 'pl.DataFrame'" + }, "to_parquet": { "kind": "method", "signature": "(self, path: 'str | Path', include: 'list[str] | None' = None, compression: \"Literal['lz4', 'uncompressed', 'snappy', 'gzip', 'brotli', 'zstd']\" = 'zstd') -> 'dict[str, Path]'" @@ -2870,7 +2910,7 @@ }, "module": "ml4t.backtest.result", "qualname": "BacktestResult", - "signature": "(trades: 'list[Trade]', equity_curve: 'list[tuple[datetime, float]]', fills: 'list[Fill]', metrics: 'dict[str, Any]', predictions: 'pl.DataFrame | None' = None, config: 'BacktestConfig | None' = None, equity: 'EquityCurve | None' = None, trade_analyzer: 'TradeAnalyzer | None' = None, portfolio_state: 'list[tuple[datetime, float, float, float, float, int]]' = , rejected_orders: 'list[Order]' = , artifact_diagnostics: 'tuple[ArtifactDiagnostic, ...]' = , _trades_df: 'pl.DataFrame | None' = None, _equity_df: 'pl.DataFrame | None' = None, _fills_df: 'pl.DataFrame | None' = None, _portfolio_state_df: 'pl.DataFrame | None' = None, _rejected_orders_df: 'pl.DataFrame | None' = None) -> None" + "signature": "(trades: 'list[Trade]', equity_curve: 'list[tuple[datetime, float]]', fills: 'list[Fill]', metrics: 'dict[str, Any]', predictions: 'pl.DataFrame | None' = None, config: 'BacktestConfig | None' = None, equity: 'EquityCurve | None' = None, trade_analyzer: 'TradeAnalyzer | None' = None, portfolio_state: 'list[tuple[datetime, float, float, float, float, int]]' = , rejected_orders: 'list[Order]' = , funding_payments: 'list[FundingPayment]' = , artifact_diagnostics: 'tuple[ArtifactDiagnostic, ...]' = , _trades_df: 'pl.DataFrame | None' = None, _equity_df: 'pl.DataFrame | None' = None, _fills_df: 'pl.DataFrame | None' = None, _portfolio_state_df: 'pl.DataFrame | None' = None, _rejected_orders_df: 'pl.DataFrame | None' = None) -> None" }, "ml4t.backtest.risk.portfolio.limits:BetaLimit": { "kind": "class", @@ -2966,7 +3006,7 @@ }, "module": "ml4t.backtest.risk.portfolio.limits", "qualname": "MaxDrawdownLimit", - "signature": "(max_drawdown: float = 0.2, action: str = 'liquidate', warn_threshold: float | None = None) -> None" + "signature": "(max_drawdown: float = 0.2, action: str = 'liquidate', warn_threshold: float | None = None, reduction_pct: float = 0.0) -> None" }, "ml4t.backtest.risk.portfolio.limits:MaxExposureLimit": { "kind": "class", @@ -3048,6 +3088,74 @@ "qualname": "VaRLimit", "signature": "(threshold: float = 0.05, confidence_level: float = 0.95, lookback_days: int = 20, action: str = 'warn', returns_key: str = 'historical_returns') -> None" }, + "ml4t.backtest.risk.portfolio:MaxDrawdownLimit": { + "kind": "class", + "members": { + "__repr__": { + "kind": "method", + "signature": "(self)" + }, + "check": { + "kind": "method", + "signature": "(self, state: ml4t.backtest.risk.portfolio.limits.PortfolioState) -> ml4t.backtest.risk.portfolio.limits.LimitResult" + } + }, + "module": "ml4t.backtest.risk.portfolio.limits", + "qualname": "MaxDrawdownLimit", + "signature": "(max_drawdown: float = 0.2, action: str = 'liquidate', warn_threshold: float | None = None, reduction_pct: float = 0.0) -> None" + }, + "ml4t.backtest.risk.portfolio:RiskManager": { + "kind": "class", + "members": { + "__repr__": { + "kind": "method", + "signature": "(self)" + }, + "can_increase_position": { + "kind": "method", + "signature": "(self, asset: 'str', amount: 'float') -> 'tuple[bool, str]'" + }, + "can_open_position": { + "kind": "method", + "signature": "(self) -> 'bool'" + }, + "current_drawdown": { + "kind": "property", + "signature": "(self) -> 'float'" + }, + "get_state": { + "kind": "method", + "signature": "(self, equity: 'float', positions: 'dict[str, float]', context: 'dict[str, Any] | None' = None) -> 'PortfolioState'" + }, + "halt_reason": { + "kind": "property", + "signature": "(self) -> 'str'" + }, + "initialize": { + "kind": "method", + "signature": "(self, initial_equity: 'float', timestamp: 'datetime | None' = None) -> 'None'" + }, + "is_halted": { + "kind": "property", + "signature": "(self) -> 'bool'" + }, + "reset_halt": { + "kind": "method", + "signature": "(self) -> 'None'" + }, + "update": { + "kind": "method", + "signature": "(self, equity: 'float', positions: 'dict[str, float]', timestamp: 'datetime | None' = None, context: 'dict[str, Any] | None' = None, broker: 'Broker | None' = None) -> 'list[LimitResult]'" + }, + "warnings": { + "kind": "property", + "signature": "(self) -> 'list[str]'" + } + }, + "module": "ml4t.backtest.risk.portfolio.manager", + "qualname": "RiskManager", + "signature": "(limits: 'list[PortfolioLimit]' = , _initial_equity: 'float' = 0.0, _high_water_mark: 'float' = 0.0, _daily_start_equity: 'float' = 0.0, _last_equity: 'float' = 0.0, _last_date: 'date | None' = None, _halted: 'bool' = False, _halt_reason: 'str' = '', _warnings: 'list[str]' = , _liquidation_applied: 'bool' = False, _reduction_applied: 'set[int]' = ) -> None" + }, "ml4t.backtest.risk.position:AllOf": { "kind": "class", "members": { @@ -3510,6 +3618,10 @@ "kind": "method", "signature": "(self) -> 'pl.DataFrame'" }, + "to_funding_dataframe": { + "kind": "method", + "signature": "(self) -> 'pl.DataFrame'" + }, "to_parquet": { "kind": "method", "signature": "(self, path: 'str | Path', include: 'list[str] | None' = None, compression: \"Literal['lz4', 'uncompressed', 'snappy', 'gzip', 'brotli', 'zstd']\" = 'zstd') -> 'dict[str, Path]'" @@ -3545,7 +3657,7 @@ }, "module": "ml4t.backtest.result", "qualname": "BacktestResult", - "signature": "(trades: 'list[Trade]', equity_curve: 'list[tuple[datetime, float]]', fills: 'list[Fill]', metrics: 'dict[str, Any]', predictions: 'pl.DataFrame | None' = None, config: 'BacktestConfig | None' = None, equity: 'EquityCurve | None' = None, trade_analyzer: 'TradeAnalyzer | None' = None, portfolio_state: 'list[tuple[datetime, float, float, float, float, int]]' = , rejected_orders: 'list[Order]' = , artifact_diagnostics: 'tuple[ArtifactDiagnostic, ...]' = , _trades_df: 'pl.DataFrame | None' = None, _equity_df: 'pl.DataFrame | None' = None, _fills_df: 'pl.DataFrame | None' = None, _portfolio_state_df: 'pl.DataFrame | None' = None, _rejected_orders_df: 'pl.DataFrame | None' = None) -> None" + "signature": "(trades: 'list[Trade]', equity_curve: 'list[tuple[datetime, float]]', fills: 'list[Fill]', metrics: 'dict[str, Any]', predictions: 'pl.DataFrame | None' = None, config: 'BacktestConfig | None' = None, equity: 'EquityCurve | None' = None, trade_analyzer: 'TradeAnalyzer | None' = None, portfolio_state: 'list[tuple[datetime, float, float, float, float, int]]' = , rejected_orders: 'list[Order]' = , funding_payments: 'list[FundingPayment]' = , artifact_diagnostics: 'tuple[ArtifactDiagnostic, ...]' = , _trades_df: 'pl.DataFrame | None' = None, _equity_df: 'pl.DataFrame | None' = None, _fills_df: 'pl.DataFrame | None' = None, _portfolio_state_df: 'pl.DataFrame | None' = None, _rejected_orders_df: 'pl.DataFrame | None' = None) -> None" }, "ml4t.backtest:Broker": { "kind": "class", @@ -3722,6 +3834,10 @@ "kind": "method", "signature": "(self, target_weights: 'dict[str, float]', order_type: 'OrderType' = ) -> 'list[Order]'" }, + "reduce_all_positions": { + "kind": "method", + "signature": "(self, fraction: 'float', reason: 'str', order_type: 'OrderType' = ) -> 'list[Order]'" + }, "reduce_position": { "kind": "method", "signature": "(self, asset: 'str', fraction: 'float', order_type: 'OrderType' = , limit_price: 'float | None' = None) -> 'Order | None'" @@ -3825,14 +3941,14 @@ }, "module": "ml4t.backtest.datafeed", "qualname": "DataFeed", - "signature": "(prices_path: str | None = None, signals_path: str | None = None, context_path: str | None = None, prices_df: polars.dataframe.frame.DataFrame | None = None, signals_df: polars.dataframe.frame.DataFrame | None = None, context_df: polars.dataframe.frame.DataFrame | None = None, *, feed_spec: ml4t.specs.market_data.FeedSpec | Any | None = None, contract: ml4t.specs.market_data.FeedSpec | Any | None = None, entity_col: str | None = None, timestamp_col: str | None = None, price_col: str | None = None, open_col: str | None = None, high_col: str | None = None, low_col: str | None = None, close_col: str | None = None, volume_col: str | None = None, vwap_col: str | None = None, bid_col: str | None = None, ask_col: str | None = None, mid_col: str | None = None, bid_size_col: str | None = None, ask_size_col: str | None = None)" + "signature": "(prices_path: str | None = None, signals_path: str | None = None, context_path: str | None = None, prices_df: polars.dataframe.frame.DataFrame | None = None, signals_df: polars.dataframe.frame.DataFrame | None = None, context_df: polars.dataframe.frame.DataFrame | None = None, *, feed_spec: ml4t.specs.market_data.FeedSpec | Any | None = None, contract: ml4t.specs.market_data.FeedSpec | Any | None = None, entity_col: str | None = None, timestamp_col: str | None = None, price_col: str | None = None, open_col: str | None = None, high_col: str | None = None, low_col: str | None = None, close_col: str | None = None, volume_col: str | None = None, vwap_col: str | None = None, bid_col: str | None = None, ask_col: str | None = None, mid_col: str | None = None, bid_size_col: str | None = None, ask_size_col: str | None = None, session_col: str | None = None)" }, "ml4t.backtest:Engine": { "kind": "class", "members": { "from_config": { "kind": "method", - "signature": "(cls, feed: 'DataFeed', strategy: 'Strategy', config: 'BacktestConfig', *, contract_specs: 'dict[str, Any] | None' = None, market_impact_model: 'Any | None' = None, execution_limits: 'Any | None' = None, lifecycle_version: 'LifecycleVersion | str' = , execution_policy: 'ExecutionPolicy | None' = None, target_intent_state: 'dict[str, Any] | None' = None) -> 'Engine'" + "signature": "(cls, feed: 'DataFeed', strategy: 'Strategy', config: 'BacktestConfig', *, contract_specs: 'dict[str, Any] | None' = None, market_impact_model: 'Any | None' = None, execution_limits: 'Any | None' = None, funding_df: 'pl.DataFrame | None' = None, lifecycle_version: 'LifecycleVersion | str' = , execution_policy: 'ExecutionPolicy | None' = None, target_intent_state: 'dict[str, Any] | None' = None) -> 'Engine'" }, "run": { "kind": "method", @@ -3845,7 +3961,7 @@ }, "module": "ml4t.backtest.engine", "qualname": "Engine", - "signature": "(feed: 'DataFeed', strategy: 'Strategy', config: 'BacktestConfig | None' = None, *, contract_specs: 'dict[str, Any] | None' = None, market_impact_model: 'Any | None' = None, execution_limits: 'Any | None' = None, lifecycle_version: 'LifecycleVersion | str' = , execution_policy: 'ExecutionPolicy | None' = None, target_intent_state: 'dict[str, Any] | None' = None)" + "signature": "(feed: 'DataFeed', strategy: 'Strategy', config: 'BacktestConfig | None' = None, *, contract_specs: 'dict[str, Any] | None' = None, market_impact_model: 'Any | None' = None, execution_limits: 'Any | None' = None, funding_df: 'pl.DataFrame | None' = None, lifecycle_version: 'LifecycleVersion | str' = , execution_policy: 'ExecutionPolicy | None' = None, target_intent_state: 'dict[str, Any] | None' = None)" }, "ml4t.backtest:ExecutionMode": { "kind": "class", @@ -4350,7 +4466,7 @@ "kind": "function", "module": "ml4t.backtest.engine", "qualname": "run_backtest", - "signature": "(prices: 'pl.DataFrame | str', strategy: 'Strategy', signals: 'pl.DataFrame | str | None' = None, context: 'pl.DataFrame | str | None' = None, config: 'BacktestConfig | str | None' = None, *, feed_spec: 'Any | None' = None, contract: 'Any | None' = None, contract_specs: 'dict[str, Any] | None' = None, market_impact_model: 'Any | None' = None, execution_limits: 'Any | None' = None, lifecycle_version: 'LifecycleVersion | str' = , execution_policy: 'ExecutionPolicy | None' = None, target_intent_state: 'dict[str, Any] | None' = None) -> 'BacktestResult'" + "signature": "(prices: 'pl.DataFrame | str', strategy: 'Strategy', signals: 'pl.DataFrame | str | None' = None, context: 'pl.DataFrame | str | None' = None, config: 'BacktestConfig | str | None' = None, *, feed_spec: 'Any | None' = None, contract: 'Any | None' = None, contract_specs: 'dict[str, Any] | None' = None, market_impact_model: 'Any | None' = None, execution_limits: 'Any | None' = None, funding_df: 'pl.DataFrame | None' = None, lifecycle_version: 'LifecycleVersion | str' = , execution_policy: 'ExecutionPolicy | None' = None, target_intent_state: 'dict[str, Any] | None' = None) -> 'BacktestResult'" } } } diff --git a/tests/contracts/test_book_links.py b/tests/contracts/test_book_links.py new file mode 100644 index 00000000..4adf4120 --- /dev/null +++ b/tests/contracts/test_book_links.py @@ -0,0 +1,40 @@ +"""Book companion path checks use the verified Git-tree manifest.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).parents[2] + + +def _load_checker(): + path = _ROOT / "validation" / "check_book_links.py" + spec = importlib.util.spec_from_file_location("ml4t_book_links", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_documented_companion_paths_exist_at_pinned_revision() -> None: + checker = _load_checker() + checker.check_links(checker.documentation_paths()) + + +def test_nonexistent_companion_path_fails(tmp_path: Path) -> None: + checker = _load_checker() + manifest = checker._MANIFEST + commit = json.loads(manifest.read_text())["commit"] + page = tmp_path / "missing.md" + page.write_text( + "[Missing](https://github.com/stefan-jansen/machine-learning-for-trading/" + f"blob/{commit}/16_strategy_simulation/not_a_notebook.ipynb)\n" + ) + with pytest.raises(ValueError, match="does not exist"): + checker.check_links([page], manifest) diff --git a/tests/contracts/test_documentation_examples.py b/tests/contracts/test_documentation_examples.py index d5ed2461..8f35e931 100644 --- a/tests/contracts/test_documentation_examples.py +++ b/tests/contracts/test_documentation_examples.py @@ -7,6 +7,7 @@ from pathlib import Path from types import ModuleType +import pytest import yaml _ROOT = Path(__file__).parents[2] @@ -49,3 +50,35 @@ def test_documentation_ci_installs_wheel_before_running_examples() -> None: install = commands.index("uv pip install") execute = commands.index("validation/check_documentation_examples.py") assert build < install < execute + + +def test_recorded_tutorial_output_mismatch_fails(tmp_path: Path) -> None: + page = tmp_path / "example.md" + page.write_text( + "\n" + "```python\nprint('actual')\n```\n\n" + "\n" + "```text\nexpected\n```\n", + encoding="utf-8", + ) + checker = _load_checker() + examples = checker.collect_examples([page]) + with pytest.raises(AssertionError, match="output differs"): + checker.run_examples(examples) + + +def test_missing_tutorial_input_fails_real_example(tmp_path: Path) -> None: + page = tmp_path / "example.md" + page.write_text( + "\n" + "```python\n" + "from ml4t.backtest.example_data import load_example_prices\n" + "load_example_prices('not-a-bundled-category')\n" + "```\n\n" + "\n" + "```text\nunreachable\n```\n", + encoding="utf-8", + ) + checker = _load_checker() + with pytest.raises(RuntimeError, match="Unknown example category"): + checker.run_examples(checker.collect_examples([page])) diff --git a/tests/contracts/test_real_strategy_runner.py b/tests/contracts/test_real_strategy_runner.py index c1918f8c..0f7143d7 100644 --- a/tests/contracts/test_real_strategy_runner.py +++ b/tests/contracts/test_real_strategy_runner.py @@ -362,12 +362,7 @@ def test_real_strategy_benchmark_interval_is_deterministic() -> None: def test_real_strategy_performance_evidence_fails_stale_source() -> None: - """An engine source that is neither measured-under nor certified is refused. - - Both escape hatches have to be closed at once. The shipped report reaches the - current source through `certified_equivalent_sources`, so blanking only the - measured-under digest leaves it publishable and proves nothing. - """ + """An engine source that is neither measured-under nor certified is refused.""" benchmark = _load_benchmark() validation = Path(__file__).parents[2] / "validation" correctness = json.loads((validation / "REAL_STRATEGY_RESULTS.json").read_text()) @@ -388,12 +383,17 @@ def test_certification_alone_publishes_a_later_engine_source() -> None: validation = Path(__file__).parents[2] / "validation" correctness = json.loads((validation / "REAL_STRATEGY_RESULTS.json").read_text()) report = json.loads((validation / "REAL_STRATEGY_PERFORMANCE.json").read_text()) + report["provenance"]["ml4t_engine_source_sha256"] = "0" * 64 + report["provenance"]["certified_equivalent_sources"] = [] + benchmark.certify_source( + report, + correctness, + validation / "REAL_STRATEGY_RESULTS.json", + "Test a later source with unchanged measured behavior", + ) provenance = report["provenance"] - certified = provenance.get("certified_equivalent_sources") or [] + certified = provenance["certified_equivalent_sources"] - # The working tree is reachable only through a certification, never because the - # timings were measured under it: those two digests must differ, or this test - # passes for the wrong reason. current = benchmark._tree_digest(benchmark.PROJECT_ROOT / "src/ml4t/backtest") assert current != provenance["ml4t_engine_source_sha256"] assert current in {entry["engine_source_sha256"] for entry in certified} @@ -406,6 +406,14 @@ def test_malformed_certification_is_refused() -> None: validation = Path(__file__).parents[2] / "validation" correctness = json.loads((validation / "REAL_STRATEGY_RESULTS.json").read_text()) report = json.loads((validation / "REAL_STRATEGY_PERFORMANCE.json").read_text()) + report["provenance"]["ml4t_engine_source_sha256"] = "0" * 64 + report["provenance"]["certified_equivalent_sources"] = [] + benchmark.certify_source( + report, + correctness, + validation / "REAL_STRATEGY_RESULTS.json", + "Test a later source with unchanged measured behavior", + ) for field in ("reason", "correctness_evidence_sha256", "correctness_pairs_passed"): changed = copy.deepcopy(report) diff --git a/tests/futures/test_funding.py b/tests/futures/test_funding.py new file mode 100644 index 00000000..a573f3b2 --- /dev/null +++ b/tests/futures/test_funding.py @@ -0,0 +1,291 @@ +"""Funding cash flows through a complete perpetual-futures backtest.""" + +import json +from datetime import datetime, timedelta + +import polars as pl +import pytest + +from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy +from ml4t.backtest.broker import Broker +from ml4t.backtest.config import DataFrequency +from ml4t.backtest.funding import FundingEvent +from ml4t.backtest.result import ArtifactReadError, BacktestResult +from ml4t.backtest.types import AssetClass, ContractSpec, ExecutionMode, OrderSide, Position + + +def _prices() -> pl.DataFrame: + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(3)] + values = [100.0, 110.0, 110.0] + return pl.DataFrame( + { + "timestamp": dates, + "asset": ["BTC-PERP"] * len(dates), + "open": values, + "high": values, + "low": values, + "close": values, + "volume": [1000] * len(dates), + } + ) + + +def _run(quantity: int, funding: pl.DataFrame | None) -> BacktestResult: + class Hold(Strategy): + def on_data(self, timestamp, data, context, broker): + if timestamp == datetime(2024, 1, 1): + side = OrderSide.BUY if quantity > 0 else OrderSide.SELL + broker.submit_order("BTC-PERP", abs(quantity), side) + + return Engine( + feed=DataFeed(prices_df=_prices()), + strategy=Hold(), + config=BacktestConfig( + initial_cash=1000, + execution_mode=ExecutionMode.SAME_BAR, + allow_short_selling=True, + allow_leverage=True, + ), + contract_specs={ + "BTC-PERP": ContractSpec( + symbol="BTC-PERP", asset_class=AssetClass.FUTURE, multiplier=2.0 + ) + }, + funding_df=funding, + ).run() + + +@pytest.mark.parametrize("quantity,expected", [(1, -2.2), (-1, 2.2)]) +def test_funding_uses_held_position_and_current_causal_mark(quantity, expected, tmp_path): + funding = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 2)], + "asset": ["BTC-PERP"], + "rate": [0.01], + } + ) + baseline = _run(quantity, None) + result = _run(quantity, funding) + + payments = result.to_funding_dataframe() + assert payments.height == 1 + payment = payments.row(0, named=True) + assert payment["quantity"] == quantity + assert payment["mark_price"] == 110.0 + assert payment["multiplier"] == 2.0 + assert payment["cash_delta"] == pytest.approx(expected) + assert result.metrics["total_funding"] == pytest.approx(expected) + assert result.metrics["num_funding_events"] == 1 + assert result.metrics["final_value"] - baseline.metrics["final_value"] == pytest.approx( + expected + ) + assert result.to_portfolio_state_dataframe()["cash"][ + -1 + ] - baseline.to_portfolio_state_dataframe()["cash"][-1] == pytest.approx(expected) + assert [trade.pnl for trade in result.trades] == [trade.pnl for trade in baseline.trades] + assert result.metrics["total_costs"] == baseline.metrics["total_costs"] + assert len(result.fills) == len(baseline.fills) == 1 + + result.to_parquet(tmp_path) + restored = BacktestResult.from_parquet(tmp_path) + assert restored.to_funding_dataframe().equals(payments) + + +def test_position_opened_at_funding_timestamp_does_not_pay(): + class OpenAtFunding(Strategy): + def on_data(self, timestamp, data, context, broker): + if timestamp == datetime(2024, 1, 2): + broker.submit_order("BTC-PERP", 1) + + funding = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 2)], + "asset": ["BTC-PERP"], + "rate": [0.01], + } + ) + result = Engine( + feed=DataFeed(prices_df=_prices()), + strategy=OpenAtFunding(), + config=BacktestConfig(initial_cash=1000, execution_mode=ExecutionMode.SAME_BAR), + funding_df=funding, + ).run() + assert result.to_funding_dataframe()["cash_delta"].to_list() == [0.0] + assert len(result.fills) == 1 + + +@pytest.mark.parametrize( + "rows", + [ + [(datetime(2024, 1, 2), "BTC-PERP", 0.01)] * 2, + [(datetime(2024, 1, 4), "BTC-PERP", 0.01)], + [(datetime(2024, 1, 2), "BTC-PERP", float("nan"))], + ], +) +def test_invalid_funding_inputs_fail_before_the_run(rows): + funding = pl.DataFrame(rows, schema=["timestamp", "asset", "rate"], orient="row") + with pytest.raises(ValueError, match="funding"): + _run(1, funding) + + +def test_amount_per_unit_is_account_currency_per_underlying_unit(): + funding = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 2)], + "asset": ["BTC-PERP"], + "amount_per_unit": [0.5], + } + ) + baseline = _run(1, None) + result = _run(1, funding) + payment = result.to_funding_dataframe().row(0, named=True) + assert payment["cash_delta"] == -1.0 # 1 contract x 2 underlying units x $0.50 + assert result.metrics["final_value"] - baseline.metrics["final_value"] == -1.0 + + +def test_multiple_funding_payments_are_atomic_if_a_mark_is_missing(): + broker = Broker(initial_cash=1000) + timestamp = datetime(2024, 1, 2) + broker.positions["A"] = Position("A", 1.0, 100.0, datetime(2024, 1, 1)) + broker.positions["B"] = Position("B", 1.0, 100.0, datetime(2024, 1, 1)) + broker._last_prices["A"] = 100.0 + before_cash = broker.cash + events = [FundingEvent(timestamp, "A", rate=0.01), FundingEvent(timestamp, "B", rate=0.01)] + + with pytest.raises(ValueError, match="funding mark"): + broker._apply_funding(events) + + assert broker.cash == before_cash + assert broker.account._lock_notional_free_cash == before_cash + + +def test_funding_artifact_rejects_missing_cash_flow_component(tmp_path): + funding = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 2)], + "asset": ["BTC-PERP"], + "rate": [0.01], + } + ) + _run(1, funding).to_parquet(tmp_path) + manifest_path = tmp_path / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["components"].pop("funding") + manifest_path.write_text(json.dumps(manifest)) + + with pytest.raises(ArtifactReadError, match="funding.parquet is required"): + BacktestResult.from_parquet(tmp_path) + recovered = BacktestResult.from_parquet(tmp_path, recovery=True) + assert any( + item.code == "component_missing" and item.component == "funding" + for item in recovered.artifact_diagnostics + ) + + +@pytest.mark.parametrize( + "metric,value", + [("num_funding_events", 2), ("total_funding", 0.0)], +) +def test_funding_artifact_rejects_inconsistent_metrics(tmp_path, metric, value): + funding = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 2)], + "asset": ["BTC-PERP"], + "rate": [0.01], + } + ) + _run(1, funding).to_parquet(tmp_path) + metrics_path = tmp_path / "metrics.json" + metrics = json.loads(metrics_path.read_text()) + metrics[metric] = value + metrics_path.write_text(json.dumps(metrics)) + + with pytest.raises(ArtifactReadError, match="funding.parquet is inconsistent"): + BacktestResult.from_parquet(tmp_path) + recovered = BacktestResult.from_parquet(tmp_path, recovery=True) + assert any( + item.code == "component_inconsistent" and item.component == "funding" + for item in recovered.artifact_diagnostics + ) + + +def test_funding_event_on_a_filtered_session_is_rejected_before_cash_changes(): + dates = [datetime(2024, 1, 5), datetime(2024, 1, 6), datetime(2024, 1, 8)] + prices = pl.DataFrame( + { + "timestamp": dates, + "asset": ["BTC-PERP"] * len(dates), + "open": [100.0] * len(dates), + "high": [100.0] * len(dates), + "low": [100.0] * len(dates), + "close": [100.0] * len(dates), + "volume": [1000] * len(dates), + } + ) + funding = pl.DataFrame({"timestamp": [dates[1]], "asset": ["BTC-PERP"], "rate": [0.01]}) + + class Noop(Strategy): + def on_data(self, timestamp, data, context, broker): + pass + + engine = Engine( + feed=DataFeed(prices_df=prices), + strategy=Noop(), + config=BacktestConfig( + initial_cash=1000, + calendar="NYSE", + enforce_sessions=True, + data_frequency=DataFrequency.DAILY, + ), + funding_df=funding, + ) + + with pytest.raises(ValueError, match="excluded by session filtering"): + engine.run() + assert engine.broker.cash == 1000.0 + + +@pytest.mark.parametrize("rate,amount", [(None, None), (0.01, 0.5)]) +def test_funding_event_requires_one_payment_convention(rate, amount): + funding = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 2)], + "asset": ["BTC-PERP"], + "rate": [rate], + "amount_per_unit": [amount], + } + ) + with pytest.raises(ValueError, match="exactly one"): + _run(1, funding) + + +def test_funding_without_same_time_asset_bar_uses_last_prior_mark(): + first = datetime(2024, 1, 1) + event_time = datetime(2024, 1, 2) + last = datetime(2024, 1, 3) + prices = pl.DataFrame( + { + "timestamp": [first, first, event_time, last, last], + "asset": ["BTC-PERP", "OTHER", "OTHER", "BTC-PERP", "OTHER"], + "open": [100.0, 1.0, 1.0, 120.0, 1.0], + "high": [100.0, 1.0, 1.0, 120.0, 1.0], + "low": [100.0, 1.0, 1.0, 120.0, 1.0], + "close": [100.0, 1.0, 1.0, 120.0, 1.0], + "volume": [1000] * 5, + } + ) + + class Hold(Strategy): + def on_data(self, timestamp, data, context, broker): + if timestamp == first: + broker.submit_order("BTC-PERP", 1) + + result = Engine( + feed=DataFeed(prices_df=prices), + strategy=Hold(), + config=BacktestConfig(initial_cash=1000, execution_mode=ExecutionMode.SAME_BAR), + funding_df=pl.DataFrame({"timestamp": [event_time], "asset": ["BTC-PERP"], "rate": [0.01]}), + ).run() + payment = result.to_funding_dataframe().row(0, named=True) + assert payment["mark_price"] == 100.0 + assert payment["cash_delta"] == -1.0 diff --git a/tests/helpers/invariants.py b/tests/helpers/invariants.py index a314ee5c..9c8b03ce 100644 --- a/tests/helpers/invariants.py +++ b/tests/helpers/invariants.py @@ -78,7 +78,7 @@ def _check_equity_terminal( initial_cash: float, realized_trades: list, ) -> None: - """Verify: initial_cash + sum(realized_pnl) + sum(open_pnl) ≈ final_value.""" + """Verify: initial cash plus trading P&L and funding equals final equity.""" if not result.equity_curve: return @@ -86,12 +86,22 @@ def _check_equity_terminal( realized_pnl = sum(t.pnl for t in realized_trades) open_trades = [t for t in result.trades if t.status == "open"] open_pnl = sum(t.pnl for t in open_trades) - - expected = initial_cash + realized_pnl + open_pnl + funding = sum(payment.cash_delta for payment in result.funding_payments) + if "total_funding" in result.metrics: + reported_funding = result.metrics["total_funding"] + assert math.isclose( + reported_funding, + funding, + abs_tol=_accounting_tolerance(reported_funding, funding), + ), "total_funding does not reconcile with funding payments" + if "num_funding_events" in result.metrics: + assert result.metrics["num_funding_events"] == len(result.funding_payments) + + expected = initial_cash + realized_pnl + open_pnl + funding diff = abs(expected - final_value) reported_trades = [*realized_trades, *open_trades] - terms = [initial_cash, *(t.pnl for t in reported_trades)] + terms = [initial_cash, *(t.pnl for t in reported_trades), funding] notionals = [abs(t.quantity) * t.exit_price * t.multiplier for t in reported_trades] tol = _accounting_tolerance( expected, @@ -104,7 +114,8 @@ def _check_equity_terminal( assert diff <= tol, ( f"Equity terminal invariant violated: " f"initial_cash({initial_cash}) + realized_pnl({realized_pnl:.6f}) + " - f"open_pnl({open_pnl:.6f}) = {expected:.6f} != final_value({final_value:.6f}), " + f"open_pnl({open_pnl:.6f}) + funding({funding:.6f}) = {expected:.6f} " + f"!= final_value({final_value:.6f}), " f"diff={diff:.10f}, tol={tol:.6f}" ) diff --git a/tests/risk/test_portfolio_limits.py b/tests/risk/test_portfolio_limits.py index d363db29..a597aee8 100644 --- a/tests/risk/test_portfolio_limits.py +++ b/tests/risk/test_portfolio_limits.py @@ -1,6 +1,7 @@ """Tests for portfolio-level risk limits.""" import numpy as np +import pytest from ml4t.backtest.risk.portfolio.limits import ( BetaLimit, @@ -158,6 +159,36 @@ def test_warn_threshold(self): assert result.action == "warn" +@pytest.mark.parametrize("fraction", [0.0, -0.1, 1.1, float("nan"), float("inf")]) +def test_drawdown_reduction_rejects_invalid_fraction(fraction): + with pytest.raises(ValueError, match="reduction_pct"): + MaxDrawdownLimit(action="reduce", reduction_pct=fraction) + + +def test_drawdown_reduction_reports_explicit_fraction(): + limit = MaxDrawdownLimit(max_drawdown=0.10, action="reduce", reduction_pct=0.5) + state = PortfolioState( + equity=80.0, + initial_equity=100.0, + high_water_mark=100.0, + current_drawdown=0.20, + num_positions=1, + positions={"A": 80.0}, + daily_pnl=-20.0, + gross_exposure=80.0, + net_exposure=80.0, + ) + result = limit.check(state) + assert result.action == "reduce" + assert result.reduction_pct == 0.5 + + +@pytest.mark.parametrize("limit_type", [DailyLossLimit, VaRLimit, CVaRLimit, BetaLimit]) +def test_other_limits_reject_unimplemented_reduction(limit_type): + with pytest.raises(ValueError, match="does not support"): + limit_type(action="reduce") + + class TestMaxPositionsLimit: """Test MaxPositionsLimit.""" @@ -669,17 +700,9 @@ def test_cvar_greater_than_var(self): # CVaR should be >= VaR assert cvar >= var - def test_reduce_action(self): - """Test reduce action.""" - limit = CVaRLimit(threshold=0.02, confidence_level=0.95, lookback_days=20, action="reduce") - np.random.seed(42) - returns = np.random.normal(0.0, 0.05, 30) # High vol - returns[0:3] = [-0.15, -0.12, -0.14] - - state = self._make_state(returns) - result = limit.check(state) - assert result.breached - assert result.action == "reduce" + def test_reduce_action_is_rejected_when_no_fraction_contract_exists(self): + with pytest.raises(ValueError, match="does not support"): + CVaRLimit(action="reduce") class TestPortfolioStateContext: diff --git a/tests/risk/test_portfolio_manager.py b/tests/risk/test_portfolio_manager.py index 3418ae60..73f378a6 100644 --- a/tests/risk/test_portfolio_manager.py +++ b/tests/risk/test_portfolio_manager.py @@ -1,10 +1,11 @@ """Tests for RiskManager portfolio-level risk management.""" -from datetime import datetime +from datetime import datetime, timedelta +import polars as pl import pytest -from ml4t.backtest import BacktestResult +from ml4t.backtest import BacktestConfig, BacktestResult, DataFeed, Engine, Strategy from ml4t.backtest.broker import Broker from ml4t.backtest.models import NoCommission, NoSlippage from ml4t.backtest.risk.portfolio.limits import ( @@ -402,6 +403,99 @@ def test_get_state(self): class TestRiskManagerIntegration: """Integration tests for RiskManager.""" + def test_drawdown_reduction_fills_once_in_complete_backtest(self): + class ReducingStrategy(Strategy): + def on_start(self, broker): + self.manager = RiskManager( + limits=[ + MaxDrawdownLimit( + max_drawdown=0.10, + action="reduce", + reduction_pct=0.5, + ) + ] + ) + self.manager.initialize(initial_equity=broker.get_account_value()) + self.breaches = [] + + def on_data(self, timestamp, data, context, broker): + position = broker.get_position("A") + results = self.manager.update( + equity=broker.get_account_value(), + positions={"A": position.market_value} if position is not None else {}, + timestamp=timestamp, + broker=broker, + ) + self.breaches.extend(result for result in results if result.action == "reduce") + if timestamp == datetime(2024, 1, 1): + broker.submit_order("A", 10) + + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(5)] + prices = pl.DataFrame( + { + "timestamp": dates, + "asset": ["A"] * 5, + "open": [100.0, 100.0, 80.0, 80.0, 80.0], + "high": [100.0, 100.0, 80.0, 80.0, 80.0], + "low": [100.0, 100.0, 80.0, 80.0, 80.0], + "close": [100.0, 100.0, 80.0, 80.0, 80.0], + "volume": [1000] * 5, + } + ) + strategy = ReducingStrategy() + result = Engine( + feed=DataFeed(prices_df=prices), + strategy=strategy, + config=BacktestConfig(initial_cash=1000), + ).run() + + fills = result.to_fills_dataframe() + assert fills.height == 2 + assert fills["quantity"].to_list() == [10.0, 5.0] + assert fills["exit_reason_detail"].to_list()[1] == ( + "risk reduction: drawdown 20.0% >= 10.0%" + ) + assert all(breach.reduction_pct == 0.5 for breach in strategy.breaches) + final = result.to_portfolio_state_dataframe().tail(1).row(0, named=True) + assert final["gross_exposure"] == 400.0 + assert final["cash"] == 400.0 + + def test_new_breach_after_recovery_reduces_again(self): + manager = RiskManager( + limits=[MaxDrawdownLimit(max_drawdown=0.10, action="reduce", reduction_pct=0.5)] + ) + manager.initialize(initial_equity=100000.0) + broker = Broker( + initial_cash=100000.0, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + ) + open_long_position(broker, "AAPL", 100.0, 150.0) + mark_prices(broker, {"AAPL": 150.0}) + + manager.update(equity=80000.0, positions={"AAPL": 15000.0}, broker=broker) + manager.update(equity=79000.0, positions={"AAPL": 15000.0}, broker=broker) + assert len(broker.get_pending_orders()) == 1 + broker._process_orders() + assert broker.positions["AAPL"].quantity == 50.0 + + manager.update(equity=100000.0, positions={"AAPL": 7500.0}, broker=broker) + manager.update(equity=80000.0, positions={"AAPL": 7500.0}, broker=broker) + assert len(broker.get_pending_orders()) == 1 + assert broker.get_pending_orders()[0].quantity == 25.0 + + def test_reduction_without_broker_does_not_change_manager_state(self): + manager = RiskManager( + limits=[MaxDrawdownLimit(max_drawdown=0.10, action="reduce", reduction_pct=0.5)] + ) + manager.initialize(initial_equity=100.0) + + with pytest.raises(ValueError, match="broker"): + manager.update(equity=80.0, positions={"A": 80.0}) + + assert manager.current_drawdown == 0.0 + assert not manager.is_halted + def test_full_workflow(self): """Test complete RiskManager workflow.""" limits = [ diff --git a/tests/test_broker.py b/tests/test_broker.py index 7b56f478..a14e8d21 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -2681,6 +2681,44 @@ def test_rebalance_closes_positions_not_in_target(self, broker_with_position): class TestP1PositionModification: """Test P1 position modification methods (reduce_position, buy, sell).""" + def test_reduce_all_positions_covers_short_with_risk_reason(self, broker): + broker.positions["AAPL"] = Position( + asset="AAPL", + quantity=-100.0, + entry_price=150.0, + entry_time=datetime(2024, 1, 1, 9, 30), + ) + mark_prices(broker, {"AAPL": 140.0}) + + orders = broker.reduce_all_positions(0.25, "drawdown exceeded") + + assert len(orders) == 1 + assert orders[0].side is OrderSide.BUY + assert orders[0].quantity == 25.0 + assert orders[0]._risk_exit_reason == "risk reduction: drawdown exceeded" + broker._process_orders() + assert broker.positions["AAPL"].quantity == -75.0 + + def test_reduce_all_positions_restores_pending_orders_on_failure( + self, broker_with_position, monkeypatch + ): + broker = broker_with_position + mark_prices(broker, {"AAPL": 150.0}) + pending = broker.submit_order("AAPL", 10) + assert pending is not None + before_count = len(broker.orders) + + def fail_reduction(*args, **kwargs): + raise RuntimeError("reduction failed") + + monkeypatch.setattr(broker, "submit_order", fail_reduction) + with pytest.raises(RuntimeError, match="reduction failed"): + broker.reduce_all_positions(0.5, "drawdown exceeded") + + assert pending.status is OrderStatus.PENDING + assert broker.get_pending_orders() == [pending] + assert len(broker.orders) == before_count + def test_reduce_position_half(self, broker_with_position): """Test reduce_position sells half of a long position.""" broker = broker_with_position diff --git a/tests/test_commission_flip.py b/tests/test_commission_flip.py new file mode 100644 index 00000000..8c6c0e57 --- /dev/null +++ b/tests/test_commission_flip.py @@ -0,0 +1,91 @@ +"""End-to-end commission accounting for a one-order position flip.""" + +from datetime import datetime, timedelta + +import polars as pl + +from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy +from ml4t.backtest.types import OrderSide + + +class CountingTicketFee: + def __init__(self): + self.calls = [] + + def calculate(self, asset, quantity, price): + self.calls.append((asset, quantity, price)) + return 1.0 + + +def test_flat_ticket_fee_is_charged_once_for_one_flip_order(): + class Flip(Strategy): + def on_data(self, timestamp, data, context, broker): + if timestamp == datetime(2024, 1, 1): + broker.submit_order("A", 100, OrderSide.BUY) + elif timestamp == datetime(2024, 1, 3): + broker.submit_order("A", 200, OrderSide.SELL) + + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(5)] + prices = pl.DataFrame( + { + "timestamp": dates, + "asset": ["A"] * len(dates), + "open": [10.0] * len(dates), + "high": [10.0] * len(dates), + "low": [10.0] * len(dates), + "close": [10.0] * len(dates), + "volume": [10000] * len(dates), + } + ) + engine = Engine( + feed=DataFeed(prices_df=prices), + strategy=Flip(), + config=BacktestConfig( + initial_cash=10000, + allow_short_selling=True, + allow_leverage=True, + next_bar_submission_precheck=True, + ), + ) + model = CountingTicketFee() + engine.broker.commission_model = model + result = engine.run() + + assert len(engine.broker.orders) == 2 + assert [fill.commission for fill in result.fills] == [1.0, 1.0] + assert [quantity for _, quantity, _ in model.calls] == [100.0, 200.0] + assert result.to_portfolio_state_dataframe()["cash"][-1] == 10998.0 + assert result.trades[0].fees + result.trades[1].fees == 2.0 + + +def test_rejected_submission_does_not_advance_commission_model(): + class OversizedOrder(Strategy): + def on_data(self, timestamp, data, context, broker): + if timestamp == datetime(2024, 1, 1): + broker.submit_order("A", 1000) + + dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(3)] + prices = pl.DataFrame( + { + "timestamp": dates, + "asset": ["A"] * len(dates), + "open": [10.0] * len(dates), + "high": [10.0] * len(dates), + "low": [10.0] * len(dates), + "close": [10.0] * len(dates), + "volume": [10000] * len(dates), + } + ) + engine = Engine( + feed=DataFeed(prices_df=prices), + strategy=OversizedOrder(), + config=BacktestConfig(initial_cash=1000, next_bar_submission_precheck=True), + ) + model = CountingTicketFee() + engine.broker.commission_model = model + result = engine.run() + + assert not result.fills + assert len(result.rejected_orders) == 1 + assert model.calls == [] + assert result.to_portfolio_state_dataframe()["cash"][-1] == 1000.0 diff --git a/tests/test_result.py b/tests/test_result.py index 63126e51..0d331b5a 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -8,12 +8,14 @@ from datetime import datetime, timedelta from pathlib import Path from types import SimpleNamespace +from zoneinfo import ZoneInfo import numpy as np import polars as pl import pytest from ml4t.specs.market_data import FeedSpec +from ml4t.backtest import DataFeed, Engine, Strategy from ml4t.backtest.config import BacktestConfig from ml4t.backtest.result import ( ArtifactIncompleteError, @@ -1526,3 +1528,44 @@ def test_without_the_reconciliation_the_join_still_raises(self, monkeypatch): enrich_trades_with_signals( self._trades(None), self._signals("UTC"), signal_columns=["momentum"] ) + + +@pytest.mark.parametrize("zone", ["UTC", "America/New_York", None]) +def test_result_exports_preserve_feed_timestamp_timezone(zone): + """Execution records join to the feed on the same timestamp and asset.""" + tz = ZoneInfo(zone) if zone is not None else None + dates = [datetime(2024, 1, 1, tzinfo=tz) + timedelta(days=i) for i in range(5)] + prices = pl.DataFrame( + { + "timestamp": dates, + "asset": ["A"] * len(dates), + "open": [100.0] * len(dates), + "high": [100.0] * len(dates), + "low": [100.0] * len(dates), + "close": [100.0] * len(dates), + "volume": [1000] * len(dates), + } + ) + + class RoundTrip(Strategy): + def on_data(self, timestamp, data, context, broker): + if timestamp == dates[0]: + broker.submit_order("A", 1) + elif timestamp == dates[2]: + broker.close_position("A") + + result = Engine( + feed=DataFeed(prices_df=prices), + strategy=RoundTrip(), + config=BacktestConfig(initial_cash=1000), + ).run() + feed_dtype = prices.schema["timestamp"] + fills = result.to_fills_dataframe() + assert fills.height == 2 + assert fills.schema["timestamp"] == feed_dtype + assert fills.join(prices.select("timestamp", "asset"), on=["timestamp", "asset"]).height == 2 + assert result.to_trades_dataframe().schema["entry_time"] == feed_dtype + assert result.to_trades_dataframe().schema["exit_time"] == feed_dtype + assert result.to_equity_dataframe().schema["timestamp"] == feed_dtype + assert result.to_portfolio_state_dataframe().schema["timestamp"] == feed_dtype + assert result.to_rejected_orders_dataframe().schema["timestamp"] == feed_dtype diff --git a/tests/test_session_decisions.py b/tests/test_session_decisions.py new file mode 100644 index 00000000..6657c6d9 --- /dev/null +++ b/tests/test_session_decisions.py @@ -0,0 +1,158 @@ +"""Cross-sectional decisions over daily bars with different real close times.""" + +from datetime import date, datetime, timedelta + +import polars as pl +import pytest + +from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy +from ml4t.backtest.execution.rebalancer import TargetWeightExecutor +from ml4t.backtest.types import ExecutionMode, OrderType + + +def _prices(days: int = 3) -> pl.DataFrame: + rows = [] + for day in range(days): + session = date(2024, 1, 2) + timedelta(days=day) + for asset, minute in (("ES", 0), ("ZC", 15)): + rows.append( + { + "timestamp": datetime(session.year, session.month, session.day, 21, minute), + "session_date": session, + "asset": asset, + "close": 100.0, + "open": 100.0, + } + ) + return pl.DataFrame(rows) + + +class _CrossSection(Strategy): + def __init__(self) -> None: + self.events: list[tuple[datetime, set[str]]] = [] + self.executor = TargetWeightExecutor() + + def on_data(self, timestamp, data, context, broker): + self.events.append((timestamp, set(data))) + self.executor.execute(dict.fromkeys(data, 0.5), data, broker) + + +def test_exact_timestamp_mode_reproduces_partial_cross_section(): + strategy = _CrossSection() + engine = Engine( + feed=DataFeed(prices_df=_prices(1)), + strategy=strategy, + config=BacktestConfig(execution_mode=ExecutionMode.SAME_BAR), + ) + engine.run() + + assert [assets for _, assets in strategy.events] == [{"ES"}, {"ZC"}] + assert any(order.asset == "ES" for order in engine.broker.get_pending_orders()) + + +def test_explicit_session_date_combines_decision_and_defers_each_fill(): + strategy = _CrossSection() + engine = Engine( + feed=DataFeed(prices_df=_prices(), session_col="session_date"), + strategy=strategy, + config=BacktestConfig(execution_mode=ExecutionMode.NEXT_BAR), + ) + result = engine.run() + + assert strategy.events == [(datetime(2024, 1, day, 21, 15), {"ES", "ZC"}) for day in (2, 3, 4)] + assert set(engine.broker.get_positions()) == {"ES", "ZC"} + fills = result.to_fills_dataframe() + assert fills.height == 2 + assert set(fills["timestamp"].to_list()) == { + datetime(2024, 1, 3, 21), + datetime(2024, 1, 3, 21, 15), + } + + +def test_session_mode_rejects_same_bar_execution_before_run(): + feed = DataFeed(prices_df=_prices(), session_col="session_date") + with pytest.raises(ValueError, match="NEXT_BAR"): + Engine( + feed=feed, + strategy=_CrossSection(), + config=BacktestConfig(execution_mode=ExecutionMode.SAME_BAR), + ) + + +def test_missing_or_duplicate_session_bars_fail_before_strategy_runs(): + prices = _prices() + without_zc = prices.filter( + ~((pl.col("session_date") == date(2024, 1, 3)) & (pl.col("asset") == "ZC")) + ) + with pytest.raises(ValueError, match="missing bars.*ZC"): + DataFeed(prices_df=without_zc, session_col="session_date") + + with pytest.raises(ValueError, match="duplicate bar"): + DataFeed(prices_df=pl.concat([prices, prices.head(1)]), session_col="session_date") + + +def test_mixed_sessions_at_one_timestamp_fail_before_strategy_runs(): + prices = ( + _prices(1) + .with_columns( + pl.when(pl.col("asset") == "ZC") + .then(pl.lit(date(2024, 1, 3))) + .otherwise(pl.col("session_date")) + .alias("session_date") + ) + .with_columns(pl.lit(datetime(2024, 1, 2, 21)).alias("timestamp")) + ) + with pytest.raises(ValueError, match="different sessions"): + DataFeed(prices_df=prices, session_col="session_date") + + +def test_session_decision_moc_waits_for_next_matching_asset_bar(): + class SubmitMoc(Strategy): + def on_data(self, timestamp, data, context, broker): + if timestamp.day == 2: + broker.submit_order("ZC", 1, order_type=OrderType.MOC) + + result = Engine( + feed=DataFeed(prices_df=_prices(), session_col="session_date"), + strategy=SubmitMoc(), + config=BacktestConfig(execution_mode=ExecutionMode.NEXT_BAR), + ).run() + assert result.to_fills_dataframe()["timestamp"].to_list() == [datetime(2024, 1, 3, 21, 15)] + + +def test_explicit_sessions_handle_holiday_and_dst_with_real_utc_closes(): + rows = [] + for session, hour in ( + (date(2024, 3, 8), 21), + (date(2024, 3, 11), 20), + (date(2024, 3, 29), 20), + (date(2024, 4, 1), 20), + ): + for asset, minute in (("ES", 0), ("ZC", 15)): + rows.append( + { + "timestamp": datetime(session.year, session.month, session.day, hour, minute), + "session_date": session, + "asset": asset, + "close": 100.0, + "open": 100.0, + } + ) + strategy = _CrossSection() + engine = Engine( + DataFeed(prices_df=pl.DataFrame(rows), session_col="session_date"), + strategy, + BacktestConfig( + execution_mode=ExecutionMode.NEXT_BAR, calendar="NYSE", enforce_sessions=True + ), + ) + result = engine.run() + assert [timestamp for timestamp, _ in strategy.events] == [ + datetime(2024, 3, 8, 21, 15), + datetime(2024, 3, 11, 20, 15), + datetime(2024, 4, 1, 20, 15), + ] + assert set(result.to_fills_dataframe()["timestamp"].to_list()) == { + datetime(2024, 3, 11, 20), + datetime(2024, 3, 11, 20, 15), + } diff --git a/validation/CORRECTNESS_RESULTS.json b/validation/CORRECTNESS_RESULTS.json index 95cc20ef..9c8f4ada 100644 --- a/validation/CORRECTNESS_RESULTS.json +++ b/validation/CORRECTNESS_RESULTS.json @@ -63,7 +63,7 @@ "version": "3.1.1" } }, - "generated_at": "2026-09-15T11:50:20.062846+00:00", + "generated_at": "2026-09-24T01:09:13.329860+00:00", "input_policy": { "float_quantum": "0.00000001", "rounding": "ROUND_HALF_EVEN", @@ -704,7 +704,7 @@ "scenario_id": "01" }, "detail": null, - "duration_seconds": 7.32623203197727, + "duration_seconds": 2.4247171190800145, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -1361,7 +1361,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -1374,7 +1374,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -2034,7 +2034,7 @@ "scenario_id": "02" }, "detail": null, - "duration_seconds": 5.35004635999212, + "duration_seconds": 2.42568556801416, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -2691,7 +2691,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -2704,7 +2704,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -2861,7 +2861,7 @@ "scenario_id": "03" }, "detail": null, - "duration_seconds": 4.616500789998099, + "duration_seconds": 2.5785939740017056, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -3009,7 +3009,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "3b0720d05e9e5869aa6f01432d17918e2db8c0d80810310e6c39c8bf82d1aee0", @@ -3022,7 +3022,7 @@ }, "input_digest": "02743db0d03a02ec414e579929e5e4729b5f99124ab2a41bb234adf93626f62a", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -3179,7 +3179,7 @@ "scenario_id": "04" }, "detail": null, - "duration_seconds": 4.99394488701364, + "duration_seconds": 2.407202239963226, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -3327,7 +3327,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "3b0720d05e9e5869aa6f01432d17918e2db8c0d80810310e6c39c8bf82d1aee0", @@ -3340,7 +3340,7 @@ }, "input_digest": "c013a16b8902a5599e53c27dae34849fcf327b4e89ec69d00c951903578f948a", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -4010,7 +4010,7 @@ "scenario_id": "05" }, "detail": null, - "duration_seconds": 5.018424712005071, + "duration_seconds": 2.4113599320407957, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -4671,7 +4671,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -4684,7 +4684,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -5354,7 +5354,7 @@ "scenario_id": "06" }, "detail": null, - "duration_seconds": 5.204034858994419, + "duration_seconds": 2.921618398046121, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -6015,7 +6015,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -6028,7 +6028,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -6688,7 +6688,7 @@ "scenario_id": "07" }, "detail": null, - "duration_seconds": 5.341969433997292, + "duration_seconds": 2.5871748520294204, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -7345,7 +7345,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -7358,7 +7358,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -8018,7 +8018,7 @@ "scenario_id": "08" }, "detail": null, - "duration_seconds": 4.823792911978671, + "duration_seconds": 2.438635880011134, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -8675,7 +8675,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -8688,7 +8688,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -8892,7 +8892,7 @@ "scenario_id": "09" }, "detail": null, - "duration_seconds": 5.289036353002302, + "duration_seconds": 2.4125146430451423, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -9093,7 +9093,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "647d9a4a1a219599288faf756e995322ffee249ad29d3fbb88d3b1859249421a", @@ -9106,7 +9106,7 @@ }, "input_digest": "0857d4181ff37765143cd13fb7b67ead75a163a9ece87f9d258bb436175f2568", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -9367,7 +9367,7 @@ "scenario_id": "10" }, "detail": null, - "duration_seconds": 5.359243611979764, + "duration_seconds": 2.48359670501668, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -9625,7 +9625,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "27979784dbb2f9dd6cf72c0d89ea956d49003ed83a57ed254cb6a2cbd234b6a5", @@ -9638,7 +9638,7 @@ }, "input_digest": "23496f457ab5fcdf8c02626db8af05c791278cc307dff08599f673c376e9116c", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -10298,7 +10298,7 @@ "scenario_id": "11" }, "detail": null, - "duration_seconds": 5.182704731996637, + "duration_seconds": 2.392537266947329, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -10955,7 +10955,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -10968,7 +10968,7 @@ }, "input_digest": "24a69cd2961c5f77332e19aa92432884a792bd64d1e045dac33f1922902e3ba3", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -11172,7 +11172,7 @@ "scenario_id": "12" }, "detail": null, - "duration_seconds": 5.404365098016569, + "duration_seconds": 2.7538477249909192, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -11373,7 +11373,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "15f4500f48791f0c67d7c57f2554d0689a35a6dd71e07a8117de510acf106770", @@ -11386,7 +11386,7 @@ }, "input_digest": "06b9362c086c33e65085233ecbe78d43de5c0f00950eb8b19d6df6e89ba76d3b", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -11533,7 +11533,7 @@ "scenario_id": "13" }, "detail": null, - "duration_seconds": 4.7869301559985615, + "duration_seconds": 2.384882851038128, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -11677,7 +11677,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "647d9a4a1a219599288faf756e995322ffee249ad29d3fbb88d3b1859249421a", @@ -11690,7 +11690,7 @@ }, "input_digest": "8948480a577fa0d6a769f0af879559553b872df5c12ebbdc621237db201f1d99", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -11837,7 +11837,7 @@ "scenario_id": "14" }, "detail": null, - "duration_seconds": 6.040454359987052, + "duration_seconds": 2.417551827034913, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -11981,7 +11981,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "647d9a4a1a219599288faf756e995322ffee249ad29d3fbb88d3b1859249421a", @@ -11994,7 +11994,7 @@ }, "input_digest": "ea9583aa3a0de9f4e92d22968932fd83ac3e7d6e8d8a3e4f689e59868390c7d2", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -12141,7 +12141,7 @@ "scenario_id": "15" }, "detail": null, - "duration_seconds": 5.495154478005134, + "duration_seconds": 2.3937213680474088, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -12285,7 +12285,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "647d9a4a1a219599288faf756e995322ffee249ad29d3fbb88d3b1859249421a", @@ -12298,7 +12298,7 @@ }, "input_digest": "a11960a1bd3f521902fe84af312597b4c7928f97c2dd5b0b37b898d5a00ce3e6", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -12901,7 +12901,7 @@ "scenario_id": "16" }, "detail": null, - "duration_seconds": 5.516873009008123, + "duration_seconds": 2.486586573999375, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -13501,7 +13501,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "647d9a4a1a219599288faf756e995322ffee249ad29d3fbb88d3b1859249421a", @@ -13514,7 +13514,7 @@ }, "input_digest": "ffd7241a6fbac9130b1d3b499285d0df2610825316987918d110592cbf847c7d", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -47804,7 +47804,7 @@ "scenario_id": "17" }, "detail": null, - "duration_seconds": 5.790067452995572, + "duration_seconds": 2.5340354420477524, "framework": "vectorbt_pro", "framework_result": { "capabilities": { @@ -82091,7 +82091,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -82104,7 +82104,7 @@ }, "input_digest": "4fb3be3cba6d7c832eeb1fd8c72523b5260ea67fa5ecf3bceeb8e21256e0f998", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -82764,7 +82764,7 @@ "scenario_id": "01" }, "detail": null, - "duration_seconds": 10.24261298100464, + "duration_seconds": 3.3456006130436435, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -83421,7 +83421,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -83434,7 +83434,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -84094,7 +84094,7 @@ "scenario_id": "02" }, "detail": null, - "duration_seconds": 8.035243109014118, + "duration_seconds": 3.4563437330070883, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -84751,7 +84751,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -84764,7 +84764,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -84921,7 +84921,7 @@ "scenario_id": "03" }, "detail": null, - "duration_seconds": 7.607576164009515, + "duration_seconds": 3.7107325709657744, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -85069,7 +85069,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "3b0720d05e9e5869aa6f01432d17918e2db8c0d80810310e6c39c8bf82d1aee0", @@ -85082,7 +85082,7 @@ }, "input_digest": "02743db0d03a02ec414e579929e5e4729b5f99124ab2a41bb234adf93626f62a", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -85239,7 +85239,7 @@ "scenario_id": "04" }, "detail": null, - "duration_seconds": 7.356586324021919, + "duration_seconds": 3.3568190570222214, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -85387,7 +85387,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "3b0720d05e9e5869aa6f01432d17918e2db8c0d80810310e6c39c8bf82d1aee0", @@ -85400,7 +85400,7 @@ }, "input_digest": "c013a16b8902a5599e53c27dae34849fcf327b4e89ec69d00c951903578f948a", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -86070,7 +86070,7 @@ "scenario_id": "05" }, "detail": null, - "duration_seconds": 7.073731406999286, + "duration_seconds": 3.4058225370245054, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -86731,7 +86731,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -86744,7 +86744,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -86774,7 +86774,7 @@ { "comparison": null, "detail": "Scenario explicitly excludes this framework", - "duration_seconds": 8.350005373358727e-06, + "duration_seconds": 5.058012902736664e-06, "framework": "vectorbt_oss", "framework_result": null, "ml4t_result": null, @@ -87418,7 +87418,7 @@ "scenario_id": "07" }, "detail": null, - "duration_seconds": 7.54126718401676, + "duration_seconds": 3.3862659139558673, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -88075,7 +88075,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -88088,7 +88088,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -88748,7 +88748,7 @@ "scenario_id": "08" }, "detail": null, - "duration_seconds": 7.565157022996573, + "duration_seconds": 3.4436761640245095, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -89405,7 +89405,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -89418,7 +89418,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -89622,7 +89622,7 @@ "scenario_id": "09" }, "detail": null, - "duration_seconds": 6.8318166859971825, + "duration_seconds": 3.4993690520059317, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -89823,7 +89823,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "b5511731b0b2768bf962b3ac1d81c21a82f96d04896692f85bef5e5030ce2b1c", @@ -89836,7 +89836,7 @@ }, "input_digest": "0857d4181ff37765143cd13fb7b67ead75a163a9ece87f9d258bb436175f2568", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -90097,7 +90097,7 @@ "scenario_id": "10" }, "detail": null, - "duration_seconds": 7.817633163998835, + "duration_seconds": 3.474260456976481, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -90355,7 +90355,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "27979784dbb2f9dd6cf72c0d89ea956d49003ed83a57ed254cb6a2cbd234b6a5", @@ -90368,7 +90368,7 @@ }, "input_digest": "23496f457ab5fcdf8c02626db8af05c791278cc307dff08599f673c376e9116c", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -91028,7 +91028,7 @@ "scenario_id": "11" }, "detail": null, - "duration_seconds": 7.033022508985596, + "duration_seconds": 3.4526096960762516, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -91685,7 +91685,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -91698,7 +91698,7 @@ }, "input_digest": "24a69cd2961c5f77332e19aa92432884a792bd64d1e045dac33f1922902e3ba3", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -91902,7 +91902,7 @@ "scenario_id": "12" }, "detail": null, - "duration_seconds": 7.187127559998771, + "duration_seconds": 4.293192793964408, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -92103,7 +92103,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "85ef0c7c62fc2cf1f841196fce585253cbfa5fe4d3beb0e6c204003994d7f28f", @@ -92116,7 +92116,7 @@ }, "input_digest": "06b9362c086c33e65085233ecbe78d43de5c0f00950eb8b19d6df6e89ba76d3b", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -92263,7 +92263,7 @@ "scenario_id": "13" }, "detail": null, - "duration_seconds": 8.938931696000509, + "duration_seconds": 3.6954419559333473, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -92407,7 +92407,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "b5511731b0b2768bf962b3ac1d81c21a82f96d04896692f85bef5e5030ce2b1c", @@ -92420,7 +92420,7 @@ }, "input_digest": "8948480a577fa0d6a769f0af879559553b872df5c12ebbdc621237db201f1d99", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -92567,7 +92567,7 @@ "scenario_id": "14" }, "detail": null, - "duration_seconds": 8.725699881004402, + "duration_seconds": 4.190676840022206, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -92711,7 +92711,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "b5511731b0b2768bf962b3ac1d81c21a82f96d04896692f85bef5e5030ce2b1c", @@ -92724,7 +92724,7 @@ }, "input_digest": "ea9583aa3a0de9f4e92d22968932fd83ac3e7d6e8d8a3e4f689e59868390c7d2", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -92871,7 +92871,7 @@ "scenario_id": "15" }, "detail": null, - "duration_seconds": 8.378973232000135, + "duration_seconds": 3.4919412350282073, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -93015,7 +93015,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "b5511731b0b2768bf962b3ac1d81c21a82f96d04896692f85bef5e5030ce2b1c", @@ -93028,7 +93028,7 @@ }, "input_digest": "a11960a1bd3f521902fe84af312597b4c7928f97c2dd5b0b37b898d5a00ce3e6", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -93631,7 +93631,7 @@ "scenario_id": "16" }, "detail": null, - "duration_seconds": 9.164283446007175, + "duration_seconds": 3.4726691340329126, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -94231,7 +94231,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "b5511731b0b2768bf962b3ac1d81c21a82f96d04896692f85bef5e5030ce2b1c", @@ -94244,7 +94244,7 @@ }, "input_digest": "ffd7241a6fbac9130b1d3b499285d0df2610825316987918d110592cbf847c7d", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -128534,7 +128534,7 @@ "scenario_id": "17" }, "detail": null, - "duration_seconds": 8.741845145006664, + "duration_seconds": 3.9416198300896212, "framework": "vectorbt_oss", "framework_result": { "capabilities": { @@ -162821,7 +162821,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -162834,7 +162834,7 @@ }, "input_digest": "4fb3be3cba6d7c832eeb1fd8c72523b5260ea67fa5ecf3bceeb8e21256e0f998", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -163514,7 +163514,7 @@ "scenario_id": "01" }, "detail": null, - "duration_seconds": 1.0193816569808405, + "duration_seconds": 0.26726809109095484, "framework": "backtrader", "framework_result": { "capabilities": { @@ -164193,7 +164193,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -164206,7 +164206,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -164886,7 +164886,7 @@ "scenario_id": "02" }, "detail": null, - "duration_seconds": 0.5241827529971488, + "duration_seconds": 0.3663969690678641, "framework": "backtrader", "framework_result": { "capabilities": { @@ -165565,7 +165565,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -165578,7 +165578,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -165736,7 +165736,7 @@ "scenario_id": "03" }, "detail": null, - "duration_seconds": 0.5492475189967081, + "duration_seconds": 0.2969786908943206, "framework": "backtrader", "framework_result": { "capabilities": { @@ -165885,7 +165885,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -165898,7 +165898,7 @@ }, "input_digest": "02743db0d03a02ec414e579929e5e4729b5f99124ab2a41bb234adf93626f62a", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -166056,7 +166056,7 @@ "scenario_id": "04" }, "detail": null, - "duration_seconds": 0.42870302198571153, + "duration_seconds": 0.26376285299193114, "framework": "backtrader", "framework_result": { "capabilities": { @@ -166205,7 +166205,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -166218,7 +166218,7 @@ }, "input_digest": "c013a16b8902a5599e53c27dae34849fcf327b4e89ec69d00c951903578f948a", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -166908,7 +166908,7 @@ "scenario_id": "05" }, "detail": null, - "duration_seconds": 0.5069007290003356, + "duration_seconds": 0.24508818506728858, "framework": "backtrader", "framework_result": { "capabilities": { @@ -167589,7 +167589,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -167602,7 +167602,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -168292,7 +168292,7 @@ "scenario_id": "06" }, "detail": null, - "duration_seconds": 0.5001841589983087, + "duration_seconds": 0.21455272403545678, "framework": "backtrader", "framework_result": { "capabilities": { @@ -168973,7 +168973,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -168986,7 +168986,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -169666,7 +169666,7 @@ "scenario_id": "07" }, "detail": null, - "duration_seconds": 0.4467000389995519, + "duration_seconds": 0.20543705392628908, "framework": "backtrader", "framework_result": { "capabilities": { @@ -170345,7 +170345,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -170358,7 +170358,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -171038,7 +171038,7 @@ "scenario_id": "08" }, "detail": null, - "duration_seconds": 0.38910984998801723, + "duration_seconds": 0.20719261898193508, "framework": "backtrader", "framework_result": { "capabilities": { @@ -171717,7 +171717,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -171730,7 +171730,7 @@ }, "input_digest": "465e20dbdd050a15410d58b7391e304491566407369632fbda07da88e4cc1cee", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -171936,7 +171936,7 @@ "scenario_id": "09" }, "detail": null, - "duration_seconds": 0.4832958269980736, + "duration_seconds": 0.2017657730029896, "framework": "backtrader", "framework_result": { "capabilities": { @@ -172139,7 +172139,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -172152,7 +172152,7 @@ }, "input_digest": "0857d4181ff37765143cd13fb7b67ead75a163a9ece87f9d258bb436175f2568", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -172416,7 +172416,7 @@ "scenario_id": "10" }, "detail": null, - "duration_seconds": 0.44575912499567494, + "duration_seconds": 0.19875362305901945, "framework": "backtrader", "framework_result": { "capabilities": { @@ -172677,7 +172677,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -172690,7 +172690,7 @@ }, "input_digest": "23496f457ab5fcdf8c02626db8af05c791278cc307dff08599f673c376e9116c", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -173360,7 +173360,7 @@ "scenario_id": "11" }, "detail": null, - "duration_seconds": 0.4717228980152868, + "duration_seconds": 0.21421630796976388, "framework": "backtrader", "framework_result": { "capabilities": { @@ -174027,7 +174027,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -174040,7 +174040,7 @@ }, "input_digest": "24a69cd2961c5f77332e19aa92432884a792bd64d1e045dac33f1922902e3ba3", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -174246,7 +174246,7 @@ "scenario_id": "12" }, "detail": null, - "duration_seconds": 0.5406882640090771, + "duration_seconds": 0.20441574498545378, "framework": "backtrader", "framework_result": { "capabilities": { @@ -174449,7 +174449,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "573f73f81275679129b0b0cda5fdf37aa7c8dadf009a5f5aca55fb7be9f9985e", @@ -174462,7 +174462,7 @@ }, "input_digest": "06b9362c086c33e65085233ecbe78d43de5c0f00950eb8b19d6df6e89ba76d3b", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -174610,7 +174610,7 @@ "scenario_id": "13" }, "detail": null, - "duration_seconds": 0.6709157639998011, + "duration_seconds": 0.1959168459288776, "framework": "backtrader", "framework_result": { "capabilities": { @@ -174755,7 +174755,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -174768,7 +174768,7 @@ }, "input_digest": "8948480a577fa0d6a769f0af879559553b872df5c12ebbdc621237db201f1d99", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -174916,7 +174916,7 @@ "scenario_id": "14" }, "detail": null, - "duration_seconds": 0.4571755039796699, + "duration_seconds": 0.1969940010458231, "framework": "backtrader", "framework_result": { "capabilities": { @@ -175061,7 +175061,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -175074,7 +175074,7 @@ }, "input_digest": "ea9583aa3a0de9f4e92d22968932fd83ac3e7d6e8d8a3e4f689e59868390c7d2", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -175222,7 +175222,7 @@ "scenario_id": "15" }, "detail": null, - "duration_seconds": 0.4268780780257657, + "duration_seconds": 0.193323764950037, "framework": "backtrader", "framework_result": { "capabilities": { @@ -175367,7 +175367,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -175380,7 +175380,7 @@ }, "input_digest": "a11960a1bd3f521902fe84af312597b4c7928f97c2dd5b0b37b898d5a00ce3e6", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -175992,7 +175992,7 @@ "scenario_id": "16" }, "detail": null, - "duration_seconds": 1.04536647698842, + "duration_seconds": 0.34429927996825427, "framework": "backtrader", "framework_result": { "capabilities": { @@ -176601,7 +176601,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "6a425065c946d387fafc9cb9978bb86d7754e617273669247cf1149fbd23cc03", @@ -176614,7 +176614,7 @@ }, "input_digest": "ffd7241a6fbac9130b1d3b499285d0df2610825316987918d110592cbf847c7d", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -212104,7 +212104,7 @@ "scenario_id": "17" }, "detail": null, - "duration_seconds": 1.5695829210162628, + "duration_seconds": 0.5432185269892216, "framework": "backtrader", "framework_result": { "capabilities": { @@ -247593,7 +247593,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -247606,7 +247606,7 @@ }, "input_digest": "4fb3be3cba6d7c832eeb1fd8c72523b5260ea67fa5ecf3bceeb8e21256e0f998", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -247981,7 +247981,7 @@ "scenario_id": "01" }, "detail": null, - "duration_seconds": 7.1238878989825025, + "duration_seconds": 2.3009805569890887, "framework": "zipline", "framework_result": { "capabilities": { @@ -248357,7 +248357,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -248370,7 +248370,7 @@ }, "input_digest": "1e10efa2535c6df89cf6723633fd6f36e07a7f3449632f4aebaa518c1afade05", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -248745,7 +248745,7 @@ "scenario_id": "02" }, "detail": null, - "duration_seconds": 4.496338641998591, + "duration_seconds": 2.2011894549941644, "framework": "zipline", "framework_result": { "capabilities": { @@ -249121,7 +249121,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -249134,7 +249134,7 @@ }, "input_digest": "1e10efa2535c6df89cf6723633fd6f36e07a7f3449632f4aebaa518c1afade05", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -249291,7 +249291,7 @@ "scenario_id": "03" }, "detail": null, - "duration_seconds": 4.278434530977393, + "duration_seconds": 2.1290805600583553, "framework": "zipline", "framework_result": { "capabilities": { @@ -249443,7 +249443,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "16de00f1f72fcdd0199f55a77aaf5fe9c2e99450ae25a5350bd854eebf3896f5", @@ -249456,7 +249456,7 @@ }, "input_digest": "2ade3aa0a65bc5315f2297bc8bbc19b45dfdb8db50e52d56fc85ecb4a82facdd", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -249613,7 +249613,7 @@ "scenario_id": "04" }, "detail": null, - "duration_seconds": 4.2948006659862585, + "duration_seconds": 2.340645650983788, "framework": "zipline", "framework_result": { "capabilities": { @@ -249765,7 +249765,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "16de00f1f72fcdd0199f55a77aaf5fe9c2e99450ae25a5350bd854eebf3896f5", @@ -249778,7 +249778,7 @@ }, "input_digest": "f576ca0b7380cdcf6eb6c99d5b8467e796d9811d6fb1cd66191df1688a948b62", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -250163,7 +250163,7 @@ "scenario_id": "05" }, "detail": null, - "duration_seconds": 4.659967012994457, + "duration_seconds": 2.2611100990325212, "framework": "zipline", "framework_result": { "capabilities": { @@ -250543,7 +250543,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -250556,7 +250556,7 @@ }, "input_digest": "1e10efa2535c6df89cf6723633fd6f36e07a7f3449632f4aebaa518c1afade05", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -250941,7 +250941,7 @@ "scenario_id": "06" }, "detail": null, - "duration_seconds": 5.038704253005562, + "duration_seconds": 2.198857027105987, "framework": "zipline", "framework_result": { "capabilities": { @@ -251321,7 +251321,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -251334,7 +251334,7 @@ }, "input_digest": "1e10efa2535c6df89cf6723633fd6f36e07a7f3449632f4aebaa518c1afade05", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -251709,7 +251709,7 @@ "scenario_id": "07" }, "detail": null, - "duration_seconds": 4.631009447999531, + "duration_seconds": 2.2210966009879485, "framework": "zipline", "framework_result": { "capabilities": { @@ -252085,7 +252085,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -252098,7 +252098,7 @@ }, "input_digest": "1e10efa2535c6df89cf6723633fd6f36e07a7f3449632f4aebaa518c1afade05", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -252473,7 +252473,7 @@ "scenario_id": "08" }, "detail": null, - "duration_seconds": 4.321471406990895, + "duration_seconds": 2.1789409660268575, "framework": "zipline", "framework_result": { "capabilities": { @@ -252849,7 +252849,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -252862,7 +252862,7 @@ }, "input_digest": "1e10efa2535c6df89cf6723633fd6f36e07a7f3449632f4aebaa518c1afade05", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -253066,7 +253066,7 @@ "scenario_id": "09" }, "detail": null, - "duration_seconds": 4.899360688985325, + "duration_seconds": 2.3980092089623213, "framework": "zipline", "framework_result": { "capabilities": { @@ -253271,7 +253271,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "94534fbfeca18ae041b780a18fbfe6884c21c0ce8c84d15d1d8e2096ba59cce7", @@ -253284,7 +253284,7 @@ }, "input_digest": "52803556b6f294e3ad1223585e297f6dab81add8ca8cf22db7ed227dc14fe627", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -253314,7 +253314,7 @@ { "comparison": null, "detail": "Scenario explicitly excludes this framework", - "duration_seconds": 7.2620168793946505e-06, + "duration_seconds": 3.7349527701735497e-06, "framework": "zipline", "framework_result": null, "ml4t_result": null, @@ -253673,7 +253673,7 @@ "scenario_id": "11" }, "detail": null, - "duration_seconds": 4.356207232020097, + "duration_seconds": 2.306697252090089, "framework": "zipline", "framework_result": { "capabilities": { @@ -254049,7 +254049,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "8901a4a7a750c26fb43ceb5d89b4c6ccf9279503da8659b8398cce30d90c8d9c", @@ -254062,7 +254062,7 @@ }, "input_digest": "8a8791a9d96df21d21b4fa14ebf111ea1417ec8424fd87ac9b4b79515ca6c952", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -254266,7 +254266,7 @@ "scenario_id": "12" }, "detail": null, - "duration_seconds": 5.5409411170112435, + "duration_seconds": 2.269118699012324, "framework": "zipline", "framework_result": { "capabilities": { @@ -254471,7 +254471,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "1d86423ea9c4ce2c32fc3b60b01566b4a0fe7f5629ee4cf2bf6899fd4d728f69", @@ -254484,7 +254484,7 @@ }, "input_digest": "ad5a0b9abf3e7e3ae35e06811443d816a29ac95b66e0bc7ce6b49a175f1e5fce", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -254631,7 +254631,7 @@ "scenario_id": "13" }, "detail": null, - "duration_seconds": 4.687048289983068, + "duration_seconds": 2.2137621820438653, "framework": "zipline", "framework_result": { "capabilities": { @@ -254779,7 +254779,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "94534fbfeca18ae041b780a18fbfe6884c21c0ce8c84d15d1d8e2096ba59cce7", @@ -254792,7 +254792,7 @@ }, "input_digest": "bc94952cbe0b0c493e4a5599ddcd00a92412a9c8c838ea8e5c02d84d217baf0c", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -254939,7 +254939,7 @@ "scenario_id": "14" }, "detail": null, - "duration_seconds": 4.336000664014136, + "duration_seconds": 2.3711717680562288, "framework": "zipline", "framework_result": { "capabilities": { @@ -255087,7 +255087,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "94534fbfeca18ae041b780a18fbfe6884c21c0ce8c84d15d1d8e2096ba59cce7", @@ -255100,7 +255100,7 @@ }, "input_digest": "a0bd681ff924f45192415219403b02e639df271a2aa5987bb9b1e263b2d8c88a", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -255247,7 +255247,7 @@ "scenario_id": "15" }, "detail": null, - "duration_seconds": 4.369437166984426, + "duration_seconds": 2.1742781409993768, "framework": "zipline", "framework_result": { "capabilities": { @@ -255395,7 +255395,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "94534fbfeca18ae041b780a18fbfe6884c21c0ce8c84d15d1d8e2096ba59cce7", @@ -255408,7 +255408,7 @@ }, "input_digest": "ce9cac417809758597595322703b30800145707fa0db44bd7f2cd0775790c114", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -255783,7 +255783,7 @@ "scenario_id": "16" }, "detail": null, - "duration_seconds": 6.151586705993395, + "duration_seconds": 2.765984929050319, "framework": "zipline", "framework_result": { "capabilities": { @@ -256159,7 +256159,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "94534fbfeca18ae041b780a18fbfe6884c21c0ce8c84d15d1d8e2096ba59cce7", @@ -256172,7 +256172,7 @@ }, "input_digest": "88cfc732983d3fa783e181fd4c97f8d95f7833e20dd03de0c201aaa85e83ca8b", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { @@ -275471,7 +275471,7 @@ "scenario_id": "17" }, "detail": null, - "duration_seconds": 6.819903054012684, + "duration_seconds": 3.0084816509624943, "framework": "zipline", "framework_result": { "capabilities": { @@ -294771,7 +294771,7 @@ "canonical_records": "fa1d9846bdc895cf62e6435097a8953d131ac16f6ae910d41ba81a5090c43707", "capabilities": "dd0f50965982db8aae711f850740fc1d26c5f09d8b7c3d24221b347a7e14c8aa", "comparator": "de6683dd96cab7fc9ce005f4651b7de359b8ddfcd6402b543960133ca35b0f5e", - "engine": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", + "engine": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "manifest": "38e64faa733ac8412b0a1cabec5357be229852f19b432f2f215a7bf20f3a2780", "ml4t_runner": "6e9753811241863f59fc6607f315237aa1caec673372ef3b3a85fc959cf426e0", "profile": "4d2cacb23e64dacdbb2eda228691671d3a5d327dbbdd899de310551b244bae54", @@ -294784,7 +294784,7 @@ }, "input_digest": "6fdd33a401576a2adde590f2f8d5986f5893e4663336033ba6c0f8f221d5d526", "ml4t": { - "commit": "5834980f2db61df223131bb0a657ce52fefe787e", + "commit": "fde7254403b60f4a556c5f7beabaf62274f1ae13", "dirty": false }, "python": { diff --git a/validation/METHODOLOGY.md b/validation/METHODOLOGY.md index ec928c5c..295c3128 100644 --- a/validation/METHODOLOGY.md +++ b/validation/METHODOLOGY.md @@ -1,6 +1,6 @@ # Validation Methodology -*Last updated: 2026-08-14* +*Last updated: 2026-09-23* ## Core Principle @@ -62,10 +62,10 @@ The `default` profile represents ml4t's own opinion on the most reasonable setti | Dimension | ml4t Default | Rationale | |-----------|-------------|-----------| | Execution | next_bar_open | No look-ahead bias | -| Costs | 0.1% commission + 0.1% slippage | Conservative but not punishing | +| Costs | No commission or slippage | Reproducible baseline; add explicit cost models for the venue | | Short selling | disabled | Require explicit opt-in | | Leverage | disabled | Require explicit opt-in | -| Share type | fractional | Simpler for research | +| Share type | integer | Default equity execution uses whole shares | | Fill ordering | exit_first | Capital-efficient | | Rebalance | incremental | Most accurate cash tracking | | Stop basis | fill_price | Based on actual execution | @@ -380,25 +380,25 @@ The table reports engine-call wall time for all 17 correctness-passing pairs. Th | Real strategy | Pinned framework | Framework median (95% CI), s | ML4T median (95% CI), s | Framework / ML4T median | |---|---|---:|---:|---:| -| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.287 (0.286-0.289) | 0.415 (0.414-0.419) | 0.692x | -| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.171 (0.171-0.174) | 0.415 (0.413-0.417) | 0.412x | -| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.355 (9.303-9.424) | 0.430 (0.426-0.432) | 21.762x | -| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.872 (3.867-3.910) | 0.632 (0.628-0.638) | 6.127x | -| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.564 (2.487-2.599) | 0.750 (0.749-0.757) | 3.418x | -| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.286 (0.284-0.289) | 0.432 (0.429-0.435) | 0.663x | -| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 2.495 (2.488-2.526) | 0.429 (0.427-0.433) | 5.812x | -| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.801 (2.709-2.891) | 0.657 (0.655-0.661) | 4.266x | -| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.283 (0.281-0.286) | 0.146 (0.145-0.147) | 1.939x | -| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.139 (0.138-0.141) | 0.146 (0.145-0.147) | 0.952x | -| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.425 (0.420-0.432) | 0.146 (0.145-0.147) | 2.920x | -| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.913 (0.877-0.953) | 0.153 (0.152-0.157) | 5.952x | -| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.795 (0.787-0.838) | 20.555 (20.486-20.600) | 0.039x | -| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 16.995 (16.960-17.024) | 20.495 (20.453-20.534) | 0.829x | -| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 495.553 (494.227-510.273) | 21.470 (21.386-21.543) | 23.082x | -| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 107.609 (106.834-109.150) | 22.132 (22.082-22.234) | 4.862x | -| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 47.683 (47.333-48.015) | 26.368 (26.222-26.473) | 1.808x | - -Measured 2026-09-03 on `Linux-6.8.0-138-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). +| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.293 (0.290-0.298) | 0.424 (0.421-0.434) | 0.691x | +| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.192 (0.176-0.493) | 0.414 (0.413-0.417) | 0.462x | +| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.631 (9.428-9.710) | 0.439 (0.434-0.444) | 21.954x | +| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.888 (3.878-3.959) | 0.621 (0.618-0.626) | 6.256x | +| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.638 (2.516-2.707) | 0.750 (0.743-0.757) | 3.520x | +| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.290 (0.289-0.294) | 0.410 (0.406-0.958) | 0.708x | +| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 6.639 (5.831-8.332) | 0.428 (0.422-1.235) | 15.504x | +| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.828 (2.737-2.952) | 1.327 (0.657-2.183) | 2.131x | +| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.288 (0.286-0.294) | 0.148 (0.147-0.149) | 1.947x | +| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.144 (0.142-0.146) | 0.145 (0.145-0.146) | 0.988x | +| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.434 (0.430-0.440) | 0.147 (0.146-0.149) | 2.947x | +| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.953 (0.936-1.164) | 0.158 (0.155-0.164) | 6.033x | +| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 3.806 (3.336-4.341) | 23.306 (21.906-25.833) | 0.163x | +| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 17.116 (17.069-17.633) | 25.738 (24.983-27.163) | 0.665x | +| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 668.611 (642.993-718.202) | 26.887 (26.406-29.406) | 24.868x | +| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 117.445 (116.308-118.391) | 26.821 (25.473-27.644) | 4.379x | +| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 48.436 (48.221-48.986) | 27.688 (27.575-27.796) | 1.749x | + +Measured 2026-09-24 on `Linux-6.8.0-139-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). ### Synthetic diagnostic scenarios diff --git a/validation/README.md b/validation/README.md index 6eac8d82..10bd5829 100644 --- a/validation/README.md +++ b/validation/README.md @@ -56,25 +56,25 @@ The table reports engine-call wall time for all 17 correctness-passing pairs. Th | Real strategy | Pinned framework | Framework median (95% CI), s | ML4T median (95% CI), s | Framework / ML4T median | |---|---|---:|---:|---:| -| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.287 (0.286-0.289) | 0.415 (0.414-0.419) | 0.692x | -| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.171 (0.171-0.174) | 0.415 (0.413-0.417) | 0.412x | -| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.355 (9.303-9.424) | 0.430 (0.426-0.432) | 21.762x | -| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.872 (3.867-3.910) | 0.632 (0.628-0.638) | 6.127x | -| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.564 (2.487-2.599) | 0.750 (0.749-0.757) | 3.418x | -| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.286 (0.284-0.289) | 0.432 (0.429-0.435) | 0.663x | -| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 2.495 (2.488-2.526) | 0.429 (0.427-0.433) | 5.812x | -| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.801 (2.709-2.891) | 0.657 (0.655-0.661) | 4.266x | -| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.283 (0.281-0.286) | 0.146 (0.145-0.147) | 1.939x | -| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.139 (0.138-0.141) | 0.146 (0.145-0.147) | 0.952x | -| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.425 (0.420-0.432) | 0.146 (0.145-0.147) | 2.920x | -| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.913 (0.877-0.953) | 0.153 (0.152-0.157) | 5.952x | -| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.795 (0.787-0.838) | 20.555 (20.486-20.600) | 0.039x | -| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 16.995 (16.960-17.024) | 20.495 (20.453-20.534) | 0.829x | -| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 495.553 (494.227-510.273) | 21.470 (21.386-21.543) | 23.082x | -| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 107.609 (106.834-109.150) | 22.132 (22.082-22.234) | 4.862x | -| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 47.683 (47.333-48.015) | 26.368 (26.222-26.473) | 1.808x | - -Measured 2026-09-03 on `Linux-6.8.0-138-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). +| ETF allocation | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.293 (0.290-0.298) | 0.424 (0.421-0.434) | 0.691x | +| ETF allocation | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.192 (0.176-0.493) | 0.414 (0.413-0.417) | 0.462x | +| ETF allocation | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 9.631 (9.428-9.710) | 0.439 (0.434-0.444) | 21.954x | +| ETF allocation | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 3.888 (3.878-3.959) | 0.621 (0.618-0.626) | 6.256x | +| ETF allocation | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.638 (2.516-2.707) | 0.750 (0.743-0.757) | 3.520x | +| CME futures | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.290 (0.289-0.294) | 0.410 (0.406-0.958) | 0.708x | +| CME futures | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 6.639 (5.831-8.332) | 0.428 (0.422-1.235) | 15.504x | +| Crypto perpetual funding | [LEAN 18001](https://github.com/QuantConnect/Lean) | 2.828 (2.737-2.952) | 1.327 (0.657-2.183) | 2.131x | +| FX allocation (USD-quoted pairs) | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 0.288 (0.286-0.294) | 0.148 (0.147-0.149) | 1.947x | +| FX allocation (USD-quoted pairs) | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 0.144 (0.142-0.146) | 0.145 (0.145-0.146) | 0.988x | +| FX allocation (USD-quoted pairs) | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 0.434 (0.430-0.440) | 0.147 (0.146-0.149) | 2.947x | +| FX allocation (USD-quoted pairs) | [LEAN 18001](https://github.com/QuantConnect/Lean) | 0.953 (0.936-1.164) | 0.158 (0.155-0.164) | 6.033x | +| US equity panel | [VectorBT Pro 2026.6.27](https://github.com/polakowo/vectorbt.pro) | 3.806 (3.336-4.341) | 23.306 (21.906-25.833) | 0.163x | +| US equity panel | [VectorBT OSS 1.1.0](https://pypi.org/project/vectorbt/1.1.0/) | 17.116 (17.069-17.633) | 25.738 (24.983-27.163) | 0.665x | +| US equity panel | [Backtrader 1.9.78.123](https://pypi.org/project/backtrader/1.9.78.123/) | 668.611 (642.993-718.202) | 26.887 (26.406-29.406) | 24.868x | +| US equity panel | [Zipline Reloaded 3.1.1](https://pypi.org/project/zipline-reloaded/3.1.1/) | 117.445 (116.308-118.391) | 26.821 (25.473-27.644) | 4.379x | +| US equity panel | [LEAN 18001](https://github.com/QuantConnect/Lean) | 48.436 (48.221-48.986) | 27.688 (27.575-27.796) | 1.749x | + +Measured 2026-09-24 on `Linux-6.8.0-139-generic-x86_64-with-glibc2.39` with 24 logical CPUs. Each side used one isolated warm-up process and ten isolated measured processes. The timer includes only the engine call; it excludes input loading, model inference, target construction, adapter preparation, result extraction, serialization, reporting. These measurements apply only to the named strategy, framework version, frozen input bundle, and machine. Raw samples and bootstrap intervals are retained in [real-strategy performance evidence](https://github.com/ml4t/backtest/blob/main/validation/REAL_STRATEGY_PERFORMANCE.json). ### Synthetic diagnostic scenarios diff --git a/validation/REAL_STRATEGY_PERFORMANCE.json b/validation/REAL_STRATEGY_PERFORMANCE.json index d65db076..5f1b87fe 100644 --- a/validation/REAL_STRATEGY_PERFORMANCE.json +++ b/validation/REAL_STRATEGY_PERFORMANCE.json @@ -1,5 +1,5 @@ { - "correctness_evidence_generated_at": "2026-09-15T13:31:29.709411+00:00", + "correctness_evidence_generated_at": "2026-09-24T01:50:22.858146+00:00", "environment": { "cpu_count": 24, "frameworks": { @@ -96,59 +96,15 @@ } }, "machine": "x86_64", - "platform": "Linux-6.8.0-138-generic-x86_64-with-glibc2.39", + "platform": "Linux-6.8.0-139-generic-x86_64-with-glibc2.39", "processor": "x86_64" }, - "generated_at": "2026-09-03T11:44:14.344493+00:00", + "generated_at": "2026-09-24T05:37:41.451299+00:00", "provenance": { - "certified_equivalent_sources": [ - { - "certified_at": "2026-09-11T23:03:52.881741+00:00", - "correctness_evidence_generated_at": "2026-09-11T22:46:21.943835+00:00", - "correctness_evidence_sha256": "a18591c6d0e3ccf686dc6591f855a81b3052f0a50106d6b4e6fa1062f7d60819", - "correctness_pairs_passed": 17, - "engine_source_sha256": "d625ed4aec3e4cd745f2ebd5138d9f6177c187f53cf6fe613d258a952d99a744", - "ml4t_commit": "5441ca6290710d5f4f6f39d29fbac41ae6f58e8f", - "reason": "Removed the LinearImpact.permanent_fraction constructor parameter, which calculate() never read. No code path consumed it, so no arithmetic, allocation or branch changed, and the 17 required pairs re-derive byte-identical metrics under the new source. _tree_digest hashes every tracked .py under the engine, so this docstring-scale edit moves the digest as far as a rewritten fill model would.", - "sources": { - "backtrader": "a456cc3bf9ed6708d239f21ed2c2495a3c19a9a8306634fee24619c2c0484459", - "benchmark": "0ccc3c90786dc6bc7b8d5992097cd977e2568c39c20f1490d83531b20e6a5188", - "comparison_input": "a00bf9383c3e5561333ef0fa734745296cf9cda6fe7155ce950713c1ba2da4f9", - "lean_crypto": "82f1fbb6054fba25d74a4eabbc9ad4eae808caf89c00f560edefa63304da0dc4", - "lean_equity": "f566341d0f4103eda45280684be1b8a91f9da1a46a3a06477cba3b49674d89e8", - "lean_fx": "85c18eb1368bfac938fd9909eaa481de867cd864b1f6852281da8af7c2b44951", - "ml4t": "c8a5eb818a64a128dce41bc9827adea0a46fdab87a11f418abb19d5df8c7bff9", - "vectorbt_oss": "c1817727bbf49d1551a072717db324c3e70484eefbdc27aa84af2a093e3e4d52", - "vectorbt_pro": "c1817727bbf49d1551a072717db324c3e70484eefbdc27aa84af2a093e3e4d52", - "zipline": "c23b547767fad7a60f8bd6b0abd1d8f1c1acf99ef0faab3da8fd34621b0ec482" - } - }, - { - "certified_at": "2026-09-15T13:31:38.119983+00:00", - "correctness_evidence_generated_at": "2026-09-15T13:31:29.709411+00:00", - "correctness_evidence_sha256": "b5978f0d23ff56d313dcd6abe91559fde0a9f7e1c94fd1d196777fc225c34b3d", - "correctness_pairs_passed": 17, - "engine_source_sha256": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2", - "ml4t_commit": "281370c037f64f4f37556acf0929622027c50e6d", - "reason": "The change adds _align_join_time_zones and one call site in enrich_trades_with_signals (51 added lines, 0 deletions). The timed region is the single line result = engine.run() at validation/real_strategy_runner.py:386, and enrich_trades_with_signals has no caller anywhere in src/ and runs only after that call returns, so the added code cannot execute inside the measured interval. The declared timing_policy excludes result extraction and reporting.", - "sources": { - "backtrader": "a456cc3bf9ed6708d239f21ed2c2495a3c19a9a8306634fee24619c2c0484459", - "benchmark": "0ccc3c90786dc6bc7b8d5992097cd977e2568c39c20f1490d83531b20e6a5188", - "comparison_input": "a00bf9383c3e5561333ef0fa734745296cf9cda6fe7155ce950713c1ba2da4f9", - "lean_crypto": "82f1fbb6054fba25d74a4eabbc9ad4eae808caf89c00f560edefa63304da0dc4", - "lean_equity": "f566341d0f4103eda45280684be1b8a91f9da1a46a3a06477cba3b49674d89e8", - "lean_fx": "85c18eb1368bfac938fd9909eaa481de867cd864b1f6852281da8af7c2b44951", - "ml4t": "c8a5eb818a64a128dce41bc9827adea0a46fdab87a11f418abb19d5df8c7bff9", - "vectorbt_oss": "c1817727bbf49d1551a072717db324c3e70484eefbdc27aa84af2a093e3e4d52", - "vectorbt_pro": "c1817727bbf49d1551a072717db324c3e70484eefbdc27aa84af2a093e3e4d52", - "zipline": "c23b547767fad7a60f8bd6b0abd1d8f1c1acf99ef0faab3da8fd34621b0ec482" - } - } - ], - "ml4t_engine_source_sha256": "8011ecdaf90807b4fe4fb5ada518a237311774b32891179bf1fad0a0baad8a58", + "ml4t_engine_source_sha256": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe", "sources": { "backtrader": "a456cc3bf9ed6708d239f21ed2c2495a3c19a9a8306634fee24619c2c0484459", - "benchmark": "99164392057a3ed3810c4dbe23a9c6084eb30f1553c92eb3a54f5d61e8d9b344", + "benchmark": "0ccc3c90786dc6bc7b8d5992097cd977e2568c39c20f1490d83531b20e6a5188", "comparison_input": "a00bf9383c3e5561333ef0fa734745296cf9cda6fe7155ce950713c1ba2da4f9", "lean_crypto": "82f1fbb6054fba25d74a4eabbc9ad4eae808caf89c00f560edefa63304da0dc4", "lean_equity": "f566341d0f4103eda45280684be1b8a91f9da1a46a3a06477cba3b49674d89e8", @@ -166,55 +122,55 @@ "framework": "vectorbt_pro", "framework_engine": { "ci_95_seconds": [ - 0.28554552848800085, - 0.2885417409997899 + 0.2900016970233992, + 0.297742429014761 ], - "maximum_seconds": 0.29064874499454163, - "median_seconds": 0.2872375610168092, - "minimum_seconds": 0.2836474619980436, + "maximum_seconds": 0.31405577599070966, + "median_seconds": 0.2925560909206979, + "minimum_seconds": 0.289397667045705, "output_identity": { "equity.parquet": "9bef5099c36b4a61b32670d19340ebb9f4adba07c63359a5c31a345ad51d1cd6", "fills.parquet": "698baf228da467fcce287f6f7da4903b3421d2649b181e3905a3681905251ffd" }, "samples_seconds": [ - 0.2878881780197844, - 0.2846226939873304, - 0.2836474619980436, - 0.2882649439852685, - 0.286586944013834, - 0.2885417409997899, - 0.29064874499454163, - 0.28637371701188385, - 0.28858046399545856, - 0.2864683629886713 + 0.28968335897661746, + 0.2900016970233992, + 0.29298339993692935, + 0.31405577599070966, + 0.289397667045705, + 0.2975651180604473, + 0.29627046699170023, + 0.2904333530459553, + 0.29921439103782177, + 0.29212878190446645 ] }, - "framework_to_ml4t_median_ratio": 0.6924827859284183, + "framework_to_ml4t_median_ratio": 0.6906261460648622, "input_bundle_sha256": "01f38079ce47821a5379d3769e86f4a2170b88033108153bfc0f928698e946db", "ml4t_engine": { "ci_95_seconds": [ - 0.4141692244884325, - 0.41945260000647977 + 0.4206434920197353, + 0.43413781898561865 ], - "maximum_seconds": 0.4208305799984373, - "median_seconds": 0.4147937925008591, - "minimum_seconds": 0.41231122802128084, + "maximum_seconds": 0.44225622492376715, + "median_seconds": 0.4236099264235236, + "minimum_seconds": 0.4181013370398432, "output_identity": { "equity.parquet": "30c2d39d1165342a599754cfd6ae76019d212f753ab49c837895c9f6ce942339", - "fills.parquet": "2219d69d7d36fc654a61cf3044e7db0dc641bfa4923a847b39afdc41671171f4", + "fills.parquet": "904a72fd66278fbb84c164e799c8dfc3d826bb1efdb0c3a4132f72203039368f", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.42046853801002726, - 0.4144462959957309, - 0.41231122802128084, - 0.41445882100379094, - 0.41389215298113413, - 0.41595029999734834, - 0.41429151402553543, - 0.41945260000647977, - 0.4208305799984373, - 0.4151287639979273 + 0.4181013370398432, + 0.4206434920197353, + 0.431770077906549, + 0.4251039489172399, + 0.44225622492376715, + 0.42823008296545595, + 0.42211590392980725, + 0.4197556939907372, + 0.4218772370368242, + 0.44004555500578135 ] } }, @@ -224,55 +180,55 @@ "framework": "vectorbt_oss", "framework_engine": { "ci_95_seconds": [ - 0.17068579950137064, - 0.17357376401196234 + 0.175615189014934, + 0.49315099901286885 ], - "maximum_seconds": 0.18489615400903858, - "median_seconds": 0.17132284450053703, - "minimum_seconds": 0.16843955099466257, + "maximum_seconds": 1.029292443068698, + "median_seconds": 0.19159923505503684, + "minimum_seconds": 0.17306145397014916, "output_identity": { "equity.parquet": "9bef5099c36b4a61b32670d19340ebb9f4adba07c63359a5c31a345ad51d1cd6", "fills.parquet": "a563dc47e59cc8d1ecf49a490f1e83842e5c4ca67a6a56adb411a58eb0842646" }, "samples_seconds": [ - 0.18489615400903858, - 0.17261791799683124, - 0.17124448300455697, - 0.17079768699477427, - 0.17080065800109878, - 0.16843955099466257, - 0.1705709410016425, - 0.1714012059965171, - 0.1738385669887066, - 0.17357376401196234 + 0.19964565406553447, + 0.20955988799687475, + 0.1835528160445392, + 1.029292443068698, + 0.239498105016537, + 0.776742110028863, + 0.175615189014934, + 0.17990780505351722, + 0.17306145397014916, + 0.1735441500786692 ] }, - "framework_to_ml4t_median_ratio": 0.4124410450618539, + "framework_to_ml4t_median_ratio": 0.4623417459666571, "input_bundle_sha256": "01f38079ce47821a5379d3769e86f4a2170b88033108153bfc0f928698e946db", "ml4t_engine": { "ci_95_seconds": [ - 0.41297478799242526, - 0.41699278698069975 + 0.41261054255301133, + 0.4170511480187997 ], - "maximum_seconds": 0.4207736090174876, - "median_seconds": 0.4153874754993012, - "minimum_seconds": 0.40992282700608484, + "maximum_seconds": 0.4176567249232903, + "median_seconds": 0.4144104155129753, + "minimum_seconds": 0.40947630698792636, "output_identity": { "equity.parquet": "30c2d39d1165342a599754cfd6ae76019d212f753ab49c837895c9f6ce942339", - "fills.parquet": "2219d69d7d36fc654a61cf3044e7db0dc641bfa4923a847b39afdc41671171f4", + "fills.parquet": "904a72fd66278fbb84c164e799c8dfc3d826bb1efdb0c3a4132f72203039368f", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.4166069460043218, - 0.4207736090174876, - 0.4155321999860462, - 0.41386474401224405, - 0.41524275101255625, - 0.4170354550005868, - 0.40992282700608484, - 0.4125243329908699, - 0.41699278698069975, - 0.41297478799242526 + 0.4147904319688678, + 0.41382401203736663, + 0.4170511480187997, + 0.40947630698792636, + 0.41271749197039753, + 0.41403039905708283, + 0.4176567249232903, + 0.411397073068656, + 0.4174045389518142, + 0.41666107904165983 ] } }, @@ -282,56 +238,56 @@ "framework": "backtrader", "framework_engine": { "ci_95_seconds": [ - 9.30285160850326, - 9.423639220010955 + 9.428437763010152, + 9.710194949992001 ], - "maximum_seconds": 9.45949614199344, - "median_seconds": 9.355300096503925, - "minimum_seconds": 9.132323172001634, + "maximum_seconds": 10.17647002497688, + "median_seconds": 9.630951289960649, + "minimum_seconds": 9.37383054906968, "output_identity": { "equity.parquet": "194818b08af3ceac17f2ff8b143d76184b63f95a92ab2db1ee4a13b903843d92", "fills.parquet": "8fbc351395431fcaf3d986c1d72011520394fa202db5b25ea2a79f49f4c013ab", "rejected_orders.parquet": "29756624602e47c7f667e995e892fd158828381420d570a8a8a65d23d84d617b" }, "samples_seconds": [ - 9.425851352018071, - 9.262009288999252, - 9.352195490006125, - 9.342917869973462, - 9.34369392800727, - 9.132323172001634, - 9.389201070007402, - 9.358404703001725, - 9.45949614199344, - 9.423639220010955 + 9.679804097977467, + 9.616229709936306, + 9.740585802006535, + 9.645672869984992, + 9.479653650079854, + 9.37722187594045, + 9.703809328028001, + 10.17647002497688, + 9.37383054906968, + 9.46689756307751 ] }, - "framework_to_ml4t_median_ratio": 21.7615755934312, + "framework_to_ml4t_median_ratio": 21.954399486329535, "input_bundle_sha256": "01f38079ce47821a5379d3769e86f4a2170b88033108153bfc0f928698e946db", "ml4t_engine": { "ci_95_seconds": [ - 0.42605314050160814, - 0.431600633499329 + 0.4344198580365628, + 0.4441501100664027 ], - "maximum_seconds": 0.4326717059884686, - "median_seconds": 0.4298999425082002, - "minimum_seconds": 0.42367077799281105, + "maximum_seconds": 0.4469653470441699, + "median_seconds": 0.43867978698108345, + "minimum_seconds": 0.4290196599904448, "output_identity": { "equity.parquet": "c0d9b87df7d1d490134679c571414ee3f5d5dc96cba2fd81e18c24c920ee9b60", - "fills.parquet": "11fee07c68fa9550b8c9bfa8590940e33a0247db7640c280043b6eddcaf7fa12", - "rejected_orders.parquet": "12abf1f224c27116ad669c98592fc527880414b152e921de2fb362eb8c3815d3" + "fills.parquet": "7dac5c83214c8d03368049095db934a284d7f89b46e240ccb9896cce90bcfd1b", + "rejected_orders.parquet": "cc78f48b47cfd5253b16288011b7811dc5e702ada6927580bdfa2353a0d05ea8" }, "samples_seconds": [ - 0.43116882801405154, - 0.42472128602094017, - 0.42367077799281105, - 0.4305817059939727, - 0.4320324389846064, - 0.4311954760050867, - 0.4273849949822761, - 0.42921817902242765, - 0.42622934401151724, - 0.4326717059884686 + 0.4290196599904448, + 0.44298302405513823, + 0.43867243302520365, + 0.43726814200636, + 0.4425441790372133, + 0.43315088690724224, + 0.4469653470441699, + 0.4457560410955921, + 0.43868714093696326, + 0.4344198580365628 ] } }, @@ -341,55 +297,55 @@ "framework": "zipline", "framework_engine": { "ci_95_seconds": [ - 3.8670725979900453, - 3.9098115700180642 + 3.87795311602531, + 3.9590794310206547 ], - "maximum_seconds": 3.952663065982051, - "median_seconds": 3.8715068249875912, - "minimum_seconds": 3.851871683000354, + "maximum_seconds": 4.06156149005983, + "median_seconds": 3.888152966508642, + "minimum_seconds": 3.8552848449908197, "output_identity": { "equity.parquet": "e67ccdf71043cd216a9d8ea6f96f6cc0eba564907919ef1b867bce4726e36b31", "fills.parquet": "611b2ca3adc8a24484dcee08d5fba4c9219c0c0c6f4b27d701db51ea98390572" }, "samples_seconds": [ - 3.871085986989783, - 3.952663065982051, - 3.9098115700180642, - 3.8673573849955574, - 3.851871683000354, - 3.8719276629853994, - 3.8767027010035235, - 3.870405462977942, - 3.912462295993464, - 3.8637397330021486 + 3.8822625660104677, + 3.908796950010583, + 3.9590794310206547, + 3.962741830968298, + 4.06156149005983, + 3.8736436660401523, + 3.8913325699977577, + 3.8796646130504087, + 3.8552848449908197, + 3.884973363019526 ] }, - "framework_to_ml4t_median_ratio": 6.126579592244658, + "framework_to_ml4t_median_ratio": 6.256086160815378, "input_bundle_sha256": "01f38079ce47821a5379d3769e86f4a2170b88033108153bfc0f928698e946db", "ml4t_engine": { "ci_95_seconds": [ - 0.6284003740001936, - 0.6377887904964155 + 0.6179514050018042, + 0.6255218000151217 ], - "maximum_seconds": 0.6476017300155945, - "median_seconds": 0.6319197794946376, - "minimum_seconds": 0.6249592270178255, + "maximum_seconds": 0.62759894807823, + "median_seconds": 0.621499267523177, + "minimum_seconds": 0.6117444499395788, "output_identity": { "equity.parquet": "578498185c7abd3b869578c8492937a09ce6cf8904ea64650375587fd397952a", - "fills.parquet": "b068c42a1d63409086e4cc638aec586818f9025f661897e7bdd34e5e4d8f9c6f", + "fills.parquet": "d3daa2754b23c49802344748cd082d98920a97014694b1af1e40055ea4a4a76b", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.6333068659878336, - 0.6297633819922339, - 0.6313362790097017, - 0.6249592270178255, - 0.6297856899909675, - 0.6422707150049973, - 0.6325032799795736, - 0.6476017300155945, - 0.6270150580094196, - 0.6363518029975239 + 0.6209644980262965, + 0.6202658739639446, + 0.6159874090226367, + 0.6179514050018042, + 0.62759894807823, + 0.6227562909480184, + 0.6255218000151217, + 0.6117444499395788, + 0.6220340370200574, + 0.6265018660342321 ] } }, @@ -399,56 +355,56 @@ "framework": "lean", "framework_engine": { "ci_95_seconds": [ - 2.4874195760057773, - 2.599079799008905 + 2.515638191951439, + 2.707405475433916 ], - "maximum_seconds": 2.7125714069989044, - "median_seconds": 2.564114402513951, - "minimum_seconds": 2.47689789001015, + "maximum_seconds": 4.277152065886185, + "median_seconds": 2.6383553470368497, + "minimum_seconds": 2.463276338065043, "output_identity": { - "equity.parquet": "906fd1b8d49ebc67b790dee610327360d55e4cacb77b34c5732b8a3c124fe398", - "fills.parquet": "e0891a195392d6b92fa5956a58c4a57a84b62b8ae11e48dca6a617f2fa126a18", + "equity.parquet": "0a644fee4073605011407b32f7cc1443360ce939e00602c2ab357d2d2f797baa", + "fills.parquet": "a24ebc61f9cb814d9e786d3256f64634d90122d475ed5d1c2dbf75668adcf536", "rejected_orders.parquet": "9e7aedba072cbcd23432976f03fb6edef656da961cd2e73dbd75188842b29c36" }, "samples_seconds": [ - 2.56353892700281, - 2.564689878025092, - 2.5824199889902957, - 2.5214746879937593, - 2.47689789001015, - 2.482074404018931, - 2.6281440810125787, - 2.5700155170052312, - 2.4874195760057773, - 2.7125714069989044 + 2.4993772000307217, + 2.515638191951439, + 2.6755189020186663, + 2.7485075009753928, + 2.463276338065043, + 2.666303449892439, + 4.277152065886185, + 2.6394270199816674, + 2.5843563260277733, + 2.637283674092032 ] }, - "framework_to_ml4t_median_ratio": 3.418485618498456, + "framework_to_ml4t_median_ratio": 3.5199600238072577, "input_bundle_sha256": "01f38079ce47821a5379d3769e86f4a2170b88033108153bfc0f928698e946db", "ml4t_engine": { "ci_95_seconds": [ - 0.7486901020165533, - 0.7566314754949417 + 0.7431577030802146, + 0.7573418769752607 ], - "maximum_seconds": 0.7671177280135453, - "median_seconds": 0.750073186980444, - "minimum_seconds": 0.7482178049976937, + "maximum_seconds": 0.7589061490725726, + "median_seconds": 0.7495412814896554, + "minimum_seconds": 0.740414077998139, "output_identity": { "equity.parquet": "9d2ae671c05691680de1a947edc07b5805d3d17c4fffdb0d9cb002908d5d9189", - "fills.parquet": "6dabab0285fe7d1d41c6008f9db1910fd0eb5117ce132cda2eac13fa47581b18", + "fills.parquet": "e8903f6676292f1021bd420c2d530322639bdb2f9534e5130f4ae8b41a04ddd0", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.75504609500058, - 0.7502520319831092, - 0.749083264003275, - 0.7518141379987355, - 0.7614488129911479, - 0.7498943419777788, - 0.7482178049976937, - 0.7483703329926357, - 0.7671177280135453, - 0.7486901020165533 + 0.7563386870315298, + 0.7482074870495126, + 0.7428623960586265, + 0.7431577030802146, + 0.7573418769752607, + 0.749361485010013, + 0.7579125961055979, + 0.7589061490725726, + 0.7497210779692978, + 0.740414077998139 ] } }, @@ -458,55 +414,55 @@ "framework": "vectorbt_pro", "framework_engine": { "ci_95_seconds": [ - 0.28389174649782944, - 0.2887275889952434 + 0.2885498870164156, + 0.2940297279856168 ], - "maximum_seconds": 0.29045845798100345, - "median_seconds": 0.28636373148765415, - "minimum_seconds": 0.28267808200325817, + "maximum_seconds": 0.3007154769729823, + "median_seconds": 0.29048270400380716, + "minimum_seconds": 0.2882876090006903, "output_identity": { "equity.parquet": "40991bd7b1641af22425d7bac85980f54f37d25e460f56710a7994eeaf5669c0", "fills.parquet": "6348edba773d059a5417470a3fca1378a6853909266e833b99dc6ec0160b3b78" }, "samples_seconds": [ - 0.28724599999259226, - 0.2858317520003766, - 0.2839527069882024, - 0.28267808200325817, - 0.2902091779978946, - 0.2844167379953433, - 0.2877347480098251, - 0.2833667550003156, - 0.2868957109749317, - 0.29045845798100345 + 0.288515966036357, + 0.2905243040295318, + 0.29565473296679556, + 0.2882876090006903, + 0.3007154769729823, + 0.28967332001775503, + 0.29044110397808254, + 0.2885498870164156, + 0.2926626129774377, + 0.292404723004438 ] }, - "framework_to_ml4t_median_ratio": 0.6631588833815962, + "framework_to_ml4t_median_ratio": 0.7082094829709413, "input_bundle_sha256": "c7191027f550ef1be0c9528dac08e7c2bf6a76c86d935f593efeb1ad8b628c39", "ml4t_engine": { "ci_95_seconds": [ - 0.428688713509473, - 0.43454156002553646 + 0.40621895994991064, + 0.9579270925023593 ], - "maximum_seconds": 0.4363564869854599, - "median_seconds": 0.43181768149952404, - "minimum_seconds": 0.42630503099644557, + "maximum_seconds": 1.5739495350280777, + "median_seconds": 0.41016494552604854, + "minimum_seconds": 0.4042154859052971, "output_identity": { "equity.parquet": "b61097a9c1c8635ccc9278db28aa955a69c16bac261515fa6ccff06211ca45f6", - "fills.parquet": "fc54ddad09f939944a9070e8a4215089d0dcded8adcc3f6872e78ecec7c9102c", - "rejected_orders.parquet": "f63d7bb35fa856811f134b23dcda951357aa799d3ff565c8416a0a0794b731e0" + "fills.parquet": "0cea5189115b3c7ec680d7f73895278b83ff097e863324bd944c9272d794edbb", + "rejected_orders.parquet": "2b4e500b98a08814aa8b42e0693c7bca7076cf9006444f5b26600bb9669647a7" }, "samples_seconds": [ - 0.4363564869854599, - 0.4286542210029438, - 0.43411476400797255, - 0.43559188302606344, - 0.42630503099644557, - 0.43349123702500947, - 0.4287232060160022, - 0.43276859901379794, - 0.4286969269742258, - 0.43086676398525015 + 0.40621895994991064, + 0.40606525202747434, + 0.4074287120020017, + 0.4042154859052971, + 0.4106503500370309, + 0.4132733520818874, + 0.41067701298743486, + 0.4096795410150662, + 1.5051771720172837, + 1.5739495350280777 ] } }, @@ -516,56 +472,56 @@ "framework": "backtrader", "framework_engine": { "ci_95_seconds": [ - 2.487967457011109, - 2.526018163494882 + 5.8306038425071165, + 8.332070957985707 ], - "maximum_seconds": 2.569449374015676, - "median_seconds": 2.494809443000122, - "minimum_seconds": 2.481621830986114, + "maximum_seconds": 8.799221163033508, + "median_seconds": 6.63913488748949, + "minimum_seconds": 5.297625788953155, "output_identity": { "equity.parquet": "8f7ec2015c441a7d7723116aadcc7b30bdabb8f30fa4ea108fa96cfff6dcf075", "fills.parquet": "e9888baac855ce576654e66159958eceb70833876507b7228737c7d49a9f8d0b", "rejected_orders.parquet": "28769b824e4cce25769e28dd6764b6ae4e66b1fad0b70e695eba96fa27eb3cef" }, "samples_seconds": [ - 2.569449374015676, - 2.4946508139837533, - 2.487967457011109, - 2.483746792015154, - 2.5549371949746273, - 2.497099132015137, - 2.4949680720164906, - 2.5185832470015157, - 2.4939123849908356, - 2.481621830986114 + 5.936102256993763, + 5.662118365056813, + 5.297625788953155, + 5.99908931995742, + 7.196253928937949, + 7.160146994981915, + 8.332070957985707, + 8.799221163033508, + 8.426080056931823, + 6.118122779997066 ] }, - "framework_to_ml4t_median_ratio": 5.811638815571466, + "framework_to_ml4t_median_ratio": 15.503975288952617, "input_bundle_sha256": "c7191027f550ef1be0c9528dac08e7c2bf6a76c86d935f593efeb1ad8b628c39", "ml4t_engine": { "ci_95_seconds": [ - 0.4273611745011294, - 0.4325851899920963 + 0.42172171105630696, + 1.2346106839831918 ], - "maximum_seconds": 0.43512895799358375, - "median_seconds": 0.4292781300027855, - "minimum_seconds": 0.4252439269912429, + "maximum_seconds": 1.2817764669889584, + "median_seconds": 0.4282214569975622, + "minimum_seconds": 0.41942463093437254, "output_identity": { "equity.parquet": "2478697a24a7b812e5e84dda67fa5fdbb3dc27455f9bb1b28491f082e96ff501", - "fills.parquet": "0ad9d59fde347bc5cf76ab5387b479668bbc64228e1ca2091df56122e693aae3", + "fills.parquet": "4cc366af96c4484a6e84db18f6367b89fabb4d98784b883ef1cde7bcc766eb78", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.4277158589975443, - 0.4333903399819974, - 0.4297198849963024, - 0.43135667699971236, - 0.4325851899920963, - 0.42789013299625367, - 0.43512895799358375, - 0.4288363750092685, - 0.4268322160060052, - 0.4252439269912429 + 1.249723534914665, + 1.2817764669889584, + 1.2346106839831918, + 0.43139286199584603, + 0.41945610602851957, + 0.42398731608409435, + 0.4265086619416252, + 0.4218658530153334, + 0.4299342520534992, + 0.41942463093437254 ] } }, @@ -575,56 +531,56 @@ "framework": "lean", "framework_engine": { "ci_95_seconds": [ - 2.7094629269995494, - 2.8912153329874855 + 2.737479996983893, + 2.9521514074876904 ], - "maximum_seconds": 2.9968422400124837, - "median_seconds": 2.801038935489487, - "minimum_seconds": 2.68302350200247, + "maximum_seconds": 5.8178522599628195, + "median_seconds": 2.828298416978214, + "minimum_seconds": 2.661781172035262, "output_identity": { - "equity.parquet": "9fb5d72364e039e75a28a1dcb77c06082418d698cac2467de8dc0c84371d2762", - "fills.parquet": "93ca6c8936e944e5f077e5b9b272a5467bac5ae828650029fc198dacd33ef46b", + "equity.parquet": "7175e01ac24e001ebcc1ae56855f198cd5e4f07d92a376e0361a314ffd766cb8", + "fills.parquet": "c095672f9b5d9bcafdce2f003cb93952c118e5b1eede9db853b1a39b282fcad8", "rejected_orders.parquet": "9e7aedba072cbcd23432976f03fb6edef656da961cd2e73dbd75188842b29c36" }, "samples_seconds": [ - 2.8912153329874855, - 2.9968422400124837, - 2.6896367039880715, - 2.68302350200247, - 2.911805715993978, - 2.7292891500110272, - 2.873639104014728, - 2.839817078987835, - 2.762260791991139, - 2.7145858370058704 + 3.0119967089267448, + 2.737479996983893, + 5.8178522599628195, + 2.764150841976516, + 2.910047380020842, + 2.661781172035262, + 2.892306106048636, + 2.7186104599386454, + 2.796464517014101, + 2.8601323169423267 ] }, - "framework_to_ml4t_median_ratio": 4.266156698399411, + "framework_to_ml4t_median_ratio": 2.1307424559523884, "input_bundle_sha256": "2acee3c8542043266e6f9c0dfc9434c95b4469ef6fcaf0556efc569fc9721cdd", "ml4t_engine": { "ci_95_seconds": [ - 0.6548837659938727, - 0.6614908079791348 + 0.6565737564815208, + 2.1832261129748076 ], - "maximum_seconds": 0.6643680910055991, - "median_seconds": 0.6565719764912501, - "minimum_seconds": 0.6494209709926508, + "maximum_seconds": 2.2424513859441504, + "median_seconds": 1.327376947447192, + "minimum_seconds": 0.6452092870604247, "output_identity": { "equity.parquet": "0b98b8b65e7cc6885a27f9ffff6268d6d15a82b613ab06e4e57bbf96805ef1c9", - "fills.parquet": "b19ec75bb792f9109356b3127ea8580935932b00a9fb9dae92a970f643d74705", + "fills.parquet": "57998fb15bb342363f4046982dc9a4a267778875314687991cbdcca1750060d5", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.6614908079791348, - 0.6643680910055991, - 0.6551232409838121, - 0.656845068995608, - 0.6584545439982321, - 0.6643094899773132, - 0.6494209709926508, - 0.6562988839868922, - 0.6561191559885629, - 0.6536483759991825 + 0.6590511460090056, + 0.6540963669540361, + 0.6452092870604247, + 0.65794847602956, + 0.7014746950007975, + 1.9532791998935863, + 2.2369316320400685, + 2.115875629009679, + 2.2424513859441504, + 2.1832261129748076 ] } }, @@ -634,55 +590,55 @@ "framework": "vectorbt_pro", "framework_engine": { "ci_95_seconds": [ - 0.2809414380026283, - 0.28584351851895917 + 0.28634460049215704, + 0.2939993189647794 ], - "maximum_seconds": 0.2883088879752904, - "median_seconds": 0.28271306499664206, - "minimum_seconds": 0.2783307459903881, + "maximum_seconds": 2.133399173966609, + "median_seconds": 0.2883332470082678, + "minimum_seconds": 0.2853023150237277, "output_identity": { "equity.parquet": "c667f7404ce6368f5fa73e165414980287d8943b3ac2abc2d434d6db14786f6f", "fills.parquet": "9c7d40cd1f2cb24ce6288084615f64696192c391547568f60116807dfbc0758f" }, "samples_seconds": [ - 0.28796336802770384, - 0.2824393089977093, - 0.2829868209955748, - 0.2802182379818987, - 0.28161942298174836, - 0.2883088879752904, - 0.2838454349839594, - 0.2837236690102145, - 0.28166463802335784, - 0.2783307459903881 + 2.133399173966609, + 0.2853023150237277, + 0.2870907839387655, + 0.2871479649329558, + 0.2939993189647794, + 0.2943767210235819, + 0.2855412360513583, + 0.28801989706698805, + 0.28864659694954753, + 0.29183984093833715 ] }, - "framework_to_ml4t_median_ratio": 1.9393634353319569, + "framework_to_ml4t_median_ratio": 1.94688518348125, "input_bundle_sha256": "48922296cea03bfc9867a9c4e1a1d352a0bd54c0b48671e3ca8d1fdddb95d762", "ml4t_engine": { "ci_95_seconds": [ - 0.14505504499538802, - 0.14667728898348287 + 0.14696804992854595, + 0.14891324302880093 ], - "maximum_seconds": 0.1509846999833826, - "median_seconds": 0.1457762170030037, - "minimum_seconds": 0.1421222960052546, + "maximum_seconds": 0.15570206800475717, + "median_seconds": 0.148099769547116, + "minimum_seconds": 0.14523314801044762, "output_identity": { "equity.parquet": "d2ab479f54be94a77da5d6b5edcc4e019c985b2edf134c2082df9b90e8bbddf9", - "fills.parquet": "7fa36302b8f71391ffa0470deaea103d02f2c1278f70177815e3e48d3c11810e", + "fills.parquet": "a2cea17b1aa5c792685c78c97d3700d19e3a12abf03f1ef53224d41403a2ff1d", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.14598925798782147, - 0.14513368799816817, - 0.1457257329893764, - 0.14582670101663098, - 0.14505504499538802, - 0.14503437900566496, - 0.1509846999833826, - 0.1421222960052546, - 0.1475023700040765, - 0.14667728898348287 + 0.14716051891446114, + 0.1495700170053169, + 0.14885442389640957, + 0.148092049988918, + 0.14810748910531402, + 0.14714952395297587, + 0.14825646905228496, + 0.14523314801044762, + 0.14677558094263077, + 0.15570206800475717 ] } }, @@ -692,55 +648,55 @@ "framework": "vectorbt_oss", "framework_engine": { "ci_95_seconds": [ - 0.13754021801287308, - 0.1413268009928288 + 0.14231256995117292, + 0.14608762296848 ], - "maximum_seconds": 0.14422649898915552, - "median_seconds": 0.1387957935075974, - "minimum_seconds": 0.135567949997494, + "maximum_seconds": 0.15810407302342355, + "median_seconds": 0.14366071502445266, + "minimum_seconds": 0.13813161593861878, "output_identity": { "equity.parquet": "116edf23b5f28d364e865907b69c84cadcd46cc7094764b77b38c3be4a0643da", "fills.parquet": "43cc7f5cde82d078b5620d532fd35a704d4372a28dad196eaa1b0cbe7a4f697f" }, "samples_seconds": [ - 0.13840279701980762, - 0.139568018988939, - 0.14308558299671859, - 0.14422649898915552, - 0.13754021801287308, - 0.135567949997494, - 0.14002962899394333, - 0.1391887899953872, - 0.13838270198903047, - 0.13701783001306467 + 0.14351372991222888, + 0.14292211702559143, + 0.1436876190127805, + 0.13813161593861878, + 0.14363381103612483, + 0.14608762296848, + 0.1466356300516054, + 0.15810407302342355, + 0.14471301797311753, + 0.14111140999011695 ] }, - "framework_to_ml4t_median_ratio": 0.9517668155264412, + "framework_to_ml4t_median_ratio": 0.9882869360716513, "input_bundle_sha256": "48922296cea03bfc9867a9c4e1a1d352a0bd54c0b48671e3ca8d1fdddb95d762", "ml4t_engine": { "ci_95_seconds": [ - 0.1447887455142336, - 0.14715092899859883 + 0.1448381410446018, + 0.1464932659873739 ], - "maximum_seconds": 0.14833555501536466, - "median_seconds": 0.14582962049462367, - "minimum_seconds": 0.1442177559947595, + "maximum_seconds": 0.1472564940340817, + "median_seconds": 0.14536336541641504, + "minimum_seconds": 0.143077731016092, "output_identity": { "equity.parquet": "91fc93f96be91701932992950807a28c8d0e70bf96b36419171ddfd3f7e3e4ff", - "fills.parquet": "a3059de769d2114484cd120ec9e56b70404c50a187b7056c8bb37db8478a0134", + "fills.parquet": "d526ec929f9291d9fc1d1842d7337fe24b0b5216a9f479a4107c70834c4ff3ee", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.1457435579795856, - 0.14518950000638142, - 0.14591568300966173, - 0.1461662469955627, - 0.14833555501536466, - 0.14602854999247938, - 0.14438799102208577, - 0.14827330800471827, - 0.145086723001441, - 0.1442177559947595 + 0.147095917025581, + 0.143077731016092, + 0.14579966804012656, + 0.14477691904176027, + 0.1464932659873739, + 0.1449414169183001, + 0.1448524680454284, + 0.14578531391452998, + 0.14489936304744333, + 0.1472564940340817 ] } }, @@ -750,56 +706,56 @@ "framework": "backtrader", "framework_engine": { "ci_95_seconds": [ - 0.4199030729942024, - 0.43193558798520826 + 0.4298818615498021, + 0.43957427295390517 ], - "maximum_seconds": 0.44734891699044965, - "median_seconds": 0.42490502599684987, - "minimum_seconds": 0.4180895620083902, + "maximum_seconds": 0.4604668020037934, + "median_seconds": 0.4338197730248794, + "minimum_seconds": 0.42206139594782144, "output_identity": { "equity.parquet": "8356c90186bb5ee467d3c3c1ee473acecbb89bdb0b36c9dec8ba35bf68b315ef", "fills.parquet": "4d1f97749b28f76c2736ba9df05fdc5aee46409054cd3e43cede9144a03ab83c", "rejected_orders.parquet": "28769b824e4cce25769e28dd6764b6ae4e66b1fad0b70e695eba96fa27eb3cef" }, "samples_seconds": [ - 0.43223666402627714, - 0.4180895620083902, - 0.42251148002105765, - 0.4269157150120009, - 0.4292930939991493, - 0.42289433698169887, - 0.41964370498317294, - 0.4199030729942024, - 0.43193558798520826, - 0.44734891699044965 + 0.4375792840728536, + 0.43166758795268834, + 0.43957427295390517, + 0.4269790221005678, + 0.44113637891132385, + 0.42206139594782144, + 0.43439839396160096, + 0.43278470099903643, + 0.4604668020037934, + 0.43324115208815783 ] }, - "framework_to_ml4t_median_ratio": 2.919662876838548, + "framework_to_ml4t_median_ratio": 2.946996815690775, "input_bundle_sha256": "48922296cea03bfc9867a9c4e1a1d352a0bd54c0b48671e3ca8d1fdddb95d762", "ml4t_engine": { "ci_95_seconds": [ - 0.14518085900635924, - 0.14739416650263593 + 0.1461060909787193, + 0.14869934448506683 ], - "maximum_seconds": 0.1548990169831086, - "median_seconds": 0.1455322220135713, - "minimum_seconds": 0.14440724899759516, + "maximum_seconds": 0.15201944101136178, + "median_seconds": 0.14720741152996197, + "minimum_seconds": 0.14476263104006648, "output_identity": { "equity.parquet": "8303060ba6c52232569452705febab5a6eb308b7f98bc82985bd06bfc56c28fb", - "fills.parquet": "25df0967710f939d4b2b3cd3f3637f7448a1aa8a8d526a5b61b5c83571e84844", + "fills.parquet": "82f617a7584707dc1075534e0a981e689481e7e2ebb78bcff043154d5b6189c6", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.14497113300603814, - 0.14818612701492384, - 0.14440724899759516, - 0.14708193400292657, - 0.1548990169831086, - 0.14545863002422266, - 0.14539058500668034, - 0.1453318799904082, - 0.14560581400291994, - 0.146602205990348 + 0.1461060909787193, + 0.14564719202462584, + 0.14662873395718634, + 0.15201944101136178, + 0.14476263104006648, + 0.14772679307498038, + 0.1479792109457776, + 0.14668802998494357, + 0.14941947802435607, + 0.14838470797985792 ] } }, @@ -809,56 +765,56 @@ "framework": "lean", "framework_engine": { "ci_95_seconds": [ - 0.8773026489943732, - 0.9533085440052673 + 0.9356942480662838, + 1.163711266941391 ], - "maximum_seconds": 0.9673503069789149, - "median_seconds": 0.9130055215064203, - "minimum_seconds": 0.8551066289946903, + "maximum_seconds": 1.6388701400719583, + "median_seconds": 0.952616770984605, + "minimum_seconds": 0.9163093130337074, "output_identity": { - "equity.parquet": "cb6aa497553239db6c7414156ab932186c47974c5891ada4bea96e7a83bd2061", - "fills.parquet": "98a2d8e111408518d4b3d4b40573415f67c09c7f55767057a50e4689637b664b", + "equity.parquet": "0d2751a207b9fee6360548c61c7d4f8c5975384a639575adcdc632ac562bffe7", + "fills.parquet": "b57996e6b2cc4b261de38866c0c64878c1e5220b750de80b04564767080a52d9", "rejected_orders.parquet": "9e7aedba072cbcd23432976f03fb6edef656da961cd2e73dbd75188842b29c36" }, "samples_seconds": [ - 0.906807599007152, - 0.9192034440056887, - 0.9479660390061326, - 0.9010869449994061, - 0.9673503069789149, - 0.8773026489943732, - 0.9605227510037366, - 0.8676749280130025, - 0.8551066289946903, - 0.9533085440052673 + 0.9163093130337074, + 0.9724616790190339, + 0.9485308360308409, + 1.186076671932824, + 1.6388701400719583, + 0.9356942480662838, + 0.9487062549451366, + 0.9565272870240733, + 1.163711266941391, + 0.9338941839523613 ] }, - "framework_to_ml4t_median_ratio": 5.951507252400165, + "framework_to_ml4t_median_ratio": 6.033058566969102, "input_bundle_sha256": "48922296cea03bfc9867a9c4e1a1d352a0bd54c0b48671e3ca8d1fdddb95d762", "ml4t_engine": { "ci_95_seconds": [ - 0.1521106895088451, - 0.15719534500385635 + 0.15491063089575619, + 0.1641812429879792 ], - "maximum_seconds": 0.15817101701395586, - "median_seconds": 0.1534074450028129, - "minimum_seconds": 0.15100426398566924, + "maximum_seconds": 0.1727963569574058, + "median_seconds": 0.15789947344455868, + "minimum_seconds": 0.15280074696056545, "output_identity": { "equity.parquet": "20c7470a3770a852d56bb4790ba22e21010d061f2aa077b328ffbbdca1651cc2", - "fills.parquet": "fd2e8f60f73c28d5f252bdc1ae72eacc2165c793f5d32e43612741e6d783dd30", + "fills.parquet": "630cd7753bf97bcdde690e05b1065e960f520d22a5622fd0ca4f7f26835a054f", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 0.1515475710039027, - 0.15719534500385635, - 0.15100426398566924, - 0.15350587500142865, - 0.15817101701395586, - 0.1526738080137875, - 0.15500060000340454, - 0.15330901500419714, - 0.15251966202049516, - 0.15789884398691356 + 0.15562730294186622, + 0.1727963569574058, + 0.16888348700013012, + 0.15849790291395038, + 0.15491063089575619, + 0.1594789989758283, + 0.1537122828885913, + 0.15968903002794832, + 0.15280074696056545, + 0.15730104397516698 ] } }, @@ -868,55 +824,55 @@ "framework": "vectorbt_pro", "framework_engine": { "ci_95_seconds": [ - 0.7874248115113005, - 0.8377640749968123 + 3.336437991936691, + 4.340994625003077 ], - "maximum_seconds": 0.9157754240150098, - "median_seconds": 0.7949095129879424, - "minimum_seconds": 0.7838948410062585, + "maximum_seconds": 4.953136228956282, + "median_seconds": 3.8055060979677364, + "minimum_seconds": 0.8202721000416204, "output_identity": { "equity.parquet": "533030a43e7dcfd5961b898ad78139632b57cdc36952680fac5dbf651e664885", "fills.parquet": "12fe4de7986d8803f6de85dab2089777823ade9300fda127d62aec08b68d1f72" }, "samples_seconds": [ - 0.8102761670015752, - 0.7883659350045491, - 0.7855645200179424, - 0.7892851030046586, - 0.7982651509810239, - 0.8129221879935358, - 0.7838948410062585, - 0.9157754240150098, - 0.791553874994861, - 0.8652519829920493 + 3.878830279922113, + 0.8202721000416204, + 3.3738032239489257, + 3.364297664957121, + 3.73218191601336, + 3.2990727599244565, + 4.087871203082614, + 4.555743600009009, + 4.340994625003077, + 4.953136228956282 ] }, - "framework_to_ml4t_median_ratio": 0.03867173809347579, + "framework_to_ml4t_median_ratio": 0.16328304156486445, "input_bundle_sha256": "02926d8a7f1c1f5b4855b2393d1763885288ef64950d5cf521ca96c61f454d88", "ml4t_engine": { "ci_95_seconds": [ - 20.48596853449999, - 20.599761563003995 + 21.905962607008405, + 25.832905145478435 ], - "maximum_seconds": 20.648501724994276, - "median_seconds": 20.555308661496383, - "minimum_seconds": 20.425600546004716, + "maximum_seconds": 29.599702261039056, + "median_seconds": 23.30619310797192, + "minimum_seconds": 21.732965360977687, "output_identity": { "equity.parquet": "974f900cf826b55a2cda697c7f8b11abb3c526fa472c5a406cdb7b8c7d0613c0", - "fills.parquet": "cc1907552c0ebe1b8b74aa8f72ed39c27903eed9e34e0f70f2ca14ee3fe7a3d7", - "rejected_orders.parquet": "64ef01bac600740a590295d52b543d6deadf6b3c64dcda1af141873ae097d4fb" + "fills.parquet": "923752318482ad571f2d677a6ae1440448200b6cd87616264deb95a9a31b7d9c", + "rejected_orders.parquet": "4222d93b23310ba3c189de88d51ef83d28b54fb7f4a09b95e46ea94f8ad58e56" }, "samples_seconds": [ - 20.569528984982753, - 20.622427879017778, - 20.449230501020793, - 20.648501724994276, - 20.522706567979185, - 20.541088338010013, - 20.586456601013197, - 20.57709524699021, - 20.507462484994903, - 20.425600546004716 + 25.161155793000944, + 21.871996363974176, + 24.40696905099321, + 26.504654497955926, + 25.386266839923337, + 29.599702261039056, + 22.20541716495063, + 21.939928850042634, + 21.732965360977687, + 21.91702615795657 ] } }, @@ -926,55 +882,55 @@ "framework": "vectorbt_oss", "framework_engine": { "ci_95_seconds": [ - 16.959977449994767, - 17.023569879005663 + 17.069319283997174, + 17.633346832939424 ], - "maximum_seconds": 17.07916622198536, - "median_seconds": 16.9949149099848, - "minimum_seconds": 16.94968985399464, + "maximum_seconds": 18.72887076297775, + "median_seconds": 17.11634712049272, + "minimum_seconds": 16.98897422896698, "output_identity": { "equity.parquet": "3af99a56f5f6366c769907b955bd752be34ba56d59ae475667ee12482c6ed23e", "fills.parquet": "374d0949b1a63984f6fee5c20699a67c8337d13bc89f25a81d9e7529e206d2c4" }, "samples_seconds": [ - 17.042154588008998, - 16.993152696988545, - 17.00498517000233, - 17.01882042098441, - 17.07916622198536, - 16.950593157991534, - 16.959977449994767, - 16.991588705976028, - 16.94968985399464, - 16.996677122981055 + 17.094699335983023, + 17.03593622101471, + 16.98897422896698, + 17.10580162203405, + 18.019019889063202, + 17.10270234697964, + 17.234466051100753, + 17.126892618951388, + 17.633346832939424, + 18.72887076297775 ] }, - "framework_to_ml4t_median_ratio": 0.8292416699730313, + "framework_to_ml4t_median_ratio": 0.6650299261095273, "input_bundle_sha256": "02926d8a7f1c1f5b4855b2393d1763885288ef64950d5cf521ca96c61f454d88", "ml4t_engine": { "ci_95_seconds": [ - 20.453040241001872, - 20.533734439013642 + 24.983315304969437, + 27.163160011463333 ], - "maximum_seconds": 20.610721355013084, - "median_seconds": 20.49452593299793, - "minimum_seconds": 20.434713395981817, + "maximum_seconds": 29.06330027198419, + "median_seconds": 25.73770961049013, + "minimum_seconds": 24.81676509801764, "output_identity": { - "equity.parquet": "26fbf3b10249580d4c8b6eefbd8591d20fa54140217b3ad65301c6779816260a", - "fills.parquet": "87c09726609b8a908b71b68c2a294e0d6b3cbe4f2a0f5a9b0b4840538a78571b", - "rejected_orders.parquet": "b8fd2976a15d14c33fcb387284a6292dcd8c349f0c292cbfb2c0fd74169e652c" + "equity.parquet": "9a820fbc8f125ccce82789572667ed58120140a531b18d5b3e162ec9b241fed8", + "fills.parquet": "a61d4d9a4a7781bc7d0d8d800aae715becae0a3901fc13566385e4fdf70f7379", + "rejected_orders.parquet": "c9ea02a0d5fdc7867b5c507fa2dafb5984c2d734678b22abc0098a433f4b56b1" }, "samples_seconds": [ - 20.434713395981817, - 20.453107789013302, - 20.489535972010344, - 20.554611566010863, - 20.512857312016422, - 20.532295659999363, - 20.610721355013084, - 20.4572258219996, - 20.448854660004145, - 20.499515893985517 + 25.70389811799396, + 24.81676509801764, + 26.260063141933642, + 29.06330027198419, + 24.983315304969437, + 24.871099403011613, + 25.211128549068235, + 28.066256880993024, + 27.121518951957114, + 25.7715211029863 ] } }, @@ -984,56 +940,56 @@ "framework": "backtrader", "framework_engine": { "ci_95_seconds": [ - 494.2269169654901, - 510.27303914300865 + 642.9925360960187, + 718.2018002989935 ], - "maximum_seconds": 531.350485374016, - "median_seconds": 495.55252874150756, - "minimum_seconds": 492.39570217599976, + "maximum_seconds": 755.0583970909938, + "median_seconds": 668.6108569320058, + "minimum_seconds": 571.016019238974, "output_identity": { "equity.parquet": "d4500a220d756773171dc588722b82c57cc1c7948670c2f0e41fde3862778b58", "fills.parquet": "7f3d118ca01063450d37d9e70ed6aa4ce5b6a0d975aa1b279723f516484f77b0", "rejected_orders.parquet": "28769b824e4cce25769e28dd6764b6ae4e66b1fad0b70e695eba96fa27eb3cef" }, "samples_seconds": [ - 494.49598994798725, - 514.874948451994, - 494.6423118210223, - 510.27303914300865, - 493.9578439829929, - 501.6122114500031, - 494.3053281689936, - 531.350485374016, - 496.4627456619928, - 492.39570217599976 + 571.016019238974, + 671.0628502479522, + 642.9925360960187, + 638.342453709105, + 666.1588636160595, + 755.0583970909938, + 698.4066179379588, + 737.8176368899876, + 718.2018002989935, + 663.2327087180456 ] }, - "framework_to_ml4t_median_ratio": 23.08159898786381, + "framework_to_ml4t_median_ratio": 24.8676628499208, "input_bundle_sha256": "02926d8a7f1c1f5b4855b2393d1763885288ef64950d5cf521ca96c61f454d88", "ml4t_engine": { "ci_95_seconds": [ - 21.38583242200548, - 21.54328417198849 + 26.405730456055608, + 29.40559812099673 ], - "maximum_seconds": 21.643897917994764, - "median_seconds": 21.46959268298815, - "minimum_seconds": 21.35507860200596, + "maximum_seconds": 42.88268547307234, + "median_seconds": 26.886758959502913, + "minimum_seconds": 25.705174163915217, "output_identity": { - "equity.parquet": "59159ec6ab718fc43048968a5d232c82dfb322c5f45899c212e14481c3e99773", - "fills.parquet": "bfae63fd1eacd4d7dee69d27809fb0b79c84c576d5dce15364aaf05a2ac2f936", + "equity.parquet": "69d7e17ff26305c728572cc82e019c28e29c950de01ce51940e205965de245f4", + "fills.parquet": "e5a8c855ed69fdb9a024dd8408bea72892d617f147c8e465bc9e5a1b2fab0265", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 21.419378380989656, - 21.365852688002633, - 21.39332401601132, - 21.643897917994764, - 21.35507860200596, - 21.539257733995328, - 21.54731060998165, - 21.405812156008324, - 21.53930448999745, - 21.519806984986644 + 42.88268547307234, + 25.705174163915217, + 26.063800260075368, + 26.747660652035847, + 26.934621419990435, + 26.459219371899962, + 29.40559812099673, + 30.456195703940466, + 27.435421627946198, + 26.83889649901539 ] } }, @@ -1043,55 +999,55 @@ "framework": "zipline", "framework_engine": { "ci_95_seconds": [ - 106.83352753649524, - 109.14979357349512 + 116.30800138204359, + 118.39059418800753 ], - "maximum_seconds": 117.95733895900776, - "median_seconds": 107.60890489349549, - "minimum_seconds": 106.01340769199305, + "maximum_seconds": 118.78390398900956, + "median_seconds": 117.44515722896904, + "minimum_seconds": 115.46986341103911, "output_identity": { "equity.parquet": "916f29fa7170cf1d1318154aff2bdb61db9838da5a780513ba06c42ccf98d3a0", "fills.parquet": "916a68a6f44ea70b6905cca094028d16f8746e3f19c3029637ecb50c5462306a" }, "samples_seconds": [ - 107.50128808099544, - 106.01340769199305, - 107.25137960899156, - 107.87613571700058, - 106.41567546399892, - 117.95733895900776, - 110.46573096700013, - 107.83385617999011, - 107.71652170599555, - 106.84217402699869 + 116.30800138204359, + 117.57503147993702, + 117.31528297800105, + 117.06837280991022, + 118.78390398900956, + 115.46986341103911, + 118.77544961904641, + 117.87836588697974, + 116.18007634195965, + 118.39059418800753 ] }, - "framework_to_ml4t_median_ratio": 4.862239476470224, + "framework_to_ml4t_median_ratio": 4.378906780114713, "input_bundle_sha256": "02926d8a7f1c1f5b4855b2393d1763885288ef64950d5cf521ca96c61f454d88", "ml4t_engine": { "ci_95_seconds": [ - 22.08169161299884, - 22.23382121999748 + 25.47267946100328, + 27.643725128495134 ], - "maximum_seconds": 22.393758883001283, - "median_seconds": 22.131551811515237, - "minimum_seconds": 22.03865885498817, + "maximum_seconds": 28.743276142980903, + "median_seconds": 26.82065710151801, + "minimum_seconds": 25.11828896997031, "output_identity": { - "equity.parquet": "362d9598366bf548deed068d1eface38e74b874cf352682f79cdb908381cb585", - "fills.parquet": "7af38401bed6a5453090bb124ca645df0f91cf417e0440b72e7693c7d22508a5", + "equity.parquet": "6f1c371e4fcce3c1c3829f71c398807c721e95fa7d03a5c4796119a398453a66", + "fills.parquet": "2db9ccefd91648644c1abefca68701deb4795a8e6da509935beaffa5793e8501", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 22.262472025991883, - 22.12110824297997, - 22.131656308018137, - 22.23382121999748, - 22.197976440977072, - 22.085626848973334, - 22.393758883001283, - 22.03865885498817, - 22.042274983017705, - 22.131447315012338 + 27.13208918296732, + 27.896943790954538, + 27.457406406058, + 26.06535679800436, + 28.743276142980903, + 26.509225020068698, + 25.191814696067013, + 25.11828896997031, + 25.47267946100328, + 27.39050646603573 ] } }, @@ -1101,56 +1057,56 @@ "framework": "lean", "framework_engine": { "ci_95_seconds": [ - 47.33300897400477, - 48.01486862101592 + 48.22138740302762, + 48.98638103704434 ], - "maximum_seconds": 48.93350173300132, - "median_seconds": 47.68267791901599, - "minimum_seconds": 46.93845382897416, + "maximum_seconds": 49.30812434700783, + "median_seconds": 48.43596676498419, + "minimum_seconds": 48.0310333740199, "output_identity": { - "equity.parquet": "0b28b2c49304bf054f81d906ce0673298989a3f3ef044cec0cb2e3570fc3cd2f", - "fills.parquet": "d56df1f4cb022403ddc3165d064f93b57b8bb210712775a9af3bda1fbf9eb6a6", + "equity.parquet": "a943d6831889ddc227f4f12f103797c854ae6facecac3fbe399d3a84be2579e1", + "fills.parquet": "fd13eeca0e70144131f5a231a2a2e2ec8581437035f887c3af604c9ffec5cc75", "rejected_orders.parquet": "9e7aedba072cbcd23432976f03fb6edef656da961cd2e73dbd75188842b29c36" }, "samples_seconds": [ - 48.93350173300132, - 47.391672911006026, - 47.39366507701925, - 47.275934634002624, - 47.971690761012724, - 47.977595238015056, - 47.33300897400477, - 48.01486862101592, - 48.02573545300402, - 46.93845382897416 + 48.241816133027896, + 48.25772183504887, + 48.49347179895267, + 48.0310333740199, + 48.98638103704434, + 48.945306104957126, + 48.18505297100637, + 48.99020988610573, + 49.30812434700783, + 48.378461731015705 ] }, - "framework_to_ml4t_median_ratio": 1.8083761067475683, + "framework_to_ml4t_median_ratio": 1.7493778180777746, "input_bundle_sha256": "02926d8a7f1c1f5b4855b2393d1763885288ef64950d5cf521ca96c61f454d88", "ml4t_engine": { "ci_95_seconds": [ - 26.22179457700986, - 26.472655913996277 + 27.575467039016075, + 27.796223080484197 ], - "maximum_seconds": 26.64365133800311, - "median_seconds": 26.36767746548867, - "minimum_seconds": 26.150949718023185, + "maximum_seconds": 27.996221969951876, + "median_seconds": 27.687539115024265, + "minimum_seconds": 27.42275011492893, "output_identity": { - "equity.parquet": "b175134ca1075055bfc2ded472e325749b34a359f6d957ffdd897b0521fae957", - "fills.parquet": "b42bfe84ffe2954289ba5138fed5bc4f487d356c7192f3f67521c6d6e50c5059", + "equity.parquet": "b5907100a1469d4c9ff63af5bbeb328cddf7d72e6d98db6d9d796488c95545f2", + "fills.parquet": "7eb685423c6c5f32168e7ea8cc19976cb0404aef075514e657cee9e3f458d0d2", "rejected_orders.parquet": "043204927e46137ca25cc0fa23883f4868af2b57f48c50d034fd56e113da12e8" }, "samples_seconds": [ - 26.37750849899021, - 26.23593746300321, - 26.49751085101161, - 26.23863661501673, - 26.150949718023185, - 26.472655913996277, - 26.443023221974727, - 26.357846431987127, - 26.64365133800311, - 26.20495253900299 + 27.42275011492893, + 27.996221969951876, + 27.575467039016075, + 27.580613891012035, + 27.69515867100563, + 27.767369729001075, + 27.528504969901405, + 27.825076431967318, + 27.6799195590429, + 27.79439065803308 ] } } diff --git a/validation/REAL_STRATEGY_RESULTS.json b/validation/REAL_STRATEGY_RESULTS.json index bdfeaba4..6ea17fa4 100644 --- a/validation/REAL_STRATEGY_RESULTS.json +++ b/validation/REAL_STRATEGY_RESULTS.json @@ -20,7 +20,7 @@ "us_equities_panel": "session date" } }, - "generated_at": "2026-09-15T13:31:29.709411+00:00", + "generated_at": "2026-09-24T01:50:22.858146+00:00", "provenance": { "adapters": { "backtrader": "a456cc3bf9ed6708d239f21ed2c2495a3c19a9a8306634fee24619c2c0484459", @@ -131,9 +131,9 @@ }, "machine": "x86_64", "ml4t": { - "commit": "281370c037f64f4f37556acf0929622027c50e6d", + "commit": "0524f8ecef19a83d8950b4cfc6fb6a384ec459cf", "dirty": false, - "engine_source_sha256": "d17db9333a28f2341881682bcaa2158e6fdcb815b678750ea9b564c840ff04c2" + "engine_source_sha256": "553e3e351f2de59bc17fc4f42b67e4e4f923d059b44eecd6a69e114df4b4a8fe" }, "platform": "Linux-6.8.0-139-generic-x86_64-with-glibc2.39", "python": "3.12.11" @@ -142,13 +142,13 @@ { "case_study": "etfs", "engine_seconds": { - "framework": 0.3012495180009864, - "ml4t": 0.4178840699896682, + "framework": 1.1340462770313025, + "ml4t": 1.2943288600072265, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "0857701369d558da786b842946e1442b1543e038f17c0fad1305d5af6128b047", - "ml4t_manifest_sha256": "7f2f8b3bd9165093f183916981a53f83310d139b97e0a5223aa4ae67378b4b5c" + "framework_manifest_sha256": "44f1ffaf162456369fa006ef5aa075a20423127a3f827dd4211adec1fa442cd6", + "ml4t_manifest_sha256": "6471618270dbf4dfa8fed409e074fb3ff96647d8e12a856b089de3cf982a82f9" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -209,13 +209,13 @@ { "case_study": "etfs", "engine_seconds": { - "framework": 0.17159602098399773, - "ml4t": 0.4245808279956691, + "framework": 1.0902336100116372, + "ml4t": 1.2939870660193264, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "d633cea890b0c0e2968e821ab74415568383575331e2662597e247b6f12fa2d6", - "ml4t_manifest_sha256": "d148fb7911e0d163bcb13c2071aadb0dfb6439db4a92eee636fe9a73ffa3ebfb" + "framework_manifest_sha256": "4bda15d8c20b681d468a1f54c81a967f7fb9a12d6a0fe65bcf122b433c0e4025", + "ml4t_manifest_sha256": "ce8e5c941b9682e822e6b7653a81ea4b635228ffe6f9fd9868d625d46a7b68e6" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -276,13 +276,13 @@ { "case_study": "etfs", "engine_seconds": { - "framework": 9.250578976003453, - "ml4t": 0.431104512012098, + "framework": 23.003436908009462, + "ml4t": 1.1710805310867727, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "6055f77fc340eb56b5b8a6b1fa9f07408448a5a8855f0cec07d9ee30ab9a7de0", - "ml4t_manifest_sha256": "e299ff8dc5ab8447f040b0ad5ecf928bb36cb30a2fb2213e7f1d99d76c1cf78a" + "framework_manifest_sha256": "2c321c02039c5d16ae30a4bc677b4779dbc8719896fa16262a10f9be9aedae95", + "ml4t_manifest_sha256": "4c34a465e1ed166eabe4b33c1905409e1b440b81fdebb918473b06b1d47458ef" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -353,13 +353,13 @@ { "case_study": "etfs", "engine_seconds": { - "framework": 3.8712173529784195, - "ml4t": 0.6347731610003393, + "framework": 15.617935788934119, + "ml4t": 1.7292126989923418, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "8aba98c66cb38ab9aa74ee86c92cf4e8f880a0fcfd8381f3e2317c887f22abe0", - "ml4t_manifest_sha256": "e445525ec3360707e6312836d2998293b2ebc6c7f62cdbdcc938c24edbafb791" + "framework_manifest_sha256": "9259f755de4a5deefaf89e6a6d13647061193db0e49d89e7631314b1c3e65c0d", + "ml4t_manifest_sha256": "d9a6f6210dd5d378d9a244c73772aefdaffbed945a6add5e611c2b2a0d2716e8" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -420,13 +420,13 @@ { "case_study": "etfs", "engine_seconds": { - "framework": 2.563918431987986, - "ml4t": 0.7410554349771701, + "framework": 7.2253005559323356, + "ml4t": 2.232580687967129, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "34281a2e326ebfff7ab7aec45c9937417207ea0e207c80e62cfbae6333f0752b", - "ml4t_manifest_sha256": "e6651f07bee5cedd81514a2497ab6eebfdb86265a2249859bd7602ae1780ff68" + "framework_manifest_sha256": "8dfa63a34ca4f69adfe5a5ebb667ab74114c5545dd05097870b37f2ce3dd2161", + "ml4t_manifest_sha256": "7b8426a090590cd9e9a96977c308de245349e8f5d94d95721a621d74885483ef" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -515,13 +515,13 @@ { "case_study": "cme_futures", "engine_seconds": { - "framework": 0.29563516899361275, - "ml4t": 0.40924375399481505, + "framework": 1.0428278229665011, + "ml4t": 0.9311261599650607, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "7fd530a9b9b98092a83945383e50fce623d12e1474e75a731a2df3d5d72b49cb", - "ml4t_manifest_sha256": "336f5df8ccedf6ad24aee3805df33dbf52025c7e891037f11db783a13b5f2dc9" + "framework_manifest_sha256": "65264b17b19a01dfa389029873603b9fe9a5d04db757d9070167364c94a936da", + "ml4t_manifest_sha256": "0a0960be223d66a24b88568160bdca9e3f5adb2b17bc58954ce24bfd223192ba" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -589,13 +589,13 @@ { "case_study": "cme_futures", "engine_seconds": { - "framework": 2.5101827140024398, - "ml4t": 0.4099253540043719, + "framework": 5.8616829010425135, + "ml4t": 1.1937822529580444, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "53ba1365c98a2bf83c2cb3cb64997b033f0f9477bb679ea04f6976241fd4a73c", - "ml4t_manifest_sha256": "1c517cb75ca2d7a6ca50dbfc635f34f0f4763d16a4f53d0a49f6483351a40b13" + "framework_manifest_sha256": "ec546c132593e7167baf8ccb5069ce7fb46ecc6af57d32dfdf7cde8cef87fd41", + "ml4t_manifest_sha256": "5258c7bd6e49a847afd875cf7346eb2f4b4fcd462bce579535a80d81de891fa8" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -708,13 +708,13 @@ { "case_study": "crypto_perps_funding", "engine_seconds": { - "framework": 2.9909919280034956, - "ml4t": 0.6590293670014944, + "framework": 8.64055325195659, + "ml4t": 1.861964081064798, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "259233699dc3af904ec90adf9a84370fcf8853606e12fd8b5e2310f0556db1db", - "ml4t_manifest_sha256": "153fef720f284160fe7a5ce646b16e0713e68e9b1600160dbdd78b8be7c6b14a" + "framework_manifest_sha256": "fc1ddaaf85d1bce9f43e6f7afd2fb9c7398bf4b2cdbb703e5fc670fe5f7e98ad", + "ml4t_manifest_sha256": "7b84318804e49728015771eae24b58a82c63963fd5e77167bb7765987f3d5707" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -785,13 +785,13 @@ { "case_study": "fx_pairs", "engine_seconds": { - "framework": 0.2835449099948164, - "ml4t": 0.14416209698538296, + "framework": 1.0617584569845349, + "ml4t": 0.2712107199477032, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "acf8b79953602475ee47fefa6626876269680803300780c0ac00eeac80be50ea", - "ml4t_manifest_sha256": "97ed99acebd247fa7dbdbb8f4fac8ca8a7a2890dc688f62daa31ad42dcea56a4" + "framework_manifest_sha256": "63b30da67c7b8315b1bfa355479d78d083046543cabb8f49e5fa0083a6338697", + "ml4t_manifest_sha256": "84ed03c40397d27e5024027ee66d0c0647774d4a26548960ca0fa062dc14036c" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -852,13 +852,13 @@ { "case_study": "fx_pairs", "engine_seconds": { - "framework": 0.1381809530139435, - "ml4t": 0.14607450299081393, + "framework": 0.43417372403200716, + "ml4t": 0.367529945098795, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "bc659ef47bd87ae040feb9befa0554d04e177168e19423a5b8732b87ac9b8204", - "ml4t_manifest_sha256": "c33e799ac8aa6687f1b19d686c88498f98389a8c9cf54985c86b0a0322a1648b" + "framework_manifest_sha256": "7bae5d5874974aaacab017c970db33df1b65beb918449084be6d774057063007", + "ml4t_manifest_sha256": "5a17717ceebcf9ac1cedd534815b60da584f6c3fb654671fd98420ea3e1b37d7" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -919,13 +919,13 @@ { "case_study": "fx_pairs", "engine_seconds": { - "framework": 0.4282577330013737, - "ml4t": 0.14699051299248822, + "framework": 1.0406507139559835, + "ml4t": 0.17209112795535475, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "1d1a97e611e8190705535a720dd8a4999c0d6a4b4a0b9810c35f24fb9ccd9e4b", - "ml4t_manifest_sha256": "fdf726c843df36a55f74f78c719e61be420fc27422b297ee35d65238f4206f19" + "framework_manifest_sha256": "09a6ccdf1c3c28051988e232e8515975f5cdec92f14efd98ffaa2a93dcef8902", + "ml4t_manifest_sha256": "32edb292c16b71cb2fb06225d6cef7e34409cbdbd26b62cfe1dc1718412703ea" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -1003,13 +1003,13 @@ { "case_study": "fx_pairs", "engine_seconds": { - "framework": 0.8820918059791438, - "ml4t": 0.15384751901729032, + "framework": 0.9791555179981515, + "ml4t": 0.17271842202171683, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "a8b7c8cdfae148cbcf290984ed766702cc34ffedfa8785676fee04ae062412ae", - "ml4t_manifest_sha256": "e14d67ca7a7942f0af203d5b1fac189ac1fdb634ba274778d6bb8cb6aefa1b6e" + "framework_manifest_sha256": "b08f5d5fb24c7ea3b6a2545c86cdc49b94050eb136be545ffae0714ccd76cfe4", + "ml4t_manifest_sha256": "ebb575c8a173bb875d61ae646d6f96a978305ffe733ac4820935f83591139b0e" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -1080,13 +1080,13 @@ { "case_study": "us_equities_panel", "engine_seconds": { - "framework": 1.1545971239975188, - "ml4t": 22.224579717993038, + "framework": 1.2900436379713938, + "ml4t": 38.869148430996574, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "6fea1b999ad392fd24f83bc00605b19ca790beb44c9463a9729a6e9c09a7e463", - "ml4t_manifest_sha256": "b6086ba08236e1dd4210d2a652c60383ab871d1afc15d00244834cb0e13b9d9c" + "framework_manifest_sha256": "acdc68f862f8ba07917fd16a59a19ec5ac6825b8a26f5ec8c16bc96c9bf4a1e7", + "ml4t_manifest_sha256": "88ae2e84f8d3187a4f5d84e78fe8441f5bc652cb08ce4d5591018edbc50d5587" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -1147,13 +1147,13 @@ { "case_study": "us_equities_panel", "engine_seconds": { - "framework": 32.34269512802712, - "ml4t": 22.326772470987635, + "framework": 37.01605061593, + "ml4t": 26.629379056044854, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "8e4166623fe420e7bb87d5f434ef2cdcd9682fbc06c229a6df5e001574614fa9", - "ml4t_manifest_sha256": "ed1ccfa36b774ed5b9bca71633696b89ebfe009e7228bf74b07f38c27d7f891f" + "framework_manifest_sha256": "ef2984a8e6e6d7a5f9803333535f28fa8c6e35f6bb225b57a57139c9153e6136", + "ml4t_manifest_sha256": "eb964c1f9ed81c7c94b7c50da65faa242e1474c697b63ceb54acd77b01a5dba5" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -1214,13 +1214,13 @@ { "case_study": "us_equities_panel", "engine_seconds": { - "framework": 530.3924281870131, - "ml4t": 23.18982775398763, + "framework": 616.757336596027, + "ml4t": 89.94735079002567, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "46be7e38fdf76ac620845561a8c57b46c6d7990cedf45baca8cbdf78497b750f", - "ml4t_manifest_sha256": "935fcdc814bce29138288f43b97e5a1526b81dee445c7ff810849554ac2b33f3" + "framework_manifest_sha256": "8095a3f577941a4e25156608891932c18d177d6f9668122f0e9cad2b025b5280", + "ml4t_manifest_sha256": "15b5f87860d12c3f895c827d10bc867361a1e63347db0b96d69472775e161691" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -1291,13 +1291,13 @@ { "case_study": "us_equities_panel", "engine_seconds": { - "framework": 111.63805862699519, - "ml4t": 25.451601275999565, + "framework": 344.35249897500034, + "ml4t": 80.43836854794063, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "63a619ddd54011c1a5fa61331be937a3f1ecf7b21157b7ecf5af5e12415a5995", - "ml4t_manifest_sha256": "b38286e4b3424bfe0db6d14afd5713ff4e4e1db4ce6a1bc4997ec6b975f5ddbc" + "framework_manifest_sha256": "072b1be44da81fd4cff02b38297784a617978175db3aeab1e2bb3a1ddb0ff7e0", + "ml4t_manifest_sha256": "d641a7796c7bb76ceff56fb7333ab04085f77b259f1b1040c51cbefa33407bfe" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", @@ -1358,13 +1358,13 @@ { "case_study": "us_equities_panel", "engine_seconds": { - "framework": 58.19452185500995, - "ml4t": 29.576513651001733, + "framework": 50.57741306605749, + "ml4t": 28.115149467950687, "single_run_diagnostic_only": true }, "evidence": { - "framework_manifest_sha256": "26a68bf79514cdfcc7c5c46c5fa34a3e02fc119a572bc9f6f10f97c4f70ee456", - "ml4t_manifest_sha256": "d3fe4950c969353d22da071b732bbba13f904dbf26c789631933cc8e98e22527" + "framework_manifest_sha256": "65de260c3f364fabc16a83a1c87339abee1c408fbb735782456942289fd3105b", + "ml4t_manifest_sha256": "5149fb64bc949f7b8a05ea34dc50b6be9dca9715bebc78d1891bf9e753c15f39" }, "excluded_surfaces": { "accepted_unfilled_orders": "not exposed by every native runner", diff --git a/validation/book_link_manifest.json b/validation/book_link_manifest.json new file mode 100644 index 00000000..cd6888f3 --- /dev/null +++ b/validation/book_link_manifest.json @@ -0,0 +1,27 @@ +{ + "commit": "366e1d51ace2d851776499a68da3d6e3c2641b02", + "blobs": { + "16_strategy_simulation/02_futures_backtesting.ipynb": "66fd0866d02a5e30e6f07d9ae5b2f39d121f1d98", + "16_strategy_simulation/04_single_asset_ml4t_backtest.ipynb": "7cdb38231b8250531db3c17eebcb63959471fda0", + "16_strategy_simulation/05_stateful_strategies.ipynb": "c9b0e806e72cf904ccc48945f06eda96eece6e9b", + "16_strategy_simulation/07_engine_divergence_anatomy.ipynb": "aeb353475eb93c14ab42abbc5db60275df893242", + "16_strategy_simulation/09_performance_reporting.ipynb": "12dde67e2edf8df5b1f2c7aae2e2b7525c765219", + "16_strategy_simulation/16_case_study_lean_parity.ipynb": "de98d605792b27609ffe0d0c03dab259ec6171b9", + "16_strategy_simulation/README.md": "b436ed0350cbec2cb5a9fb277130204d74f133cc", + "17_portfolio_construction/01_portfolio_metrics.ipynb": "3af45156465b3ce47c392dc6af986a894a8170b0", + "17_portfolio_construction/07_conformal_position_sizing.ipynb": "7a3753f0e58937f5f1df7d6b8984e56b30edeb7d", + "17_portfolio_construction/08_library_comparison.ipynb": "d25ec660b899b6bde0d4641b01c180ebd96379bc", + "17_portfolio_construction/README.md": "174d5bcd8f168d1dc65618ed8d1c2eb66e0b7f98", + "18_transaction_costs/03_market_impact_calibration.ipynb": "f38fefefec5196418891e555b595831ca750b315", + "18_transaction_costs/10_gross_vs_net_performance.ipynb": "29cb706310c8cef06da42b6971031f0eeceb312b", + "18_transaction_costs/README.md": "ccaebc87036f89db7af2885b750fc7006f33c0f2", + "19_risk_management/02_exit_strategies.ipynb": "31c39d3f5659799e50a25202b4844ba50103db61", + "19_risk_management/10_ml4t_backtest_risk_demo.ipynb": "f2876ce2b5257d48d9334ed5b80c4e0d603581e7", + "19_risk_management/README.md": "902319e5f5b6d84c4d25a29c79283f1356b4a22b", + "case_studies/cme_futures/13_backtest.ipynb": "0f8889fe88b8af872ebaa97665c792d80992b6de", + "case_studies/crypto_perps_funding/16_costs.ipynb": "c1e414e238db695c5676c8a4ec7571ae09350ea3", + "case_studies/etfs/14_backtest.ipynb": "75ee6786570de1f954891094c5d2768280fee0ab", + "case_studies/etfs/16_risk_management.ipynb": "9cafc173af7709109a2ff2b7180fac46df79ca58", + "case_studies/fx_pairs/13_backtest.ipynb": "6c945090230f659cc5c6baf4940526d48ad807b5" + } +} diff --git a/validation/check_artifacts.py b/validation/check_artifacts.py index 2138b7b2..2c29b036 100644 --- a/validation/check_artifacts.py +++ b/validation/check_artifacts.py @@ -83,14 +83,22 @@ def artifact_failures(directory: Path) -> list[str]: package_files = {path.removeprefix("src/") for path in _python_files(_PACKAGE)} | { "ml4t/backtest/py.typed" } - expected_wheel = package_files | { - f"{dist_info}/METADATA", - f"{dist_info}/WHEEL", - f"{dist_info}/licenses/LICENSE", - f"{dist_info}/RECORD", + package_data = { + path.relative_to(_ROOT).as_posix() for path in (_PACKAGE / "example_data").glob("*.csv") } + expected_wheel = ( + package_files + | {path.removeprefix("src/") for path in package_data} + | { + f"{dist_info}/METADATA", + f"{dist_info}/WHEEL", + f"{dist_info}/licenses/LICENSE", + f"{dist_info}/RECORD", + } + ) expected_sdist = ( _python_files(_PACKAGE) + | package_data | _python_files(_TESTS) | set().union(*(_data_files(path) for path in _APPROVED_TEST_DATA)) | { diff --git a/validation/check_book_links.py b/validation/check_book_links.py new file mode 100644 index 00000000..030b9673 --- /dev/null +++ b/validation/check_book_links.py @@ -0,0 +1,102 @@ +"""Verify pinned companion-repository links against a checked Git tree.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path +from urllib.parse import unquote + +_ROOT = Path(__file__).parents[1] +_MANIFEST = _ROOT / "validation" / "book_link_manifest.json" +_COMPANION_URL = "https://github.com/stefan-jansen/machine-learning-for-trading" +_BLOB_URL = re.compile( + re.escape(_COMPANION_URL) + r"/blob/(?P[0-9a-f]{40})/(?P[^)\s#?]+)" +) +_MARKDOWN_URL = re.compile( + r"\]\((https://github\.com/stefan-jansen/machine-learning-for-trading[^)]+)\)" +) + + +def documentation_paths() -> list[Path]: + return sorted((_ROOT / "docs").rglob("*.md")) + [_ROOT / "README.md"] + + +def companion_links(paths: list[Path]) -> dict[str, str]: + """Return linked companion paths and reject mutable or unsupported URL forms.""" + links: dict[str, str] = {} + for page in paths: + for match in _MARKDOWN_URL.finditer(page.read_text(encoding="utf-8")): + url = match.group(1) + if url == _COMPANION_URL or url == _COMPANION_URL + "/": + continue + blob = _BLOB_URL.fullmatch(url) + if blob is None: + raise ValueError(f"Unpinned companion URL in {page}: {url}") + path = unquote(blob.group("path")) + previous = links.setdefault(path, blob.group("commit")) + if previous != blob.group("commit"): + raise ValueError(f"Multiple companion revisions for {path}") + return links + + +def check_links(paths: list[Path], manifest_path: Path = _MANIFEST) -> None: + """Check every linked path against the manifest generated from the companion Git tree.""" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + commit = manifest["commit"] + blobs = manifest["blobs"] + links = companion_links(paths) + for path, revision in links.items(): + if revision != commit: + raise ValueError(f"Companion link uses {revision}, expected {commit}: {path}") + if path not in blobs: + raise ValueError(f"Companion path does not exist at {commit}: {path}") + if paths == documentation_paths() and set(links) != set(blobs): + raise ValueError("Book link manifest does not match the current documentation links") + print(f"Checked {len(links)} companion paths at {commit}") + + +def write_manifest(companion_repo: Path) -> None: + """Record linked blob identities from the checked companion revision.""" + commit = subprocess.check_output( + ["git", "-C", str(companion_repo), "rev-parse", "HEAD"], text=True + ).strip() + links = companion_links(documentation_paths()) + if set(links.values()) != {commit}: + raise ValueError(f"All companion links must use checked revision {commit}") + tree = subprocess.check_output( + ["git", "-C", str(companion_repo), "ls-tree", "-r", commit], text=True + ) + available: dict[str, str] = {} + for line in tree.splitlines(): + metadata, path = line.split("\t", 1) + mode, object_type, object_id = metadata.split() + if mode in {"100644", "100755"} and object_type == "blob": + available[path] = object_id + missing = set(links) - set(available) + if missing: + raise ValueError(f"Companion paths absent from {commit}: {sorted(missing)}") + manifest = {"commit": commit, "blobs": {path: available[path] for path in sorted(links)}} + _MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print(f"Recorded {len(links)} companion paths at {commit}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("paths", nargs="*", type=Path) + parser.add_argument("--write", action="store_true") + parser.add_argument("--companion-repo", type=Path) + args = parser.parse_args() + if args.write: + if args.paths or args.companion_repo is None: + parser.error("--write requires --companion-repo and no path arguments") + write_manifest(args.companion_repo) + else: + check_links(args.paths or documentation_paths()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/validation/check_documentation_examples.py b/validation/check_documentation_examples.py index f55bce94..064f7ec2 100644 --- a/validation/check_documentation_examples.py +++ b/validation/check_documentation_examples.py @@ -3,19 +3,30 @@ from __future__ import annotations import argparse +import difflib import os import re import subprocess import sys import tempfile from dataclasses import dataclass +from importlib.metadata import version from pathlib import Path _ROOT = Path(__file__).parents[1] _DEFAULT_PATHS = ( _ROOT / "README.md", + _ROOT / "docs" / "index.md", _ROOT / "docs" / "getting-started" / "installation.md", _ROOT / "docs" / "getting-started" / "quickstart.md", + _ROOT / "docs" / "tutorials" / "data.md", + _ROOT / "docs" / "tutorials" / "orders-and-timing.md", + _ROOT / "docs" / "tutorials" / "multiasset-rebalancing.md", + _ROOT / "docs" / "tutorials" / "costs-and-funding.md", + _ROOT / "docs" / "tutorials" / "accounts-and-constraints.md", + _ROOT / "docs" / "tutorials" / "risk-and-state.md", + _ROOT / "docs" / "tutorials" / "profiles-and-parity.md", + _ROOT / "docs" / "tutorials" / "results-and-analysis.md", _ROOT / "docs" / "user-guide" / "execution-semantics.md", ) _REQUIRED_EXAMPLES = frozenset( @@ -23,7 +34,25 @@ "installation-import", "preopen-mixed-rules", "readme-quickstart", + "home-example", "quickstart-minimal", + "smoke-equity", + "smoke-etf", + "smoke-future", + "smoke-fx", + "smoke-crypto-perp", + "tutorial-orders-timing", + "tutorial-multiasset-equity-etf", + "tutorial-multiasset-future", + "tutorial-multiasset-fx", + "tutorial-costs-equity", + "tutorial-costs-funding", + "tutorial-costs-flip", + "tutorial-accounts-constraints", + "tutorial-risk-state", + "tutorial-profiles-inspect", + "tutorial-profiles-one-setting", + "tutorial-results-export", } ) _EXAMPLE = re.compile( @@ -31,6 +60,11 @@ r"```(?Ppython|bash)\n(?P.*?)\n```", flags=re.DOTALL, ) +_OUTPUT = re.compile( + r"\s*" + r"```text\n(?P.*?)\n```", + flags=re.DOTALL, +) @dataclass(frozen=True) @@ -39,6 +73,7 @@ class Example: language: str code: str source: Path + expected_output: str | None = None def collect_examples( @@ -48,19 +83,34 @@ def collect_examples( examples: list[Example] = [] names: set[str] = set() for path in paths: - for match in _EXAMPLE.finditer(path.read_text(encoding="utf-8")): + content = path.read_text(encoding="utf-8") + outputs: dict[str, str] = {} + for match in _OUTPUT.finditer(content): + name = match["name"] + if name in outputs: + raise ValueError(f"Duplicate documentation output name: {name}") + outputs[name] = match["output"] + "\n" + page_names: set[str] = set() + for match in _EXAMPLE.finditer(content): name = match["name"] if name in names: raise ValueError(f"Duplicate documentation example name: {name}") names.add(name) + page_names.add(name) + if name.startswith(("smoke-", "tutorial-")) and name not in outputs: + raise ValueError(f"Documentation example {name} has no recorded output") examples.append( Example( name=name, language=match["language"], code=match["code"], source=path, + expected_output=outputs.get(name), ) ) + unknown = outputs.keys() - page_names + if unknown: + raise ValueError(f"Documentation outputs have no example in {path}: {sorted(unknown)}") if require_all: missing = sorted(_REQUIRED_EXAMPLES - names) if missing: @@ -79,14 +129,50 @@ def run_examples(examples: list[Example]) -> None: environment.pop("PYTHONPATH", None) for example in examples: - print(f"Running {example.name} from {example.source.relative_to(_ROOT)}") + display_source = ( + example.source.relative_to(_ROOT) + if example.source.is_relative_to(_ROOT) + else example.source + ) + print(f"Running {example.name} from {display_source}") if example.language == "python": script = workdir / f"{example.name}.py" script.write_text(example.code + "\n", encoding="utf-8") command = [sys.executable, "-I", str(script)] else: command = ["bash", "-euo", "pipefail", "-c", example.code] - subprocess.run(command, cwd=workdir, env=environment, check=True) + completed = subprocess.run( + command, + cwd=workdir, + env=environment, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if completed.returncode: + raise RuntimeError( + f"Documentation example {example.name} failed:\n" + f"{completed.stdout}{completed.stderr}" + ) + if example.expected_output is not None: + expected = example.expected_output.replace( + "{package_version}", version("ml4t-backtest") + ) + if completed.stdout != expected: + diff = "".join( + difflib.unified_diff( + expected.splitlines(keepends=True), + completed.stdout.splitlines(keepends=True), + fromfile="documented", + tofile="executed", + ) + ) + raise AssertionError( + f"Documentation example {example.name} output differs:\n{diff}" + ) + elif completed.stdout: + print(completed.stdout, end="") def main() -> int: diff --git a/validation/release_checks.txt b/validation/release_checks.txt index 66a386d5..a42181f6 100644 --- a/validation/release_checks.txt +++ b/validation/release_checks.txt @@ -1,4 +1,5 @@ validation/check_artifacts.py +validation/check_book_links.py validation/check_coverage.py validation/check_documentation_examples.py validation/check_documentation_identity.py