diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..86ce92ac --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: "docs" + cancel-in-progress: true + +jobs: + build: + name: Build & Deploy Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --dev --extra docs + + - name: Build docs + run: uv run mkdocs build --strict + + - name: Deploy to website repo + uses: cpina/github-action-push-to-another-repository@v1.7.2 + env: + SSH_DEPLOY_KEY: ${{ secrets.DOCS_DEPLOY_KEY }} + with: + source-directory: site/ + destination-github-username: ml4t + destination-repository-name: website + target-directory: static/docs/backtest/ + target-branch: main + commit-message: "docs(backtest): update from ml4t/backtest@${{ github.sha }}" + user-name: ml4t-bot + user-email: bot@ml4trading.io diff --git a/.gitignore b/.gitignore index 5577d6b4..192305ba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ *.egg-info/ dist/ build/ +site/ eggs/ *.egg diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0868cadf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +## 0.1.0b11 - 2026-03-24 + +### Added + +- `BacktestResult.predictions` and `BacktestResult.to_predictions_dataframe()` to preserve + the raw prediction or model-input DataFrame passed into the backtest. +- `predictions.parquet` export/import support in `BacktestResult.to_parquet()` and + `BacktestResult.from_parquet()`. + +### Changed + +- Engine results now treat the raw `signals_df` input surface as predictions for downstream + diagnostics, matching `ml4t-diagnostic`'s current contract. +- Parquet import falls back from legacy `signals.parquet` to the new predictions surface. +- User guides and README now document the raw predictions surface for downstream analysis. + +### Validation + +- `uv run ruff check src/ml4t/backtest/result.py src/ml4t/backtest/engine.py tests/test_result.py tests/test_core.py` +- `uv run pytest tests/test_result.py tests/test_core.py -q` +- `uv run ty check` +- `uv run python -m mkdocs build --strict` + +## 0.1.0b10 - 2026-03-24 + +### Added + +- `BacktestConfig` support for serialized top-level `feed` and passthrough `metadata` + sections, enabling sparse input presets with generic provenance fields. +- `BacktestResult.to_spec_dict()` for a resolved runtime snapshot containing the full + replayable config, library version, and realized run window. +- `spec.yaml` export alongside `config.yaml` in `BacktestResult.to_parquet()`. + +### Changed + +- `BacktestConfig.to_dict()` now emits plain-data feed metadata that round-trips safely + through dict, YAML, and Parquet persistence workflows. +- `BacktestResult.from_parquet()` now falls back to `spec.yaml` when `config.yaml` is absent. +- User guides now document the config workflow for sparse input, resolved output, `feed`, + `metadata`, and reproducibility exports. + +### Validation + +- `uv run ruff check src/ml4t/backtest/config.py src/ml4t/backtest/result.py tests/test_broker.py tests/test_result.py` +- `uv run pytest tests/test_broker.py tests/test_result.py -q` +- `uv run ty check` +- `uv run python -m mkdocs build --strict` + +## 0.1.0b9 - 2026-03-24 + +### Added + +- Quote-aware `DataFeed` support for `price_col`, bid, ask, midpoint, and quote-size caches. +- New `ExecutionPrice` sources: `price`, `bid`, `ask`, `quote_mid`, and `quote_side`. +- Separate `mark_price` configuration for open-position marking. +- `BacktestResult.to_fills_dataframe()` and persisted `fills.parquet` export/import. +- `BacktestResult.to_portfolio_state_dataframe()` and persisted `portfolio_state.parquet`. +- Quote context fields on `Fill` and summarized quote context on `Trade`. +- Activity and exposure metrics: `num_fills`, `num_rebalance_events`, `unique_symbols_traded`, + `total_filled_notional`, `avg_turnover`, `max_turnover`, `avg_open_positions`, + and `max_open_positions`. + +### Changed + +- `FeedSpec.price_col` now drives the broker reference price instead of being collapsed back to `close`. +- Market execution can use side-aware quotes: buys at ask, sells at bid. +- `QUOTE_SIDE` marking prices long inventory on the bid and short inventory on the ask. +- Result persistence now includes fills alongside trades, equity, daily P&L, metrics, and config. +- Result persistence now includes portfolio state alongside trades, fills, equity, + daily P&L, metrics, and config. +- User guides and README now document quote-aware feeds, mark pricing, and fill export. +- User guides and README now document portfolio-state reporting and quote-aware audit fields. + +### Performance + +- Legacy OHLCV hot path remains faster than the pre-optimization baseline. +- Quote-aware execution adds moderate overhead relative to the optimized OHLCV path, while staying ahead of the legacy baseline in local benchmarks. + +### Validation + +- `uv run ty check` +- `pre-commit run --all-files` +- `uv run pytest tests/ -q` diff --git a/README.md b/README.md index f91db79a..9638c593 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ Backtesting requires accurate simulation of order execution, position tracking, - Event-driven architecture with point-in-time correctness (no look-ahead bias) - Exit-first order processing matching real broker behavior - Configurable execution modes (same-bar or next-bar fills) +- Quote-aware execution and marking with `price`, bid, ask, midpoint, and side-aware sources - Position-level risk rules (stop-loss, take-profit, trailing stops) - Portfolio-level constraints (max positions, drawdown limits) - Cash, margin, and crypto account policies +- First-class trade, fill, and portfolio-state export for audit and downstream analysis - 40+ behavioral knobs for framework-specific parity The same Strategy class used in backtesting works unchanged in ml4t-live for production deployment. @@ -46,7 +48,7 @@ class SignalStrategy(Strategy): def on_data(self, timestamp, data, context, broker): for asset, bar in data.items(): signal = bar.get("signals", {}).get("prediction", 0) - price = bar.get("close", 0) + price = bar.get("price", bar.get("close", 0)) position = broker.get_position(asset) if position is None and signal > 0.5: @@ -68,8 +70,11 @@ result = engine.run() print(f"Total Return: {result.metrics['total_return_pct']:.2f}%") print(f"Sharpe Ratio: {result.metrics['sharpe']:.2f}") +print(result.to_fills_dataframe().head()) ``` +`bar["price"]` follows `FeedSpec.price_col` when you provide one, so the same strategy works for close-based bars and quote-aware feeds. + ## Risk Management Position-level exit rules: @@ -135,6 +140,59 @@ config = BacktestConfig( ) ``` +## Quote-Aware Execution + +```python +from ml4t.backtest import BacktestConfig, DataFeed +from ml4t.backtest.config import ExecutionPrice + +feed = DataFeed( + prices_df=quotes, + 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, +) +``` + +With `QUOTE_SIDE`, buys fill at the ask and sells fill at the bid when quotes are present. `mark_price` is configured separately, so you can trade on one source and mark the book on another. + +Quote-aware runs also preserve the microstructure context in the result surface: + +- `result.to_fills_dataframe()` includes bid/ask/midpoint/spread/size context +- `result.to_trades_dataframe()` includes nullable entry/exit quote summaries +- `result.to_portfolio_state_dataframe()` reflects the configured mark source over time +- `result.to_predictions_dataframe()` preserves the raw model/input surface for downstream + diagnostics + +## Reproducible Config Snapshots + +`BacktestConfig` is also the serializable backtest preset surface. You can keep +input configs sparse, then persist the fully resolved config that actually ran. + +```python +config = BacktestConfig.from_yaml("config/my_backtest.yaml") +result = Engine(feed, strategy, config).run() + +resolved_config = result.config.to_dict() +runtime_spec = result.to_spec_dict() +written = result.to_parquet("results/run_001") +``` + +The exported result directory includes: + +- `config.yaml` for the replayable resolved config payload +- `spec.yaml` for the richer runtime snapshot with library version and realized run window + +Use top-level `feed` in `BacktestConfig` for generic feed semantics and top-level +`metadata` for user-defined provenance like input paths or strategy ids. + ## Commission and Slippage ```python @@ -234,12 +292,14 @@ Benchmark on 250 assets x 20 years daily data (1.26M bars): ## Documentation - [Getting Started](docs/getting-started/quickstart.md) — your first backtest +- [Data Feed](docs/user-guide/data-feed.md) — `price_col`, quote columns, and feed wiring - [Strategies](docs/user-guide/strategies.md) — strategy interface and templates - [Stateful Strategies](docs/user-guide/stateful-strategies.md) — advanced event-driven patterns (Kelly sizing, pairs trading, circuit breakers) - [Execution Semantics](docs/user-guide/execution-semantics.md) — fill timing, ordering, stops - [Configuration](docs/user-guide/configuration.md) — 40+ behavioral knobs - [Risk Management](docs/user-guide/risk-management.md) — stops, trails, portfolio limits - [Rebalancing](docs/user-guide/rebalancing.md) — weight-based portfolio management +- [Results & Analysis](docs/user-guide/results.md) — trades, fills, equity, and Parquet export - [Market Impact](docs/user-guide/market-impact.md) — commission, slippage, and impact models - [Profiles](docs/user-guide/profiles.md) — framework parity presets @@ -248,7 +308,8 @@ Benchmark on 250 assets x 20 years daily data (1.26M bars): - **Event-driven**: Each bar processes sequentially with exit-first logic - **Point-in-time**: No access to future data within strategy callbacks - **Configurable fills**: Match behavior of different backtesting frameworks -- **Parquet export**: Results serializable for analysis with ml4t-diagnostic +- **Quote-aware**: Optional bid/ask/mid/size caches with side-aware market fills +- **Parquet export**: Trades, fills, equity, daily P&L, and config are serializable - **Type-safe**: 0 type diagnostics (ty/Astral), full type annotations ## Related Libraries diff --git a/docs/api/index.md b/docs/api/index.md index d2a6af09..c10ed8af 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -117,6 +117,7 @@ Auto-generated from source docstrings. options: show_root_heading: true members: + - to_predictions_dataframe - to_trades_dataframe - to_equity_dataframe - to_dict diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 3dd316fd..cda22484 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -42,7 +42,7 @@ print(f"Trades: {result.metrics['num_trades']}") ## Data Format -DataFeed expects a Polars DataFrame with these columns: +DataFeed expects a Polars DataFrame keyed by `timestamp` and `asset` plus at least one price column. Standard OHLCV is the default: | Column | Type | Required | |--------|------|----------| @@ -57,6 +57,8 @@ DataFeed expects a Polars DataFrame with these columns: For multi-asset backtests, stack all assets in a single DataFrame -- the engine handles partitioning by timestamp automatically. +`bar["price"]` is always populated. By default it follows `close`, but it switches to `FeedSpec.price_col` or the `price_col=` override when you provide one. + ## Strategy Callbacks Every strategy subclasses `Strategy` and implements `on_data`: @@ -72,7 +74,7 @@ class MyStrategy(Strategy): Args: timestamp: Current bar's datetime - data: Dict of {asset: {open, high, low, close, volume, signals}} + 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 """ @@ -99,7 +101,7 @@ class SignalStrategy(Strategy): if signal > 0.7 and position is None: # Buy 10% of portfolio value equity = broker.get_account_value() - shares = int(equity * 0.10 / bar["close"]) + shares = int(equity * 0.10 / bar["price"]) if shares > 0: broker.submit_order(asset, shares) @@ -110,6 +112,31 @@ class SignalStrategy(Strategy): 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 @@ -167,7 +194,7 @@ class ProtectedStrategy(Strategy): for asset, bar in data.items(): if broker.get_position(asset) is None: equity = broker.get_account_value() - shares = int(equity * 0.10 / bar["close"]) + shares = int(equity * 0.10 / bar["price"]) if shares > 0: broker.submit_order(asset, shares) ``` @@ -193,10 +220,22 @@ print(trades_df.head()) 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") ``` +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: diff --git a/docs/index.md b/docs/index.md index 2f4d3f57..7f0db0ad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,6 +34,8 @@ result = run_backtest(prices, BuyAndHold(), config="backtrader") **Configurable execution semantics.** Every behavioral difference between backtesting frameworks (fill ordering, stop modes, cash policies, settlement) is a named config parameter. Switch profiles to replicate any framework exactly. +**Quote-aware when you need it.** The feed can cache bid, ask, midpoint, and quote sizes additively. Market execution and position marking can use `price`, `bid`, `ask`, `quote_mid`, or `quote_side`. + **Validated at scale.** 225,000+ trades verified trade-by-trade against VectorBT Pro, Backtrader, Zipline, and LEAN on real market data (250 assets x 20 years). **Fast.** 19x faster than Backtrader, 8x faster than Zipline, 5x faster than LEAN on identical workloads. Processes 40,000+ bars/second across 250 assets. @@ -42,9 +44,11 @@ result = run_backtest(prices, BuyAndHold(), config="backtrader") |---------|-------------| | Event-driven | Point-in-time correctness, no look-ahead bias | | 40+ behavioral knobs | Every execution detail is configurable | +| Quote-aware execution | Side-aware fills and separate mark pricing | | 10 framework profiles | Match VectorBT, Backtrader, Zipline, LEAN exactly | | Risk management | Stop-loss, take-profit, trailing stops, portfolio limits | | Multi-asset | Rebalancing, weight targets, exit-first ordering | +| Rich persistence | Export trades, fills, equity, portfolio state, and daily P&L to Parquet | ## Parity Validation diff --git a/docs/overrides/.gitkeep b/docs/overrides/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index b1e50953..3b6c8100 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -2,6 +2,14 @@ `BacktestConfig` is the single source of truth for all backtest behavior. Every behavioral difference between frameworks is a named parameter -- no subclassing or monkey-patching required. +It is also the canonical serializable backtest preset: + +- pass a partial config as a Python `dict`, YAML, or JSON-equivalent mapping +- let `BacktestConfig` fill in defaults +- persist the fully resolved snapshot from the executed result + +This keeps the input simple while still giving you an exact replayable record of what ran. + ## Creating a Config ```python @@ -49,7 +57,27 @@ Account type is determined by the flag combination: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `execution_mode` | ExecutionMode | NEXT_BAR | When orders fill (SAME_BAR or NEXT_BAR) | -| `execution_price` | ExecutionPrice | OPEN | Price used for market fills (OPEN, CLOSE, VWAP, MID) | +| `execution_price` | ExecutionPrice | OPEN | Price used for market fills | +| `mark_price` | ExecutionPrice | PRICE | Price used for open-position marking | + +Available `ExecutionPrice` values: + +| Value | Meaning | +|-------|---------| +| `OPEN` | Use the bar open | +| `CLOSE` | Use the bar close | +| `VWAP` | Use the feed reference price as a VWAP proxy | +| `MID` | Use `(high + low) / 2` | +| `PRICE` | Use `FeedSpec.price_col` / `bar["price"]` | +| `BID` | Use best bid | +| `ASK` | Use best ask | +| `QUOTE_MID` | Use explicit or derived midpoint | +| `QUOTE_SIDE` | Buy at ask, sell at bid; for marking, longs use bid and shorts use ask | + +Quote-aware settings change both execution semantics and reporting. When you use +`BID`, `ASK`, `QUOTE_MID`, or `QUOTE_SIDE`, fills and trades preserve the +underlying quote context and portfolio-state snapshots reflect the configured +mark source. ### Stop Configuration @@ -132,6 +160,47 @@ Account type is determined by the flag combination: | `data_frequency` | DataFrequency | DAILY | Data frequency (DAILY, 1m, 5m, 15m, 30m, 1h) | | `enforce_sessions` | bool | False | Skip bars outside trading sessions | +### Feed Contract + +`BacktestConfig` can also carry a serialized `FeedSpec` under the top-level `feed` +section. This lets you capture how the input data should be interpreted without +introducing a second config object. + +Supported keys mirror `FeedSpec`: + +- `timestamp_col` +- `entity_col` +- `price_col` +- `open_col` +- `high_col` +- `low_col` +- `close_col` +- `volume_col` +- `bid_col` +- `ask_col` +- `mid_col` +- `bid_size_col` +- `ask_size_col` +- `calendar` +- `timezone` +- `data_frequency` +- `bar_type` +- `timestamp_semantics` +- `session_start_time` + +### Metadata + +Use the top-level `metadata` section for any user-defined provenance that the +library does not interpret directly, for example: + +- strategy id or strategy name +- paths to price or prediction inputs +- experiment ids +- notes + +`metadata` round-trips through `to_dict()`, `from_dict()`, `to_yaml()`, and +`from_yaml()` unchanged. + ## YAML Configuration Save and load configs for reproducibility: @@ -153,6 +222,7 @@ account: allow_leverage: false execution: execution_price: open + mark_price: price execution_mode: next_bar stops: stop_fill_mode: stop_price @@ -169,8 +239,36 @@ cash: orders: fill_ordering: exit_first reject_on_insufficient_cash: true +feed: + timestamp_col: timestamp + entity_col: symbol + price_col: close +metadata: + strategy_id: topk_monthly_v1 + prices_path: /path/to/prices.parquet ``` +You can keep input specs sparse. Any omitted fields fall back to library defaults. +After execution, `result.config.to_dict()` gives you the fully resolved config +snapshot with defaults filled in. + +## Resolved Snapshot + +`BacktestResult` can export a richer runtime snapshot that includes the resolved +config plus run metadata such as the realized time window: + +```python +result = run_backtest(...) + +# Replayable config payload +resolved_config = result.config.to_dict() + +# Richer runtime spec +runtime_spec = result.to_spec_dict() +``` + +`runtime_spec["config"]` remains compatible with `BacktestConfig.from_dict()`. + ## Validation Call `validate()` to check for potential issues: @@ -199,6 +297,7 @@ config = BacktestConfig( initial_cash=100_000, execution_mode=ExecutionMode.NEXT_BAR, execution_price=ExecutionPrice.OPEN, + mark_price=ExecutionPrice.PRICE, commission_type=CommissionType.PERCENTAGE, commission_rate=0.002, slippage_type=SlippageType.PERCENTAGE, @@ -217,6 +316,8 @@ config = BacktestConfig( initial_cash=10_000, allow_short_selling=True, execution_mode=ExecutionMode.SAME_BAR, + execution_price=ExecutionPrice.CLOSE, + mark_price=ExecutionPrice.PRICE, share_type=ShareType.FRACTIONAL, commission_type=CommissionType.PERCENTAGE, commission_rate=0.001, @@ -233,11 +334,46 @@ config = BacktestConfig( commission_type=CommissionType.NONE, slippage_type=SlippageType.NONE, execution_mode=ExecutionMode.SAME_BAR, + mark_price=ExecutionPrice.PRICE, share_type=ShareType.FRACTIONAL, skip_cash_validation=True, ) ``` +### Quote-Aware Microstructure + +```python +config = BacktestConfig( + execution_mode=ExecutionMode.NEXT_BAR, + execution_price=ExecutionPrice.QUOTE_SIDE, + mark_price=ExecutionPrice.QUOTE_MID, + commission_type=CommissionType.PERCENTAGE, + commission_rate=0.0005, + slippage_type=SlippageType.NONE, +) +``` + +This configuration: + +- crosses the spread at execution via `QUOTE_SIDE` +- marks inventory at midpoint +- keeps commission separate +- avoids layering synthetic slippage on top unless you explicitly want extra impact + +### Quote-Aware Equities + +```python +config = BacktestConfig( + execution_mode=ExecutionMode.SAME_BAR, + execution_price=ExecutionPrice.QUOTE_SIDE, + mark_price=ExecutionPrice.QUOTE_SIDE, + commission_type=CommissionType.NONE, + slippage_type=SlippageType.NONE, +) +``` + +Use this with a `DataFeed` whose `FeedSpec` maps `price_col`, `bid_col`, `ask_col`, and optionally quote sizes. + ## See It in Action The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses BacktestConfig across all case studies: diff --git a/docs/user-guide/data-feed.md b/docs/user-guide/data-feed.md index 50602700..fee81c9c 100644 --- a/docs/user-guide/data-feed.md +++ b/docs/user-guide/data-feed.md @@ -1,21 +1,38 @@ # Data Feed -`DataFeed` converts a Polars DataFrame into per-bar data for the engine. It handles partitioning by timestamp, multi-asset iteration, and optional signals/context data. +`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 -The prices DataFrame must have these columns: +The prices DataFrame must always include: | Column | Type | Description | |--------|------|-------------| | `timestamp` | Datetime | Bar timestamp | | `asset` | String | Asset identifier | + +Standard OHLCV feeds usually provide: + +| Column | Type | Description | +|--------|------|-------------| | `open` | Float | Opening price | | `high` | Float | High price | | `low` | Float | Low price | | `close` | Float | Closing price | | `volume` | Float | Trading volume | +`DataFeed` also exposes a normalized `bar["price"]` field. By default it follows `close`, but if your `FeedSpec` or constructor sets `price_col`, that column becomes the broker reference price. + +Optional quote columns are carried through when present: + +| Column | Description | +|--------|-------------| +| `bid_col` | Best bid price | +| `ask_col` | Best ask price | +| `mid_col` | Explicit midpoint if your data provides one | +| `bid_size_col` | Bid-side available size | +| `ask_size_col` | Ask-side available size | + ## Basic Usage ```python @@ -35,6 +52,33 @@ prices = pl.DataFrame({ feed = DataFeed(prices_df=prices) ``` +Inside `on_data()`, each asset bar contains `price`, `open`, `high`, `low`, `close`, `volume`, plus any available quote fields and `signals`. + +## FeedSpec and Column Overrides + +Use `FeedSpec` or explicit keyword overrides when your schema differs from OHLCV defaults: + +```python +from ml4t.backtest import DataFeed +from ml4t.data.artifacts.market_data import FeedSpec + +feed = DataFeed( + prices_df=quotes, + feed_spec=FeedSpec( + timestamp_col="ts", + entity_col="symbol", + price_col="mid_price", + close_col="last_trade", + bid_col="bid", + ask_col="ask", + bid_size_col="bid_size", + ask_size_col="ask_size", + ), +) +``` + +Constructor keyword arguments override `FeedSpec` fields, so you can keep a shared spec and specialize it for a single backtest. + ## Multi-Asset Data Stack all assets in a single DataFrame. The engine handles partitioning by timestamp automatically: @@ -77,6 +121,35 @@ def on_data(self, timestamp, data, context, broker): Any column in the signals DataFrame (other than `timestamp` and `asset`) becomes a signal. +## Quote-Aware Execution Inputs + +Quote columns are additive: you can keep OHLCV behavior unchanged, or opt into quote-aware execution in config: + +```python +from ml4t.backtest import BacktestConfig +from ml4t.backtest.config import ExecutionPrice + +config = BacktestConfig( + execution_price=ExecutionPrice.QUOTE_SIDE, + mark_price=ExecutionPrice.QUOTE_SIDE, +) +``` + +When quotes are present: + +- `ExecutionPrice.PRICE` uses `FeedSpec.price_col` +- `ExecutionPrice.BID` and `ExecutionPrice.ASK` use the best quote on that side +- `ExecutionPrice.QUOTE_MID` uses the explicit midpoint or derives `(bid + ask) / 2` +- `ExecutionPrice.QUOTE_SIDE` buys at ask and sells at bid + +If a quote field is missing, the broker falls back to the reference price or OHLC value for the configured source. + +Those quote inputs also flow into the reporting layer: + +- `result.to_fills_dataframe()` preserves fill-level quote context +- `result.to_trades_dataframe()` preserves entry/exit quote summaries +- `result.to_portfolio_state_dataframe()` reflects the configured mark source + ## Context Data Context provides per-bar metadata that isn't tied to individual assets: @@ -137,7 +210,7 @@ result = run_backtest("data/prices.parquet", strategy, signals="data/signals.par ## Performance -DataFeed pre-partitions data by timestamp at initialization and pre-extracts column indices for O(1) per-bar access. For 1M bars, this uses roughly 100 MB (10x less than converting everything to Python dicts upfront). +DataFeed pre-partitions data by timestamp at initialization and pre-extracts column indices for O(1) per-bar access. For 1M bars, this uses roughly 100 MB (10x less than converting everything to Python dicts upfront). Quote columns are cached additively, so the legacy OHLCV path stays unchanged unless you provide quote data. ## See It in Action diff --git a/docs/user-guide/execution-semantics.md b/docs/user-guide/execution-semantics.md index 0e427233..78058b00 100644 --- a/docs/user-guide/execution-semantics.md +++ b/docs/user-guide/execution-semantics.md @@ -38,7 +38,7 @@ config = BacktestConfig(execution_mode=ExecutionMode.SAME_BAR) ### Execution Price -The `execution_price` parameter controls which bar price is used for market order fills: +The `execution_price` parameter controls which price source is used for market order fills: | Value | Fill Price | Typical Use | |-------|-----------|-------------| @@ -46,6 +46,48 @@ The `execution_price` parameter controls which bar price is used for market orde | `CLOSE` | Current bar's close | VectorBT 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 | +| `BID` | Best bid quote | Passive or conservative sell-side marking | +| `ASK` | Best ask quote | Aggressive buy-side fills | +| `QUOTE_MID` | Quote midpoint | Microstructure-aware marking | +| `QUOTE_SIDE` | Ask for buys, bid for sells | Side-aware market execution | + +`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. + +### Mark Price + +Open positions are marked independently of how market orders fill. `mark_price` uses the same `ExecutionPrice` enum as `execution_price`. + +This is useful when you want to: + +- fill orders at `QUOTE_SIDE` but mark inventory at `QUOTE_MID` +- trade from a synthetic `price_col` while keeping fills at `OPEN` or `CLOSE` +- mark long inventory conservatively on the bid and short inventory on the ask via `QUOTE_SIDE` + +```python +from ml4t.backtest.config import ExecutionPrice + +config = BacktestConfig( + execution_price=ExecutionPrice.QUOTE_SIDE, + mark_price=ExecutionPrice.QUOTE_MID, +) +``` + +If the requested quote field is unavailable, the broker falls back to the feed reference price and then to OHLC where applicable. + +### Quote-Aware Backtests + +When you provide bid/ask data and enable quote-aware execution or marking, the +backtest is quote-aware, not just OHLCV-aware. + +That affects both execution and reporting: + +- fills preserve the quote source and nullable quote context used for execution +- trades preserve entry and exit quote summaries +- portfolio state reflects the configured `mark_price` + +This makes quote-side behavior auditable after the run instead of burying it in +aggregate PnL only. ## Fill Ordering @@ -101,7 +143,7 @@ When using EXIT_FIRST, entries are processed after exits. The `entry_order_prior ## Stop and Take-Profit Execution -Position rules (StopLoss, TakeProfit, TrailingStop) are evaluated on every bar using OHLC data. The key question is: **at what price does a triggered stop fill?** +Position rules (StopLoss, TakeProfit, TrailingStop) are evaluated on every bar using OHLC data. Quote-aware execution changes market fills and position marking, but stop triggers still evaluate against bar data. The key question is: **at what price does a triggered stop fill?** ### Stop Fill Modes @@ -185,6 +227,26 @@ config = BacktestConfig( ## Commission and Slippage +## Quote Context on Fills + +Every fill records the price source that was used along with nullable quote context: + +- `price_source` +- `reference_price` +- `quote_mid_price` +- `bid_price` +- `ask_price` +- `spread` +- `bid_size` +- `ask_size` +- `available_size` + +That data is available both in memory and in `result.to_fills_dataframe()` / `fills.parquet`, which makes it possible to audit quote-side behavior after the run. + +Trade summaries preserve the same context at entry and exit, and +`result.to_portfolio_state_dataframe()` reflects the configured mark source for +each end-of-bar snapshot. + ### Commission Models | Type | Calculation | Config | @@ -215,6 +277,13 @@ config = BacktestConfig( | `FIXED` | Fixed $ per share | `slippage_fixed=0.01` | | `VOLUME_BASED` | Size vs volume | `slippage_rate=0.1` (10% volume limit) | +Slippage models remain separate from quote-side execution: + +- `QUOTE_SIDE` crosses the observed spread using bid/ask quotes +- slippage adds an extra synthetic execution penalty on top of the chosen source + +This lets you model spread and market impact separately. + Stop orders can have additional slippage via `stop_slippage_rate`: ```python diff --git a/docs/user-guide/profiles.md b/docs/user-guide/profiles.md index c6f4ed1a..58d85584 100644 --- a/docs/user-guide/profiles.md +++ b/docs/user-guide/profiles.md @@ -53,6 +53,8 @@ config.commission_rate = 0.002 config.initial_cash = 500_000 ``` +Profiles define behavioral defaults. Quote-aware feeds layer on top of them: you can start from a preset, then override `execution_price`, `mark_price`, and the feed's `price_col` / quote columns without changing the rest of the profile. + ## Profile Comparison ### Execution diff --git a/docs/user-guide/results.md b/docs/user-guide/results.md index fc9123fb..bf0aa3b0 100644 --- a/docs/user-guide/results.md +++ b/docs/user-guide/results.md @@ -1,6 +1,21 @@ # Results & Analysis -`Engine.run()` returns a `BacktestResult` containing trades, equity curve, fills, and computed metrics. Everything is accessible as Python objects, Polars DataFrames, or Parquet files. +`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. + +This applies to both classic OHLCV backtests and quote-aware backtests. When you run +with bid/ask-aware execution or marking, the result surface preserves the quote +context used to produce fills and trade summaries. + +For reproducibility, `BacktestResult` also exposes: + +- `result.config.to_dict()` for the fully resolved replayable config payload +- `result.to_spec_dict()` for a richer runtime snapshot including library version and realized window +- `result.to_predictions_dataframe()` for the raw prediction/input surface passed into the + backtest, when available +- `result.to_parquet(...)`, which writes `config.yaml`, `spec.yaml`, and `predictions.parquet` + when available ## Metrics @@ -21,6 +36,9 @@ print(f"Calmar: {m['calmar']:.2f}") # Trades print(f"Trades: {m['num_trades']}") +print(f"Fills: {m['num_fills']}") +print(f"Rebalances: {m['num_rebalance_events']}") +print(f"Symbols: {m['unique_symbols_traded']}") print(f"Win Rate: {m['win_rate']:.1%}") print(f"Profit Factor: {m['profit_factor']:.2f}") print(f"Expectancy: ${m['expectancy']:.2f}") @@ -40,6 +58,11 @@ print(f"Commission: ${m['total_commission']:.2f}") print(f"Slippage: ${m['total_slippage']:.2f}") print(f"Total Costs: ${m['total_costs']:.2f}") print(f"Avg Cost Drag: {m['avg_cost_drag']:.4%}") +print(f"Filled Notional: ${m['total_filled_notional']:,.2f}") +print(f"Avg Turnover: {m['avg_turnover']:.2%}") +print(f"Max Turnover: {m['max_turnover']:.2%}") +print(f"Avg Open Pos: {m['avg_open_positions']:.2f}") +print(f"Max Open Pos: {m['max_open_positions']}") # Gross vs Net print(f"Gross P&L: ${m['total_gross_pnl']:.2f}") @@ -63,6 +86,9 @@ print(f"Net PF: {m['profit_factor']:.2f}") | `sortino` | Sortino ratio | | `calmar` | Calmar ratio | | `num_trades` | Total completed trades | +| `num_fills` | Total execution events | +| `num_rebalance_events` | Unique timestamps with at least one fill | +| `unique_symbols_traded` | Number of symbols with at least one fill | | `winning_trades` | Number of winning trades | | `losing_trades` | Number of losing trades | | `win_rate` | Win rate (0 to 1) | @@ -75,13 +101,41 @@ print(f"Net PF: {m['profit_factor']:.2f}") | `largest_loss` | Worst single trade return (decimal, negative) | | `payoff_ratio` | avg_win / \|avg_loss\| (size-normalized reward-to-risk) | | `total_commission` | Total commission paid | -| `total_slippage` | Total slippage cost (entry + exit) | +| `total_slippage` | Total slippage cost in dollars (entry + exit) | +| `total_filled_notional` | Sum of absolute filled notional across all fills | +| `avg_turnover` | Mean per-bar one-way turnover from fills | +| `max_turnover` | Maximum per-bar one-way turnover from fills | +| `avg_open_positions` | Mean number of open positions across bars | +| `max_open_positions` | Maximum number of open positions across bars | | `total_gross_pnl` | Total P&L from price moves only (before costs) | | `total_costs` | Total transaction costs (commission + slippage) | | `avg_cost_drag` | Average cost as fraction of trade notional | | `gross_profit_factor` | Profit factor from raw price moves (isolates edge from costs) | | `skipped_bars` | Bars skipped by calendar filter | +### Reporting Model + +`BacktestResult` now exposes three distinct raw reporting surfaces: + +- `trades`: flat-to-flat lifecycle summaries +- `fills`: execution blotter rows +- `portfolio_state`: end-of-bar portfolio snapshots + +For rebalancing strategies, `num_trades` is not the right proxy for trading activity. +Use `num_fills`, `num_rebalance_events`, and `total_filled_notional` instead. + +Turnover uses a one-way execution-based definition: + +```python +turnover_t = filled_notional_at_timestamp / equity_at_timestamp +``` + +Bars with no fills contribute `0`. This means: + +- buying a fully cash portfolio into a fully invested book is turnover `1.0` +- selling a fully invested book back to cash is turnover `1.0` +- fully rotating one full book into another is turnover `2.0` + ### Cost Decomposition Every trade carries a full cost breakdown, letting you separate strategy edge from execution costs: @@ -102,11 +156,27 @@ for trade in result.trades: | `trade.cost_drag` | Total cost as fraction of notional | | `trade.fees` | Total commission (entry + exit) | | `trade.entry_slippage` | Per-unit slippage on entry | -| `trade.slippage` | Per-unit slippage on exit | +| `trade.exit_slippage` | Per-unit slippage on exit | | `trade.multiplier` | Contract multiplier (1.0 for equities, 50.0 for ES futures) | +Trade records also summarize nullable quote context for the entry and exit: + +| Property | Description | +|----------|-------------| +| `trade.entry_quote_mid_price` / `trade.exit_quote_mid_price` | Quote midpoint at fill time | +| `trade.entry_bid_price` / `trade.exit_bid_price` | Best bid at fill time | +| `trade.entry_ask_price` / `trade.exit_ask_price` | Best ask at fill time | +| `trade.entry_spread` / `trade.exit_spread` | Bid/ask spread at fill time | +| `trade.entry_available_size` / `trade.exit_available_size` | Side-aware available quote size | + `pnl_percent` is direction-aware: positive means profitable for both long and short trades. +Quote-aware backtests therefore leave an explicit audit trail: + +- fills record the reference price, bid, ask, midpoint, spread, and available size +- trades summarize entry/exit quote context +- portfolio state reflects the configured `mark_price` + ## Trade Analyzer `result.trade_analyzer` provides aggregate statistics on closed trades: @@ -159,11 +229,21 @@ Returns a Polars DataFrame with columns: | `pnl_percent` | Float | Direction-aware percentage return | | `bars_held` | Int | Holding period | | `fees` | Float | Total commission | -| `slippage` | Float | Exit slippage | +| `exit_slippage` | Float | Exit slippage | | `mfe` | Float | Maximum favorable excursion | | `mae` | Float | Maximum adverse excursion | | `entry_slippage` | Float | Per-unit slippage on entry | | `multiplier` | Float | Contract multiplier (futures) | +| `entry_quote_mid_price` | Float | Entry quote midpoint | +| `entry_bid_price` | Float | Entry best bid | +| `entry_ask_price` | Float | Entry best ask | +| `entry_spread` | Float | Entry spread | +| `entry_available_size` | Float | Entry-side available size | +| `exit_quote_mid_price` | Float | Exit quote midpoint | +| `exit_bid_price` | Float | Exit best bid | +| `exit_ask_price` | Float | Exit best ask | +| `exit_spread` | Float | Exit spread | +| `exit_available_size` | Float | Exit-side available size | | `gross_pnl` | Float | Price-move P&L before fees | | `net_return` | Float | Direction-aware net return including fees | | `total_slippage_cost` | Float | Entry + exit slippage in dollars | @@ -191,6 +271,51 @@ Returns a Polars DataFrame with columns: | `drawdown` | Float | Current drawdown from HWM | | `high_water_mark` | Float | Running maximum equity | +## Portfolio State DataFrame + +```python +portfolio_df = result.to_portfolio_state_dataframe() +print(portfolio_df.head()) +``` + +Returns a Polars DataFrame with columns: + +| Column | Type | Description | +|--------|------|-------------| +| `timestamp` | Datetime | Bar timestamp | +| `equity` | Float | Portfolio equity after bar processing | +| `cash` | Float | Cash balance | +| `gross_exposure` | Float | Sum of absolute marked position values | +| `net_exposure` | Float | Signed sum of marked position values | +| `open_positions` | Int | Number of open positions | + +This is the right primitive for downstream diagnostics such as: + +- average invested fraction +- gross and net exposure analysis +- time in market +- occupancy and utilization metrics + +## Predictions DataFrame + +If you passed `signals_df` into `DataFeed` or `run_backtest(...)`, the raw input +is available on the result: + +```python +predictions_df = result.to_predictions_dataframe() +print(predictions_df.head()) +``` + +This returns the original Polars DataFrame without reinterpretation. It is meant +for downstream diagnostics and attribution, for example: + +- joining predictions to fills or trades +- evaluating score distributions for winning vs losing trades +- comparing model inputs to realized execution and PnL + +The important convention is that this surface is treated as the raw model/input +surface, not as a guaranteed post-mapping trading signal surface. + ## Fills Access every individual order fill: @@ -203,6 +328,13 @@ for fill in result.fills: print(f" Slippage: ${fill.slippage:.4f}") ``` +Or convert them directly to a Polars DataFrame: + +```python +fills_df = result.to_fills_dataframe() +print(fills_df.select(["asset", "side", "price", "price_source", "bid_price", "ask_price"])) +``` + Fill objects carry order-type metadata for audit: | Field | Description | @@ -213,6 +345,21 @@ Fill objects carry order-type metadata for audit: | `fill.price` | Actual fill price | | `fill.commission` | Commission charged | | `fill.slippage` | Slippage applied | +| `fill.price_source` | Configured source used for the fill | +| `fill.reference_price` | Feed reference price (`bar["price"]`) | +| `fill.quote_mid_price` | Quote midpoint at fill time | +| `fill.bid_price` / `fill.ask_price` | Best bid / ask | +| `fill.spread` | Bid-ask spread | +| `fill.bid_size` / `fill.ask_size` | Quote sizes | +| `fill.available_size` | Side-aware size used for the fill context | + +For quote-aware backtests, `fills.parquet` is the first place to look when you want +to verify whether a result difference came from: + +- quote-side execution +- synthetic slippage +- commission +- the configured mark source ## Dictionary Output @@ -228,9 +375,17 @@ result_dict = result.to_dict() Save results for later analysis or integration with ml4t-diagnostic: ```python -# Export trades and equity to Parquet +# Export all result components to Parquet / JSON / YAML result.to_parquet("./results/my_backtest") -# Creates: my_backtest_trades.parquet, my_backtest_equity.parquet +# Creates: +# trades.parquet +# fills.parquet +# predictions.parquet # if raw prediction inputs were supplied +# equity.parquet +# portfolio_state.parquet +# daily_pnl.parquet +# metrics.json +# config.yaml # when config is attached # Reload later from ml4t.backtest.result import BacktestResult @@ -241,15 +396,17 @@ result = BacktestResult.from_parquet("./results/my_backtest") ### Portfolio Analysis (Recommended) -The simplest way to bridge backtest results into ml4t-diagnostic is `to_portfolio_analysis()`: +The simplest way to bridge backtest results into ml4t-diagnostic is +`portfolio_analysis_from_result()`: ```python from ml4t.backtest import Engine +from ml4t.diagnostic.integration import portfolio_analysis_from_result result = engine.run() # One-liner bridge to ml4t-diagnostic -analysis = result.to_portfolio_analysis(calendar="NYSE") +analysis = portfolio_analysis_from_result(result, calendar="NYSE") # Now use PortfolioAnalysis methods print(f"Sharpe: {analysis.sharpe_ratio():.2f}") @@ -257,21 +414,30 @@ print(f"Max DD: {analysis.max_drawdown():.2%}") monthly = analysis.compute_monthly_returns() ``` -The method extracts daily returns via `to_daily_pnl()` and sets `periods_per_year` from the calendar (252 for NYSE, 365 for crypto, etc.). If no calendar is passed, it uses the config's calendar. +The helper extracts daily returns via `to_daily_pnl()` and sets +`periods_per_year` from the calendar (252 for NYSE, 365 for crypto, etc.). +If no calendar is passed, it uses the config's calendar. + +For richer diagnostics, pass these alongside returns: + +- `result.to_trades_dataframe()` +- `result.to_fills_dataframe()` +- `result.to_portfolio_state_dataframe()` ```python # Crypto backtest -analysis = result.to_portfolio_analysis(calendar="crypto") +analysis = portfolio_analysis_from_result(result, calendar="crypto") # With benchmark -analysis = result.to_portfolio_analysis( +analysis = portfolio_analysis_from_result( + result, calendar="NYSE", benchmark=spy_returns, # numpy array or Polars Series ) # Gross vs net comparison -analysis_gross = results_gross.to_portfolio_analysis(calendar="crypto") -analysis_net = results_net.to_portfolio_analysis(calendar="crypto") +analysis_gross = portfolio_analysis_from_result(results_gross, calendar="crypto") +analysis_net = portfolio_analysis_from_result(results_net, calendar="crypto") ``` !!! note "Requires ml4t-diagnostic" @@ -340,7 +506,7 @@ print(result.config.preset_name) The [Machine Learning for Trading](https://github.com/stefan-jansen/machine-learning-for-trading) book uses BacktestResult in every case study: -- **Ch16 / NB05** (`performance_reporting`) — `to_portfolio_analysis()`, MFE/MAE analysis, gross vs net comparison, full 24-section tearsheet +- **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 diff --git a/mkdocs.yml b/mkdocs.yml index e9dce5ef..8e709964 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,9 +3,9 @@ site_name: ML4T Backtest site_description: Event-driven backtesting engine with VectorBT Pro validation -site_url: https://ml4t.io/docs/backtest/ -repo_url: https://github.com/stefan-jansen/ml4t-backtest -repo_name: stefan-jansen/ml4t-backtest +site_url: https://ml4trading.io/docs/backtest/ +repo_url: https://github.com/ml4t/backtest +repo_name: ml4t/backtest # Theme configuration - Picasso Blue Period palette theme: diff --git a/pyproject.toml b/pyproject.toml index 311f82aa..2da03289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ requires-python = ">=3.12" # Core dependencies dependencies = [ + "ml4t-data>=0.1.0b7", "polars>=0.20.0", "pandas>=2.0.0", "numpy>=1.24.0", @@ -111,16 +112,13 @@ dev = [ "ruff>=0.8.0", "ty", "pre-commit>=3.3.0", - "ml4t-diagnostic", # Sibling package for tearsheet integration tests ] # Documentation dependencies docs = [ - "sphinx>=7.0.0", - "sphinx-rtd-theme>=1.3.0", - "sphinx-autodoc-typehints>=1.24.0", - "myst-parser>=2.0.0", - "nbsphinx>=0.9.0", + "mkdocs>=1.6,<2", + "mkdocs-material>=9.5.0", + "mkdocstrings[python]>=0.24.0", ] # All optional dependencies @@ -129,11 +127,11 @@ all = [ ] [project.urls] -Homepage = "https://github.com/ml4t/ml4t-backtest" -Documentation = "https://ml4t-backtest.readthedocs.io" -Repository = "https://github.com/ml4t/ml4t-backtest" -Issues = "https://github.com/ml4t/ml4t-backtest/issues" -Changelog = "https://github.com/ml4t/ml4t-backtest/blob/main/CHANGELOG.md" +Homepage = "https://github.com/ml4t/backtest" +Documentation = "https://ml4trading.io/docs/backtest/" +Repository = "https://github.com/ml4t/backtest" +Issues = "https://github.com/ml4t/backtest/issues" +Changelog = "https://github.com/ml4t/backtest/blob/main/CHANGELOG.md" [dependency-groups] dev = [ @@ -250,5 +248,6 @@ exclude_lines = [ ] [tool.uv.sources] +ml4t-data = { path = "../ml4t-data", editable = true } +ml4t-engineer = { path = "../ml4t-engineer", editable = true } # Sibling package for optional tearsheet integration -ml4t-diagnostic = { path = "../ml4t-diagnostic", editable = true } diff --git a/src/ml4t/backtest/__init__.py b/src/ml4t/backtest/__init__.py index 8a1f957d..553c5696 100644 --- a/src/ml4t/backtest/__init__.py +++ b/src/ml4t/backtest/__init__.py @@ -20,6 +20,7 @@ # Execution: rebalancing from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor +from .execution.schedule import RebalanceCadence, RebalanceSchedule, resolve_rebalance_timestamps from .result import BacktestResult # Risk management rules (position-level) @@ -71,6 +72,9 @@ # Execution: rebalancing "RebalanceConfig", "TargetWeightExecutor", + "RebalanceCadence", + "RebalanceSchedule", + "resolve_rebalance_timestamps", # Risk rules "StopLoss", "TakeProfit", diff --git a/src/ml4t/backtest/_validation_imports.py b/src/ml4t/backtest/_validation_imports.py index 4c81a1d6..96058545 100644 --- a/src/ml4t/backtest/_validation_imports.py +++ b/src/ml4t/backtest/_validation_imports.py @@ -11,6 +11,7 @@ from .execution.impact import LinearImpact from .execution.limits import VolumeParticipationLimit from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor +from .execution.schedule import RebalanceCadence, RebalanceSchedule, resolve_rebalance_timestamps from .models import ( FixedSlippage, NoCommission, @@ -55,6 +56,9 @@ "LinearImpact", "RebalanceConfig", "TargetWeightExecutor", + "RebalanceCadence", + "RebalanceSchedule", + "resolve_rebalance_timestamps", "WaterMarkSource", "TrailHwmSource", "InitialHwmSource", diff --git a/src/ml4t/backtest/analytics/annualization.py b/src/ml4t/backtest/analytics/annualization.py new file mode 100644 index 00000000..25cb8250 --- /dev/null +++ b/src/ml4t/backtest/analytics/annualization.py @@ -0,0 +1,154 @@ +"""Helpers for annualization and session-aware result semantics.""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime +from typing import Any + +from ml4t.data.artifacts.market_data import FeedSpec, TimestampSemantics + +from ..calendar import get_schedule +from ..config import DataFrequency +from ..sessions import SessionConfig + +_ANNUALIZATION_FACTORS: dict[str, int] = { + "crypto": 365, + "NYSE": 252, + "NASDAQ": 252, + "CME_Equity": 252, + "CME_Agriculture": 252, + "CME_Globex_Energy_and_Metals": 252, + "LSE": 253, + "XETRA": 252, + "TSX": 252, + "HKEX": 252, + "JPX": 245, +} + +_BAR_MINUTES: dict[DataFrequency, float] = { + DataFrequency.HOURLY: 60.0, + DataFrequency.MINUTE_30: 30.0, + DataFrequency.MINUTE_15: 15.0, + DataFrequency.MINUTE_5: 5.0, + DataFrequency.MINUTE_1: 1.0, +} + +_DEFAULT_TRADING_DAYS_PER_YEAR = 252.0 +_DEFAULT_SESSION_MINUTES = 390.0 + + +def get_annualization_factor(calendar: str | None) -> int: + """Get annualization factor for a trading calendar.""" + if calendar is None: + return int(_DEFAULT_TRADING_DAYS_PER_YEAR) + + cal_upper = calendar.upper() + for name, factor in _ANNUALIZATION_FACTORS.items(): + if name.upper() == cal_upper: + return factor + + try: + from pandas_market_calendars import get_calendar + + cal = get_calendar(calendar) + schedule = cal.schedule("2024-01-01", "2024-12-31") + return len(schedule) + except Exception: + return int(_DEFAULT_TRADING_DAYS_PER_YEAR) + + +def resolve_periods_per_year( + data_frequency: DataFrequency | Any | None, + *, + calendar: str | None, +) -> float | None: + """Resolve periods-per-year from configured feed cadence and calendar metadata.""" + frequency = _coerce_frequency(data_frequency) + if frequency is None: + return None + + if frequency == DataFrequency.DAILY: + return float(get_annualization_factor(calendar)) + + bar_minutes = _BAR_MINUTES.get(frequency) + if bar_minutes is None: + return None + + session_minutes = _session_minutes_per_day(calendar) + if session_minutes is None: + session_minutes = _DEFAULT_SESSION_MINUTES + + annual_days = float(get_annualization_factor(calendar)) + return float(annual_days * (session_minutes / bar_minutes)) + + +def should_session_align( + *, + calendar: str | None, + feed_spec: FeedSpec | Any | None = None, + timestamps: Sequence[datetime] | None = None, +) -> bool: + """Determine whether result aggregation should align to trading sessions.""" + spec = FeedSpec.from_any(feed_spec) if feed_spec is not None else None + semantics = spec.timestamp_semantics if spec is not None else None + if semantics is not None and not isinstance(semantics, TimestampSemantics): + semantics = TimestampSemantics(str(semantics)) + + if semantics == TimestampSemantics.SESSION_LABEL: + return False + + resolved_calendar = calendar if calendar is not None else (spec.calendar if spec else None) + if resolved_calendar is None: + return False + + session_start_time = spec.session_start_time if spec is not None else None + session_config = SessionConfig( + calendar=resolved_calendar, + session_start_time=session_start_time, + ) + if session_config.get_session_start_hour() < 12: + return False + + if semantics in {TimestampSemantics.EVENT_TIME, TimestampSemantics.BAR_CLOSE}: + return True + + return not (timestamps and _timestamps_look_date_labeled(timestamps)) + + +def _coerce_frequency(data_frequency: DataFrequency | Any | None) -> DataFrequency | None: + if data_frequency is None: + return None + if isinstance(data_frequency, DataFrequency): + return data_frequency + try: + return DataFrequency(str(data_frequency)) + except ValueError: + return None + + +def _session_minutes_per_day(calendar: str | None) -> float | None: + if calendar is None: + return None + if calendar.upper() == "CRYPTO": + return 24.0 * 60.0 + + schedule = get_schedule(calendar, "2024-01-02", "2024-01-12", include_breaks=True) + if schedule.is_empty(): + return None + + row = schedule.row(0, named=True) + minutes = (row["market_close"] - row["market_open"]).total_seconds() / 60.0 + break_start = row.get("break_start") + break_end = row.get("break_end") + if break_start is not None and break_end is not None: + minutes -= (break_end - break_start).total_seconds() / 60.0 + + return minutes if minutes > 0 else None + + +def _timestamps_look_date_labeled(timestamps: Sequence[datetime]) -> bool: + return all( + ts.hour == 0 and ts.minute == 0 and ts.second == 0 and ts.microsecond == 0 + for ts in timestamps + ) diff --git a/src/ml4t/backtest/analytics/bridge.py b/src/ml4t/backtest/analytics/bridge.py index e45dc3b0..14b275a1 100644 --- a/src/ml4t/backtest/analytics/bridge.py +++ b/src/ml4t/backtest/analytics/bridge.py @@ -49,7 +49,7 @@ def to_trade_record(trade: Trade) -> dict[str, Any]: "pnl_percent": trade.pnl_percent, "bars_held": trade.bars_held, "fees": trade.fees, - "slippage": trade.slippage, + "exit_slippage": trade.exit_slippage, "exit_reason": trade.exit_reason, "status": trade.status, "mfe": trade.mfe, diff --git a/src/ml4t/backtest/analytics/equity.py b/src/ml4t/backtest/analytics/equity.py index a49906dc..21e2808d 100644 --- a/src/ml4t/backtest/analytics/equity.py +++ b/src/ml4t/backtest/analytics/equity.py @@ -1,10 +1,14 @@ """Equity curve tracking and analysis.""" +from __future__ import annotations + from dataclasses import dataclass, field from datetime import datetime +from typing import TYPE_CHECKING import numpy as np +from .annualization import resolve_periods_per_year from .metrics import ( TRADING_DAYS_PER_YEAR, cagr, @@ -16,6 +20,9 @@ volatility, ) +if TYPE_CHECKING: + from ..config import BacktestConfig + @dataclass class EquityCurve: @@ -28,6 +35,7 @@ class EquityCurve: timestamps: list[datetime] = field(default_factory=list) values: list[float] = field(default_factory=list) + periods_per_year_override: float | None = None def append(self, timestamp: datetime, value: float) -> None: """Add a data point.""" @@ -80,7 +88,9 @@ def years(self) -> float: @property def periods_per_year(self) -> float: - """Infer annualization factor from observed bar frequency.""" + """Annualization factor, preferring configured cadence over elapsed-time inference.""" + if self.periods_per_year_override is not None: + return float(self.periods_per_year_override) if len(self.values) < 2 or len(self.timestamps) < 2: return float(TRADING_DAYS_PER_YEAR) elapsed_seconds = (self.timestamps[-1] - self.timestamps[0]).total_seconds() @@ -92,6 +102,16 @@ def periods_per_year(self) -> float: return float(TRADING_DAYS_PER_YEAR) return float(inferred) + @classmethod + def from_config(cls, config: BacktestConfig) -> EquityCurve: + """Create an equity curve with annualization metadata derived from config.""" + feed_spec = config.resolved_feed_spec + periods_per_year = resolve_periods_per_year( + feed_spec.data_frequency, + calendar=feed_spec.calendar, + ) + return cls(periods_per_year_override=periods_per_year) + def max_drawdown_info(self) -> tuple[float, int, int]: """Maximum drawdown with peak/trough indices.""" return max_drawdown(self.values) diff --git a/src/ml4t/backtest/broker.py b/src/ml4t/backtest/broker.py index bed1a297..2a3b5784 100644 --- a/src/ml4t/backtest/broker.py +++ b/src/ml4t/backtest/broker.py @@ -47,6 +47,7 @@ from .accounting.policy import AccountPolicy from .config import BacktestConfig from .execution import ExecutionLimits, MarketImpactModel + from .risk.position import PositionRule class Broker: @@ -60,6 +61,7 @@ def __init__( stop_slippage_rate: float = 0.0, execution_mode: ExecutionMode = ExecutionMode.SAME_BAR, execution_price: ExecutionPrice = ExecutionPrice.CLOSE, + mark_price: ExecutionPrice = ExecutionPrice.PRICE, stop_fill_mode: StopFillMode = StopFillMode.STOP_PRICE, stop_level_basis: StopLevelBasis = StopLevelBasis.FILL_PRICE, trail_hwm_source: WaterMarkSource = WaterMarkSource.CLOSE, @@ -111,6 +113,7 @@ def __init__( self.stop_slippage_rate = stop_slippage_rate self.execution_mode = execution_mode self.execution_price = execution_price + self.mark_price = mark_price self.stop_fill_mode = stop_fill_mode self.stop_level_basis = stop_level_basis self.trail_hwm_source = trail_hwm_source @@ -187,14 +190,21 @@ def __init__( self.trades: list[Trade] = [] self._order_counter = 0 self._current_time: datetime | None = None - self._current_prices: dict[str, float] = {} # close prices + self._current_prices: dict[str, float] = {} # FeedSpec.price_col values self._current_opens: dict[str, float] = {} # open prices for next-bar execution self._current_highs: dict[str, float] = {} # high prices for limit/stop checks self._current_lows: dict[str, float] = {} # low prices for limit/stop checks + self._current_closes: dict[str, float] = {} self._current_volumes: dict[str, float] = {} + self._current_bids: dict[str, float] = {} + self._current_asks: dict[str, float] = {} + self._current_mids: dict[str, float] = {} + self._current_bid_sizes: dict[str, float] = {} + self._current_ask_sizes: dict[str, float] = {} self._current_signals: dict[str, dict[str, float]] = {} self._last_prices: dict[str, float] = {} self._asset_bars_seen: dict[str, int] = {} + self._rebalance_counter = 0 self._orders_this_bar: list[Order] = [] # Orders placed this bar (for next-bar mode) self._orders_this_bar_ids: set[str] = set() @@ -311,6 +321,7 @@ def from_config( stop_slippage_rate=config.stop_slippage_rate, execution_mode=config.execution_mode, execution_price=config.execution_price, + mark_price=config.mark_price, stop_fill_mode=config.stop_fill_mode, stop_level_basis=config.stop_level_basis, trail_hwm_source=config.trail_hwm_source, @@ -365,6 +376,133 @@ def get_multiplier(self, asset: str) -> float: spec = self._contract_specs.get(asset) return spec.multiplier if spec else 1.0 + def _next_rebalance_id(self) -> str: + self._rebalance_counter += 1 + return f"rebalance-{self._rebalance_counter}" + + def get_quote_mid(self, asset: str) -> float | None: + """Return explicit quote midpoint or derive it from bid/ask.""" + mid = self._current_mids.get(asset) + if mid is not None: + return mid + bid = self._current_bids.get(asset) + ask = self._current_asks.get(asset) + if bid is not None and ask is not None: + return (bid + ask) / 2.0 + return None + + def get_price_for_source( + self, + source: ExecutionPrice, + asset: str, + *, + side: OrderSide | None = None, + quantity: float | None = None, + use_open: bool = False, + ) -> float | None: + """Resolve a configured price source with sensible OHLCV fallbacks.""" + if ( + use_open + and self.execution_mode == ExecutionMode.NEXT_BAR + and source + not in { + ExecutionPrice.BID, + ExecutionPrice.ASK, + ExecutionPrice.QUOTE_MID, + ExecutionPrice.QUOTE_SIDE, + } + ): + return self._current_opens.get(asset, self._current_prices.get(asset)) + + if source == ExecutionPrice.PRICE: + return self._current_prices.get(asset, self._current_closes.get(asset)) + if source == ExecutionPrice.CLOSE: + return self._current_closes.get(asset, self._current_prices.get(asset)) + if source == ExecutionPrice.OPEN: + return self._current_opens.get(asset, self._current_prices.get(asset)) + if source == ExecutionPrice.MID: + high = self._current_highs.get(asset) + low = self._current_lows.get(asset) + if high is not None and low is not None: + return (high + low) / 2.0 + return self._current_prices.get(asset, self._current_closes.get(asset)) + if source == ExecutionPrice.VWAP: + return self._current_prices.get(asset, self._current_closes.get(asset)) + if source == ExecutionPrice.BID: + return self._current_bids.get(asset, self._current_prices.get(asset)) + if source == ExecutionPrice.ASK: + return self._current_asks.get(asset, self._current_prices.get(asset)) + if source == ExecutionPrice.QUOTE_MID: + return self.get_quote_mid(asset) or self._current_prices.get(asset) + if source == ExecutionPrice.QUOTE_SIDE: + if side is None and quantity is not None: + side = OrderSide.BUY if quantity > 0 else OrderSide.SELL + if side == OrderSide.BUY: + return self._current_asks.get( + asset, + self._current_opens.get(asset) if use_open else self._current_prices.get(asset), + ) + if side == OrderSide.SELL: + return self._current_bids.get( + asset, + self._current_opens.get(asset) if use_open else self._current_prices.get(asset), + ) + return self.get_quote_mid(asset) or self._current_prices.get(asset) + return self._current_prices.get(asset, self._current_closes.get(asset)) + + def get_mark_price( + self, + asset: str, + *, + quantity: float | None = None, + use_open: bool = False, + ) -> float | None: + """Resolve the configured mark price for an asset.""" + mark_side = None + if self.mark_price == ExecutionPrice.QUOTE_SIDE and quantity is not None: + mark_side = OrderSide.SELL if quantity > 0 else OrderSide.BUY + return self.get_price_for_source( + self.mark_price, + asset, + side=mark_side, + quantity=quantity, + use_open=use_open, + ) + + def get_available_size(self, asset: str, side: OrderSide | None = None) -> float | None: + """Return side-aware quote size when available, otherwise bar volume.""" + if side == OrderSide.BUY: + return self._current_ask_sizes.get(asset, self._current_volumes.get(asset)) + if side == OrderSide.SELL: + return self._current_bid_sizes.get(asset, self._current_volumes.get(asset)) + return self._current_volumes.get(asset) + + def get_quote_context( + self, asset: str, side: OrderSide | None = None + ) -> dict[str, float | None]: + """Return quote context for fills and trade summaries.""" + bid = self._current_bids.get(asset) + ask = self._current_asks.get(asset) + quote_mid = self.get_quote_mid(asset) + spread = ask - bid if bid is not None and ask is not None else None + return { + "reference_price": self._current_prices.get(asset), + "quote_mid_price": quote_mid, + "bid_price": bid, + "ask_price": ask, + "spread": spread, + "bid_size": self._current_bid_sizes.get(asset), + "ask_size": self._current_ask_sizes.get(asset), + "available_size": self.get_available_size(asset, side), + } + + def mark_account_positions(self, use_open: bool = False) -> None: + """Synchronize account position marks using configured price semantics.""" + for asset, position in self.account.positions.items(): + mark_price = self.get_mark_price(asset, quantity=position.quantity, use_open=use_open) + if mark_price is not None: + position.current_price = mark_price + # === Trading Statistics === def configure_stats( @@ -588,7 +726,7 @@ def last_rejection_reason(self) -> str | None: # === Risk Management === - def set_position_rules(self, rules, asset: str | None = None) -> None: + def set_position_rules(self, rules: PositionRule, asset: str | None = None) -> None: """Set position rules globally or per-asset. Args: @@ -803,7 +941,9 @@ def update_order(self, order_id: str, **kwargs) -> bool: def cancel_order(self, order_id: str) -> bool: return self._order_book.cancel_order(order_id) - def close_position(self, asset: str) -> Order | None: + def close_position( + self, asset: str, _options: SubmitOrderOptions | None = None + ) -> Order | None: """Close an open position for the given asset. Submits a market order to fully close the position. @@ -821,7 +961,7 @@ def close_position(self, asset: str) -> Order | None: pos = self.positions.get(asset) if pos and pos.quantity != 0: side = OrderSide.SELL if pos.quantity > 0 else OrderSide.BUY - return self.submit_order(asset, abs(pos.quantity), side) + return self.submit_order(asset, abs(pos.quantity), side, _options=_options) return None # === Position Modification (P1 Features) === @@ -1214,6 +1354,7 @@ def _order_to_target_value( price: float, order_type: OrderType, limit_price: float | None, + _options: SubmitOrderOptions | None = None, ) -> Order | None: """Internal helper to order toward a target value.""" # Bug #2 fix: Include contract multiplier in value calculations @@ -1237,11 +1378,21 @@ def _order_to_target_value( # Submit order if delta_qty > 0: return self.submit_order( - asset, delta_qty, OrderSide.BUY, order_type, limit_price=limit_price + asset, + delta_qty, + OrderSide.BUY, + order_type, + limit_price=limit_price, + _options=_options, ) elif delta_qty < 0: return self.submit_order( - asset, abs(delta_qty), OrderSide.SELL, order_type, limit_price=limit_price + asset, + abs(delta_qty), + OrderSide.SELL, + order_type, + limit_price=limit_price, + _options=_options, ) return None @@ -1278,6 +1429,7 @@ def rebalance_to_weights( orders: list[Order] = [] sells: list[tuple[str, float]] = [] # (asset, target_value) buys: list[tuple[str, float]] = [] # (asset, target_value) + rebalance_id: str | None = None scaled_weights = { asset: weight * self.rebalance_headroom_pct for asset, weight in target_weights.items() @@ -1298,6 +1450,12 @@ def allows_trading(asset: str) -> bool: return True return self._asset_bars_seen.get(asset, 0) >= self.late_asset_min_bars + def rebalance_options() -> SubmitOrderOptions: + nonlocal rebalance_id + if rebalance_id is None: + rebalance_id = self._next_rebalance_id() + return SubmitOrderOptions(rebalance_id=rebalance_id) + # Calculate target values and categorize as buys or sells for asset, weight in scaled_weights.items(): if not allows_trading(asset): @@ -1331,7 +1489,14 @@ def allows_trading(asset: str) -> bool: for asset, target_value in sells: price = resolve_price(asset) if price is not None: - order = self._order_to_target_value(asset, target_value, price, order_type, None) + order = self._order_to_target_value( + asset, + target_value, + price, + order_type, + None, + rebalance_options(), + ) if order: orders.append(order) @@ -1339,7 +1504,14 @@ def allows_trading(asset: str) -> bool: for asset, target_value in buys: price = resolve_price(asset) if price is not None: - order = self._order_to_target_value(asset, target_value, price, order_type, None) + order = self._order_to_target_value( + asset, + target_value, + price, + order_type, + None, + rebalance_options(), + ) if order: orders.append(order) @@ -1371,17 +1543,57 @@ def _update_time( timestamp: datetime, prices: dict[str, float], opens: dict[str, float], - highs: dict[str, float], - lows: dict[str, float], - volumes: dict[str, float], - signals: dict[str, dict], + highs: dict[str, float] | None = None, + lows: dict[str, float] | None = None, + *rest, + **kwargs, ): + if kwargs: + if rest: + raise TypeError("_update_time does not accept mixed positional/keyword cache args") + highs = highs if highs is not None else kwargs.pop("highs", None) + lows = lows if lows is not None else kwargs.pop("lows", None) + closes = kwargs.pop("closes", prices) + volumes = kwargs.pop("volumes") + bids = kwargs.pop("bids", {}) + asks = kwargs.pop("asks", {}) + mids = kwargs.pop("mids", {}) + bid_sizes = kwargs.pop("bid_sizes", {}) + ask_sizes = kwargs.pop("ask_sizes", {}) + signals = kwargs.pop("signals") + if kwargs: + raise TypeError(f"_update_time got unexpected keyword arguments: {sorted(kwargs)}") + elif len(rest) == 2: + volumes, signals = rest + closes = prices + bids = {} + asks = {} + mids = {} + bid_sizes = {} + ask_sizes = {} + elif len(rest) == 8: + closes, volumes, bids, asks, mids, bid_sizes, ask_sizes, signals = rest + else: + raise TypeError( + "_update_time expects either legacy arguments " + "(timestamp, prices, opens, highs, lows, volumes, signals) " + "or quote-aware arguments with closes/bid/ask caches." + ) + if highs is None or lows is None: + raise TypeError("_update_time requires highs and lows") + self._current_time = timestamp self._current_prices = prices self._current_opens = opens self._current_highs = highs self._current_lows = lows + self._current_closes = closes self._current_volumes = volumes + self._current_bids = bids + self._current_asks = asks + self._current_mids = mids + self._current_bid_sizes = bid_sizes + self._current_ask_sizes = ask_sizes self._current_signals = signals self._bar_index += 1 diff --git a/src/ml4t/backtest/config.py b/src/ml4t/backtest/config.py index 3e8029e3..6cd103d9 100644 --- a/src/ml4t/backtest/config.py +++ b/src/ml4t/backtest/config.py @@ -22,11 +22,14 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass, field, replace from enum import Enum from pathlib import Path +from typing import Any import yaml +from ml4t.data.artifacts.base import serialize_artifact_value +from ml4t.data.artifacts.market_data import FeedSpec, TimestampSemantics from .types import ExecutionMode, StopFillMode, StopLevelBasis @@ -34,10 +37,15 @@ class ExecutionPrice(str, Enum): """Price used for order execution.""" + PRICE = "price" # Use FeedSpec.price_col / broker reference price CLOSE = "close" # Use bar's close price OPEN = "open" # Use bar's open price VWAP = "vwap" # Volume-weighted average price (requires volume data) MID = "mid" # (high + low) / 2 + BID = "bid" # Use best bid quote + ASK = "ask" # Use best ask quote + QUOTE_MID = "quote_mid" # Use quote midpoint + QUOTE_SIDE = "quote_side" # Buy at ask / sell at bid class ShareType(str, Enum): @@ -228,6 +236,47 @@ class InitialHwmSource(str, Enum): BAR_HIGH = "bar_high" # Use bar's high (VBT Pro with OHLC) +def _feed_spec_to_dict(feed_spec: FeedSpec) -> dict[str, Any]: + """Serialize feed metadata to plain Python data for config round-trips.""" + return serialize_artifact_value(asdict(feed_spec)) + + +def _to_backtest_frequency(value: DataFrequency | Any | None) -> DataFrequency | None: + if value is None: + return None + if isinstance(value, DataFrequency): + return value + if isinstance(value, Enum): + value = value.value + + normalized = str(value).strip().lower() + mapping = { + "daily": DataFrequency.DAILY, + "1d": DataFrequency.DAILY, + "d": DataFrequency.DAILY, + "weekly": DataFrequency.IRREGULAR, + "monthly": DataFrequency.IRREGULAR, + "minute": DataFrequency.MINUTE_1, + "1m": DataFrequency.MINUTE_1, + "1min": DataFrequency.MINUTE_1, + "5m": DataFrequency.MINUTE_5, + "5min": DataFrequency.MINUTE_5, + "5minute": DataFrequency.MINUTE_5, + "15m": DataFrequency.MINUTE_15, + "15min": DataFrequency.MINUTE_15, + "15minute": DataFrequency.MINUTE_15, + "30m": DataFrequency.MINUTE_30, + "30min": DataFrequency.MINUTE_30, + "30minute": DataFrequency.MINUTE_30, + "hour": DataFrequency.HOURLY, + "hourly": DataFrequency.HOURLY, + "1h": DataFrequency.HOURLY, + "tick": DataFrequency.IRREGULAR, + "second": DataFrequency.IRREGULAR, + } + return mapping.get(normalized, DataFrequency.IRREGULAR) + + class TrailStopTiming(str, Enum): """Timing of water mark update relative to trailing stop check. @@ -318,6 +367,7 @@ class BacktestConfig: # === Execution Timing === execution_price: ExecutionPrice = ExecutionPrice.OPEN + mark_price: ExecutionPrice = ExecutionPrice.PRICE execution_mode: ExecutionMode = ExecutionMode.NEXT_BAR # Order execution timing # === Stop Configuration === @@ -497,6 +547,102 @@ def get_effective_account_type(self) -> str: # === Metadata === preset_name: str | None = None # Name of preset this was loaded from + feed_spec: FeedSpec | None = field(default=None, repr=False, compare=False) + metadata: dict[str, Any] = field(default_factory=dict, repr=False, compare=False) + _explicit_timezone: bool = field(default=False, init=False, repr=False, compare=False) + _explicit_data_frequency: bool = field(default=False, init=False, repr=False, compare=False) + + def __new__(cls, *args: Any, **kwargs: Any): + instance = super().__new__(cls) + field_names = [name for name, value in cls.__dataclass_fields__.items() if value.init] + provided = set(kwargs) + provided.update(field_names[: len(args)]) + instance._provided_init_fields = provided + return instance + + def __post_init__(self) -> None: + provided = getattr(self, "_provided_init_fields", set()) + self._explicit_timezone = "timezone" in provided + self._explicit_data_frequency = "data_frequency" in provided + if hasattr(self, "_provided_init_fields"): + delattr(self, "_provided_init_fields") + if self.feed_spec is None: + return + + self.feed_spec = FeedSpec.from_any(self.feed_spec) + if self.calendar is None and self.feed_spec.calendar: + self.calendar = self.feed_spec.calendar + if not self._explicit_timezone and self.feed_spec.timezone: + self.timezone = self.feed_spec.timezone + + spec_frequency = _to_backtest_frequency(self.feed_spec.data_frequency) + if not self._explicit_data_frequency and spec_frequency is not None: + self.data_frequency = spec_frequency + + @property + def resolved_feed_spec(self) -> FeedSpec: + """Effective feed metadata after applying runtime config precedence.""" + base = self.feed_spec if self.feed_spec is not None else FeedSpec() + return base.with_overrides( + calendar=self.calendar, + timezone=self.timezone, + data_frequency=self.data_frequency, + ) + + @property + def resolved_calendar(self) -> str | None: + return self.resolved_feed_spec.calendar + + @property + def resolved_timezone(self) -> str: + """Effective runtime timezone with UTC fallback.""" + return self.resolved_feed_spec.timezone or "UTC" + + @property + def resolved_data_frequency(self) -> DataFrequency: + resolved_frequency = _to_backtest_frequency(self.resolved_feed_spec.data_frequency) + return resolved_frequency or self.data_frequency + + @property + def resolved_session_start_time(self) -> str | None: + return self.resolved_feed_spec.session_start_time + + @property + def resolved_timestamp_semantics(self) -> TimestampSemantics | None: + return self.resolved_feed_spec.timestamp_semantics + + def merge_feed_spec(self, feed_spec: FeedSpec | Any | None) -> BacktestConfig: + """Fill missing runtime config from feed metadata without mutating user config.""" + effective_feed_spec = self.feed_spec if self.feed_spec is not None else feed_spec + if effective_feed_spec is None: + return self + + effective_feed_spec = FeedSpec.from_any(effective_feed_spec) + updates: dict[str, Any] = {"feed_spec": effective_feed_spec} + if self.calendar is None and effective_feed_spec.calendar: + updates["calendar"] = effective_feed_spec.calendar + if ( + not self._explicit_timezone + and effective_feed_spec.timezone + and effective_feed_spec.timezone != self.timezone + ): + updates["timezone"] = effective_feed_spec.timezone + + spec_frequency = _to_backtest_frequency(effective_feed_spec.data_frequency) + if ( + not self._explicit_data_frequency + and spec_frequency is not None + and spec_frequency != self.data_frequency + ): + updates["data_frequency"] = spec_frequency + + if len(updates) == 1 and self.feed_spec == effective_feed_spec: + return self + + merged = replace(self, **updates) + merged._explicit_timezone = self._explicit_timezone + merged._explicit_data_frequency = self._explicit_data_frequency + return merged def to_dict(self) -> dict: """Convert config to dictionary for serialization.""" @@ -512,6 +658,7 @@ def to_dict(self) -> dict: }, "execution": { "execution_price": self.execution_price.value, + "mark_price": self.mark_price.value, "execution_mode": self.execution_mode.value, }, "stops": { @@ -567,6 +714,8 @@ def to_dict(self) -> dict: "data_frequency": self.data_frequency.value, "enforce_sessions": self.enforce_sessions, }, + "feed": _feed_spec_to_dict(self.resolved_feed_spec), + "metadata": serialize_artifact_value(self.metadata), } @classmethod @@ -595,6 +744,8 @@ def from_dict( "settlement", "orders", "calendar", + "feed", + "metadata", } unknown_sections = set(data) - allowed_sections if unknown_sections: @@ -610,7 +761,7 @@ def from_dict( "fixed_margin_schedule", "short_cash_policy", }, - "execution": {"execution_price", "execution_mode"}, + "execution": {"execution_price", "mark_price", "execution_mode"}, "stops": { "stop_fill_mode", "stop_level_basis", @@ -645,8 +796,35 @@ def from_dict( "data_frequency", "enforce_sessions", }, + "feed": { + "timestamp_col", + "entity_col", + "price_col", + "open_col", + "high_col", + "low_col", + "close_col", + "volume_col", + "bid_col", + "ask_col", + "mid_col", + "bid_size_col", + "ask_size_col", + "calendar", + "timezone", + "data_frequency", + "bar_type", + "timestamp_semantics", + "session_start_time", + }, } for section, cfg in data.items(): + if section == "metadata": + if not isinstance(cfg, dict): + raise TypeError( + f"Section 'metadata' must be a dict, got {type(cfg).__name__}" + ) + continue if not isinstance(cfg, dict): raise TypeError(f"Section '{section}' must be a dict, got {type(cfg).__name__}") unknown_keys = set(cfg) - allowed_keys_by_section[section] @@ -665,6 +843,13 @@ def from_dict( settle_cfg = data.get("settlement", {}) order_cfg = data.get("orders", {}) cal_cfg = data.get("calendar", {}) + feed_cfg = data.get("feed", {}) + metadata = data.get("metadata", {}) + + if metadata is None: + metadata = {} + if not isinstance(metadata, dict): + raise TypeError(f"Section 'metadata' must be a dict, got {type(metadata).__name__}") allow_short_selling = acct_cfg.get("allow_short_selling", False) allow_leverage = acct_cfg.get("allow_leverage", False) @@ -680,6 +865,7 @@ def from_dict( short_cash_policy=ShortCashPolicy(acct_cfg.get("short_cash_policy", "credit")), # Execution execution_price=ExecutionPrice(exec_cfg.get("execution_price", "open")), + mark_price=ExecutionPrice(exec_cfg.get("mark_price", "price")), execution_mode=ExecutionMode(exec_cfg.get("execution_mode", "next_bar")), # Stops stop_fill_mode=StopFillMode(stops_cfg.get("stop_fill_mode", "stop_price")), @@ -730,6 +916,8 @@ def from_dict( enforce_sessions=cal_cfg.get("enforce_sessions", False), # Metadata preset_name=preset_name, + feed_spec=FeedSpec.from_any(feed_cfg) if feed_cfg else None, + metadata=dict(metadata), ) def to_yaml(self, path: str | Path) -> None: @@ -794,6 +982,7 @@ def describe(self) -> str: "Execution:", f" Execution mode: {self.execution_mode.value}", f" Execution price: {self.execution_price.value}", + f" Mark price: {self.mark_price.value}", "", "Stops:", f" Fill mode: {self.stop_fill_mode.value}", diff --git a/src/ml4t/backtest/core/execution_engine.py b/src/ml4t/backtest/core/execution_engine.py index 976ddd67..335d3e9e 100644 --- a/src/ml4t/backtest/core/execution_engine.py +++ b/src/ml4t/backtest/core/execution_engine.py @@ -28,7 +28,6 @@ def _is_exit_order(self, order) -> bool: def _process_orders_exit_first(self, use_open: bool = False): broker = self.broker fill = broker._fill_engine - mark_prices = broker._current_opens if use_open else broker._current_prices exit_orders = [] entry_orders = [] orders_this_bar_ids = broker._orders_this_bar_ids @@ -59,7 +58,7 @@ def _process_orders_exit_first(self, use_open: bool = False): else: fill.update_partial_order(order) - broker.account.mark_to_market(mark_prices) + broker.mark_account_positions(use_open=use_open) entry_orders = self._sort_entry_orders(entry_orders, use_open=use_open) for order in entry_orders: @@ -69,7 +68,6 @@ def _process_orders_exit_first(self, use_open: bool = False): def _process_orders_fifo(self, use_open: bool = False): broker = self.broker - mark_prices = broker._current_opens if use_open else broker._current_prices eligible_orders = [] orders_this_bar_ids = broker._orders_this_bar_ids for order in broker.pending_orders[:]: @@ -85,7 +83,7 @@ def _process_orders_fifo(self, use_open: bool = False): for order in eligible_orders: self._process_single_order(order, use_open, filled_orders) if filled_orders and filled_orders[-1] is order: - broker.account.mark_to_market(mark_prices) + broker.mark_account_positions(use_open=use_open) self._cleanup_filled_orders(filled_orders) @@ -105,7 +103,6 @@ def _process_orders_sequential(self, use_open: bool = False): """ broker = self.broker fill = broker._fill_engine - mark_prices = broker._current_opens if use_open else broker._current_prices eligible_orders = [] orders_this_bar_ids = broker._orders_this_bar_ids for order in broker.pending_orders[:]: @@ -150,7 +147,7 @@ def _process_orders_sequential(self, use_open: bool = False): # Mark-to-market after every fill so the next order sees updated cash if filled_orders and filled_orders[-1] is order: - broker.account.mark_to_market(mark_prices) + broker.mark_account_positions(use_open=use_open) self._cleanup_filled_orders(filled_orders) @@ -330,14 +327,13 @@ def _cleanup_filled_orders(self, filled_orders: list) -> None: def _sort_entry_orders(self, orders: list, use_open: bool) -> list: """Sort entry orders under EXIT_FIRST based on configured priority.""" broker = self.broker + fill = broker._fill_engine priority = broker.entry_order_priority.value if priority == "submission": return orders - prices = broker._current_opens if use_open else broker._current_prices - def notional(order) -> float: - px = prices.get(order.asset) + px = fill.get_fill_price_for_order(order, use_open) if px is None: px = broker._current_prices.get( order.asset, broker._current_opens.get(order.asset, 0.0) diff --git a/src/ml4t/backtest/core/fill_engine.py b/src/ml4t/backtest/core/fill_engine.py index 6e09c401..5a575592 100644 --- a/src/ml4t/backtest/core/fill_engine.py +++ b/src/ml4t/backtest/core/fill_engine.py @@ -2,8 +2,8 @@ from __future__ import annotations -from ..config import ExecutionPrice, ShareType -from ..types import ExecutionMode, OrderSide, OrderType +from ..config import ShareType +from ..types import OrderSide, OrderType class FillEngine: @@ -96,23 +96,12 @@ def try_partial_fill(self, order, fill_price: float) -> bool: def get_fill_price_for_order(self, order, use_open: bool) -> float | None: broker = self.broker - if use_open and broker.execution_mode == ExecutionMode.NEXT_BAR: - return broker._current_opens.get(order.asset) - # Dispatch on execution_price setting - ep = broker.execution_price - if ep == ExecutionPrice.OPEN: - return broker._current_opens.get(order.asset) - if ep == ExecutionPrice.MID: - h = broker._current_highs.get(order.asset) - lo = broker._current_lows.get(order.asset) - if h is not None and lo is not None: - return (h + lo) / 2.0 - return broker._current_prices.get(order.asset) - if ep == ExecutionPrice.VWAP: - # VWAP not available at bar level; fall back to close - return broker._current_prices.get(order.asset) - # Default: CLOSE - return broker._current_prices.get(order.asset) + return broker.get_price_for_source( + broker.execution_price, + order.asset, + side=order.side, + use_open=use_open, + ) def get_effective_quantity(self, order) -> float: remaining = self.broker._partial_orders.get(order.order_id) diff --git a/src/ml4t/backtest/core/order_book.py b/src/ml4t/backtest/core/order_book.py index dd4c6fcd..52980b56 100644 --- a/src/ml4t/backtest/core/order_book.py +++ b/src/ml4t/backtest/core/order_book.py @@ -58,6 +58,7 @@ def submit_order( limit_price=limit_price, stop_price=stop_price, trail_amount=trail_amount, + rebalance_id=options.rebalance_id if options is not None else None, order_id=f"ORD-{broker._order_counter}", created_at=broker._current_time, ) @@ -237,7 +238,9 @@ def _reset_submission_shadow_if_needed(self) -> None: self._submission_shadow_positions = { asset: ( pos.quantity, - broker._current_prices.get(asset, pos.current_price or pos.entry_price), + broker.get_mark_price(asset, quantity=pos.quantity) + or pos.current_price + or pos.entry_price, ) for asset, pos in broker.positions.items() if abs(pos.quantity) > self._QTY_EPS @@ -250,7 +253,7 @@ def _build_shadow_policy_positions(self) -> dict[str, Position]: for asset, (qty, basis_price) in self._submission_shadow_positions.items(): if abs(qty) <= self._QTY_EPS: continue - mark_price = broker._current_prices.get(asset, basis_price) + mark_price = broker.get_mark_price(asset, quantity=qty) or basis_price positions[asset] = Position( asset=asset, quantity=qty, diff --git a/src/ml4t/backtest/core/portfolio_ledger.py b/src/ml4t/backtest/core/portfolio_ledger.py index 7f893f0c..353b61a0 100644 --- a/src/ml4t/backtest/core/portfolio_ledger.py +++ b/src/ml4t/backtest/core/portfolio_ledger.py @@ -12,7 +12,7 @@ def __init__(self, broker): def get_account_value(self) -> float: value = self.broker.cash for asset, pos in self.broker.positions.items(): - price = self.broker._current_prices.get(asset) + price = self.broker.get_mark_price(asset, quantity=pos.quantity) if price is None: price = self.broker._last_prices.get(asset) if price is None: diff --git a/src/ml4t/backtest/core/shared.py b/src/ml4t/backtest/core/shared.py index 48ceb9b3..d0f4d6bc 100644 --- a/src/ml4t/backtest/core/shared.py +++ b/src/ml4t/backtest/core/shared.py @@ -20,6 +20,7 @@ class SubmitOrderOptions: """Internal options for submit_order behavior.""" eligible_in_next_bar_mode: bool = False + rebalance_id: str | None = None def is_exit_order(order: Order, positions: dict[str, Position]) -> bool: diff --git a/src/ml4t/backtest/datafeed.py b/src/ml4t/backtest/datafeed.py index 08b78bcb..8a28bf15 100644 --- a/src/ml4t/backtest/datafeed.py +++ b/src/ml4t/backtest/datafeed.py @@ -9,11 +9,26 @@ import polars as pl +from ml4t.data.artifacts.market_data import FeedSpec + class _AssetsData(dict[str, dict[str, Any]]): """Internal per-bar payload with pre-extracted broker views.""" - __slots__ = ("_prices", "_opens", "_highs", "_lows", "_volumes", "_signals") + __slots__ = ( + "_prices", + "_opens", + "_highs", + "_lows", + "_closes", + "_volumes", + "_bids", + "_asks", + "_mids", + "_bid_sizes", + "_ask_sizes", + "_signals", + ) def __init__(self): super().__init__() @@ -21,7 +36,13 @@ def __init__(self): self._opens: dict[str, Any] = {} self._highs: dict[str, Any] = {} self._lows: dict[str, Any] = {} + self._closes: dict[str, Any] = {} self._volumes: dict[str, Any] = {} + self._bids: dict[str, Any] = {} + self._asks: dict[str, Any] = {} + self._mids: dict[str, Any] = {} + self._bid_sizes: dict[str, Any] = {} + self._ask_sizes: dict[str, Any] = {} self._signals: dict[str, dict[str, Any]] = {} @@ -56,8 +77,25 @@ def __init__( signals_df: pl.DataFrame | None = None, context_df: pl.DataFrame | None = None, *, + feed_spec: FeedSpec | Any | None = None, + contract: 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, + 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, ): + if feed_spec is not None and contract is not None: + raise ValueError("Pass either feed_spec or contract, not both") + self.prices = ( prices_df if prices_df is not None @@ -77,8 +115,36 @@ def __init__( if self.prices is None: raise ValueError("prices_path or prices_df required") - # Resolve entity column name - self._entity_col = self._resolve_entity_col(entity_col, self.prices.columns) + raw_spec = FeedSpec.from_any(feed_spec if feed_spec is not None else contract) + self.feed_spec = raw_spec.with_overrides( + entity_col=entity_col, + timestamp_col=timestamp_col, + price_col=price_col, + open_col=open_col, + high_col=high_col, + low_col=low_col, + close_col=close_col, + volume_col=volume_col, + bid_col=bid_col, + ask_col=ask_col, + mid_col=mid_col, + bid_size_col=bid_size_col, + ask_size_col=ask_size_col, + ).resolve(self.prices.columns, self.ENTITY_COL_CANDIDATES) + self.contract = self.feed_spec + self._timestamp_col = self.feed_spec.timestamp_col + self._entity_col = self.feed_spec.entity_col + self._price_col = self.feed_spec.price_col + self._open_col = self.feed_spec.open_col + self._high_col = self.feed_spec.high_col + self._low_col = self.feed_spec.low_col + self._close_col = self.feed_spec.close_col + self._volume_col = self.feed_spec.volume_col + self._bid_col = self.feed_spec.bid_col + self._ask_col = self.feed_spec.ask_col + self._mid_col = self.feed_spec.mid_col + self._bid_size_col = self.feed_spec.bid_size_col + self._ask_size_col = self.feed_spec.ask_size_col # Pre-partition data by timestamp for O(1) lookups # Store DataFrames (memory efficient) instead of dicts (memory explosion) @@ -93,26 +159,52 @@ def __init__( self._timestamps = self._get_timestamps() self._idx = 0 self._signal_columns = ( - [c for c in self.signals.columns if c not in ("timestamp", self._entity_col)] + [c for c in self.signals.columns if c not in (self._timestamp_col, self._entity_col)] if self.signals is not None else [] ) self._context_columns = ( - [c for c in self.context.columns if c != "timestamp"] + [c for c in self.context.columns if c != self._timestamp_col] if self.context is not None else [] ) price_cols = self.prices.columns self._price_asset_idx = price_cols.index(self._entity_col) - self._price_open_idx = price_cols.index("open") if "open" in price_cols else -1 - self._price_high_idx = price_cols.index("high") if "high" in price_cols else -1 - self._price_low_idx = price_cols.index("low") if "low" in price_cols else -1 - self._price_close_idx = price_cols.index("close") if "close" in price_cols else -1 - self._price_volume_idx = price_cols.index("volume") if "volume" in price_cols else -1 + self._price_open_idx = ( + price_cols.index(self._open_col) if self._open_col in price_cols else -1 + ) + self._price_high_idx = ( + price_cols.index(self._high_col) if self._high_col in price_cols else -1 + ) + self._price_low_idx = price_cols.index(self._low_col) if self._low_col in price_cols else -1 + self._price_close_idx = ( + price_cols.index(self._close_col) if self._close_col in price_cols else -1 + ) + self._price_price_idx = ( + price_cols.index(self._price_col) + if self._price_col in price_cols + else self._price_close_idx + ) + self._price_volume_idx = ( + price_cols.index(self._volume_col) if self._volume_col in price_cols else -1 + ) + self._price_bid_idx = price_cols.index(self._bid_col) if self._bid_col in price_cols else -1 + self._price_ask_idx = price_cols.index(self._ask_col) if self._ask_col in price_cols else -1 + self._price_mid_idx = price_cols.index(self._mid_col) if self._mid_col in price_cols else -1 + self._price_bid_size_idx = ( + price_cols.index(self._bid_size_col) if self._bid_size_col in price_cols else -1 + ) + self._price_ask_size_idx = ( + price_cols.index(self._ask_size_col) if self._ask_size_col in price_cols else -1 + ) if self.signals is not None: signal_cols = self.signals.columns + if self._timestamp_col not in signal_cols: + raise ValueError( + f"timestamp_col={self._timestamp_col!r} not found in signal columns {signal_cols}" + ) self._signal_asset_idx = signal_cols.index(self._entity_col) self._signal_col_indices = [signal_cols.index(c) for c in self._signal_columns] else: @@ -121,6 +213,10 @@ def __init__( if self.context is not None: context_cols = self.context.columns + if self._timestamp_col not in context_cols: + raise ValueError( + f"timestamp_col={self._timestamp_col!r} not found in context columns {context_cols}" + ) self._context_col_indices = [context_cols.index(c) for c in self._context_columns] else: self._context_col_indices = [] @@ -134,9 +230,7 @@ def _resolve_entity_col(cls, explicit: str | None, columns: list[str]) -> str: """ if explicit is not None: if explicit not in columns: - raise ValueError( - f"entity_col={explicit!r} not found in columns {columns}" - ) + raise ValueError(f"entity_col={explicit!r} not found in columns {columns}") return explicit for candidate in cls.ENTITY_COL_CANDIDATES: if candidate in columns: @@ -153,8 +247,12 @@ def _partition_by_timestamp(self, df: pl.DataFrame) -> dict[datetime, pl.DataFra data in columnar format (minimal memory overhead). """ result: dict[datetime, pl.DataFrame] = {} - for ts_df in df.partition_by("timestamp", maintain_order=True): - ts = ts_df["timestamp"][0] + if self._timestamp_col not in df.columns: + raise ValueError( + f"timestamp_col={self._timestamp_col!r} not found in columns {df.columns}" + ) + for ts_df in df.partition_by(self._timestamp_col, maintain_order=True): + ts = ts_df[self._timestamp_col][0] result[ts] = ts_df return result @@ -177,6 +275,11 @@ def n_bars(self) -> int: """Number of unique timestamps/bars.""" return len(self._timestamps) + @property + def timestamps(self) -> tuple[datetime, ...]: + """Unique feed timestamps in iteration order.""" + return tuple(self._timestamps) + def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: if self._idx >= len(self._timestamps): raise StopIteration @@ -191,7 +294,13 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: price_high_idx = self._price_high_idx price_low_idx = self._price_low_idx price_close_idx = self._price_close_idx + price_price_idx = self._price_price_idx price_volume_idx = self._price_volume_idx + price_bid_idx = self._price_bid_idx + price_ask_idx = self._price_ask_idx + price_mid_idx = self._price_mid_idx + price_bid_size_idx = self._price_bid_size_idx + price_ask_size_idx = self._price_ask_size_idx # Convert price DataFrame slice to dicts (lazy, only current bar) price_df = self._prices_by_ts.get(ts) @@ -199,10 +308,16 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: for row in price_df.iter_rows(named=False): asset = row[price_asset_idx] close = row[price_close_idx] if price_close_idx >= 0 else None + price = row[price_price_idx] if price_price_idx >= 0 else close open_ = row[price_open_idx] if price_open_idx >= 0 else close high = row[price_high_idx] if price_high_idx >= 0 else close low = row[price_low_idx] if price_low_idx >= 0 else close volume = row[price_volume_idx] if price_volume_idx >= 0 else 0.0 + bid = row[price_bid_idx] if price_bid_idx >= 0 else None + ask = row[price_ask_idx] if price_ask_idx >= 0 else None + mid = row[price_mid_idx] if price_mid_idx >= 0 else None + bid_size = row[price_bid_size_idx] if price_bid_size_idx >= 0 else None + ask_size = row[price_ask_size_idx] if price_ask_size_idx >= 0 else None if open_ is None: open_ = close @@ -212,8 +327,13 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: low = close if volume is None: volume = 0.0 + if price is None: + price = close + if mid is None and bid is not None and ask is not None: + mid = (bid + ask) / 2.0 assets_data[asset] = { + "price": price, "open": open_, "high": high, "low": low, @@ -221,12 +341,33 @@ def __next__(self) -> tuple[datetime, dict[str, dict], dict[str, Any]]: "volume": volume, "signals": {}, } - if close is not None: - assets_data._prices[asset] = close + if bid is not None: + assets_data[asset]["bid"] = bid + if ask is not None: + assets_data[asset]["ask"] = ask + if mid is not None: + assets_data[asset]["mid"] = mid + if bid_size is not None: + assets_data[asset]["bid_size"] = bid_size + if ask_size is not None: + assets_data[asset]["ask_size"] = ask_size + if price is not None: + assets_data._prices[asset] = price assets_data._opens[asset] = open_ assets_data._highs[asset] = high assets_data._lows[asset] = low + assets_data._closes[asset] = close assets_data._volumes[asset] = volume + if bid is not None: + assets_data._bids[asset] = bid + if ask is not None: + assets_data._asks[asset] = ask + if mid is not None: + assets_data._mids[asset] = mid + if bid_size is not None: + assets_data._bid_sizes[asset] = bid_size + if ask_size is not None: + assets_data._ask_sizes[asset] = ask_size assets_data._signals[asset] = assets_data[asset]["signals"] # Add signals for each asset - lazy conversion diff --git a/src/ml4t/backtest/engine.py b/src/ml4t/backtest/engine.py index 825caff9..9c8a571a 100644 --- a/src/ml4t/backtest/engine.py +++ b/src/ml4t/backtest/engine.py @@ -10,9 +10,10 @@ from .analytics import EquityCurve, TradeAnalyzer from .analytics.metrics import calmar_ratio from .broker import Broker +from .config import DataFrequency from .datafeed import DataFeed from .strategy import Strategy -from .types import ExecutionMode +from .types import ExecutionMode, OrderSide if TYPE_CHECKING: from .config import BacktestConfig @@ -78,15 +79,16 @@ def __init__( self.feed = feed self.strategy = strategy - self.config = config - self.execution_mode = config.execution_mode + self.config = config.merge_feed_spec(getattr(feed, "feed_spec", None)) + self.execution_mode = self.config.execution_mode self.broker = Broker.from_config( - config, + self.config, contract_specs=contract_specs, market_impact_model=market_impact_model, execution_limits=execution_limits, ) self.equity_curve: list[tuple[datetime, float]] = [] + self.portfolio_state: list[tuple[datetime, float, float, float, float, int]] = [] # Calendar session enforcement (lazy initialized in run()) self._calendar = None @@ -101,12 +103,13 @@ def run(self) -> BacktestResult: """ # Lazy calendar initialization (zero cost if unused) is_trading_day_fn = None - if self.config and self.config.calendar: + if self.config and self.config.resolved_calendar: from .calendar import get_calendar, is_trading_day - self._calendar = get_calendar(self.config.calendar) + self._calendar = get_calendar(self.config.resolved_calendar) is_trading_day_fn = is_trading_day + self.strategy.on_prepare(self.broker, self.feed.timestamps, self.config) self.strategy.on_start(self.broker) # Date-level cache for trading day checks (significant speedup for intraday data) @@ -114,7 +117,7 @@ def run(self) -> BacktestResult: for timestamp, assets_data, context in self.feed: # Calendar session enforcement - calendar_id = self.config.calendar if self.config else None + calendar_id = self.config.resolved_calendar if self.config else None if ( self._calendar and calendar_id @@ -123,7 +126,7 @@ def run(self) -> BacktestResult: and is_trading_day_fn ): # For daily data, check trading day; for intraday, check market hours - if self.config.data_frequency.value == "daily": + if self.config.resolved_data_frequency == DataFrequency.DAILY: if not is_trading_day_fn(calendar_id, timestamp.date()): self._skipped_bars += 1 continue @@ -140,7 +143,13 @@ def run(self) -> BacktestResult: opens = getattr(assets_data, "_opens", None) highs = getattr(assets_data, "_highs", None) lows = getattr(assets_data, "_lows", None) + closes = getattr(assets_data, "_closes", None) volumes = getattr(assets_data, "_volumes", None) + bids = getattr(assets_data, "_bids", None) + asks = getattr(assets_data, "_asks", None) + mids = getattr(assets_data, "_mids", None) + bid_sizes = getattr(assets_data, "_bid_sizes", None) + ask_sizes = getattr(assets_data, "_ask_sizes", None) signals = getattr(assets_data, "_signals", None) if ( @@ -148,17 +157,59 @@ def run(self) -> BacktestResult: or opens is None or highs is None or lows is None + or closes is None or volumes is None + or bids is None + or asks is None + or mids is None + or bid_sizes is None + or ask_sizes is None or signals is None ): - prices = {a: d["close"] for a, d in assets_data.items() if d.get("close")} + prices = { + a: price + for a, d in assets_data.items() + if (price := d.get("price", d.get("close"))) is not None + } opens = {a: d.get("open", d.get("close")) for a, d in assets_data.items()} highs = {a: d.get("high", d.get("close")) for a, d in assets_data.items()} lows = {a: d.get("low", d.get("close")) for a, d in assets_data.items()} + closes = { + a: close + for a, d in assets_data.items() + if (close := d.get("close", d.get("price"))) is not None + } volumes = {a: d.get("volume", 0) for a, d in assets_data.items()} + bids = {a: d["bid"] for a, d in assets_data.items() if d.get("bid") is not None} + asks = {a: d["ask"] for a, d in assets_data.items() if d.get("ask") is not None} + mids = {a: d["mid"] for a, d in assets_data.items() if d.get("mid") is not None} + bid_sizes = { + a: d["bid_size"] + for a, d in assets_data.items() + if d.get("bid_size") is not None + } + ask_sizes = { + a: d["ask_size"] + for a, d in assets_data.items() + if d.get("ask_size") is not None + } signals = {a: d.get("signals", {}) for a, d in assets_data.items()} - self.broker._update_time(timestamp, prices, opens, highs, lows, volumes, signals) + self.broker._update_time( + timestamp, + prices, + opens, + highs, + lows, + closes, + volumes, + bids, + asks, + mids, + bid_sizes, + ask_sizes, + signals, + ) # Process pending exits from NEXT_BAR_OPEN mode (fills at open) # This must happen BEFORE evaluate_position_rules() to clear deferred exits @@ -184,7 +235,7 @@ def run(self) -> BacktestResult: # VBT Pro behavior: HWM updated at bar end, used in NEXT bar's trail evaluation self.broker._update_water_marks() - self.equity_curve.append((timestamp, self.broker.get_account_value())) + self._record_portfolio_state(timestamp) self.strategy.on_end(self.broker) return self._generate_results() @@ -200,6 +251,84 @@ def run_dict(self) -> dict[str, Any]: """ return self.run().to_dict() + def _record_portfolio_state(self, timestamp: datetime) -> None: + """Capture per-bar portfolio state for reporting.""" + cash = self.broker.cash + gross_exposure = 0.0 + net_exposure = 0.0 + + for asset, pos in self.broker.positions.items(): + price = self.broker.get_mark_price(asset, quantity=pos.quantity) + if price is None: + price = self.broker._last_prices.get(asset, pos.current_price or pos.entry_price) + position_value = pos.quantity * price * pos.multiplier + gross_exposure += abs(position_value) + net_exposure += position_value + + equity = cash + net_exposure + self.equity_curve.append((timestamp, equity)) + self.portfolio_state.append( + (timestamp, equity, cash, gross_exposure, net_exposure, len(self.broker.positions)) + ) + + def _build_activity_metrics(self) -> dict[str, int | float]: + """Compute fill and portfolio activity metrics.""" + fills = self.broker.fills + if not fills: + avg_open_positions = ( + sum(state[5] for state in self.portfolio_state) / len(self.portfolio_state) + if self.portfolio_state + else 0.0 + ) + max_open_positions = max((state[5] for state in self.portfolio_state), default=0) + return { + "num_fills": 0, + "num_rebalance_events": 0, + "unique_symbols_traded": 0, + "total_filled_notional": 0.0, + "avg_turnover": 0.0, + "max_turnover": 0.0, + "avg_open_positions": avg_open_positions, + "max_open_positions": max_open_positions, + } + + fill_notional_by_timestamp: dict[datetime, float] = {} + total_filled_notional = 0.0 + traded_symbols: set[str] = set() + rebalance_events: set[str | datetime] = set() + + for fill in fills: + multiplier = self.broker.get_multiplier(fill.asset) + notional = abs(fill.quantity) * fill.price * multiplier + total_filled_notional += notional + fill_notional_by_timestamp[fill.timestamp] = ( + fill_notional_by_timestamp.get(fill.timestamp, 0.0) + notional + ) + traded_symbols.add(fill.asset) + rebalance_events.add(fill.rebalance_id or fill.timestamp) + + turnovers = [ + fill_notional_by_timestamp.get(timestamp, 0.0) / equity if equity else 0.0 + for timestamp, equity, *_ in self.portfolio_state + ] + avg_open_positions = ( + sum(state[5] for state in self.portfolio_state) / len(self.portfolio_state) + if self.portfolio_state + else 0.0 + ) + max_open_positions = max((state[5] for state in self.portfolio_state), default=0) + + return { + "num_fills": len(fills), + "num_rebalance_events": len(rebalance_events), + "unique_symbols_traded": len(traded_symbols), + "total_filled_notional": total_filled_notional, + "avg_turnover": sum(turnovers) / len(turnovers) if turnovers else 0.0, + "max_turnover": max(turnovers, default=0.0), + "avg_open_positions": avg_open_positions, + "max_open_positions": max_open_positions, + } + def _generate_results(self) -> BacktestResult: """Generate backtest results with full analytics.""" from .result import BacktestResult @@ -211,12 +340,14 @@ def _generate_results(self) -> BacktestResult: trades=[], equity_curve=[], fills=[], + predictions=self.feed.signals, + portfolio_state=[], metrics={"skipped_bars": self._skipped_bars}, config=self.config, ) # Build EquityCurve from raw data - equity = EquityCurve() + equity = EquityCurve.from_config(self.config) for ts, value in self.equity_curve: equity.append(ts, value) @@ -228,7 +359,14 @@ def _generate_results(self) -> BacktestResult: last_timestamp = self.equity_curve[-1][0] for asset, pos in self.broker.positions.items(): # Get last known price for this asset - last_price = self.broker._current_prices.get(asset, pos.entry_price) + last_price = ( + self.broker.get_mark_price(asset, quantity=pos.quantity) or pos.entry_price + ) + entry_quote = pos.context.get("entry_quote_context", {}) + exit_quote = self.broker.get_quote_context( + asset, + OrderSide.BUY if pos.quantity < 0 else OrderSide.SELL, + ) # Calculate mark-to-market PnL (include multiplier for futures) pnl = ( @@ -250,19 +388,30 @@ def _generate_results(self) -> BacktestResult: pnl_percent=pnl_pct, bars_held=pos.bars_held, fees=pos.entry_commission, # Only entry fees so far - slippage=0.0, # No exit slippage yet + exit_slippage=0.0, # No exit slippage yet exit_reason="end_of_backtest", status="open", mfe=pos.max_favorable_excursion, mae=pos.max_adverse_excursion, entry_slippage=pos.entry_slippage, multiplier=pos.multiplier, + entry_quote_mid_price=entry_quote.get("quote_mid_price"), + entry_bid_price=entry_quote.get("bid_price"), + entry_ask_price=entry_quote.get("ask_price"), + entry_spread=entry_quote.get("spread"), + entry_available_size=entry_quote.get("available_size"), + exit_quote_mid_price=exit_quote.get("quote_mid_price"), + exit_bid_price=exit_quote.get("bid_price"), + exit_ask_price=exit_quote.get("ask_price"), + exit_spread=exit_quote.get("spread"), + exit_available_size=exit_quote.get("available_size"), ) all_trades.append(open_trade) # Build TradeAnalyzer (only on closed trades for accurate stats) closed_trades = [t for t in all_trades if t.status == "closed"] trade_analyzer = TradeAnalyzer(closed_trades) + activity_metrics = self._build_activity_metrics() # Build metrics dictionary (backward compatible) metrics = { @@ -279,7 +428,7 @@ def _generate_results(self) -> BacktestResult: "win_rate": trade_analyzer.win_rate, # Commission/slippage from fills (includes open positions) "total_commission": sum(f.commission for f in self.broker.fills), - "total_slippage": sum(f.slippage for f in self.broker.fills), + "total_slippage": sum(t.total_slippage_cost for t in all_trades), # Additional metrics "sharpe": equity.sharpe, "sortino": equity.sortino, @@ -302,12 +451,16 @@ def _generate_results(self) -> BacktestResult: "gross_profit_factor": trade_analyzer.gross_profit_factor, # Calendar enforcement "skipped_bars": self._skipped_bars, + # Activity and exposure summaries + **activity_metrics, } return BacktestResult( trades=all_trades, # Includes both closed and open trades equity_curve=self.equity_curve, fills=self.broker.fills, + predictions=self.feed.signals, + portfolio_state=self.portfolio_state, metrics=metrics, config=self.config, equity=equity, @@ -361,6 +514,8 @@ def run_backtest( 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, @@ -373,6 +528,8 @@ def run_backtest( signals: Optional signals DataFrame or path context: Optional context DataFrame or path config: BacktestConfig instance, preset name (str), or None for defaults + feed_spec: Optional shared dataset contract for schema and temporal metadata + contract: Alias for feed_spec 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.) @@ -402,6 +559,8 @@ def run_backtest( prices_df=prices if isinstance(prices, pl.DataFrame) else None, signals_df=signals if isinstance(signals, pl.DataFrame) else None, context_df=context if isinstance(context, pl.DataFrame) else None, + feed_spec=feed_spec, + contract=contract, ) if isinstance(config, str): diff --git a/src/ml4t/backtest/execution/__init__.py b/src/ml4t/backtest/execution/__init__.py index 0be495bf..abd679b6 100644 --- a/src/ml4t/backtest/execution/__init__.py +++ b/src/ml4t/backtest/execution/__init__.py @@ -24,6 +24,7 @@ TargetWeightExecutor, ) from .result import ExecutionResult +from .schedule import RebalanceCadence, RebalanceSchedule, resolve_rebalance_timestamps __all__ = [ # Fill Execution @@ -41,6 +42,9 @@ # Rebalancing "RebalanceConfig", "TargetWeightExecutor", + "RebalanceCadence", + "RebalanceSchedule", + "resolve_rebalance_timestamps", # Result "ExecutionResult", ] diff --git a/src/ml4t/backtest/execution/fill_executor.py b/src/ml4t/backtest/execution/fill_executor.py index 04644053..bc927280 100644 --- a/src/ml4t/backtest/execution/fill_executor.py +++ b/src/ml4t/backtest/execution/fill_executor.py @@ -56,6 +56,8 @@ class FillContext: slippage: float signed_qty: float # fill_quantity with sign (positive=buy, negative=sell) is_partial: bool + price_source: str + quote_context: dict[str, float | None] class FillExecutor: @@ -98,7 +100,7 @@ def execute(self, order: Order, base_price: float) -> bool: current_time = broker._current_time assert current_time is not None, "Cannot execute fill without current time" - volume = broker._current_volumes.get(order.asset) + available_size = broker.get_available_size(order.asset, order.side) # Get effective quantity (considering partial fills from previous bars) effective_quantity = broker._fill_engine.get_effective_quantity(order) @@ -109,7 +111,11 @@ def execute(self, order: Order, base_price: float) -> bool: if order.order_id in broker._filled_this_bar: return False - exec_result = broker.execution_limits.calculate(effective_quantity, volume, base_price) + exec_result = broker.execution_limits.calculate( + effective_quantity, + available_size, + base_price, + ) fill_quantity = exec_result.fillable_quantity if fill_quantity <= 0: @@ -125,19 +131,31 @@ def execute(self, order: Order, base_price: float) -> bool: # Apply market impact if broker.market_impact_model is not None: is_buy = order.side == OrderSide.BUY - impact = broker.market_impact_model.calculate(fill_quantity, base_price, volume, is_buy) + impact = broker.market_impact_model.calculate( + fill_quantity, + base_price, + available_size, + is_buy, + ) base_price = base_price + impact # Calculate slippage - slippage = broker.slippage_model.calculate(order.asset, fill_quantity, base_price, volume) + slippage = broker.slippage_model.calculate( + order.asset, + fill_quantity, + base_price, + available_size, + ) fill_price = base_price + slippage if order.side == OrderSide.BUY else base_price - slippage # Calculate commission commission = broker.commission_model.calculate(order.asset, fill_quantity, fill_price) + quote_context = broker.get_quote_context(order.asset, order.side) # Create fill record fill = Fill( order_id=order.order_id, + rebalance_id=order.rebalance_id, asset=order.asset, side=order.side, quantity=fill_quantity, @@ -148,6 +166,15 @@ def execute(self, order: Order, base_price: float) -> bool: order_type=order.order_type.value, limit_price=order.limit_price, stop_price=order.stop_price, + price_source=broker.execution_price.value, + reference_price=quote_context["reference_price"], + quote_mid_price=quote_context["quote_mid_price"], + bid_price=quote_context["bid_price"], + ask_price=quote_context["ask_price"], + spread=quote_context["spread"], + bid_size=quote_context["bid_size"], + ask_size=quote_context["ask_size"], + available_size=quote_context["available_size"], ) broker.fills.append(fill) @@ -172,6 +199,8 @@ def execute(self, order: Order, base_price: float) -> bool: slippage=slippage, signed_qty=signed_qty, is_partial=is_partial, + price_source=broker.execution_price.value, + quote_context=quote_context, ) # Update position and get actual commission (may change for flips) @@ -253,7 +282,7 @@ def _get_initial_hwm(self, asset: str, fill_price: float) -> float: if broker.initial_hwm_source == InitialHwmSource.BAR_HIGH: return broker._current_highs.get(asset, fill_price) elif broker.initial_hwm_source == InitialHwmSource.BAR_CLOSE: - return broker._current_prices.get(asset, fill_price) + return broker._current_closes.get(asset, broker._current_prices.get(asset, fill_price)) else: return fill_price @@ -276,7 +305,7 @@ def _get_initial_lwm(self, asset: str, fill_price: float) -> float: if broker.initial_hwm_source == InitialHwmSource.BAR_HIGH: return broker._current_lows.get(asset, fill_price) elif broker.initial_hwm_source == InitialHwmSource.BAR_CLOSE: - return broker._current_prices.get(asset, fill_price) + return broker._current_closes.get(asset, broker._current_prices.get(asset, fill_price)) else: return fill_price @@ -299,6 +328,7 @@ def _build_position_context(self, order: Order) -> dict: "stop_level_basis": broker.stop_level_basis, "trail_hwm_source": broker.trail_hwm_source, "trail_stop_timing": broker.trail_stop_timing, + "entry_quote_context": broker.get_quote_context(order.asset, order.side), } if signal_price is not None: context["signal_price"] = signal_price @@ -348,6 +378,8 @@ def _close_position(self, ctx: FillContext, pos: Position, old_qty: float) -> No pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_commission raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0 pnl_pct = raw_pct if old_qty > 0 else -raw_pct + entry_quote = pos.context.get("entry_quote_context", {}) + exit_quote = ctx.quote_context trade = Trade( symbol=order.asset, # Order.asset -> Trade.symbol @@ -360,12 +392,22 @@ def _close_position(self, ctx: FillContext, pos: Position, old_qty: float) -> No pnl_percent=pnl_pct, bars_held=pos.bars_held, fees=total_commission, - slippage=ctx.slippage, + exit_slippage=ctx.slippage, exit_reason=_get_exit_reason(order), mfe=pos.max_favorable_excursion, mae=pos.max_adverse_excursion, entry_slippage=pos.entry_slippage, multiplier=pos.multiplier, + entry_quote_mid_price=entry_quote.get("quote_mid_price"), + entry_bid_price=entry_quote.get("bid_price"), + entry_ask_price=entry_quote.get("ask_price"), + entry_spread=entry_quote.get("spread"), + entry_available_size=entry_quote.get("available_size"), + exit_quote_mid_price=exit_quote.get("quote_mid_price"), + exit_bid_price=exit_quote.get("bid_price"), + exit_ask_price=exit_quote.get("ask_price"), + exit_spread=exit_quote.get("spread"), + exit_available_size=exit_quote.get("available_size"), ) broker.trades.append(trade) del broker.positions[order.asset] @@ -403,6 +445,8 @@ def _flip_position( pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_close_commission raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0 pnl_pct = raw_pct if old_qty > 0 else -raw_pct + entry_quote = pos.context.get("entry_quote_context", {}) + exit_quote = ctx.quote_context trade = Trade( symbol=order.asset, # Order.asset -> Trade.symbol @@ -415,12 +459,22 @@ def _flip_position( pnl_percent=pnl_pct, bars_held=pos.bars_held, fees=total_close_commission, - slippage=ctx.slippage * (close_qty / ctx.fill_quantity), + exit_slippage=ctx.slippage * (close_qty / ctx.fill_quantity), exit_reason=_get_exit_reason(order), mfe=pos.max_favorable_excursion, mae=pos.max_adverse_excursion, entry_slippage=pos.entry_slippage, multiplier=pos.multiplier, + entry_quote_mid_price=entry_quote.get("quote_mid_price"), + entry_bid_price=entry_quote.get("bid_price"), + entry_ask_price=entry_quote.get("ask_price"), + entry_spread=entry_quote.get("spread"), + entry_available_size=entry_quote.get("available_size"), + exit_quote_mid_price=exit_quote.get("quote_mid_price"), + exit_bid_price=exit_quote.get("bid_price"), + exit_ask_price=exit_quote.get("ask_price"), + exit_spread=exit_quote.get("spread"), + exit_available_size=exit_quote.get("available_size"), ) broker.trades.append(trade) @@ -525,7 +579,8 @@ def _sync_account_state(self, asset: str, current_price: float | None = None) -> mark_price = ( current_price if current_price is not None - else broker._current_prices.get(asset, broker_pos.entry_price) + else broker.get_mark_price(asset, quantity=broker_pos.quantity) + or broker_pos.entry_price ) if account_pos is None: broker.account.positions[asset] = Position( diff --git a/src/ml4t/backtest/execution/rebalancer.py b/src/ml4t/backtest/execution/rebalancer.py index 5f666ae3..c17200d4 100644 --- a/src/ml4t/backtest/execution/rebalancer.py +++ b/src/ml4t/backtest/execution/rebalancer.py @@ -17,21 +17,30 @@ orders = executor.execute(target_weights, data, broker) """ +from __future__ import annotations + +from collections.abc import Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Protocol +from datetime import datetime +from typing import TYPE_CHECKING, Any, Protocol + +import polars as pl if TYPE_CHECKING: from ..broker import Broker + from ..feed_spec import FeedSpec from ..types import Order from ..config import RebalanceMode, ShareType +from ..core.shared import SubmitOrderOptions from ..types import OrderSide +from .schedule import RebalanceSchedule, resolve_rebalance_timestamps class WeightProvider(Protocol): """Protocol for anything that produces target weights.""" - def get_weights(self, data: dict, broker: "Broker") -> dict[str, float]: + def get_weights(self, data: dict, broker: Broker) -> dict[str, float]: """Return target weights (asset -> weight, should sum to <= 1.0).""" ... @@ -78,6 +87,7 @@ class RebalanceConfig: cancel_before_rebalance: bool = True account_for_pending: bool = True rebalance_mode: RebalanceMode = RebalanceMode.SNAPSHOT + schedule: RebalanceSchedule | None = None class TargetWeightExecutor: @@ -108,13 +118,48 @@ def __init__(self, config: RebalanceConfig | None = None): config: Rebalancing configuration. Uses defaults if not provided. """ self.config = config or RebalanceConfig() + self._resolved_schedule: frozenset[datetime] | None = None + + def prepare_schedule( + self, + available_timestamps: Sequence[datetime] | pl.Series, + *, + feed_spec: FeedSpec | Any | None = None, + calendar: str | None = None, + timezone: str | None = None, + session_start_time: str | None = None, + ) -> frozenset[datetime] | None: + """Resolve the configured schedule against a feed's available timestamps.""" + if self.config.schedule is None: + self._resolved_schedule = None + return None + resolved = resolve_rebalance_timestamps( + available_timestamps, + self.config.schedule, + feed_spec=feed_spec, + calendar=calendar, + timezone=timezone, + session_start_time=session_start_time, + ) + self._resolved_schedule = frozenset(resolved.to_list()) + return self._resolved_schedule + + def should_rebalance(self, timestamp: datetime) -> bool: + """Return whether the current timestamp is on the prepared schedule.""" + if self.config.schedule is None: + return True + if self._resolved_schedule is None: + raise ValueError("prepare_schedule() must be called before scheduled execution") + return timestamp in self._resolved_schedule def execute( self, target_weights: dict[str, float], data: dict[str, dict], - broker: "Broker", - ) -> list["Order"]: + broker: Broker, + *, + timestamp: datetime | None = None, + ) -> list[Order]: """Execute rebalancing to target weights. Behavior depends on ``self.config.rebalance_mode``: @@ -135,6 +180,12 @@ def execute( Returns: List of submitted orders. """ + if self.config.schedule is not None: + if timestamp is None: + raise ValueError("timestamp is required when RebalanceConfig.schedule is set") + if not self.should_rebalance(timestamp): + return [] + # 1. Cancel pending orders if configured (prevents double-allocation) if self.config.cancel_before_rebalance: for pending_order in list(broker.pending_orders): @@ -146,6 +197,13 @@ def execute( orders: list[Order] = [] mode = self.config.rebalance_mode + rebalance_id: str | None = None + + def rebalance_options() -> SubmitOrderOptions: + nonlocal rebalance_id + if rebalance_id is None: + rebalance_id = broker._next_rebalance_id() + return SubmitOrderOptions(rebalance_id=rebalance_id) # 2. Get current weights (effective or actual based on config) if self.config.account_for_pending and not self.config.cancel_before_rebalance: @@ -175,7 +233,15 @@ def execute( # 5. Process reductions for target assets first (frees cash for buys). for asset in reducing_assets: target_wt = target_weights[asset] - order = self._process_asset(asset, target_wt, current_weights, equity, data, broker) + order = self._process_asset( + asset, + target_wt, + current_weights, + equity, + data, + broker, + rebalance_id=rebalance_options().rebalance_id, + ) if order is not None: orders.append(order) if mode in (RebalanceMode.INCREMENTAL, RebalanceMode.HYBRID) and order is not None: @@ -189,7 +255,9 @@ def execute( if asset not in target_weights: pos = broker.get_position(asset) if pos and pos.quantity != 0: - close_order: Order | None = broker.close_position(asset) + close_order: Order | None = broker.close_position( + asset, _options=rebalance_options() + ) if close_order: orders.append(close_order) @@ -203,7 +271,15 @@ def execute( # 7. Process increases for target assets. for asset in increasing_assets: target_wt = target_weights[asset] - order = self._process_asset(asset, target_wt, current_weights, equity, data, broker) + order = self._process_asset( + asset, + target_wt, + current_weights, + equity, + data, + broker, + rebalance_id=rebalance_options().rebalance_id, + ) if order is not None: orders.append(order) if mode in (RebalanceMode.INCREMENTAL, RebalanceMode.HYBRID) and order is not None: @@ -221,8 +297,10 @@ def _process_asset( current_weights: dict[str, float], equity: float, data: dict[str, dict], - broker: "Broker", - ) -> "Order | None": + broker: Broker, + *, + rebalance_id: str | None = None, + ) -> Order | None: """Process a single asset for rebalancing. Returns: @@ -274,7 +352,8 @@ def _process_asset( # Submit order side = OrderSide.BUY if shares > 0 else OrderSide.SELL - return broker.submit_order(asset, abs(shares), side) + options = SubmitOrderOptions(rebalance_id=rebalance_id) + return broker.submit_order(asset, abs(shares), side, _options=options) def _get_rebalance_price(self, asset: str, data: dict[str, dict]) -> float | None: """Return the current bar close used for new rebalance trades.""" @@ -286,7 +365,7 @@ def _get_position_price( asset: str, pos, data: dict[str, dict], - broker: "Broker", + broker: Broker, ) -> float | None: """Return a mark price for an existing position, tolerating sparse bars.""" price = self._get_rebalance_price(asset, data) @@ -300,7 +379,7 @@ def _get_position_price( price = pos.entry_price return price - def _get_current_weights(self, broker: "Broker", data: dict[str, dict]) -> dict[str, float]: + def _get_current_weights(self, broker: Broker, data: dict[str, dict]) -> dict[str, float]: """Get current portfolio weights from held positions only. Args: @@ -325,7 +404,7 @@ def _get_current_weights(self, broker: "Broker", data: dict[str, dict]) -> dict[ return weights - def _get_effective_weights(self, broker: "Broker", data: dict[str, dict]) -> dict[str, float]: + def _get_effective_weights(self, broker: Broker, data: dict[str, dict]) -> dict[str, float]: """Get effective weights including pending orders. This prevents double-allocation when execute() is called multiple times @@ -367,7 +446,7 @@ def preview( self, target_weights: dict[str, float], data: dict[str, dict], - broker: "Broker", + broker: Broker, ) -> list[dict]: """Preview trades without executing. diff --git a/src/ml4t/backtest/execution/schedule.py b/src/ml4t/backtest/execution/schedule.py new file mode 100644 index 00000000..4e903fb8 --- /dev/null +++ b/src/ml4t/backtest/execution/schedule.py @@ -0,0 +1,303 @@ +"""Rebalance schedule resolution utilities.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import date, datetime, time +from enum import Enum +from typing import Any + +import polars as pl +from ml4t.data.artifacts.market_data import FeedSpec, TimestampSemantics + +from ..calendar import get_schedule +from ..config import DataFrequency, _to_backtest_frequency +from ..sessions import SessionConfig, assign_session_date + + +class RebalanceCadence(str, Enum): + """Supported rebalance cadences.""" + + EVERY_BAR = "every_bar" + EVERY_SESSION = "every_session" + FIXED_N_SESSIONS = "fixed_n_sessions" + WEEKLY = "weekly" + MONTH_END = "month_end" + EXPLICIT_TIMESTAMPS = "explicit_timestamps" + + +@dataclass(frozen=True) +class RebalanceSchedule: + """Describe when a strategy or executor should rebalance.""" + + cadence: RebalanceCadence = RebalanceCadence.EVERY_BAR + every_n: int = 1 + timestamps: tuple[datetime, ...] = () + + def __post_init__(self) -> None: + if self.cadence == RebalanceCadence.FIXED_N_SESSIONS and self.every_n < 1: + raise ValueError("RebalanceSchedule.every_n must be >= 1") + if self.cadence == RebalanceCadence.EXPLICIT_TIMESTAMPS and not self.timestamps: + raise ValueError("Explicit timestamp schedules require at least one timestamp") + + @classmethod + def every_bar(cls) -> RebalanceSchedule: + return cls(cadence=RebalanceCadence.EVERY_BAR) + + @classmethod + def every_session(cls) -> RebalanceSchedule: + return cls(cadence=RebalanceCadence.EVERY_SESSION) + + @classmethod + def fixed_n_sessions(cls, n: int) -> RebalanceSchedule: + return cls(cadence=RebalanceCadence.FIXED_N_SESSIONS, every_n=n) + + @classmethod + def weekly(cls) -> RebalanceSchedule: + return cls(cadence=RebalanceCadence.WEEKLY) + + @classmethod + def month_end(cls) -> RebalanceSchedule: + return cls(cadence=RebalanceCadence.MONTH_END) + + @classmethod + def explicit_timestamps(cls, timestamps: Sequence[datetime]) -> RebalanceSchedule: + return cls( + cadence=RebalanceCadence.EXPLICIT_TIMESTAMPS, + timestamps=tuple(sorted({_coerce_timestamp(ts) for ts in timestamps})), + ) + + +def resolve_rebalance_timestamps( + available_timestamps: Sequence[datetime] | pl.Series, + schedule: RebalanceSchedule | RebalanceCadence | str, + *, + feed_spec: FeedSpec | Any | None = None, + calendar: str | None = None, + timezone: str | None = None, + session_start_time: str | None = None, + data_frequency: Any | None = None, + timestamp_semantics: TimestampSemantics | str | None = None, +) -> pl.Series: + """Resolve rebalance timestamps from available bars and schedule semantics.""" + ts_list = _normalize_timestamps(available_timestamps) + if not ts_list: + return pl.Series("timestamp", [], dtype=pl.Datetime("us")) + + schedule = _coerce_schedule(schedule) + cadence = schedule.cadence + + if cadence == RebalanceCadence.EVERY_BAR: + return pl.Series("timestamp", ts_list) + + if cadence == RebalanceCadence.EXPLICIT_TIMESTAMPS: + explicit = set(schedule.timestamps) + return pl.Series("timestamp", [ts for ts in ts_list if ts in explicit]) + + metadata = _resolve_schedule_metadata( + ts_list, + feed_spec=feed_spec, + calendar=calendar, + timezone=timezone, + session_start_time=session_start_time, + data_frequency=data_frequency, + timestamp_semantics=timestamp_semantics, + ) + session_config = _build_session_config( + ts_list, + calendar=metadata["calendar"], + timezone=metadata["timezone"], + timezone_explicit=metadata["timezone_explicit"], + session_start_time=metadata["session_start_time"], + ) + session_dates, session_closes = _resolve_sessions( + ts_list, + session_config=session_config, + timestamp_semantics=metadata["timestamp_semantics"], + ) + + if cadence == RebalanceCadence.EVERY_SESSION: + return pl.Series("timestamp", session_closes) + + if cadence == RebalanceCadence.FIXED_N_SESSIONS: + return pl.Series("timestamp", session_closes[:: schedule.every_n]) + + grouped: dict[tuple[int, int], datetime] = {} + for session_date, ts in zip(session_dates, session_closes, strict=False): + if cadence == RebalanceCadence.WEEKLY: + key = session_date.isocalendar()[:2] + elif cadence == RebalanceCadence.MONTH_END: + key = (session_date.year, session_date.month) + else: + raise ValueError(f"Unsupported rebalance cadence: {cadence}") + grouped[key] = ts + + return pl.Series("timestamp", list(grouped.values())) + + +def _normalize_timestamps(available_timestamps: Sequence[datetime] | pl.Series) -> list[datetime]: + if isinstance(available_timestamps, pl.Series): + if available_timestamps.is_empty(): + return [] + return sorted({_coerce_timestamp(ts) for ts in available_timestamps.to_list()}) + return sorted({_coerce_timestamp(ts) for ts in available_timestamps}) + + +def _coerce_timestamp(value: datetime | date) -> datetime: + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime.combine(value, time.min) + raise TypeError(f"Unsupported timestamp type: {type(value).__name__}") + + +def _coerce_schedule(schedule: RebalanceSchedule | RebalanceCadence | str) -> RebalanceSchedule: + if isinstance(schedule, RebalanceSchedule): + return schedule + if isinstance(schedule, str): + schedule = RebalanceCadence(schedule) + return RebalanceSchedule(cadence=schedule) + + +def _resolve_schedule_metadata( + timestamps: Sequence[datetime], + *, + feed_spec: FeedSpec | Any | None, + calendar: str | None, + timezone: str | None, + session_start_time: str | None, + data_frequency: Any | None, + timestamp_semantics: TimestampSemantics | str | None, +) -> dict[str, Any]: + spec = FeedSpec.from_any(feed_spec) if feed_spec is not None else None + + resolved_calendar = calendar if calendar is not None else (spec.calendar if spec else None) + resolved_timezone = timezone if timezone is not None else (spec.timezone if spec else None) + timezone_explicit = resolved_timezone is not None + if resolved_timezone is None: + resolved_timezone = "UTC" + resolved_session_start = ( + session_start_time + if session_start_time is not None + else (spec.session_start_time if spec else None) + ) + resolved_frequency = ( + data_frequency if data_frequency is not None else (spec.data_frequency if spec else None) + ) + semantics = ( + timestamp_semantics + if timestamp_semantics is not None + else (spec.timestamp_semantics if spec else None) + ) + + if semantics is None: + semantics = _infer_timestamp_semantics(timestamps, resolved_frequency) + elif not isinstance(semantics, TimestampSemantics): + semantics = TimestampSemantics(str(semantics)) + + return { + "calendar": resolved_calendar, + "timezone": resolved_timezone, + "timezone_explicit": timezone_explicit, + "session_start_time": resolved_session_start, + "data_frequency": resolved_frequency, + "timestamp_semantics": semantics, + } + + +def _infer_timestamp_semantics( + timestamps: Sequence[datetime], + data_frequency: Any | None, +) -> TimestampSemantics: + if data_frequency is not None: + frequency = _to_backtest_frequency(data_frequency) + if frequency == DataFrequency.DAILY and _timestamps_look_date_labeled(timestamps): + return TimestampSemantics.SESSION_LABEL + + if _timestamps_look_date_labeled(timestamps): + return TimestampSemantics.SESSION_LABEL + + return TimestampSemantics.EVENT_TIME + + +def _timestamps_look_date_labeled(timestamps: Sequence[datetime]) -> bool: + return all( + ts.hour == 0 and ts.minute == 0 and ts.second == 0 and ts.microsecond == 0 + for ts in timestamps + ) + + +def _build_session_config( + timestamps: Sequence[datetime], + *, + calendar: str | None, + timezone: str, + timezone_explicit: bool, + session_start_time: str | None, +) -> SessionConfig: + if calendar is None: + return SessionConfig( + calendar="UTC", timezone=timezone, session_start_time=session_start_time + ) + + inferred_timezone = timezone + if not timezone_explicit: + schedule = get_schedule(calendar, timestamps[0].date(), timestamps[-1].date()) + if not schedule.is_empty(): + inferred_timezone = schedule["timezone"][0] + + return SessionConfig( + calendar=_normalize_session_calendar(calendar), + timezone=inferred_timezone, + session_start_time=session_start_time, + ) + + +def _normalize_session_calendar(calendar: str) -> str: + normalized = calendar.upper() + if normalized in {"NYSE", "XNYS", "AMEX"}: + return "NYSE" + if normalized == "NASDAQ": + return "NASDAQ" + if normalized in {"CME", "CME_EQUITY"}: + return "CME_Equity" + if normalized == "CBOT": + return "CBOT" + if normalized == "NYMEX": + return "NYMEX" + if normalized == "COMEX": + return "COMEX" + return calendar + + +def _resolve_sessions( + timestamps: Sequence[datetime], + *, + session_config: SessionConfig, + timestamp_semantics: TimestampSemantics, +) -> tuple[list[datetime], list[datetime]]: + tz = _session_config_timezone(session_config) + session_start_hour = session_config.get_session_start_hour() + session_start_minute = session_config.get_session_start_minute() + + session_closes: dict[datetime, datetime] = {} + for ts in timestamps: + if timestamp_semantics == TimestampSemantics.SESSION_LABEL: + session_date = _session_label_date(ts, tz) + else: + session_date = assign_session_date(ts, tz, session_start_hour, session_start_minute) + session_closes[session_date] = ts + session_dates = list(session_closes.keys()) + return session_dates, list(session_closes.values()) + + +def _session_label_date(timestamp: datetime, timezone) -> datetime: + ts_local = timestamp if timestamp.tzinfo is None else timestamp.astimezone(timezone) + return datetime(ts_local.year, ts_local.month, ts_local.day) + + +def _session_config_timezone(session_config: SessionConfig): + from zoneinfo import ZoneInfo + + return ZoneInfo(session_config.timezone) diff --git a/src/ml4t/backtest/export.py b/src/ml4t/backtest/export.py index e2180966..514d0393 100644 --- a/src/ml4t/backtest/export.py +++ b/src/ml4t/backtest/export.py @@ -129,6 +129,14 @@ def batch_export( record["final_value"] = metrics.get("final_value", 0.0) record["total_commission"] = metrics.get("total_commission", 0.0) record["total_slippage"] = metrics.get("total_slippage", 0.0) + record["num_fills"] = metrics.get("num_fills", 0) + record["num_rebalance_events"] = metrics.get("num_rebalance_events", 0) + record["unique_symbols_traded"] = metrics.get("unique_symbols_traded", 0) + record["total_filled_notional"] = metrics.get("total_filled_notional", 0.0) + record["avg_turnover"] = metrics.get("avg_turnover", 0.0) + record["max_turnover"] = metrics.get("max_turnover", 0.0) + record["avg_open_positions"] = metrics.get("avg_open_positions", 0.0) + record["max_open_positions"] = metrics.get("max_open_positions", 0) summary_records.append(record) diff --git a/src/ml4t/backtest/result.py b/src/ml4t/backtest/result.py index 553610df..3c4005fc 100644 --- a/src/ml4t/backtest/result.py +++ b/src/ml4t/backtest/result.py @@ -8,7 +8,7 @@ >>> engine = Engine(feed, strategy) >>> result = engine.run() >>> - >>> # Export trades to Parquet + >>> # Export trades and raw predictions to Parquet >>> result.to_parquet("./results/my_backtest") >>> >>> # Get DataFrames @@ -28,61 +28,20 @@ from typing import TYPE_CHECKING, Any, Literal import polars as pl +from ml4t.data.artifacts.market_data import FeedSpec -from .types import Fill, Trade +try: + from ._version import __version__ +except ImportError: # pragma: no cover - fallback for local editable edge cases + __version__ = "0.0.0.dev0" +from .analytics.annualization import should_session_align +from .types import Fill, OrderSide, Trade if TYPE_CHECKING: from .analytics import EquityCurve, TradeAnalyzer from .config import BacktestConfig -# Annualization factors for common trading calendars -_ANNUALIZATION_FACTORS: dict[str, int] = { - "crypto": 365, # 24/7 trading - "NYSE": 252, - "NASDAQ": 252, - "CME_Equity": 252, - "CME_Agriculture": 252, - "CME_Globex_Energy_and_Metals": 252, - "LSE": 253, - "XETRA": 252, - "TSX": 252, - "HKEX": 252, - "JPX": 245, -} - - -def _get_annualization_factor(calendar: str | None) -> int: - """Get annualization factor for a trading calendar. - - Args: - calendar: Trading calendar name. If None, defaults to 252. - - Returns: - Number of trading days per year for the calendar. - """ - if calendar is None: - return 252 # Default for equities - - # Check known calendars (case-insensitive) - cal_upper = calendar.upper() - for name, factor in _ANNUALIZATION_FACTORS.items(): - if name.upper() == cal_upper: - return factor - - # Try pandas_market_calendars for unknown calendars - try: - from pandas_market_calendars import get_calendar - - cal = get_calendar(calendar) - # Estimate from typical year - schedule = cal.schedule("2024-01-01", "2024-12-31") - return len(schedule) - except Exception: - # Default fallback - return 252 - - @dataclass class BacktestResult: """Structured backtest result with export capabilities. @@ -97,6 +56,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) + predictions: Raw prediction DataFrame passed into the backtest (optional) metrics: Dictionary of computed performance metrics config: BacktestConfig used for the backtest (optional) equity: EquityCurve analytics object @@ -107,13 +67,33 @@ class BacktestResult: 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]] = field( + default_factory=list + ) # Cached DataFrames (computed on demand) _trades_df: pl.DataFrame | None = field(default=None, repr=False) _equity_df: pl.DataFrame | None = field(default=None, repr=False) + _fills_df: pl.DataFrame | None = field(default=None, repr=False) + _portfolio_state_df: pl.DataFrame | None = field(default=None, repr=False) + + def _feed_spec(self) -> FeedSpec | None: + if self.config is None: + return None + return self.config.resolved_feed_spec + + def _auto_session_aligned(self, calendar: str | None = None) -> bool: + timestamps = [ts for ts, _ in self.equity_curve] + resolved_calendar = calendar or (self.config.resolved_calendar if self.config else None) + return should_session_align( + calendar=resolved_calendar, + feed_spec=self._feed_spec(), + timestamps=timestamps, + ) def to_trades_dataframe(self) -> pl.DataFrame: """Convert trades to Polars DataFrame. @@ -121,7 +101,7 @@ def to_trades_dataframe(self) -> pl.DataFrame: Returns DataFrame with columns: symbol, entry_time, exit_time, entry_price, exit_price, quantity, direction, pnl, pnl_percent, bars_held, - fees, slippage, mfe, mae, entry_slippage, multiplier, + fees, exit_slippage, mfe, mae, entry_slippage, multiplier, gross_pnl, net_return, total_slippage_cost, cost_drag, exit_reason, status @@ -158,11 +138,21 @@ def to_trades_dataframe(self) -> pl.DataFrame: "pnl_percent": t.pnl_percent, "bars_held": t.bars_held, "fees": t.fees, - "slippage": t.slippage, + "exit_slippage": t.exit_slippage, "mfe": t.mfe, "mae": t.mae, "entry_slippage": t.entry_slippage, "multiplier": t.multiplier, + "entry_quote_mid_price": t.entry_quote_mid_price, + "entry_bid_price": t.entry_bid_price, + "entry_ask_price": t.entry_ask_price, + "entry_spread": t.entry_spread, + "entry_available_size": t.entry_available_size, + "exit_quote_mid_price": t.exit_quote_mid_price, + "exit_bid_price": t.exit_bid_price, + "exit_ask_price": t.exit_ask_price, + "exit_spread": t.exit_spread, + "exit_available_size": t.exit_available_size, "gross_pnl": t.gross_pnl, "net_return": t.net_return, "total_slippage_cost": t.total_slippage_cost, @@ -175,6 +165,51 @@ def to_trades_dataframe(self) -> pl.DataFrame: self._trades_df = pl.DataFrame(records, schema=self._trades_schema()) return self._trades_df + def to_fills_dataframe(self) -> pl.DataFrame: + """Convert fills to Polars DataFrame.""" + if self._fills_df is not None: + return self._fills_df + + if not self.fills: + return pl.DataFrame(schema=self._fills_schema()) + + records = [] + for fill in self.fills: + records.append( + { + "order_id": fill.order_id, + "rebalance_id": fill.rebalance_id, + "asset": fill.asset, + "side": fill.side.value, + "quantity": fill.quantity, + "price": fill.price, + "timestamp": fill.timestamp, + "commission": fill.commission, + "slippage": fill.slippage, + "order_type": fill.order_type, + "limit_price": fill.limit_price, + "stop_price": fill.stop_price, + "price_source": fill.price_source, + "reference_price": fill.reference_price, + "quote_mid_price": fill.quote_mid_price, + "bid_price": fill.bid_price, + "ask_price": fill.ask_price, + "spread": fill.spread, + "bid_size": fill.bid_size, + "ask_size": fill.ask_size, + "available_size": fill.available_size, + } + ) + + self._fills_df = pl.DataFrame(records, schema=self._fills_schema()) + return self._fills_df + + def to_predictions_dataframe(self) -> pl.DataFrame: + """Return the raw prediction DataFrame used as backtest input.""" + if self.predictions is None: + return pl.DataFrame() + return self.predictions + def to_equity_dataframe(self) -> pl.DataFrame: """Convert equity curve to Polars DataFrame. @@ -222,6 +257,39 @@ def to_equity_dataframe(self) -> pl.DataFrame: return self._equity_df + def to_portfolio_state_dataframe(self) -> pl.DataFrame: + """Convert portfolio state snapshots to Polars DataFrame. + + Returns DataFrame with columns: + timestamp, equity, cash, gross_exposure, net_exposure, open_positions + + Returns: + Polars DataFrame with one row per bar, sorted by timestamp + """ + if self._portfolio_state_df is not None: + return self._portfolio_state_df + + if not self.portfolio_state: + return pl.DataFrame(schema=self._portfolio_state_schema()) + + self._portfolio_state_df = ( + pl.DataFrame( + self.portfolio_state, + schema=[ + "timestamp", + "equity", + "cash", + "gross_exposure", + "net_exposure", + "open_positions", + ], + orient="row", + ) + .sort("timestamp") + .cast(self._portfolio_state_schema()) + ) + return self._portfolio_state_df + def to_daily_pnl(self, session_aligned: bool = False) -> pl.DataFrame: """Get daily P&L DataFrame. @@ -253,14 +321,14 @@ def to_daily_pnl(self, session_aligned: bool = False) -> pl.DataFrame: # Build equity DataFrame equity_df = self.to_equity_dataframe() - if session_aligned and self.config and self.config.calendar: + if session_aligned and self.config and self.config.resolved_calendar: # Use session alignment from .sessions import SessionConfig, compute_session_pnl session_config = SessionConfig( - calendar=self.config.calendar, - timezone=self.config.timezone, - session_start_time=getattr(self.config, "session_start_time", None), + calendar=self.config.resolved_calendar, + timezone=self.config.resolved_timezone, + session_start_time=self.config.resolved_session_start_time, ) return compute_session_pnl(self.equity_curve, session_config) @@ -334,14 +402,13 @@ def to_daily_returns( >>> result = engine.run() >>> daily_returns = result.to_daily_returns(calendar="NYSE") >>> # Use with ml4t-diagnostic - >>> from ml4t.diagnostic import sharpe_ratio + >>> from ml4t.diagnostic.evaluation.metrics.risk_adjusted import sharpe_ratio >>> sharpe = sharpe_ratio(daily_returns.to_numpy(), annualization_factor=252) """ # Determine session alignment if session_aligned is None: - # Auto-detect: CME calendars typically need session alignment - cal = calendar or (self.config.calendar if self.config else None) - session_aligned = cal is not None and "CME" in str(cal).upper() + cal = calendar or (self.config.resolved_calendar if self.config else None) + session_aligned = self._auto_session_aligned(cal) # Get daily P&L DataFrame daily_df = self.to_daily_pnl(session_aligned=session_aligned) @@ -377,220 +444,6 @@ def to_trade_records(self) -> list[dict[str, Any]]: return to_trade_records(self.trades) - def to_portfolio_analysis( - self, - calendar: str | None = None, - benchmark: Any = None, - ) -> Any: - """Create a PortfolioAnalysis with properly aligned dates. - - This is the recommended bridge from backtest results to - ml4t-diagnostic analysis. It extracts daily returns with dates - and creates a PortfolioAnalysis with the correct annualization. - - Args: - calendar: Trading calendar for annualization and session alignment. - - "crypto": 365 days/year (24/7) - - "NYSE", "NASDAQ": 252 days/year - - Any pandas_market_calendars calendar - If None, uses config calendar or defaults to 252. - benchmark: Optional benchmark returns (pl.Series, np.ndarray, - or list) for alpha/beta calculation. - - Returns: - PortfolioAnalysis instance from ml4t.diagnostic - - Raises: - ImportError: If ml4t-diagnostic is not installed - - Example: - >>> result = engine.run() - >>> analysis = result.to_portfolio_analysis(calendar="crypto") - >>> stats = analysis.compute_summary_stats() - >>> print(f"Sharpe: {stats.sharpe_ratio:.2f}") - """ - try: - from ml4t.diagnostic.evaluation import PortfolioAnalysis - except ImportError as e: - raise ImportError( - "ml4t-diagnostic is required for to_portfolio_analysis(). " - "Install with: pip install ml4t-diagnostic" - ) from e - - # Determine calendar and annualization - cal = calendar or (self.config.calendar if self.config else None) - periods_per_year = _get_annualization_factor(cal) - session_aligned = cal is not None and "CME" in str(cal).upper() - - # Extract daily returns with dates - daily_df = self.to_daily_pnl(session_aligned=session_aligned) - if daily_df.is_empty(): - import numpy as np - - return PortfolioAnalysis( - returns=np.array([]), - periods_per_year=periods_per_year, - ) - - date_col = "date" if "date" in daily_df.columns else "session_date" - dates = daily_df[date_col].to_list() - returns = daily_df["return_pct"].to_numpy() - - return PortfolioAnalysis( - returns=returns, - dates=dates, - benchmark=benchmark, - periods_per_year=periods_per_year, - ) - - def compute_metrics( - self, - calendar: str | None = None, - confidence_intervals: bool = False, - ) -> dict[str, Any]: - """Compute performance metrics via ml4t-diagnostic. - - This method provides properly computed risk-adjusted metrics using - daily returns. For intraday backtests, this is critical to get - correct Sharpe/Sortino ratios (bar-level returns give wrong values). - - Args: - calendar: Trading calendar for annualization: - - "crypto": 365 days/year (24/7) - - "NYSE", "NASDAQ": 252 days/year - - Any pandas_market_calendars calendar - If None, uses config calendar or defaults to 252. - confidence_intervals: If True, include bootstrap CI for Sharpe - (requires ml4t-diagnostic with bootstrap support) - - Returns: - Dictionary with metrics: - - sharpe_ratio: Annualized Sharpe ratio - - sortino_ratio: Annualized Sortino ratio - - calmar_ratio: CAGR / Max Drawdown - - max_drawdown: Maximum drawdown (negative) - - cagr: Compound annual growth rate - - total_return: Total return - - num_trades: Number of trades - - win_rate: Percentage of winning trades - - profit_factor: Gross profits / Gross losses - - expectancy: Average expected P&L per trade - Plus trade statistics from TradeAnalyzer - - Raises: - ImportError: If ml4t-diagnostic is not installed - - Example: - >>> result = engine.run() - >>> metrics = result.compute_metrics(calendar="crypto") - >>> print(f"Sharpe: {metrics['sharpe_ratio']:.2f}") - """ - # Import from ml4t-diagnostic (optional dependency) - # Using dynamic import to avoid type checker issues with optional deps - import importlib - from collections.abc import Callable - - try: - diagnostic = importlib.import_module("ml4t.diagnostic") - sharpe_ratio: Callable[..., float] = diagnostic.sharpe_ratio - sortino_ratio: Callable[..., float] = diagnostic.sortino_ratio - except (ImportError, AttributeError) as e: - raise ImportError( - "ml4t-diagnostic is required for compute_metrics(). " - "Install with: pip install ml4t-diagnostic" - ) from e - - # Get calendar and annualization factor - cal = calendar or (self.config.calendar if self.config else None) - annualization_factor = _get_annualization_factor(cal) - - # Get daily returns - daily_returns = self.to_daily_returns(calendar=cal) - returns_array = daily_returns.to_numpy() - - # Compute metrics via diagnostic library - metrics: dict[str, Any] = {} - - # Risk-adjusted returns - if len(returns_array) > 0: - metrics["sharpe_ratio"] = sharpe_ratio( - returns_array, annualization_factor=annualization_factor - ) - metrics["sortino_ratio"] = sortino_ratio( - returns_array, annualization_factor=annualization_factor - ) - else: - metrics["sharpe_ratio"] = 0.0 - metrics["sortino_ratio"] = 0.0 - - # Drawdown metrics (from equity curve) - equity_df = self.to_equity_dataframe() - if len(equity_df) > 0: - # Get max drawdown as float - dd_values = equity_df["drawdown"].to_list() - max_dd: float = min(dd_values) if dd_values else 0.0 - metrics["max_drawdown"] = max_dd - - # CAGR from total return and days - equity_values = equity_df["equity"].to_list() - first_val: float = equity_values[0] if equity_values else 1.0 - last_val: float = equity_values[-1] if equity_values else 1.0 - total_return: float = (last_val / first_val) - 1.0 - metrics["total_return"] = total_return - - # Days in backtest - timestamps = equity_df["timestamp"].to_list() - first_ts = timestamps[0] - last_ts = timestamps[-1] - days: float = (last_ts - first_ts).total_seconds() / 86400 - years: float = days / 365.25 - if years > 0: - cagr: float = (1 + total_return) ** (1 / years) - 1 - metrics["cagr"] = cagr - if max_dd != 0: - metrics["calmar_ratio"] = cagr / abs(max_dd) - else: - metrics["calmar_ratio"] = 0.0 - else: - metrics["cagr"] = 0.0 - metrics["calmar_ratio"] = 0.0 - else: - metrics["max_drawdown"] = 0.0 - metrics["total_return"] = 0.0 - metrics["cagr"] = 0.0 - metrics["calmar_ratio"] = 0.0 - - # Trade statistics - if self.trade_analyzer is not None: - ta = self.trade_analyzer - metrics["num_trades"] = ta.num_trades - metrics["win_rate"] = ta.win_rate - metrics["profit_factor"] = ta.profit_factor - metrics["expectancy"] = ta.expectancy - metrics["avg_trade"] = ta.avg_trade - metrics["avg_winner"] = ta.avg_win - metrics["avg_loser"] = ta.avg_loss - metrics["total_fees"] = ta.total_fees - elif self.trades: - # Compute basic stats - wins = [t for t in self.trades if t.pnl > 0] - losses = [t for t in self.trades if t.pnl < 0] - metrics["num_trades"] = len(self.trades) - metrics["win_rate"] = len(wins) / len(self.trades) if self.trades else 0.0 - total_wins = sum(t.pnl for t in wins) - total_losses = abs(sum(t.pnl for t in losses)) - metrics["profit_factor"] = total_wins / total_losses if total_losses > 0 else 0.0 - metrics["avg_trade"] = sum(t.pnl for t in self.trades) / len(self.trades) - metrics["expectancy"] = metrics["avg_trade"] - else: - metrics["num_trades"] = 0 - metrics["win_rate"] = 0.0 - metrics["profit_factor"] = 0.0 - metrics["expectancy"] = 0.0 - metrics["avg_trade"] = 0.0 - - return metrics - def to_dict(self) -> dict[str, Any]: """Export as dictionary (backward compatible with Engine.run()). @@ -603,14 +456,38 @@ def to_dict(self) -> dict[str, Any]: "trades": self.trades, "equity_curve": self.equity_curve, "fills": self.fills, + "portfolio_state": self.portfolio_state, } ) + if self.predictions is not None: + result["predictions"] = self.predictions if self.equity is not None: result["equity"] = self.equity if self.trade_analyzer is not None: result["trade_analyzer"] = self.trade_analyzer return result + def to_spec_dict(self) -> dict[str, Any]: + """Export a resolved runtime spec for reproducibility. + + Returns: + Dictionary containing the fully resolved config, library version, + and realized run window. The nested ``config`` payload remains + compatible with ``BacktestConfig.from_dict()``. + """ + config_dict = self.config.to_dict() if self.config is not None else {} + start = self.equity_curve[0][0].isoformat() if self.equity_curve else None + end = self.equity_curve[-1][0].isoformat() if self.equity_curve else None + return { + "version": 1, + "library_version": __version__, + "config": config_dict, + "window": { + "start": start, + "end": end, + }, + } + # Dict-like access keeps validation scripts and older notebook code working. def __getitem__(self, key: str) -> Any: return self.to_dict()[key] @@ -635,15 +512,20 @@ def to_parquet( Creates directory structure: {path}/ trades.parquet + fills.parquet + predictions.parquet equity.parquet + portfolio_state.parquet daily_pnl.parquet metrics.json config.yaml (if config available) + spec.yaml (if config available) Args: path: Directory path to write files include: Components to include. Default: all. - Options: ["trades", "equity", "daily_pnl", "metrics", "config"] + Options: ["trades", "fills", "predictions", "equity", "portfolio_state", "daily_pnl", + "metrics", "config"] compression: Parquet compression codec (default: "zstd") Returns: @@ -653,7 +535,17 @@ def to_parquet( path.mkdir(parents=True, exist_ok=True) if include is None: - include = ["trades", "equity", "daily_pnl", "metrics", "config"] + include = [ + "trades", + "fills", + "predictions", + "equity", + "portfolio_state", + "daily_pnl", + "metrics", + "config", + "spec", + ] written: dict[str, Path] = {} @@ -662,11 +554,28 @@ def to_parquet( self.to_trades_dataframe().write_parquet(trades_path, compression=compression) written["trades"] = trades_path + if "fills" in include: + fills_path = path / "fills.parquet" + self.to_fills_dataframe().write_parquet(fills_path, compression=compression) + written["fills"] = fills_path + + if "predictions" in include and self.predictions is not None: + predictions_path = path / "predictions.parquet" + self.to_predictions_dataframe().write_parquet(predictions_path, compression=compression) + written["predictions"] = predictions_path + if "equity" in include: equity_path = path / "equity.parquet" self.to_equity_dataframe().write_parquet(equity_path, compression=compression) written["equity"] = equity_path + if "portfolio_state" in include: + portfolio_state_path = path / "portfolio_state.parquet" + self.to_portfolio_state_dataframe().write_parquet( + portfolio_state_path, compression=compression + ) + written["portfolio_state"] = portfolio_state_path + if "daily_pnl" in include: daily_path = path / "daily_pnl.parquet" self.to_daily_pnl().write_parquet(daily_path, compression=compression) @@ -705,6 +614,17 @@ def to_parquet( except (ImportError, AttributeError): pass # Skip if yaml not available or config has no to_dict + if "spec" in include and self.config is not None: + spec_path = path / "spec.yaml" + try: + import yaml + + with open(spec_path, "w") as f: + yaml.dump(self.to_spec_dict(), f, default_flow_style=False, sort_keys=False) + written["spec"] = spec_path + except (ImportError, AttributeError): + pass + return written @classmethod @@ -740,12 +660,22 @@ def from_parquet(cls, path: str | Path) -> BacktestResult: pnl_percent=row["pnl_percent"], bars_held=row["bars_held"], fees=fees, - slippage=row["slippage"], + exit_slippage=row.get("exit_slippage", row.get("slippage", 0.0)), exit_reason=row.get("exit_reason", "signal"), mfe=row["mfe"], mae=row["mae"], entry_slippage=row.get("entry_slippage", 0.0), multiplier=row.get("multiplier", 1.0), + entry_quote_mid_price=row.get("entry_quote_mid_price"), + entry_bid_price=row.get("entry_bid_price"), + entry_ask_price=row.get("entry_ask_price"), + entry_spread=row.get("entry_spread"), + entry_available_size=row.get("entry_available_size"), + exit_quote_mid_price=row.get("exit_quote_mid_price"), + exit_bid_price=row.get("exit_bid_price"), + exit_ask_price=row.get("exit_ask_price"), + exit_spread=row.get("exit_spread"), + exit_available_size=row.get("exit_available_size"), ) ) @@ -764,6 +694,62 @@ def from_parquet(cls, path: str | Path) -> BacktestResult: with open(metrics_path) as f: metrics = json.load(f) + fills: list[Fill] = [] + fills_path = path / "fills.parquet" + if fills_path.exists(): + fills_df = pl.read_parquet(fills_path) + for row in fills_df.iter_rows(named=True): + fills.append( + Fill( + order_id=row["order_id"], + rebalance_id=row.get("rebalance_id"), + asset=row["asset"], + side=OrderSide(row["side"]), + quantity=row["quantity"], + price=row["price"], + timestamp=row["timestamp"], + commission=row.get("commission", 0.0), + slippage=row.get("slippage", 0.0), + order_type=row.get("order_type", ""), + limit_price=row.get("limit_price"), + stop_price=row.get("stop_price"), + price_source=row.get("price_source", ""), + reference_price=row.get("reference_price"), + quote_mid_price=row.get("quote_mid_price"), + bid_price=row.get("bid_price"), + ask_price=row.get("ask_price"), + spread=row.get("spread"), + bid_size=row.get("bid_size"), + ask_size=row.get("ask_size"), + available_size=row.get("available_size"), + ) + ) + + predictions = None + predictions_path = path / "predictions.parquet" + if predictions_path.exists(): + predictions = pl.read_parquet(predictions_path) + else: + signals_path = path / "signals.parquet" + if signals_path.exists(): + predictions = pl.read_parquet(signals_path) + + portfolio_state: list[tuple[datetime, float, float, float, float, int]] = [] + portfolio_state_path = path / "portfolio_state.parquet" + if portfolio_state_path.exists(): + portfolio_state_df = pl.read_parquet(portfolio_state_path) + for row in portfolio_state_df.iter_rows(named=True): + portfolio_state.append( + ( + row["timestamp"], + row["equity"], + row["cash"], + row["gross_exposure"], + row["net_exposure"], + row["open_positions"], + ) + ) + # Load config if available config = None config_path = path / "config.yaml" @@ -778,11 +764,27 @@ def from_parquet(cls, path: str | Path) -> BacktestResult: config = BacktestConfig.from_dict(config_data) except (ImportError, Exception): pass # Skip if yaml not available or config invalid + else: + spec_path = path / "spec.yaml" + if spec_path.exists(): + try: + import yaml + + from .config import BacktestConfig + + with open(spec_path) as f: + spec_data = yaml.safe_load(f) + if isinstance(spec_data, dict) and isinstance(spec_data.get("config"), dict): + config = BacktestConfig.from_dict(spec_data["config"]) + except (ImportError, Exception): + pass return cls( trades=trades, equity_curve=equity_curve, - fills=[], # Fills not persisted by default + fills=fills, + predictions=predictions, + portfolio_state=portfolio_state, metrics=metrics, config=config, ) @@ -810,11 +812,21 @@ def _trades_schema() -> dict[str, pl.DataType]: "pnl_percent": pl.Float64(), "bars_held": pl.Int32(), "fees": pl.Float64(), - "slippage": pl.Float64(), + "exit_slippage": pl.Float64(), "mfe": pl.Float64(), "mae": pl.Float64(), "entry_slippage": pl.Float64(), "multiplier": pl.Float64(), + "entry_quote_mid_price": pl.Float64(), + "entry_bid_price": pl.Float64(), + "entry_ask_price": pl.Float64(), + "entry_spread": pl.Float64(), + "entry_available_size": pl.Float64(), + "exit_quote_mid_price": pl.Float64(), + "exit_bid_price": pl.Float64(), + "exit_ask_price": pl.Float64(), + "exit_spread": pl.Float64(), + "exit_available_size": pl.Float64(), "gross_pnl": pl.Float64(), "net_return": pl.Float64(), "total_slippage_cost": pl.Float64(), @@ -823,6 +835,33 @@ def _trades_schema() -> dict[str, pl.DataType]: "status": pl.String(), # "closed" or "open" } + @staticmethod + def _fills_schema() -> dict[str, pl.DataType]: + """Schema for fills DataFrame.""" + return { + "order_id": pl.String(), + "rebalance_id": pl.String(), + "asset": pl.String(), + "side": pl.String(), + "quantity": pl.Float64(), + "price": pl.Float64(), + "timestamp": pl.Datetime(), + "commission": pl.Float64(), + "slippage": pl.Float64(), + "order_type": pl.String(), + "limit_price": pl.Float64(), + "stop_price": pl.Float64(), + "price_source": pl.String(), + "reference_price": pl.Float64(), + "quote_mid_price": pl.Float64(), + "bid_price": pl.Float64(), + "ask_price": pl.Float64(), + "spread": pl.Float64(), + "bid_size": pl.Float64(), + "ask_size": pl.Float64(), + "available_size": pl.Float64(), + } + @staticmethod def _equity_schema() -> dict[str, pl.DataType]: """Schema for equity DataFrame.""" @@ -835,112 +874,17 @@ def _equity_schema() -> dict[str, pl.DataType]: "high_water_mark": pl.Float64(), } - def to_tearsheet( - self, - template: Literal["quant_trader", "hedge_fund", "risk_manager", "full"] = "full", - theme: Literal["default", "dark", "print", "presentation"] = "default", - title: str | None = None, - output_path: str | Path | None = None, - include_statistical: bool = True, - calendar: str | None = None, - ) -> str: - """Generate an interactive HTML tearsheet for the backtest results. - - This method integrates with ml4t.diagnostic to create comprehensive - backtest visualizations including: - - Executive summary with KPI cards and traffic lights - - Trade analysis (MFE/MAE, exit reasons, waterfall) - - Cost attribution (commission, slippage breakdown) - - Statistical validity (DSR, confidence intervals, RAS) - - Parameters - ---------- - template : {"quant_trader", "hedge_fund", "risk_manager", "full"} - Report template persona: - - "quant_trader": Trade-level focus (MFE/MAE, exit reasons) - - "hedge_fund": Risk-adjusted focus (drawdowns, costs) - - "risk_manager": Statistical focus (DSR, CI, MinTRL) - - "full": All sections enabled - theme : {"default", "dark", "print", "presentation"} - Visual theme for the report - title : str, optional - Report title. Defaults to "Backtest Tearsheet" - output_path : str or Path, optional - If provided, saves HTML to this path - include_statistical : bool - Whether to include statistical validity analysis (DSR, RAS). - Requires sufficient trades for meaningful statistics. - calendar : str, optional - Trading calendar for session alignment (e.g. "NYSE", "crypto"). - Passed to to_daily_returns() for correct daily aggregation. - - Returns - ------- - str - HTML content of the tearsheet - - Raises - ------ - ImportError - If ml4t-diagnostic is not installed - - Examples - -------- - >>> result = engine.run() - >>> html = result.to_tearsheet(template="quant_trader", theme="dark") - >>> # Or save directly to file - >>> result.to_tearsheet(output_path="backtest_report.html") - """ - try: - from ml4t.diagnostic.visualization.backtest import generate_backtest_tearsheet - except ImportError as e: - raise ImportError( - "ml4t-diagnostic is required for tearsheet generation. " - "Install it with: pip install ml4t-diagnostic[viz]" - ) from e - - # Extract data for tearsheet - trades_df = self.to_trades_dataframe() - # Use daily returns (not bar-level) for correct annualized metrics - returns = self.to_daily_returns(calendar=calendar).to_numpy() - - # Build metrics dict with all available metrics - tearsheet_metrics = dict(self.metrics) - - # Ensure common metrics are present - if "n_trades" not in tearsheet_metrics: - tearsheet_metrics["n_trades"] = len(self.trades) - if "total_pnl" not in tearsheet_metrics and self.trades: - tearsheet_metrics["total_pnl"] = sum(t.pnl for t in self.trades) - if "win_rate" not in tearsheet_metrics and self.trades: - winners = sum(1 for t in self.trades if t.pnl > 0) - tearsheet_metrics["win_rate"] = winners / len(self.trades) if self.trades else 0 - if "total_commission" not in tearsheet_metrics and self.trades: - tearsheet_metrics["total_commission"] = sum(t.fees for t in self.trades) - if "total_slippage" not in tearsheet_metrics and self.trades: - tearsheet_metrics["total_slippage"] = sum(t.total_slippage_cost for t in self.trades) - - # Extract equity curve for portfolio-level charts - equity_df = self.to_equity_dataframe() if self.equity_curve else None - - # Generate tearsheet - html = generate_backtest_tearsheet( - metrics=tearsheet_metrics, - trades=trades_df if len(trades_df) > 0 else None, - returns=returns if len(returns) > 0 else None, - equity_curve=equity_df, - template=template, - theme=theme, - title=title or "Backtest Tearsheet", - ) - - # Save to file if path provided - if output_path is not None: - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(html) - - return html + @staticmethod + def _portfolio_state_schema() -> dict[str, pl.DataType]: + """Schema for portfolio state DataFrame.""" + return { + "timestamp": pl.Datetime(), + "equity": pl.Float64(), + "cash": pl.Float64(), + "gross_exposure": pl.Float64(), + "net_exposure": pl.Float64(), + "open_positions": pl.Int32(), + } def __repr__(self) -> str: """String representation.""" diff --git a/src/ml4t/backtest/spec_bridge.py b/src/ml4t/backtest/spec_bridge.py new file mode 100644 index 00000000..31316b5b --- /dev/null +++ b/src/ml4t/backtest/spec_bridge.py @@ -0,0 +1,58 @@ +"""Pure conversions from artifact specifications into runtime contracts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ml4t.data.artifacts.market_data import FeedSpec, MarketDataSpec + + +def market_data_spec_to_feed_spec(spec: MarketDataSpec | Mapping[str, Any]) -> FeedSpec: + """Project a market-data spec onto the backtest runtime feed contract.""" + market_spec = ( + MarketDataSpec.from_mapping({str(key): value for key, value in spec.items()}) + if isinstance(spec, Mapping) + else spec + ) + schema = market_spec.schema + semantics = market_spec.semantics + return FeedSpec( + timestamp_col=schema.timestamp_col, + entity_col=schema.entity_col, + price_col=schema.price_col, + open_col=schema.open_col, + high_col=schema.high_col, + low_col=schema.low_col, + close_col=schema.close_col, + volume_col=schema.volume_col, + bid_col=schema.bid_col, + ask_col=schema.ask_col, + mid_col=schema.mid_col, + bid_size_col=schema.bid_size_col, + ask_size_col=schema.ask_size_col, + calendar=semantics.calendar, + timezone=semantics.timezone, + data_frequency=semantics.data_frequency, + bar_type=semantics.bar_type, + timestamp_semantics=semantics.timestamp_semantics, + session_start_time=semantics.session_start_time, + ) + + +def market_data_spec_to_runtime_metadata( + spec: MarketDataSpec | Mapping[str, Any], +) -> dict[str, Any]: + """Extract runtime scheduling and annualization metadata from a market-data spec.""" + feed_spec = market_data_spec_to_feed_spec(spec) + return { + "calendar": feed_spec.calendar, + "timezone": feed_spec.timezone, + "data_frequency": feed_spec.data_frequency, + "timestamp_semantics": feed_spec.timestamp_semantics, + "session_start_time": feed_spec.session_start_time, + "bar_type": feed_spec.bar_type, + } + + +__all__ = ["market_data_spec_to_feed_spec", "market_data_spec_to_runtime_metadata"] diff --git a/src/ml4t/backtest/strategies/templates.py b/src/ml4t/backtest/strategies/templates.py index c511fcf6..13d46e0a 100644 --- a/src/ml4t/backtest/strategies/templates.py +++ b/src/ml4t/backtest/strategies/templates.py @@ -8,15 +8,18 @@ from abc import abstractmethod from collections import defaultdict +from collections.abc import Sequence from datetime import datetime from statistics import mean, stdev from typing import TYPE_CHECKING, Any from ..config import ShareType +from ..execution.schedule import RebalanceSchedule, resolve_rebalance_timestamps from ..strategy import Strategy if TYPE_CHECKING: from ..broker import Broker + from ..config import BacktestConfig def _use_fractional(allow_fractional: bool | None, broker: Broker) -> bool: @@ -328,10 +331,34 @@ class LongShortStrategy(Strategy): short_count: int = 5 position_size: float = 0.05 rebalance_frequency: int = 20 + rebalance_schedule: RebalanceSchedule | None = None allow_fractional: bool | None = None # None = defer to broker.share_type def __init__(self) -> None: self.bar_count = 0 + self._resolved_schedule: frozenset[datetime] | None = None + + def on_prepare( + self, + broker: Any, + timestamps: Sequence[datetime], + config: BacktestConfig | None = None, + ) -> None: + """Resolve optional schedule-based rebalance gating before the run starts.""" + if self.rebalance_schedule is None: + self._resolved_schedule = None + return + calendar = config.resolved_calendar if config is not None else None + timezone = config.resolved_timezone if config is not None else "UTC" + feed_spec = config.resolved_feed_spec if config is not None else None + resolved = resolve_rebalance_timestamps( + timestamps, + self.rebalance_schedule, + feed_spec=feed_spec, + calendar=calendar, + timezone=timezone, + ) + self._resolved_schedule = frozenset(resolved.to_list()) def rank_assets(self, data: dict[str, dict]) -> tuple[list[str], list[str]]: """Rank assets by signal and return long/short lists. @@ -375,8 +402,12 @@ def on_data( """Rebalance portfolio periodically based on rankings.""" self.bar_count += 1 - # Only rebalance on schedule - if self.bar_count % self.rebalance_frequency != 1: + if self.rebalance_schedule is not None: + if self._resolved_schedule is None: + raise ValueError("rebalance_schedule is set but was not prepared before execution") + if timestamp not in self._resolved_schedule: + return + elif self.bar_count % self.rebalance_frequency != 1: return # Get current rankings diff --git a/src/ml4t/backtest/strategy.py b/src/ml4t/backtest/strategy.py index 83f0f9ee..b9c7606c 100644 --- a/src/ml4t/backtest/strategy.py +++ b/src/ml4t/backtest/strategy.py @@ -1,6 +1,7 @@ """Base strategy class for backtesting.""" from abc import ABC, abstractmethod +from collections.abc import Sequence from datetime import datetime from typing import Any @@ -23,6 +24,15 @@ def on_start(self, broker: Any) -> None: # noqa: B027 """Called before backtest starts.""" pass + def on_prepare( + self, + broker: Any, + timestamps: Sequence[datetime], + config: Any | None = None, + ) -> None: + """Called before on_start with access to the full feed timestamp universe.""" + return None + def on_end(self, broker: Any) -> None: # noqa: B027 """Called after backtest ends.""" pass diff --git a/src/ml4t/backtest/types.py b/src/ml4t/backtest/types.py index 5f0c0dbf..2a0ed3da 100644 --- a/src/ml4t/backtest/types.py +++ b/src/ml4t/backtest/types.py @@ -145,6 +145,7 @@ class Order: stop_price: float | None = None trail_amount: float | None = None parent_id: str | None = None + rebalance_id: str | None = None order_id: str = "" status: OrderStatus = OrderStatus.PENDING created_at: datetime | None = None @@ -343,11 +344,21 @@ class Fill: quantity: float price: float timestamp: datetime + rebalance_id: str | None = None commission: float = 0.0 slippage: float = 0.0 order_type: str = "" # OrderType.value string (for fill-level invariants) limit_price: float | None = None # For limit bound checking stop_price: float | None = None # For stop bound checking + price_source: str = "" + reference_price: float | None = None + quote_mid_price: float | None = None + bid_price: float | None = None + ask_price: float | None = None + spread: float | None = None + bid_size: float | None = None + ask_size: float | None = None + available_size: float | None = None @dataclass @@ -377,7 +388,7 @@ class Trade: pnl_percent: float bars_held: int fees: float = 0.0 # Total transaction fees (aligned with ml4t-diagnostic) - slippage: float = 0.0 + exit_slippage: float = 0.0 # Per-unit slippage on exit # Exit reason for trade analysis (cross-library API field) exit_reason: str = "signal" # ExitReason enum value as string # Trade status: "closed" (actually exited) or "open" (mark-to-market at end) @@ -388,6 +399,16 @@ class Trade: # Cost decomposition fields entry_slippage: float = 0.0 # Per-unit slippage on entry multiplier: float = 1.0 # Contract multiplier (for futures) + entry_quote_mid_price: float | None = None + entry_bid_price: float | None = None + entry_ask_price: float | None = None + entry_spread: float | None = None + entry_available_size: float | None = None + exit_quote_mid_price: float | None = None + exit_bid_price: float | None = None + exit_ask_price: float | None = None + exit_spread: float | None = None + exit_available_size: float | None = None # Optional metadata extension point metadata: dict[str, Any] | None = None @@ -432,7 +453,7 @@ def net_return(self) -> float: @property def total_slippage_cost(self) -> float: """Total slippage cost in dollars (entry + exit).""" - return (self.entry_slippage + self.slippage) * abs(self.quantity) * self.multiplier + return (self.entry_slippage + self.exit_slippage) * abs(self.quantity) * self.multiplier @property def cost_drag(self) -> float: diff --git a/tests/benchmark/test_hotpath_benchmarks.py b/tests/benchmark/test_hotpath_benchmarks.py index c3cafbd6..0e7b0fe4 100644 --- a/tests/benchmark/test_hotpath_benchmarks.py +++ b/tests/benchmark/test_hotpath_benchmarks.py @@ -42,6 +42,10 @@ def __init__( self._timestamps = sorted(all_ts) self._idx = 0 + @property + def timestamps(self) -> tuple[datetime, ...]: + return tuple(self._timestamps) + def _partition_by_timestamp(self, df: pl.DataFrame) -> dict[datetime, pl.DataFrame]: result: dict[datetime, pl.DataFrame] = {} for ts_df in df.partition_by("timestamp", maintain_order=True): @@ -140,6 +144,20 @@ def _run_engine(feed_cls, prices: pl.DataFrame, signals: pl.DataFrame) -> float: return perf_counter() - start +def _legacy_view(assets: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + return { + asset: { + "open": data.get("open"), + "high": data.get("high"), + "low": data.get("low"), + "close": data.get("close"), + "volume": data.get("volume"), + "signals": data.get("signals", {}), + } + for asset, data in assets.items() + } + + @pytest.mark.benchmark def test_optimized_feed_matches_legacy_output(): prices, signals = _build_benchmark_data(n_bars=50, n_assets=5) @@ -152,7 +170,7 @@ def test_optimized_feed_matches_legacy_output(): optimized, legacy, strict=True ): assert opt_ts == legacy_ts - assert dict(opt_assets) == legacy_assets + assert _legacy_view(dict(opt_assets)) == legacy_assets assert opt_ctx == legacy_ctx diff --git a/tests/contracts/test_execution_contracts.py b/tests/contracts/test_execution_contracts.py index 1a6dca87..8e8f2a31 100644 --- a/tests/contracts/test_execution_contracts.py +++ b/tests/contracts/test_execution_contracts.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta import polars as pl +import pytest from ml4t.backtest.config import BacktestConfig, CommissionType, ExecutionPrice, SlippageType from ml4t.backtest.engine import run_backtest from ml4t.backtest.strategy import Strategy from ml4t.backtest.types import ExecutionMode +from ml4t.data.artifacts.market_data import FeedSpec def _prices() -> pl.DataFrame: @@ -57,9 +59,71 @@ def _entry_price(mode: ExecutionMode, price: ExecutionPrice) -> float: return result.trades[0].entry_price +def _quote_prices() -> pl.DataFrame: + start = datetime(2024, 1, 1) + rows = [ + { + "timestamp": start, + "asset": "AAPL", + "open": 90.0, + "high": 105.0, + "low": 89.0, + "close": 100.0, + "mid_price": 100.0, + "bid": 99.5, + "ask": 100.5, + "bid_size": 500.0, + "ask_size": 750.0, + "volume": 1_000_000.0, + }, + { + "timestamp": start + timedelta(days=1), + "asset": "AAPL", + "open": 110.0, + "high": 112.0, + "low": 109.0, + "close": 111.0, + "mid_price": 111.0, + "bid": 110.75, + "ask": 111.25, + "bid_size": 800.0, + "ask_size": 900.0, + "volume": 1_000_000.0, + }, + ] + return pl.DataFrame(rows) + + def test_same_bar_fills_at_signal_bar_close() -> None: assert _entry_price(ExecutionMode.SAME_BAR, ExecutionPrice.CLOSE) == 100.0 def test_next_bar_fills_at_following_bar_open() -> None: assert _entry_price(ExecutionMode.NEXT_BAR, ExecutionPrice.OPEN) == 110.0 + + +def test_same_bar_quote_side_execution_uses_ask_for_buys() -> None: + config = BacktestConfig( + execution_mode=ExecutionMode.SAME_BAR, + execution_price=ExecutionPrice.QUOTE_SIDE, + mark_price=ExecutionPrice.QUOTE_SIDE, + commission_type=CommissionType.NONE, + slippage_type=SlippageType.NONE, + ) + result = run_backtest( + prices=_quote_prices(), + strategy=_BuyOnce(), + config=config, + feed_spec=FeedSpec( + price_col="mid_price", + bid_col="bid", + ask_col="ask", + bid_size_col="bid_size", + ask_size_col="ask_size", + ), + ) + + assert result.trades + assert result.trades[0].entry_price == 100.5 + assert result.trades[0].entry_ask_price == 100.5 + assert result.metrics["final_value"] == pytest.approx(100000.0 - 100.5 + 110.75) diff --git a/tests/contracts/test_public_api_surface.py b/tests/contracts/test_public_api_surface.py index ec6745fb..f2d6c95d 100644 --- a/tests/contracts/test_public_api_surface.py +++ b/tests/contracts/test_public_api_surface.py @@ -28,6 +28,9 @@ def test_root_api_contains_only_intended_core_surface() -> None: "ContractSpec", "RebalanceConfig", "TargetWeightExecutor", + "RebalanceCadence", + "RebalanceSchedule", + "resolve_rebalance_timestamps", "StopLoss", "TakeProfit", "TrailingStop", diff --git a/tests/execution/test_rebalancer.py b/tests/execution/test_rebalancer.py index a21bb31f..1487d4b9 100644 --- a/tests/execution/test_rebalancer.py +++ b/tests/execution/test_rebalancer.py @@ -10,7 +10,9 @@ ) from ml4t.backtest.config import RebalanceMode from ml4t.backtest.execution.rebalancer import RebalanceConfig, TargetWeightExecutor +from ml4t.backtest.execution.schedule import RebalanceSchedule from ml4t.backtest.models import NoCommission, NoSlippage +from ml4t.data.artifacts.market_data import FeedSpec class TestRebalanceConfig: @@ -28,6 +30,7 @@ def test_default_values(self): assert config.max_single_weight == 1.0 assert config.cancel_before_rebalance is True assert config.account_for_pending is True + assert config.schedule is None def test_custom_values(self): """Test custom configuration values.""" @@ -42,6 +45,13 @@ def test_custom_values(self): assert config.allow_short is True assert config.max_single_weight == 0.25 + def test_schedule_can_be_configured(self): + """Test schedule-based rebalance configuration.""" + config = RebalanceConfig(schedule=RebalanceSchedule.month_end()) + + assert config.schedule is not None + assert config.schedule.cadence.value == "month_end" + class TestTargetWeightExecutorBasic: """Test basic TargetWeightExecutor functionality.""" @@ -76,6 +86,8 @@ def test_empty_portfolio_rebalance(self, broker, executor, sample_data): orders = executor.execute(target_weights, sample_data, broker) assert len(orders) == 3 + assert {order.rebalance_id for order in orders} == {orders[0].rebalance_id} + assert orders[0].rebalance_id is not None # Check all are BUY orders for order in orders: assert order.side == OrderSide.BUY @@ -152,6 +164,19 @@ def test_close_position_not_in_target(self, broker, executor, sample_data): asset_orders = {o.asset: o for o in orders} assert "GOOG" in asset_orders assert asset_orders["GOOG"].side == OrderSide.SELL + assert len({order.rebalance_id for order in orders}) == 1 + + def test_rebalance_ids_change_per_execute_call(self, broker, executor, sample_data): + """Each logical rebalance call should get its own identifier.""" + first_orders = executor.execute({"AAPL": 0.3}, sample_data, broker) + broker._process_orders() + second_orders = executor.execute({"AAPL": 0.5}, sample_data, broker) + + assert len(first_orders) == 1 + assert len(second_orders) == 1 + assert first_orders[0].rebalance_id is not None + assert second_orders[0].rebalance_id is not None + assert first_orders[0].rebalance_id != second_orders[0].rebalance_id class TestTargetWeightExecutorThresholds: @@ -333,6 +358,109 @@ def test_effective_weights_with_pending(self, broker, sample_data): assert effective["AAPL"] > 0.2 # Should reflect pending order value +class TestTargetWeightExecutorScheduling: + """Test schedule-gated execution.""" + + @pytest.fixture + def broker(self): + broker = Broker( + initial_cash=100000.0, + commission_model=NoCommission(), + slippage_model=NoSlippage(), + ) + broker._update_time( + datetime(2024, 1, 1, 9, 30), + {"AAPL": 150.0}, + {"AAPL": 150.0}, + {"AAPL": 150.0}, + {"AAPL": 150.0}, + {"AAPL": 1000000}, + {}, + ) + return broker + + @pytest.fixture + def sample_data(self): + return {"AAPL": {"close": 150.0}} + + def test_unscheduled_timestamp_skips_rebalance(self, broker, sample_data): + executor = TargetWeightExecutor( + config=RebalanceConfig( + schedule=RebalanceSchedule.explicit_timestamps([datetime(2024, 1, 2, 9, 30)]) + ) + ) + executor.prepare_schedule([datetime(2024, 1, 1, 9, 30), datetime(2024, 1, 2, 9, 30)]) + + orders = executor.execute( + {"AAPL": 0.5}, + sample_data, + broker, + timestamp=datetime(2024, 1, 1, 9, 30), + ) + + assert orders == [] + + def test_scheduled_timestamp_executes_rebalance(self, broker, sample_data): + scheduled_ts = datetime(2024, 1, 2, 9, 30) + executor = TargetWeightExecutor( + config=RebalanceConfig(schedule=RebalanceSchedule.explicit_timestamps([scheduled_ts])) + ) + executor.prepare_schedule([datetime(2024, 1, 1, 9, 30), scheduled_ts]) + + orders = executor.execute( + {"AAPL": 0.5}, + sample_data, + broker, + timestamp=scheduled_ts, + ) + + assert len(orders) == 1 + assert orders[0].asset == "AAPL" + + def test_prepare_schedule_uses_feed_semantics_for_daily_labels(self, broker, sample_data): + timestamps = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + datetime(2024, 1, 5), + datetime(2024, 1, 8), + datetime(2024, 1, 9), + datetime(2024, 1, 10), + datetime(2024, 1, 11), + datetime(2024, 1, 12), + ] + executor = TargetWeightExecutor(config=RebalanceConfig(schedule=RebalanceSchedule.weekly())) + + resolved = executor.prepare_schedule( + timestamps, + feed_spec=FeedSpec( + calendar="NYSE", + data_frequency="daily", + timestamp_semantics="session_label", + ), + ) + + assert resolved == frozenset({datetime(2024, 1, 5), datetime(2024, 1, 12)}) + + def test_execute_requires_prepare_schedule_when_schedule_is_configured( + self, broker, sample_data + ): + executor = TargetWeightExecutor( + config=RebalanceConfig( + schedule=RebalanceSchedule.explicit_timestamps([datetime(2024, 1, 2, 9, 30)]) + ) + ) + + with pytest.raises(ValueError, match="prepare_schedule"): + executor.execute( + {"AAPL": 0.5}, + sample_data, + broker, + timestamp=datetime(2024, 1, 2, 9, 30), + ) + + class TestTargetWeightExecutorCashTargeting: """Test cash targeting (weights < 1.0).""" @@ -795,9 +923,13 @@ def test_full_rebalance_workflow(self): target1 = {"AAPL": 0.3, "GOOG": 0.3, "MSFT": 0.4} orders1 = executor.execute(target1, data, broker) assert len(orders1) == 3 + first_rebalance_id = orders1[0].rebalance_id + assert first_rebalance_id is not None + assert {order.rebalance_id for order in orders1} == {first_rebalance_id} # Process orders broker._process_orders() + assert {fill.rebalance_id for fill in broker.fills} == {first_rebalance_id} # Step 2: Rebalance to 50/50/0 (close MSFT) target2 = {"AAPL": 0.5, "GOOG": 0.5} @@ -805,6 +937,9 @@ def test_full_rebalance_workflow(self): # Should have AAPL buy, GOOG buy, MSFT sell (close) assert len(orders2) >= 2 + assert orders2[0].rebalance_id is not None + assert {order.rebalance_id for order in orders2} == {orders2[0].rebalance_id} + assert orders2[0].rebalance_id != first_rebalance_id class TestTargetWeightExecutorModes: diff --git a/tests/execution/test_schedule.py b/tests/execution/test_schedule.py new file mode 100644 index 00000000..4cfea231 --- /dev/null +++ b/tests/execution/test_schedule.py @@ -0,0 +1,162 @@ +"""Tests for rebalance schedule resolution.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import polars as pl +from ml4t.data.artifacts.market_data import FeedSpec + +from ml4t.backtest.execution import ( + RebalanceCadence, + RebalanceSchedule, + resolve_rebalance_timestamps, +) + + +def _make_weekday_series(start: str, end: str) -> pl.Series: + dates = pl.date_range( + datetime.strptime(start, "%Y-%m-%d"), + datetime.strptime(end, "%Y-%m-%d"), + interval="1d", + eager=True, + ) + return ( + pl.DataFrame({"timestamp": dates}) + .filter(pl.col("timestamp").dt.weekday() <= 5) + .get_column("timestamp") + ) + + +class TestResolveRebalanceTimestamps: + def test_every_bar_returns_all_timestamps(self) -> None: + timestamps = _make_weekday_series("2024-01-01", "2024-01-10") + + result = resolve_rebalance_timestamps(timestamps, RebalanceSchedule.every_bar()) + + expected = [datetime.combine(ts, datetime.min.time()) for ts in timestamps.to_list()] + assert result.to_list() == expected + + def test_explicit_timestamps_intersects_available_bars(self) -> None: + timestamps = _make_weekday_series("2024-01-01", "2024-01-10") + selected = [timestamps[1], timestamps[3], datetime(2024, 1, 31)] + + result = resolve_rebalance_timestamps( + timestamps, RebalanceSchedule.explicit_timestamps(selected) + ) + + expected = [ + datetime.combine(timestamps[1], datetime.min.time()), + datetime.combine(timestamps[3], datetime.min.time()), + ] + assert result.to_list() == expected + + def test_fixed_n_sessions_thins_session_closes(self) -> None: + timestamps = _make_weekday_series("2024-01-01", "2024-01-10") + + result = resolve_rebalance_timestamps(timestamps, RebalanceSchedule.fixed_n_sessions(2)) + + expected = [datetime.combine(ts, datetime.min.time()) for ts in timestamps.to_list()[::2]] + assert result.to_list() == expected + + def test_weekly_uses_last_available_session_in_week(self) -> None: + timestamps = _make_weekday_series("2024-01-01", "2024-01-31") + + result = resolve_rebalance_timestamps(timestamps, RebalanceSchedule.weekly()) + + assert all(ts.weekday() == 4 for ts in result.to_list()[:-1]) + + def test_month_end_uses_last_available_session_in_month(self) -> None: + timestamps = _make_weekday_series("2024-01-01", "2024-03-31") + + result = resolve_rebalance_timestamps(timestamps, RebalanceSchedule.month_end()) + + resolved = result.to_list() + assert len(resolved) == 3 + assert resolved[0].month == 1 and resolved[0].day == 31 + assert resolved[1].month == 2 and resolved[1].day == 29 + assert resolved[2].month == 3 and resolved[2].day == 29 + + def test_cme_session_grouping_uses_session_boundaries(self) -> None: + timestamps = [ + datetime(2024, 1, 7, 18, 0), + datetime(2024, 1, 8, 10, 0), + datetime(2024, 1, 8, 18, 0), + datetime(2024, 1, 9, 10, 0), + ] + + result = resolve_rebalance_timestamps( + timestamps, + RebalanceCadence.EVERY_SESSION, + calendar="CME_Equity", + timezone="America/Chicago", + ) + + assert result.to_list() == [datetime(2024, 1, 8, 10, 0), datetime(2024, 1, 9, 10, 0)] + + def test_explicit_timezone_is_not_overridden_by_calendar(self) -> None: + timestamps = [ + datetime(2024, 1, 8, 15, 0, tzinfo=UTC), + datetime(2024, 1, 8, 16, 0, tzinfo=UTC), + ] + + result = resolve_rebalance_timestamps( + timestamps, + RebalanceCadence.EVERY_SESSION, + calendar="NYSE", + timezone="America/Chicago", + ) + + assert result.to_list() == [timestamps[0], timestamps[1]] + + def test_weekly_daily_session_labels_use_labeled_dates_not_prior_sessions(self) -> None: + timestamps = _make_weekday_series("2024-01-01", "2024-01-12") + + result = resolve_rebalance_timestamps( + timestamps, + RebalanceSchedule.weekly(), + feed_spec=FeedSpec( + calendar="NYSE", + data_frequency="daily", + timestamp_semantics="session_label", + ), + ) + + assert result.to_list() == [datetime(2024, 1, 5), datetime(2024, 1, 12)] + + def test_month_end_daily_session_labels_do_not_roll_first_day_into_prior_month(self) -> None: + timestamps = pl.Series( + "timestamp", + [ + datetime(2024, 1, 30), + datetime(2024, 1, 31), + datetime(2024, 2, 1), + datetime(2024, 2, 29), + ], + ) + + result = resolve_rebalance_timestamps( + timestamps, + RebalanceSchedule.month_end(), + feed_spec=FeedSpec( + calendar="NYSE", + data_frequency="daily", + timestamp_semantics="session_label", + ), + ) + + assert result.to_list() == [datetime(2024, 1, 31), datetime(2024, 2, 29)] + + def test_daily_midnight_bars_fallback_to_session_labels_without_explicit_semantics( + self, + ) -> None: + timestamps = _make_weekday_series("2024-01-01", "2024-01-12") + + result = resolve_rebalance_timestamps( + timestamps, + RebalanceSchedule.weekly(), + data_frequency="daily", + calendar="NYSE", + ) + + assert result.to_list() == [datetime(2024, 1, 5), datetime(2024, 1, 12)] diff --git a/tests/futures/test_trade_record.py b/tests/futures/test_trade_record.py index 6ca7758a..ee52f0d8 100644 --- a/tests/futures/test_trade_record.py +++ b/tests/futures/test_trade_record.py @@ -85,7 +85,7 @@ def test_trade_creation(self, es_contract: ContractSpec): pnl_percent=0.00375, # 1441 / (2 * 4000 * 50) bars_held=10, fees=9.0, - slippage=50.0, + exit_slippage=50.0, exit_reason="signal", ) @@ -93,7 +93,7 @@ def test_trade_creation(self, es_contract: ContractSpec): assert trade.direction == "long" assert trade.pnl == 1441.0 assert trade.fees == 9.0 - assert trade.slippage == 50.0 + assert trade.exit_slippage == 50.0 def test_trade_short(self, cl_contract: ContractSpec): """Short trade with negative quantity.""" @@ -108,7 +108,7 @@ def test_trade_short(self, cl_contract: ContractSpec): pnl_percent=0.025, bars_held=5, fees=15.0, - slippage=85.0, + exit_slippage=85.0, exit_reason="take_profit", ) diff --git a/tests/helpers/invariants.py b/tests/helpers/invariants.py index 16bb9e28..e00d3514 100644 --- a/tests/helpers/invariants.py +++ b/tests/helpers/invariants.py @@ -196,7 +196,7 @@ def _check_no_nan(result: BacktestResult) -> None: "pnl", "pnl_percent", "fees", - "slippage", + "exit_slippage", "mfe", "mae", "entry_slippage", diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 829a610d..0a791985 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -31,7 +31,7 @@ def sample_winning_trade() -> Trade: pnl_percent=6.67, bars_held=5, fees=10.0, - slippage=5.0, + exit_slippage=5.0, mfe=8.0, mae=-2.0, ) @@ -51,7 +51,7 @@ def sample_losing_trade() -> Trade: pnl_percent=-5.0, bars_held=5, fees=8.0, - slippage=4.0, + exit_slippage=4.0, mfe=2.0, mae=-6.0, ) @@ -71,7 +71,7 @@ def sample_short_trade() -> Trade: pnl_percent=4.0, bars_held=4, fees=12.0, - slippage=6.0, + exit_slippage=6.0, mfe=5.0, mae=-3.0, ) @@ -119,7 +119,7 @@ def test_basic_long_trade(self, sample_winning_trade: Trade): assert record["timestamp"] == sample_winning_trade.exit_time assert record["entry_timestamp"] == sample_winning_trade.entry_time assert record["fees"] == 10.0 - assert record["slippage"] == 5.0 + assert record["exit_slippage"] == 5.0 def test_short_trade(self, sample_short_trade: Trade): """Test conversion of a short trade.""" diff --git a/tests/test_artifact_spec.py b/tests/test_artifact_spec.py new file mode 100644 index 00000000..958ef2ac --- /dev/null +++ b/tests/test_artifact_spec.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from pathlib import Path + +from ml4t.backtest.spec_bridge import ( + market_data_spec_to_feed_spec, + market_data_spec_to_runtime_metadata, +) +from ml4t.data.artifacts import ArtifactKind, FeedSpec, MarketDataSpec, TimestampSemantics +from ml4t.diagnostic.artifacts import dump_spec, load_market_data_spec, load_spec +from ml4t.engineer.artifacts import FeatureSpec, LabelSpec, PredictionSpec + + +def test_market_data_spec_from_mapping_normalizes_timestamp_semantics() -> None: + spec = MarketDataSpec.from_mapping( + { + "artifact_id": "nasdaq100_1m_nbbo_v1", + "kind": "market_data", + "schema": { + "timestamp_col": "ts", + "entity_col": "symbol", + "close_col": "last_trade_price", + "bid_col": "close_bid_price", + "ask_col": "close_ask_price", + "mid_col": "mid_close", + }, + "semantics": { + "data_frequency": "1m", + "calendar": "NYSE", + "timezone": "America/New_York", + "timestamp_semantics": "bar_close", + "session_start_time": "09:30:00", + "bar_type": "ohlcv_nbbo", + }, + } + ) + + assert spec.kind == ArtifactKind.MARKET_DATA + assert spec.schema.bid_col == "close_bid_price" + assert spec.schema.ask_col == "close_ask_price" + assert spec.semantics.timestamp_semantics == TimestampSemantics.BAR_CLOSE + + +def test_market_data_spec_to_feed_spec_preserves_quote_and_temporal_fields() -> None: + spec = MarketDataSpec.from_mapping( + { + "artifact_id": "nasdaq100_1m_nbbo_v1", + "kind": "market_data", + "schema": { + "timestamp_col": "timestamp", + "entity_col": "symbol", + "price_col": "mid_close", + "open_col": "open", + "high_col": "high", + "low_col": "low", + "close_col": "last_trade_price", + "volume_col": "volume", + "bid_col": "close_bid_price", + "ask_col": "close_ask_price", + "mid_col": "mid_close", + }, + "semantics": { + "data_frequency": "1m", + "calendar": "NYSE", + "timezone": "America/New_York", + "timestamp_semantics": "bar_close", + "session_start_time": "09:30:00", + "bar_type": "ohlcv_nbbo", + }, + } + ) + + feed_spec = market_data_spec_to_feed_spec(spec) + + assert isinstance(feed_spec, FeedSpec) + assert feed_spec.price_col == "mid_close" + assert feed_spec.close_col == "last_trade_price" + assert feed_spec.bid_col == "close_bid_price" + assert feed_spec.ask_col == "close_ask_price" + assert feed_spec.mid_col == "mid_close" + assert feed_spec.calendar == "NYSE" + assert feed_spec.timezone == "America/New_York" + assert feed_spec.timestamp_semantics == TimestampSemantics.BAR_CLOSE + + +def test_market_data_schema_keeps_close_default_when_only_price_col_is_overridden() -> None: + spec = MarketDataSpec.from_mapping( + { + "artifact_id": "nasdaq100_1m_nbbo_v1", + "kind": "market_data", + "schema": {"price_col": "mid_close"}, + } + ) + + assert spec.schema.price_col == "mid_close" + assert spec.schema.close_col == "close" + + +def test_runtime_metadata_helper_returns_feed_semantics() -> None: + metadata = market_data_spec_to_runtime_metadata( + { + "artifact_id": "us_equities_daily_bars_v1", + "kind": "market_data", + "semantics": { + "data_frequency": "1d", + "calendar": "NYSE", + "timezone": "America/New_York", + "timestamp_semantics": "session_label", + }, + } + ) + + assert metadata == { + "calendar": "NYSE", + "timezone": "America/New_York", + "data_frequency": "1d", + "timestamp_semantics": TimestampSemantics.SESSION_LABEL, + "session_start_time": None, + "bar_type": None, + } + + +def test_spec_io_yaml_round_trip(tmp_path: Path) -> None: + spec = MarketDataSpec.from_mapping( + { + "artifact_id": "us_equities_daily_bars_v1", + "kind": "market_data", + "storage": {"path": "labels/prices.parquet", "format": "parquet"}, + "schema": { + "timestamp_col": "timestamp", + "entity_col": "symbol", + "open_col": "adj_open", + "high_col": "adj_high", + "low_col": "adj_low", + "close_col": "adj_close", + "volume_col": "adj_volume", + }, + "semantics": { + "data_frequency": "1d", + "calendar": "NYSE", + "timezone": "America/New_York", + "timestamp_semantics": "bar_close", + }, + "provenance": {"source_artifacts": ["raw_prices_v1"]}, + } + ) + + path = dump_spec(spec, tmp_path / "market_data.yaml") + loaded = load_market_data_spec(path) + + assert loaded == spec + + +def test_spec_io_json_round_trip(tmp_path: Path) -> None: + spec = MarketDataSpec.from_mapping( + { + "artifact_id": "us_equities_daily_bars_v1", + "kind": "market_data", + "storage": {"path": "labels/prices.parquet", "format": "parquet"}, + "schema": {"close_col": "adj_close"}, + "semantics": {"data_frequency": "1d", "timestamp_semantics": "bar_close"}, + } + ) + + path = dump_spec(spec, tmp_path / "market_data.json") + loaded = load_market_data_spec(path) + + assert loaded == spec + + +def test_load_spec_dispatches_label_spec() -> None: + spec = load_spec( + { + "artifact_id": "us_equities_fwd_ret_1d_v1", + "kind": "labels", + "schema": { + "timestamp_col": "timestamp", + "entity_col": "symbol", + "label_col": "fwd_ret_1d", + }, + "definition": { + "family": "forward_return", + "task_type": "regression", + "horizon": "1D", + "buffer": "1D", + "source_artifact": "us_equities_daily_bars_v1", + }, + } + ) + + assert isinstance(spec, LabelSpec) + assert spec.definition.buffer == "1D" + assert spec.schema.label_col == "fwd_ret_1d" + + +def test_load_spec_dispatches_feature_spec() -> None: + spec = load_spec( + { + "artifact_id": "us_equities_financial_features_v1", + "kind": "features", + "schema": { + "timestamp_col": "timestamp", + "entity_col": "symbol", + "feature_columns": ["mom_21", "vol_21"], + }, + "definition": { + "family": "financial", + "join_keys": ["timestamp", "symbol"], + "source_artifacts": ["us_equities_daily_bars_v1"], + }, + } + ) + + assert isinstance(spec, FeatureSpec) + assert spec.schema.feature_columns == ("mom_21", "vol_21") + assert spec.definition.source_artifacts == ("us_equities_daily_bars_v1",) + + +def test_load_spec_dispatches_prediction_spec() -> None: + spec = load_spec( + { + "artifact_id": "us_equities_preds_v1", + "kind": "predictions", + "schema": { + "timestamp_col": "timestamp", + "entity_col": "symbol", + "prediction_col": "prediction", + }, + "definition": { + "split_protocol": "walk_forward_oos", + "label_artifact": "us_equities_fwd_ret_1d_v1", + "feature_artifacts": ["us_equities_financial_features_v1"], + "training_hash": "abc123", + }, + } + ) + + assert isinstance(spec, PredictionSpec) + assert spec.definition.feature_artifacts == ("us_equities_financial_features_v1",) + assert spec.definition.training_hash == "abc123" diff --git a/tests/test_broker.py b/tests/test_broker.py index a7117cb1..cec9aaa9 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -3,6 +3,7 @@ from datetime import datetime import pytest +from ml4t.data.artifacts.market_data import FeedSpec from ml4t.backtest.broker import Broker from ml4t.backtest.models import NoCommission, NoSlippage, PercentageCommission @@ -1141,6 +1142,8 @@ def test_to_dict(self): assert "commission" in result assert "slippage" in result assert "cash" in result + assert "feed" in result + assert "metadata" in result def test_from_dict_round_trip(self): """Test from_dict restores config.""" @@ -1152,6 +1155,41 @@ def test_from_dict_round_trip(self): assert restored.initial_cash == 50000.0 assert restored.commission_rate == 0.002 + def test_from_dict_round_trip_preserves_feed_and_metadata(self): + """Test from_dict restores feed contract and passthrough metadata.""" + from ml4t.backtest.config import BacktestConfig + + original = BacktestConfig( + feed_spec=FeedSpec( + timestamp_col="time", + entity_col="ticker", + price_col="mid_price", + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ), + metadata={ + "strategy_id": "topk_monthly_v1", + "prices_path": "/tmp/prices.parquet", + "notes": {"author": "test"}, + }, + ) + data = original.to_dict() + + restored = BacktestConfig.from_dict(data) + + assert restored.feed_spec is not None + assert restored.resolved_feed_spec.timestamp_col == "time" + assert restored.resolved_feed_spec.entity_col == "ticker" + assert restored.resolved_feed_spec.price_col == "mid_price" + assert restored.resolved_calendar == "NYSE" + assert restored.resolved_timezone == "America/New_York" + assert restored.metadata == { + "strategy_id": "topk_monthly_v1", + "prices_path": "/tmp/prices.parquet", + "notes": {"author": "test"}, + } + def test_to_yaml_from_yaml(self, tmp_path): """Test YAML serialization round trip.""" from ml4t.backtest.config import BacktestConfig @@ -2241,6 +2279,8 @@ def test_rebalance_to_weights(self, broker): # Should have 3 buy orders assert len(orders) == 3 + assert len({order.rebalance_id for order in orders}) == 1 + assert orders[0].rebalance_id is not None # All should be buy orders (starting from cash) for order in orders: @@ -2273,6 +2313,7 @@ def test_rebalance_closes_positions_not_in_target(self, broker_with_position): aapl_orders = [o for o in orders if o.asset == "AAPL"] assert len(aapl_orders) == 1 assert aapl_orders[0].side == OrderSide.SELL + assert len({order.rebalance_id for order in orders}) == 1 class TestP1PositionModification: diff --git a/tests/test_config_wiring.py b/tests/test_config_wiring.py index 2217212a..fad5df62 100644 --- a/tests/test_config_wiring.py +++ b/tests/test_config_wiring.py @@ -20,7 +20,9 @@ ) from ml4t.backtest.config import ( CommissionType, + DataFrequency, EntryOrderPriority, + ExecutionPrice, FillOrdering, ShareType, ShortCashPolicy, @@ -35,6 +37,7 @@ VolumeShareSlippage, ) from ml4t.backtest.types import OrderSide, Position +from ml4t.data.artifacts.market_data import FeedSpec # --------------------------------------------------------------------------- # Helpers @@ -845,11 +848,13 @@ def test_lean_strict_profile_uses_buying_power_reservation(self): assert config.settlement_delay == 2 def test_to_dict_from_dict_roundtrip(self): - config = BacktestConfig(immediate_fill=True) + config = BacktestConfig(immediate_fill=True, mark_price=ExecutionPrice.QUOTE_MID) d = config.to_dict() assert d["orders"]["immediate_fill"] is True + assert d["execution"]["mark_price"] == "quote_mid" restored = BacktestConfig.from_dict(d) assert restored.immediate_fill is True + assert restored.mark_price == ExecutionPrice.QUOTE_MID class TestFromDictDefaultParity: @@ -862,6 +867,7 @@ def test_empty_dict_matches_constructor_defaults(self): # Core execution fields that were previously mismatched assert from_empty.execution_mode == default.execution_mode assert from_empty.execution_price == default.execution_price + assert from_empty.mark_price == default.mark_price assert from_empty.rebalance_mode == default.rebalance_mode # Verify all enum fields match @@ -889,6 +895,129 @@ def test_empty_dict_matches_constructor_defaults(self): assert from_empty.settlement_delay == default.settlement_delay +class TestFeedSpecConfigResolution: + def test_constructor_canonicalizes_feed_spec_metadata(self): + config = BacktestConfig( + feed_spec={ + "calendar": "NYSE", + "timezone": "America/New_York", + "data_frequency": "minute", + } + ) + + assert isinstance(config.feed_spec, FeedSpec) + assert config.calendar == "NYSE" + assert config.timezone == "America/New_York" + assert config.data_frequency == DataFrequency.MINUTE_1 + assert config.resolved_calendar == "NYSE" + assert config.resolved_timezone == "America/New_York" + assert config.resolved_data_frequency == DataFrequency.MINUTE_1 + assert config.resolved_feed_spec.calendar == "NYSE" + assert config.resolved_feed_spec.timezone == "America/New_York" + assert config.resolved_feed_spec.data_frequency == DataFrequency.MINUTE_1 + + def test_resolved_feed_spec_preserves_explicit_runtime_over_feed_metadata(self): + config = BacktestConfig( + timezone="UTC", + data_frequency=DataFrequency.DAILY, + feed_spec=FeedSpec( + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + session_start_time="17:00", + timestamp_semantics="event_time", + ), + ) + + assert config.feed_spec is not None + assert config.feed_spec.timezone == "America/New_York" + assert config.timezone == "UTC" + assert config.data_frequency == DataFrequency.DAILY + assert config.resolved_calendar == "NYSE" + assert config.resolved_timezone == "UTC" + assert config.resolved_data_frequency == DataFrequency.DAILY + assert config.resolved_session_start_time == "17:00" + assert config.resolved_timestamp_semantics is not None + assert config.resolved_timestamp_semantics.value == "event_time" + assert config.resolved_feed_spec.calendar == "NYSE" + assert config.resolved_feed_spec.timezone == "UTC" + assert config.resolved_feed_spec.data_frequency == DataFrequency.DAILY + assert config.resolved_feed_spec.session_start_time == "17:00" + + def test_merge_feed_spec_fills_missing_runtime_fields(self): + config = BacktestConfig() + + merged = config.merge_feed_spec( + FeedSpec( + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ) + ) + + assert merged is not config + assert merged.feed_spec is not None + assert merged.calendar == "NYSE" + assert merged.timezone == "America/New_York" + assert merged.data_frequency == DataFrequency.MINUTE_1 + assert merged._explicit_timezone is False + assert merged._explicit_data_frequency is False + + def test_merge_feed_spec_preserves_explicit_runtime_fields(self): + config = BacktestConfig(timezone="UTC", data_frequency=DataFrequency.DAILY) + + merged = config.merge_feed_spec( + FeedSpec( + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ) + ) + + assert merged.calendar == "NYSE" + assert merged.timezone == "UTC" + assert merged.data_frequency == DataFrequency.DAILY + assert merged._explicit_timezone is True + assert merged._explicit_data_frequency is True + + def test_merge_feed_spec_ignores_runtime_argument_when_constructor_spec_exists(self): + config = BacktestConfig( + feed_spec=FeedSpec( + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ) + ) + + merged = config.merge_feed_spec( + FeedSpec( + calendar="CME_Equity", + timezone="America/Chicago", + data_frequency="daily", + ) + ) + + assert merged is config + assert merged.feed_spec is not None + assert merged.feed_spec.calendar == "NYSE" + assert merged.feed_spec.timezone == "America/New_York" + assert merged.feed_spec.data_frequency == "minute" + + def test_merge_feed_spec_returns_identity_when_no_updates_are_needed(self): + config = BacktestConfig( + feed_spec=FeedSpec( + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ) + ) + + merged = config.merge_feed_spec(config.feed_spec) + + assert merged is config + assert config.merge_feed_spec(None) is config + + class TestConfigModelWiring: """All commission/slippage enum choices should map to model instances.""" diff --git a/tests/test_core.py b/tests/test_core.py index dbb28da8..28659db8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,12 +4,14 @@ import polars as pl import pytest +from ml4t.data.artifacts.market_data import FeedSpec from ml4t.backtest import ( Broker, DataFeed, Engine, ExecutionMode, + Fill, OrderSide, OrderType, Strategy, @@ -18,6 +20,7 @@ from ml4t.backtest.config import ( BacktestConfig, CommissionType, + DataFrequency, SlippageType, ) from ml4t.backtest.models import PercentageCommission, VolumeShareSlippage @@ -186,6 +189,24 @@ def on_data(self, timestamp, data, context, broker): self.entered = True +class BuyThenSellStrategy(Strategy): + """Buy on first bar and fully exit on second bar.""" + + def __init__(self, asset: str, quantity: float): + self.asset = asset + self.quantity = quantity + self.bar_count = 0 + + def on_data(self, timestamp, data, context, broker): + if self.asset not in data: + return + self.bar_count += 1 + if self.bar_count == 1: + broker.submit_order(self.asset, self.quantity) + elif self.bar_count == 2: + broker.close_position(self.asset) + + class VolatilityAdjustedStopStrategy(Strategy): """Test updating stop orders based on volatility.""" @@ -481,6 +502,112 @@ def test_convenience_function(self): assert results.metrics["initial_cash"] == 50000 assert len(results.equity_curve) == 10 + def test_reports_activity_and_portfolio_state_metrics(self): + prices = pl.DataFrame( + { + "timestamp": [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + ], + "asset": ["AAPL", "AAPL", "AAPL"], + "open": [100.0, 110.0, 110.0], + "high": [100.0, 110.0, 110.0], + "low": [100.0, 110.0, 110.0], + "close": [100.0, 110.0, 110.0], + "volume": [1000.0, 1000.0, 1000.0], + } + ) + engine = Engine( + DataFeed(prices_df=prices), + BuyThenSellStrategy("AAPL", quantity=10), + BacktestConfig( + initial_cash=100000.0, + execution_mode=ExecutionMode.SAME_BAR, + commission_type=CommissionType.NONE, + slippage_type=SlippageType.NONE, + ), + ) + + results = engine.run() + portfolio_state = results.to_portfolio_state_dataframe() + + assert results.metrics["num_fills"] == 2 + assert results.metrics["num_rebalance_events"] == 2 + assert results.metrics["unique_symbols_traded"] == 1 + assert results.metrics["total_filled_notional"] == pytest.approx(2100.0) + assert results.metrics["avg_turnover"] == pytest.approx( + ((1000.0 / 100000.0) + (1100.0 / 100100.0)) / 3.0 + ) + assert results.metrics["max_turnover"] == pytest.approx(1100.0 / 100100.0) + assert results.metrics["avg_open_positions"] == pytest.approx(1.0 / 3.0) + assert results.metrics["max_open_positions"] == 1 + + assert portfolio_state.columns == [ + "timestamp", + "equity", + "cash", + "gross_exposure", + "net_exposure", + "open_positions", + ] + assert portfolio_state["open_positions"].to_list() == [1, 0, 0] + assert portfolio_state["gross_exposure"].to_list() == [1000.0, 0.0, 0.0] + + def test_activity_metrics_prefer_explicit_rebalance_ids(self): + prices = pl.DataFrame( + { + "timestamp": [datetime(2024, 1, 1), datetime(2024, 1, 2)], + "asset": ["AAPL", "AAPL"], + "open": [100.0, 101.0], + "high": [100.0, 101.0], + "low": [100.0, 101.0], + "close": [100.0, 101.0], + "volume": [1000.0, 1000.0], + } + ) + engine = Engine( + DataFeed(prices_df=prices), + BuyAndHoldStrategy("AAPL"), + BacktestConfig( + initial_cash=100000.0, + execution_mode=ExecutionMode.SAME_BAR, + commission_type=CommissionType.NONE, + slippage_type=SlippageType.NONE, + ), + ) + + ts1 = datetime(2024, 1, 1) + ts2 = datetime(2024, 1, 2) + engine.portfolio_state = [ + (ts1, 100000.0, 100000.0, 0.0, 0.0, 0), + (ts2, 100100.0, 99100.0, 1000.0, 1000.0, 1), + ] + engine.broker.fills = [ + Fill( + order_id="ORD-1", + rebalance_id="rebalance-1", + asset="AAPL", + side=OrderSide.BUY, + quantity=5.0, + price=100.0, + timestamp=ts1, + ), + Fill( + order_id="ORD-2", + rebalance_id="rebalance-1", + asset="MSFT", + side=OrderSide.BUY, + quantity=2.0, + price=200.0, + timestamp=ts2, + ), + ] + + metrics = engine._build_activity_metrics() + + assert metrics["num_rebalance_events"] == 1 + class TestTradeRecording: """Test trade recording with signals.""" @@ -613,7 +740,9 @@ def test_from_config_fixed_slippage(self): engine = Engine.from_config(feed, strategy, config) results = engine.run() - assert results.metrics["total_slippage"] > 0 + assert results.metrics["total_slippage"] == pytest.approx(5.0) + assert results.trades[0].entry_slippage == pytest.approx(0.05) + assert results.trades[0].exit_slippage == 0.0 def test_from_config_execution_mode_same_bar(self): """Test from_config with SAME_BAR execution mode.""" @@ -717,6 +846,120 @@ def test_run_backtest_with_string_preset(self): assert results.equity_curve is not None assert len(results.equity_curve) == 10 + def test_run_backtest_preserves_raw_predictions_dataframe(self): + """Test raw input predictions are available on the result surface.""" + prices = generate_prices(["AAPL"], datetime(2024, 1, 1), 10) + signals = generate_signals(["AAPL"], datetime(2024, 1, 1), 10, ["ml_score"]) + strategy = BuyAndHoldStrategy("AAPL") + + result = run_backtest(prices=prices, signals=signals, strategy=strategy, config="default") + + assert result.predictions is not None + assert result.to_predictions_dataframe().equals(signals) + + def test_run_backtest_uses_feed_spec_for_runtime_config(self): + """Feed metadata should populate runtime config when explicit config is unset.""" + + class PrepareTrackingStrategy(Strategy): + def __init__(self): + self.seen_config = None + + def on_prepare(self, broker, timestamps, config=None): + self.seen_config = config + + def on_data(self, timestamp, data, context, broker): + return None + + prices = pl.DataFrame( + { + "ts": [datetime(2024, 1, 1, 9, 30), datetime(2024, 1, 1, 9, 31)], + "ticker": ["AAPL", "AAPL"], + "open_px": [100.0, 101.0], + "high_px": [101.0, 102.0], + "low_px": [99.0, 100.0], + "close_px": [100.5, 101.5], + "vol": [1000.0, 1100.0], + } + ) + strategy = PrepareTrackingStrategy() + + result = run_backtest( + prices=prices, + strategy=strategy, + config=BacktestConfig(), + feed_spec=FeedSpec( + timestamp_col="ts", + entity_col="ticker", + open_col="open_px", + high_col="high_px", + low_col="low_px", + close_col="close_px", + volume_col="vol", + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ), + ) + + assert strategy.seen_config is not None + assert strategy.seen_config.calendar == "NYSE" + assert strategy.seen_config.timezone == "America/New_York" + assert strategy.seen_config.data_frequency == DataFrequency.MINUTE_1 + assert result.config is not None + assert result.config.calendar == "NYSE" + + def test_run_backtest_preserves_explicit_runtime_config_over_feed_spec(self): + """Explicit runtime config should not be overwritten by feed metadata.""" + + class PrepareTrackingStrategy(Strategy): + def __init__(self): + self.seen_config = None + + def on_prepare(self, broker, timestamps, config=None): + self.seen_config = config + + def on_data(self, timestamp, data, context, broker): + return None + + prices = pl.DataFrame( + { + "ts": [datetime(2024, 1, 1, 9, 30), datetime(2024, 1, 1, 9, 31)], + "ticker": ["AAPL", "AAPL"], + "open_px": [100.0, 101.0], + "high_px": [101.0, 102.0], + "low_px": [99.0, 100.0], + "close_px": [100.5, 101.5], + "vol": [1000.0, 1100.0], + } + ) + strategy = PrepareTrackingStrategy() + + result = run_backtest( + prices=prices, + strategy=strategy, + config=BacktestConfig(timezone="UTC", data_frequency=DataFrequency.DAILY), + feed_spec=FeedSpec( + timestamp_col="ts", + entity_col="ticker", + open_col="open_px", + high_col="high_px", + low_col="low_px", + close_col="close_px", + volume_col="vol", + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ), + ) + + assert strategy.seen_config is not None + assert strategy.seen_config.calendar == "NYSE" + assert strategy.seen_config.timezone == "UTC" + assert strategy.seen_config.data_frequency == DataFrequency.DAILY + assert result.config is not None + assert result.config.timezone == "UTC" + assert result.config.data_frequency == DataFrequency.DAILY + class TestEmptyDataFeed: """Tests for edge cases with empty or minimal data.""" diff --git a/tests/test_datafeed_memory.py b/tests/test_datafeed_memory.py index d31830c9..070147ef 100644 --- a/tests/test_datafeed_memory.py +++ b/tests/test_datafeed_memory.py @@ -9,7 +9,9 @@ import polars as pl import pytest -from ml4t.backtest import DataFeed +from ml4t.backtest import BacktestConfig, DataFeed +from ml4t.backtest.config import DataFrequency +from ml4t.data.artifacts.market_data import FeedSpec class TestDataFeedMemoryEfficiency: @@ -365,3 +367,191 @@ def test_symbol_with_signals(self): feed = DataFeed(prices_df=prices, signals_df=signals) _ts, data, _ctx = next(iter(feed)) assert data["AAPL"]["signals"]["momentum"] == 0.5 + + +class TestDataFeedContracts: + """Tests for shared feed contract support.""" + + def test_feed_spec_mapping_supports_custom_columns(self): + prices = pl.DataFrame( + { + "time": [datetime(2020, 1, 1)], + "ticker": ["MSFT"], + "open_px": [100.0], + "high_px": [101.0], + "low_px": [99.0], + "last_px": [100.5], + "vol": [1_000_000], + } + ) + signals = pl.DataFrame( + { + "time": [datetime(2020, 1, 1)], + "ticker": ["MSFT"], + "score": [0.75], + } + ) + context = pl.DataFrame( + { + "time": [datetime(2020, 1, 1)], + "regime": ["risk_on"], + } + ) + + feed = DataFeed( + prices_df=prices, + signals_df=signals, + context_df=context, + feed_spec={ + "timestamp_col": "time", + "entity_col": "ticker", + "open_col": "open_px", + "high_col": "high_px", + "low_col": "low_px", + "close_col": "last_px", + "volume_col": "vol", + }, + ) + + ts, data, ctx = next(iter(feed)) + assert ts == datetime(2020, 1, 1) + assert feed.feed_spec.timestamp_col == "time" + assert feed._entity_col == "ticker" + assert data["MSFT"]["open"] == 100.0 + assert data["MSFT"]["close"] == 100.5 + assert data["MSFT"]["signals"]["score"] == 0.75 + assert ctx["regime"] == "risk_on" + + def test_feed_spec_object_uses_price_col_as_close_fallback(self): + class EngineerLikeContract: + timestamp_col = "ts" + symbol_col = "ticker" + price_col = "last_price" + open_col = "open_price" + high_col = "high_price" + low_col = "low_price" + volume_col = "size" + + prices = pl.DataFrame( + { + "ts": [datetime(2020, 1, 1)], + "ticker": ["ES"], + "open_price": [4500.0], + "high_price": [4510.0], + "low_price": [4495.0], + "last_price": [4502.0], + "size": [1250], + } + ) + + feed = DataFeed(prices_df=prices, contract=EngineerLikeContract()) + + _ts, data, _ctx = next(iter(feed)) + assert data["ES"]["close"] == 4502.0 + assert data["ES"]["price"] == 4502.0 + assert feed._price_col == "last_price" + assert feed.feed_spec.close_col == "last_price" + + def test_explicit_kwargs_override_feed_spec(self): + prices = pl.DataFrame( + { + "time": [datetime(2020, 1, 1)], + "ticker": ["AAPL"], + "close_a": [100.0], + "close_b": [101.0], + } + ) + + feed = DataFeed( + prices_df=prices, + feed_spec=FeedSpec(timestamp_col="time", entity_col="ticker", close_col="close_a"), + close_col="close_b", + ) + + _ts, data, _ctx = next(iter(feed)) + assert data["AAPL"]["close"] == 101.0 + + def test_feed_spec_price_col_drives_reference_price(self): + prices = pl.DataFrame( + { + "timestamp": [datetime(2020, 1, 1)], + "asset": ["AAPL"], + "open": [100.0], + "high": [101.0], + "low": [99.0], + "close": [100.5], + "mid_price": [100.25], + "volume": [1_000_000], + } + ) + + feed = DataFeed( + prices_df=prices, + feed_spec=FeedSpec(price_col="mid_price"), + ) + + _ts, data, _ctx = next(iter(feed)) + assert data["AAPL"]["price"] == 100.25 + assert data._prices["AAPL"] == 100.25 + assert data._closes["AAPL"] == 100.5 + + def test_quote_columns_are_cached_when_present(self): + prices = pl.DataFrame( + { + "timestamp": [datetime(2020, 1, 1)], + "asset": ["ES"], + "open": [4500.0], + "high": [4510.0], + "low": [4495.0], + "close": [4502.0], + "volume": [1250.0], + "bid_px": [4501.75], + "ask_px": [4502.25], + "bid_qty": [7.0], + "ask_qty": [11.0], + } + ) + + feed = DataFeed( + prices_df=prices, + feed_spec=FeedSpec( + bid_col="bid_px", + ask_col="ask_px", + bid_size_col="bid_qty", + ask_size_col="ask_qty", + ), + ) + + _ts, data, _ctx = next(iter(feed)) + assert data["ES"]["bid"] == 4501.75 + assert data["ES"]["ask"] == 4502.25 + assert data["ES"]["mid"] == pytest.approx(4502.0) + assert data["ES"]["bid_size"] == 7.0 + assert data["ES"]["ask_size"] == 11.0 + assert data._bids["ES"] == 4501.75 + assert data._asks["ES"] == 4502.25 + assert data._mids["ES"] == pytest.approx(4502.0) + + def test_feed_spec_and_contract_are_mutually_exclusive(self): + prices = pl.DataFrame( + { + "timestamp": [datetime(2020, 1, 1)], + "asset": ["AAPL"], + "close": [100.0], + } + ) + + with pytest.raises(ValueError, match="either feed_spec or contract"): + DataFeed( + prices_df=prices, + feed_spec=FeedSpec(), + contract=FeedSpec(), + ) + + def test_weekly_and_monthly_feed_frequencies_remain_irregular(self): + assert BacktestConfig( + feed_spec=FeedSpec(data_frequency="weekly") + ).resolved_data_frequency == (DataFrequency.IRREGULAR) + assert BacktestConfig( + feed_spec=FeedSpec(data_frequency="monthly") + ).resolved_data_frequency == (DataFrequency.IRREGULAR) diff --git a/tests/test_diagnostic_integration.py b/tests/test_diagnostic_integration.py deleted file mode 100644 index 2109ded9..00000000 --- a/tests/test_diagnostic_integration.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Tests for ml4t-diagnostic integration (optional dependency).""" - -from __future__ import annotations - -from datetime import datetime, timedelta - -import numpy as np -import pytest - -from ml4t.backtest import BacktestResult -from ml4t.backtest.types import Trade - - -def create_sample_result(n_trades: int = 50) -> BacktestResult: - """Create a sample BacktestResult for testing.""" - np.random.seed(42) - trades = [] - base_time = datetime(2023, 1, 1) - - for i in range(n_trades): - entry_time = base_time + timedelta(days=i * 2) - exit_time = entry_time + timedelta(days=np.random.randint(1, 10)) - pnl = np.random.normal(50, 200) - trades.append( - Trade( - symbol=f"ASSET_{i % 5}", - entry_time=entry_time, - exit_time=exit_time, - entry_price=100.0, - exit_price=100.0 + pnl / 100, - quantity=100.0, - pnl=pnl, - pnl_percent=pnl / 10000, - bars_held=np.random.randint(1, 10), - fees=5.0, - slippage=2.0, - exit_reason="signal", - mfe=abs(np.random.normal(0.02, 0.01)), - mae=-abs(np.random.normal(0.01, 0.005)), - ) - ) - - equity = [ - (base_time + timedelta(days=i), 100000 + i * 100 + np.random.normal(0, 500)) - for i in range(252) - ] - - return BacktestResult( - trades=trades, - equity_curve=equity, - fills=[], - metrics={ - "sharpe_ratio": 1.85, - "max_drawdown": -0.12, - "total_return_pct": 25.5, - "final_value": 125500, - }, - ) - - -def has_diagnostic_library() -> bool: - """Check if ml4t-diagnostic is available.""" - import importlib.util - - return importlib.util.find_spec("ml4t.diagnostic") is not None - - -@pytest.mark.skipif(not has_diagnostic_library(), reason="ml4t-diagnostic not installed") -class TestTearsheetIntegration: - """Tests for BacktestResult.to_tearsheet() integration.""" - - def test_to_tearsheet_basic(self): - """Test basic tearsheet generation.""" - result = create_sample_result() - html = result.to_tearsheet() - - assert isinstance(html, str) - assert len(html) > 0 - assert "plotly" in html.lower() - - def test_to_tearsheet_templates(self): - """Test tearsheet with different templates.""" - result = create_sample_result() - - for template in ["quant_trader", "hedge_fund", "risk_manager", "full"]: - html = result.to_tearsheet(template=template) - assert isinstance(html, str) - assert len(html) > 0 - - def test_to_tearsheet_themes(self): - """Test tearsheet with different themes.""" - result = create_sample_result() - - for theme in ["default", "dark"]: - html = result.to_tearsheet(theme=theme) - assert isinstance(html, str) - assert len(html) > 0 - - def test_to_tearsheet_custom_title(self): - """Test tearsheet with custom title.""" - result = create_sample_result() - html = result.to_tearsheet(title="My Custom Backtest Report") - - assert isinstance(html, str) - assert "My Custom Backtest Report" in html - - def test_to_tearsheet_save_to_file(self, tmp_path): - """Test saving tearsheet to file.""" - result = create_sample_result() - output_path = tmp_path / "tearsheet.html" - - html = result.to_tearsheet(output_path=output_path) - - assert output_path.exists() - assert output_path.read_text() == html - - def test_to_tearsheet_empty_trades(self): - """Test tearsheet with no trades.""" - result = BacktestResult( - trades=[], - equity_curve=[(datetime.now(), 100000.0)], - fills=[], - metrics={"sharpe_ratio": 0.0}, - ) - - html = result.to_tearsheet() - assert isinstance(html, str) - - def test_to_tearsheet_metrics_extraction(self): - """Test that metrics are properly extracted.""" - result = create_sample_result() - - # Should not raise - metrics should be auto-populated - html = result.to_tearsheet() - assert isinstance(html, str) - - -class TestTearsheetMissingDependency: - """Test behavior when ml4t-diagnostic is not installed.""" - - @pytest.mark.skipif(has_diagnostic_library(), reason="ml4t-diagnostic IS installed") - def test_import_error_when_diagnostic_missing(self): - """Test that ImportError is raised with helpful message.""" - result = create_sample_result() - - with pytest.raises(ImportError, match="ml4t-diagnostic is required"): - result.to_tearsheet() diff --git a/tests/test_equity_curve.py b/tests/test_equity_curve.py index 96a98d4f..c6653cd9 100644 --- a/tests/test_equity_curve.py +++ b/tests/test_equity_curve.py @@ -2,7 +2,12 @@ from datetime import datetime, timedelta +import polars as pl + +from ml4t.backtest import BacktestConfig, DataFeed, Engine, Strategy from ml4t.backtest.analytics.equity import EquityCurve +from ml4t.backtest.config import DataFrequency +from ml4t.data.artifacts.market_data import FeedSpec class TestEquityCurveAnnualization: @@ -25,3 +30,56 @@ def test_periods_per_year_infers_intraday_frequency(self): eq.append(start + timedelta(minutes=i), 100_000.0 + float(i)) assert eq.periods_per_year > 252.0 + + def test_engine_equity_uses_configured_frequency_metadata(self): + """Engine-built equity should prefer configured cadence over elapsed-time inference.""" + + class HoldStrategy(Strategy): + def on_data(self, timestamp, data, context, broker): + return None + + prices = pl.DataFrame( + { + "timestamp": [datetime(2025, 1, 2, 9, 30), datetime(2025, 1, 2, 9, 31)], + "asset": ["AAPL", "AAPL"], + "close": [100.0, 101.0], + } + ) + engine = Engine( + DataFeed( + prices_df=prices, + feed_spec=FeedSpec(calendar="NYSE", data_frequency="minute"), + ), + HoldStrategy(), + BacktestConfig(data_frequency=DataFrequency.MINUTE_1, calendar="NYSE"), + ) + + result = engine.run() + + assert result.equity is not None + assert result.equity.periods_per_year == 252.0 * 390.0 + + def test_engine_equity_uses_default_config_assumptions_without_calendar(self): + """Configured intraday cadence should not fall back to elapsed-time inference.""" + + class HoldStrategy(Strategy): + def on_data(self, timestamp, data, context, broker): + return None + + prices = pl.DataFrame( + { + "timestamp": [datetime(2025, 1, 2, 9, 30), datetime(2025, 1, 2, 9, 31)], + "asset": ["AAPL", "AAPL"], + "close": [100.0, 101.0], + } + ) + engine = Engine( + DataFeed(prices_df=prices), + HoldStrategy(), + BacktestConfig(data_frequency=DataFrequency.MINUTE_1), + ) + + result = engine.run() + + assert result.equity is not None + assert result.equity.periods_per_year == 252.0 * 390.0 diff --git a/tests/test_export.py b/tests/test_export.py index ad48694b..69d0defa 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -31,7 +31,7 @@ def sample_result() -> BacktestResult: pnl_percent=3.33, bars_held=24, fees=10.0, - slippage=5.0, + exit_slippage=5.0, ), ] equity_curve = [ @@ -83,7 +83,7 @@ def multiple_results() -> list[BacktestResult]: pnl_percent=(i + 1) * 3.33, bars_held=24, fees=10.0, - slippage=5.0, + exit_slippage=5.0, ), ] equity_curve = [ @@ -128,7 +128,9 @@ def test_to_parquet_delegation(self, sample_result: BacktestResult): written = BacktestExporter.to_parquet(sample_result, path) assert "trades" in written + assert "fills" in written assert "equity" in written + assert "portfolio_state" in written assert written["trades"].exists() def test_from_parquet_delegation(self, sample_result: BacktestResult): @@ -222,6 +224,9 @@ def test_batch_export_summary_metrics(self, multiple_results: list[BacktestResul # Check all expected metrics are present expected_metrics = [ "num_trades", + "num_fills", + "num_rebalance_events", + "unique_symbols_traded", "total_return_pct", "max_drawdown_pct", "sharpe", @@ -235,6 +240,11 @@ def test_batch_export_summary_metrics(self, multiple_results: list[BacktestResul "final_value", "total_commission", "total_slippage", + "total_filled_notional", + "avg_turnover", + "max_turnover", + "avg_open_positions", + "max_open_positions", ] for metric in expected_metrics: diff --git a/tests/test_result.py b/tests/test_result.py index 41a1367e..dfdda5c9 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -10,10 +10,11 @@ import polars as pl import pytest +from ml4t.data.artifacts.market_data import FeedSpec +from ml4t.backtest.config import BacktestConfig from ml4t.backtest.result import ( BacktestResult, - _get_annualization_factor, enrich_trades_with_signals, ) from ml4t.backtest.types import Fill, OrderSide, Trade @@ -35,7 +36,7 @@ def sample_trades() -> list[Trade]: pnl_percent=3.33, bars_held=24, fees=10.0, - slippage=5.0, + exit_slippage=5.0, exit_reason="signal", mfe=4.0, mae=-1.0, @@ -51,7 +52,7 @@ def sample_trades() -> list[Trade]: pnl_percent=1.67, bars_held=36, fees=8.0, - slippage=3.0, + exit_slippage=3.0, exit_reason="stop_loss", mfe=2.5, mae=-0.5, @@ -85,6 +86,7 @@ def sample_fills() -> list[Fill]: timestamp=base_time, quantity=100.0, price=150.0, + rebalance_id="rebalance-1", commission=5.0, slippage=2.5, ), @@ -95,23 +97,61 @@ def sample_fills() -> list[Fill]: timestamp=base_time + timedelta(hours=2), quantity=100.0, price=155.0, + rebalance_id="rebalance-1", commission=5.0, slippage=2.5, ), ] +@pytest.fixture +def sample_predictions() -> pl.DataFrame: + """Create sample raw predictions used by a backtest.""" + base_time = datetime(2024, 1, 1, 10, 0) + return pl.DataFrame( + { + "timestamp": [ + base_time, + base_time, + base_time + timedelta(hours=1), + base_time + timedelta(hours=1), + ], + "asset": ["AAPL", "MSFT", "AAPL", "MSFT"], + "prediction": [0.8, -0.2, 0.6, 0.1], + "confidence": [0.9, 0.4, 0.85, 0.55], + } + ) + + +@pytest.fixture +def sample_portfolio_state() -> list[tuple[datetime, float, float, float, float, int]]: + """Create sample portfolio state snapshots for testing.""" + base_time = datetime(2024, 1, 1, 10, 0) + return [ + (base_time, 100000.0, 85000.0, 15000.0, 15000.0, 1), + (base_time + timedelta(hours=1), 100100.0, 85000.0, 15100.0, 15100.0, 1), + (base_time + timedelta(hours=2), 100500.0, 100500.0, 0.0, 0.0, 0), + (base_time + timedelta(hours=3), 100400.0, 100400.0, 0.0, 0.0, 0), + (base_time + timedelta(hours=4), 100800.0, 100800.0, 0.0, 0.0, 0), + (base_time + timedelta(hours=5), 100750.0, 100750.0, 0.0, 0.0, 0), + ] + + @pytest.fixture def backtest_result( sample_trades: list[Trade], sample_equity_curve: list[tuple[datetime, float]], sample_fills: list[Fill], + sample_predictions: pl.DataFrame, + sample_portfolio_state: list[tuple[datetime, float, float, float, float, int]], ) -> BacktestResult: """Create BacktestResult for testing.""" return BacktestResult( trades=sample_trades, equity_curve=sample_equity_curve, fills=sample_fills, + predictions=sample_predictions, + portfolio_state=sample_portfolio_state, metrics={ "final_value": 100750.0, "total_return_pct": 0.75, @@ -142,11 +182,21 @@ def test_trades_dataframe_basic(self, backtest_result: BacktestResult): "pnl_percent", "bars_held", "fees", - "slippage", + "exit_slippage", "mfe", "mae", "entry_slippage", "multiplier", + "entry_quote_mid_price", + "entry_bid_price", + "entry_ask_price", + "entry_spread", + "entry_available_size", + "exit_quote_mid_price", + "exit_bid_price", + "exit_ask_price", + "exit_spread", + "exit_available_size", "gross_pnl", "net_return", "total_slippage_cost", @@ -256,6 +306,109 @@ def test_equity_dataframe_caching(self, backtest_result: BacktestResult): assert df1 is df2 +class TestBacktestResultFillsDataFrame: + """Tests for to_fills_dataframe().""" + + def test_fills_dataframe_basic(self, backtest_result: BacktestResult): + df = backtest_result.to_fills_dataframe() + + assert isinstance(df, pl.DataFrame) + assert len(df) == 2 + assert df.columns == [ + "order_id", + "rebalance_id", + "asset", + "side", + "quantity", + "price", + "timestamp", + "commission", + "slippage", + "order_type", + "limit_price", + "stop_price", + "price_source", + "reference_price", + "quote_mid_price", + "bid_price", + "ask_price", + "spread", + "bid_size", + "ask_size", + "available_size", + ] + assert df["rebalance_id"].to_list() == ["rebalance-1", "rebalance-1"] + + def test_fills_dataframe_empty(self): + result = BacktestResult(trades=[], equity_curve=[], fills=[], metrics={}) + df = result.to_fills_dataframe() + + assert isinstance(df, pl.DataFrame) + assert len(df) == 0 + assert "order_id" in df.columns + + +class TestBacktestResultPredictionsDataFrame: + """Tests for to_predictions_dataframe().""" + + def test_predictions_dataframe_basic(self, backtest_result: BacktestResult): + df = backtest_result.to_predictions_dataframe() + + assert isinstance(df, pl.DataFrame) + assert len(df) == 4 + assert df.columns == ["timestamp", "asset", "prediction", "confidence"] + assert df["prediction"].to_list() == [0.8, -0.2, 0.6, 0.1] + + def test_predictions_dataframe_empty_when_absent(self): + result = BacktestResult(trades=[], equity_curve=[], fills=[], metrics={}) + df = result.to_predictions_dataframe() + + assert isinstance(df, pl.DataFrame) + assert len(df.columns) == 0 + + +class TestBacktestResultPortfolioStateDataFrame: + """Tests for to_portfolio_state_dataframe().""" + + def test_portfolio_state_dataframe_basic(self, backtest_result: BacktestResult): + df = backtest_result.to_portfolio_state_dataframe() + + assert isinstance(df, pl.DataFrame) + assert len(df) == 6 + assert df.columns == [ + "timestamp", + "equity", + "cash", + "gross_exposure", + "net_exposure", + "open_positions", + ] + + def test_portfolio_state_dataframe_values(self, backtest_result: BacktestResult): + df = backtest_result.to_portfolio_state_dataframe() + + assert df["equity"][0] == 100000.0 + assert df["cash"][0] == 85000.0 + assert df["gross_exposure"][0] == 15000.0 + assert df["net_exposure"][2] == 0.0 + assert df["open_positions"][0] == 1 + assert df["open_positions"][2] == 0 + + def test_portfolio_state_dataframe_empty(self): + result = BacktestResult(trades=[], equity_curve=[], fills=[], metrics={}) + df = result.to_portfolio_state_dataframe() + + assert isinstance(df, pl.DataFrame) + assert len(df) == 0 + assert "gross_exposure" in df.columns + + def test_portfolio_state_dataframe_caching(self, backtest_result: BacktestResult): + df1 = backtest_result.to_portfolio_state_dataframe() + df2 = backtest_result.to_portfolio_state_dataframe() + + assert df1 is df2 + + class TestBacktestResultDailyPnL: """Tests for to_daily_pnl().""" @@ -303,6 +456,32 @@ def test_daily_pnl_multi_day(self): assert df["date"][1] == datetime(2024, 1, 2).date() assert df["date"][2] == datetime(2024, 1, 3).date() + def test_daily_returns_auto_aligns_using_feed_session_metadata(self): + """Auto alignment should follow feed session metadata, not just calendar name.""" + from ml4t.backtest.config import BacktestConfig + + result = BacktestResult( + trades=[], + equity_curve=[ + (datetime(2024, 1, 1, 18, 0), 100000.0), + (datetime(2024, 1, 2, 10, 0), 101000.0), + ], + fills=[], + metrics={}, + config=BacktestConfig( + calendar="NYSE", + timezone="America/New_York", + feed_spec=FeedSpec( + calendar="NYSE", + session_start_time="17:00", + timestamp_semantics="event_time", + ), + ), + ) + + assert len(result.to_daily_pnl()) == 2 + assert len(result.to_daily_returns()) == 1 + class TestBacktestResultReturnsSeries: """Tests for to_returns_series().""" @@ -348,6 +527,8 @@ def test_to_dict_basic(self, backtest_result: BacktestResult): assert "trades" in d assert "equity_curve" in d assert "fills" in d + assert "predictions" in d + assert "portfolio_state" in d assert "sharpe" in d def test_repr(self, backtest_result: BacktestResult): @@ -377,24 +558,77 @@ def test_to_dict_includes_optional_analytics(self): assert "equity" in d assert "trade_analyzer" in d + def test_to_spec_dict_returns_resolved_config_snapshot(self): + """Test resolved config snapshot contains defaults, feed, metadata, and runtime window.""" + config = BacktestConfig( + initial_cash=250000.0, + commission_rate=0.0025, + feed_spec=FeedSpec( + timestamp_col="time", + entity_col="ticker", + price_col="mid_price", + calendar="NYSE", + timezone="America/New_York", + data_frequency="minute", + ), + metadata={ + "strategy_id": "topk_monthly_v2", + "signals_path": "/tmp/preds.parquet", + }, + ) + result = BacktestResult( + trades=[], + equity_curve=[ + (datetime(2024, 1, 2, 9, 30), 100000.0), + (datetime(2024, 1, 31, 16, 0), 101500.0), + ], + fills=[], + metrics={}, + config=config, + ) + + spec = result.to_spec_dict() + + assert spec["version"] == 1 + assert isinstance(spec["library_version"], str) + assert spec["config"]["cash"]["initial"] == 250000.0 + assert spec["config"]["commission"]["rate"] == 0.0025 + assert spec["config"]["feed"]["timestamp_col"] == "time" + assert spec["config"]["feed"]["entity_col"] == "ticker" + assert spec["config"]["feed"]["price_col"] == "mid_price" + assert spec["config"]["metadata"]["strategy_id"] == "topk_monthly_v2" + assert spec["window"]["start"] == "2024-01-02T09:30:00" + assert spec["window"]["end"] == "2024-01-31T16:00:00" + class TestBacktestResultParquet: """Tests for Parquet serialization.""" def test_to_parquet_basic(self, backtest_result: BacktestResult): """Test basic Parquet export.""" + backtest_result.config = BacktestConfig(metadata={"strategy_id": "default_export"}) with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "test_backtest" written = backtest_result.to_parquet(path) assert "trades" in written + assert "fills" in written + assert "predictions" in written assert "equity" in written + assert "portfolio_state" in written assert "daily_pnl" in written assert "metrics" in written + assert "config" in written + assert "spec" in written assert written["trades"].exists() + assert written["fills"].exists() + assert written["predictions"].exists() assert written["equity"].exists() + assert written["portfolio_state"].exists() assert written["metrics"].exists() + assert written["config"].exists() + assert written["spec"].exists() def test_to_parquet_selective(self, backtest_result: BacktestResult): """Test selective Parquet export.""" @@ -404,7 +638,10 @@ def test_to_parquet_selective(self, backtest_result: BacktestResult): assert "trades" in written assert "metrics" in written + assert "fills" not in written + assert "predictions" not in written assert "equity" not in written + assert "portfolio_state" not in written def test_to_parquet_config_write_failure_is_non_fatal(self): """Test config export failure is swallowed (ImportError/AttributeError path).""" @@ -425,6 +662,55 @@ def to_dict(self): written = result.to_parquet(path, include=["config"]) assert "config" not in written + def test_to_parquet_writes_spec_snapshot(self): + """Test resolved runtime spec export.""" + config = BacktestConfig( + initial_cash=75000.0, + metadata={"strategy_id": "demo"}, + ) + result = BacktestResult( + trades=[], + equity_curve=[(datetime(2024, 1, 1, 10, 0), 75000.0)], + fills=[], + metrics={}, + config=config, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "test_backtest" + written = result.to_parquet(path, include=["spec"]) + + assert "spec" in written + assert written["spec"].exists() + + import yaml + + with open(written["spec"]) as f: + spec = yaml.safe_load(f) + + assert spec["config"]["cash"]["initial"] == 75000.0 + assert spec["config"]["metadata"]["strategy_id"] == "demo" + assert spec["window"]["start"] == "2024-01-01T10:00:00" + + def test_to_parquet_writes_predictions_snapshot(self, sample_predictions: pl.DataFrame): + result = BacktestResult( + trades=[], + equity_curve=[], + fills=[], + predictions=sample_predictions, + metrics={}, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "test_backtest" + written = result.to_parquet(path, include=["predictions"]) + + assert "predictions" in written + assert written["predictions"].exists() + + loaded = pl.read_parquet(written["predictions"]) + assert loaded.equals(sample_predictions) + def test_from_parquet_roundtrip(self, backtest_result: BacktestResult): """Test Parquet save and load roundtrip.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -434,7 +720,12 @@ def test_from_parquet_roundtrip(self, backtest_result: BacktestResult): loaded = BacktestResult.from_parquet(path) assert len(loaded.trades) == len(backtest_result.trades) + assert len(loaded.fills) == len(backtest_result.fills) + assert loaded.predictions is not None + assert loaded.predictions.equals(backtest_result.predictions) assert len(loaded.equity_curve) == len(backtest_result.equity_curve) + assert len(loaded.portfolio_state) == len(backtest_result.portfolio_state) + assert loaded.fills[0].rebalance_id == "rebalance-1" assert loaded.metrics["sharpe"] == backtest_result.metrics["sharpe"] def test_to_parquet_compression(self, backtest_result: BacktestResult): @@ -468,6 +759,30 @@ def test_from_parquet_invalid_config_is_non_fatal(self, monkeypatch): loaded = BacktestResult.from_parquet(path) assert loaded.config is None + def test_from_parquet_loads_config_from_spec_when_config_yaml_missing(self): + """Test spec.yaml fallback restores replayable config.""" + config = BacktestConfig( + initial_cash=82000.0, + metadata={"strategy_id": "spec_fallback"}, + ) + result = BacktestResult( + trades=[], + equity_curve=[(datetime(2024, 2, 1, 10, 0), 82000.0)], + fills=[], + metrics={}, + config=config, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "test_backtest" + result.to_parquet(path, include=["spec"]) + + loaded = BacktestResult.from_parquet(path) + + assert loaded.config is not None + assert loaded.config.initial_cash == 82000.0 + assert loaded.config.metadata["strategy_id"] == "spec_fallback" + def test_metrics_json_serialization(self, backtest_result: BacktestResult): """Test metrics JSON contains only serializable values.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -687,114 +1002,6 @@ def test_enrich_from_to_trades_dataframe(self, backtest_result: BacktestResult): assert "symbol" in enriched.columns -class TestBacktestResultMetrics: - """Tests for annualization and compute_metrics branches.""" - - def test_get_annualization_factor_known_and_fallback(self): - assert _get_annualization_factor("nyse") == 252 - assert _get_annualization_factor("crypto") == 365 - assert _get_annualization_factor(None) == 252 - assert _get_annualization_factor("not_a_real_calendar") == 252 - - def test_compute_metrics_import_error(self, backtest_result: BacktestResult, monkeypatch): - def _raise(_name: str): - raise ImportError("nope") - - monkeypatch.setattr("importlib.import_module", _raise) - with pytest.raises(ImportError, match="ml4t-diagnostic is required"): - backtest_result.compute_metrics() - - def test_compute_metrics_with_empty_inputs(self, monkeypatch): - def _sharpe(_arr, annualization_factor): - return 1.23 + (annualization_factor * 0.0) - - def _sortino(_arr, annualization_factor): - return 2.34 + (annualization_factor * 0.0) - - diag = SimpleNamespace( - sharpe_ratio=_sharpe, - sortino_ratio=_sortino, - ) - monkeypatch.setattr("importlib.import_module", lambda _name: diag) - - result = BacktestResult(trades=[], equity_curve=[], fills=[], metrics={}) - metrics = result.compute_metrics(calendar="NYSE") - - assert metrics["sharpe_ratio"] == 0.0 - assert metrics["sortino_ratio"] == 0.0 - assert metrics["max_drawdown"] == 0.0 - assert metrics["total_return"] == 0.0 - assert metrics["cagr"] == 0.0 - assert metrics["calmar_ratio"] == 0.0 - assert metrics["num_trades"] == 0 - - def test_compute_metrics_with_trade_analyzer( - self, backtest_result: BacktestResult, monkeypatch - ): - def _sharpe(_arr, annualization_factor): - return 1.11 + (annualization_factor * 0.0) - - def _sortino(_arr, annualization_factor): - return 1.22 + (annualization_factor * 0.0) - - diag = SimpleNamespace( - sharpe_ratio=_sharpe, - sortino_ratio=_sortino, - ) - monkeypatch.setattr("importlib.import_module", lambda _name: diag) - backtest_result.trade_analyzer = SimpleNamespace( - num_trades=7, - win_rate=0.57, - profit_factor=1.8, - expectancy=0.012, - avg_trade=0.009, - avg_win=0.021, - avg_loss=-0.008, - total_fees=34.0, - ) - - metrics = backtest_result.compute_metrics(calendar="NYSE") - assert metrics["num_trades"] == 7 - assert metrics["total_fees"] == 34.0 - - def test_to_portfolio_analysis_cme_uses_session_aligned_returns(self, monkeypatch): - """Portfolio analysis should use the same daily alignment logic as daily returns.""" - import builtins - from datetime import UTC - - from ml4t.backtest.config import BacktestConfig - - class FakePortfolioAnalysis: - def __init__(self, returns, **kwargs): - self.returns = returns - self.kwargs = kwargs - - real_import = builtins.__import__ - - def _import(name, *args, **kwargs): - if name == "ml4t.diagnostic.evaluation": - return SimpleNamespace(PortfolioAnalysis=FakePortfolioAnalysis) - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", _import) - - result = BacktestResult( - trades=[], - equity_curve=[ - (datetime(2025, 1, 6, 22, 30, tzinfo=UTC), 100.0), - (datetime(2025, 1, 6, 23, 30, tzinfo=UTC), 110.0), - (datetime(2025, 1, 7, 21, 0, tzinfo=UTC), 120.0), - (datetime(2025, 1, 7, 23, 30, tzinfo=UTC), 130.0), - ], - fills=[], - metrics={}, - config=BacktestConfig(calendar="CME_Equity", timezone="America/Chicago"), - ) - expected = result.to_daily_returns(calendar="CME_Equity").to_list() - analysis = result.to_portfolio_analysis(calendar="CME_Equity") - assert list(analysis.returns) == pytest.approx(expected) - - class TestBacktestResultSchemas: """Tests for schema definitions.""" @@ -816,70 +1023,3 @@ def test_equity_schema(self): assert schema["equity"] == pl.Float64() assert schema["return"] == pl.Float64() assert schema["drawdown"] == pl.Float64() - - -class TestBacktestResultTearsheet: - """Tests for tearsheet import-error handling.""" - - def test_to_tearsheet_import_error(self, monkeypatch): - """Test to_tearsheet raises helpful ImportError when diagnostic is unavailable.""" - import builtins - - real_import = builtins.__import__ - - def _raising_import(name, *args, **kwargs): - if name == "ml4t.diagnostic.visualization.backtest": - raise ImportError("diagnostic missing") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", _raising_import) - - result = BacktestResult(trades=[], equity_curve=[], fills=[], metrics={}) - with pytest.raises( - ImportError, match="ml4t-diagnostic is required for tearsheet generation" - ): - result.to_tearsheet() - - def test_to_tearsheet_fallback_uses_total_slippage_cost(self, monkeypatch): - """Fallback metric extraction should report slippage in dollar cost units.""" - import builtins - - captured: dict[str, object] = {} - - def _generate_backtest_tearsheet(**kwargs): - captured.update(kwargs) - return "" - - real_import = builtins.__import__ - - def _import(name, *args, **kwargs): - if name == "ml4t.diagnostic.visualization.backtest": - return SimpleNamespace(generate_backtest_tearsheet=_generate_backtest_tearsheet) - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", _import) - - trade = Trade( - symbol="AAPL", - entry_time=datetime(2024, 1, 1, 9, 30), - exit_time=datetime(2024, 1, 2, 9, 30), - entry_price=100.0, - exit_price=101.0, - quantity=10.0, - pnl=10.0, - pnl_percent=0.01, - bars_held=1, - slippage=0.20, - entry_slippage=0.10, - ) - result = BacktestResult( - trades=[trade], - equity_curve=[(datetime(2024, 1, 1, 9, 30), 100000.0)], - fills=[], - metrics={}, - ) - result.to_tearsheet() - - metrics = captured["metrics"] - assert isinstance(metrics, dict) - assert metrics["total_slippage"] == pytest.approx(trade.total_slippage_cost) diff --git a/tests/test_strategy_templates.py b/tests/test_strategy_templates.py index e3e02e10..361ee1a9 100644 --- a/tests/test_strategy_templates.py +++ b/tests/test_strategy_templates.py @@ -6,12 +6,14 @@ import polars as pl from ml4t.backtest import BacktestConfig, DataFeed, Engine +from ml4t.backtest.execution.schedule import RebalanceSchedule from ml4t.backtest.strategies import ( LongShortStrategy, MeanReversionStrategy, MomentumStrategy, SignalFollowingStrategy, ) +from ml4t.data.artifacts.market_data import FeedSpec def make_price_data( @@ -294,6 +296,112 @@ def test_ranking(self): assert "E" in short_assets assert "D" in short_assets + def test_schedule_overrides_bar_frequency(self): + """Explicit schedules should override fixed bar-count rebalancing.""" + + class ScheduledLongShort(LongShortStrategy): + signal_column = "signal" + long_count = 1 + short_count = 1 + position_size = 0.1 + rebalance_frequency = 999 + rebalance_schedule = RebalanceSchedule.explicit_timestamps( + [datetime(2023, 1, 2), datetime(2023, 1, 5)] + ) + + rows = [] + for i in range(6): + timestamp = datetime(2023, 1, 1) + timedelta(days=i) + if timestamp == datetime(2023, 1, 2): + signals = {"A": 2.0, "B": -2.0, "C": 0.0} + elif timestamp == datetime(2023, 1, 5): + signals = {"A": -1.0, "B": 0.0, "C": 2.0} + else: + signals = {"A": 1.0, "B": -1.0, "C": 0.0} + rows.extend( + [ + {"timestamp": timestamp, "asset": "A", "close": 100.0, "signal": signals["A"]}, + {"timestamp": timestamp, "asset": "B", "close": 100.0, "signal": signals["B"]}, + {"timestamp": timestamp, "asset": "C", "close": 100.0, "signal": signals["C"]}, + ] + ) + + df = pl.DataFrame(rows) + feed = DataFeed( + prices_df=df, + signals_df=df.select(["timestamp", "asset", "signal"]), + ) + + engine = Engine.from_config( + feed, + ScheduledLongShort(), + BacktestConfig.from_preset("fast"), + ) + result = engine.run() + + entry_days = sorted({trade.entry_time.date() for trade in result.trades}) + assert entry_days == [datetime(2023, 1, 2).date(), datetime(2023, 1, 5).date()] + + def test_weekly_schedule_uses_feed_session_labels(self): + """Weekly schedules on daily labeled bars should rebalance on labeled Fridays.""" + + class WeeklyLongShort(LongShortStrategy): + signal_column = "signal" + long_count = 1 + short_count = 1 + position_size = 0.1 + rebalance_frequency = 999 + rebalance_schedule = RebalanceSchedule.weekly() + + rows = [] + dates = [ + datetime(2024, 1, 1), + datetime(2024, 1, 2), + datetime(2024, 1, 3), + datetime(2024, 1, 4), + datetime(2024, 1, 5), + datetime(2024, 1, 8), + datetime(2024, 1, 9), + datetime(2024, 1, 10), + datetime(2024, 1, 11), + datetime(2024, 1, 12), + ] + for timestamp in dates: + if timestamp == datetime(2024, 1, 5): + signals = {"A": 2.0, "B": -2.0, "C": 0.0} + elif timestamp == datetime(2024, 1, 12): + signals = {"A": -1.0, "B": 0.0, "C": 2.0} + else: + signals = {"A": 1.0, "B": -1.0, "C": 0.0} + rows.extend( + [ + {"timestamp": timestamp, "asset": "A", "close": 100.0, "signal": signals["A"]}, + {"timestamp": timestamp, "asset": "B", "close": 100.0, "signal": signals["B"]}, + {"timestamp": timestamp, "asset": "C", "close": 100.0, "signal": signals["C"]}, + ] + ) + + df = pl.DataFrame(rows) + feed = DataFeed( + prices_df=df, + signals_df=df.select(["timestamp", "asset", "signal"]), + feed_spec=FeedSpec( + calendar="NYSE", + data_frequency="daily", + timestamp_semantics="session_label", + ), + ) + + engine = Engine.from_config( + feed, + WeeklyLongShort(), + BacktestConfig.from_preset("fast"), + ) + result = engine.run() + + entry_days = sorted({trade.entry_time.date() for trade in result.trades}) + assert entry_days == [datetime(2024, 1, 5).date(), datetime(2024, 1, 12).date()] + class TestStrategyImports: """Test strategy template import paths.""" diff --git a/tests/test_trade_cost_decomposition.py b/tests/test_trade_cost_decomposition.py index 4dfdc6b0..c66aca96 100644 --- a/tests/test_trade_cost_decomposition.py +++ b/tests/test_trade_cost_decomposition.py @@ -138,7 +138,7 @@ def long_trade(self): pnl_percent=0.10, bars_held=4, fees=20.0, - slippage=0.05, + exit_slippage=0.05, entry_slippage=0.03, multiplier=1.0, ) @@ -156,7 +156,7 @@ def short_trade(self): pnl_percent=0.10, bars_held=4, fees=20.0, - slippage=0.05, + exit_slippage=0.05, entry_slippage=0.03, multiplier=1.0, ) @@ -174,7 +174,7 @@ def futures_trade(self): pnl_percent=0.002, # 10/5000 bars_held=4, fees=9.0, - slippage=0.25, + exit_slippage=0.25, entry_slippage=0.25, multiplier=50.0, ) @@ -357,7 +357,7 @@ def test_trade_multiplier_default(self): class TestParquetRoundtrip: def test_new_fields_survive_roundtrip(self, tmp_path): - """entry_slippage and multiplier survive write/read.""" + """entry_slippage, exit_slippage, and multiplier survive write/read.""" from ml4t.backtest.result import BacktestResult trades = [ @@ -372,7 +372,7 @@ def test_new_fields_survive_roundtrip(self, tmp_path): pnl_percent=0.002, bars_held=1, fees=9.0, - slippage=0.25, + exit_slippage=0.25, entry_slippage=0.25, multiplier=50.0, ) @@ -389,6 +389,7 @@ def test_new_fields_survive_roundtrip(self, tmp_path): assert len(loaded.trades) == 1 t = loaded.trades[0] + assert t.exit_slippage == pytest.approx(0.25) assert t.entry_slippage == pytest.approx(0.25) assert t.multiplier == pytest.approx(50.0) assert t.gross_pnl == pytest.approx(1000.0) @@ -411,7 +412,7 @@ def test_backward_compat_missing_fields(self, tmp_path): "pnl_percent": [0.10], "bars_held": [1], "fees": [0.0], - "slippage": [0.0], + "slippage": [0.12], "mfe": [0.12], "mae": [-0.03], "exit_reason": ["signal"], @@ -428,6 +429,7 @@ def test_backward_compat_missing_fields(self, tmp_path): loaded = BacktestResult.from_parquet(result_dir) assert len(loaded.trades) == 1 t = loaded.trades[0] + assert t.exit_slippage == pytest.approx(0.12) # Legacy slippage column maps through assert t.entry_slippage == 0.0 # Default assert t.multiplier == 1.0 # Default assert t.gross_pnl == pytest.approx(1000.0) diff --git a/uv.lock b/uv.lock index 51de8e6c..c2dd1949 100644 --- a/uv.lock +++ b/uv.lock @@ -7,12 +7,12 @@ resolution-markers = [ ] [[package]] -name = "alabaster" -version = "1.0.0" +name = "aiofiles" +version = "25.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, ] [[package]] @@ -39,36 +39,16 @@ wheels = [ ] [[package]] -name = "arch" -version = "8.0.0" +name = "anyio" +version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "scipy" }, - { name = "statsmodels" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/50/f8be4b21db5eb0490aef82b592d105baac957f601805ee7fe5b9182405b2/arch-8.0.0.tar.gz", hash = "sha256:5e9895c2354b9475aff50797ff2191dc64dc5f79602baf0c9321310fb864b637", size = 872623, upload-time = "2025-10-21T08:55:52.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/6e/b4379d1dee984f4a51afad9bfb49a3079ae196faf0bb834b7b5ad8e5ec6a/arch-8.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:268dfe386f8c64a1973374bc0425bdf0c7c2250c2bfd7238d98bae701827ec2b", size = 942557, upload-time = "2025-10-21T08:45:19.825Z" }, - { url = "https://files.pythonhosted.org/packages/8d/54/ab79d924327497fddb462ce51216d193e374ad2295b1003542802ed9a021/arch-8.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f4341b22279d82d0300ebd54d1d5f80324f31fc017c8138f47e810bdb81d753", size = 932106, upload-time = "2025-10-21T08:42:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d8/1d/82a772cbc8d64a804438a618f766574d3c87c888342240465761fdba9dec/arch-8.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e551820a0640736c9e9b8fa10ce50e7ae4f31e570ec229c308a3b46aaf8242a7", size = 964602, upload-time = "2025-10-21T09:13:26.715Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d3/da7d55f51bb31a10d1b4a01a22ec0180265a5afeed0d99bd4d0c7b3a61e1/arch-8.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13cbf04d45ecbee7578704a232f897cd02794d845f877158fb2838e6fb637887", size = 981331, upload-time = "2025-10-21T09:13:28.013Z" }, - { url = "https://files.pythonhosted.org/packages/db/be/b44592be8f7926e04f2646206ef83cd68f40e948465fff651b739412146a/arch-8.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fab6e25763e1ef516d8b6c932ef1d0aac3ec812d6b501fc57d8269333d02ce86", size = 983205, upload-time = "2025-10-21T09:13:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/ef/86/612d45473d0865d41934b0580fa05e6aa48167b502d0136e8bd9dd5aa581/arch-8.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b13d261e0a681b3a8a2f9c588ab37a35500bca9f3bbcc6ca1ce2d999322651d", size = 930370, upload-time = "2025-10-21T08:42:14.667Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/78f84f9e486e173356931b2bfaf0c2a6d6923f1e8975045e3416ac388215/arch-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4320a9b3707e819a97f0b0a10847e529e2f765158c617a455987a34305018617", size = 940530, upload-time = "2025-10-21T08:46:04.53Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b8/73910773efffc2d35d2739be1bdc70dfcc58a83cff35c4d62e14acceca2b/arch-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b75bc7f4af4da5aca6cbcc52284564fcce8c974cf7d89d8b9777d8c16a228b0", size = 930359, upload-time = "2025-10-21T08:40:11.285Z" }, - { url = "https://files.pythonhosted.org/packages/1c/04/bdd65c773f6ce60cae50cb4f85bcf15dcbe687df75998966e5a236125182/arch-8.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aaefeb2b23276fe286fe554e7e69fea80daff4185ecdf9fc891ba1b2c1e49ad4", size = 964843, upload-time = "2025-10-21T09:13:48.538Z" }, - { url = "https://files.pythonhosted.org/packages/d6/40/7b7ac152c35c32da2a00ba3523ea84c358478b12ee7b3b2b6892e5b9d81b/arch-8.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c1f1d8abefab2f69f7fdbef08cc18c8377667d3b8d197a1f301d97f0e686cd2", size = 982864, upload-time = "2025-10-21T09:13:50.494Z" }, - { url = "https://files.pythonhosted.org/packages/80/40/d99c7d3e0a471d5e0f3e6b3ff1145db789e5a1c4e8fed25e8c22629e87fc/arch-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a985abc5367d225a6b346782dee9e7d84381c2af5ab795a6234aa1491c96f0bb", size = 985288, upload-time = "2025-10-21T09:13:52.243Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e7/2d15374129c03b6f97321f837190cb19863204dbcff289e23cc37f035c96/arch-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:bd73bd2d811bcf0551443b6e0a10bc25af002e9eb146aff164897c70aac35e85", size = 929688, upload-time = "2025-10-21T08:42:06.529Z" }, - { url = "https://files.pythonhosted.org/packages/96/37/8d9ec002ec3e750f3ea2af42b67a1e3cf3a82523b556fd8d10d1f34a085a/arch-8.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8015f7bdcc14800dc2dc2acb01dffebddebf587df4aa27f62671e809ccfdefb1", size = 940799, upload-time = "2025-10-21T08:49:28.848Z" }, - { url = "https://files.pythonhosted.org/packages/70/c8/533ad2ef4277d2f6c95e8038088de2d80c6a41137c7d23e00b08425e2c39/arch-8.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a5a2ee20c5a0d88eda12894d8fbb8320b7bfe3436c2e40fa16da918db54eb5b4", size = 931745, upload-time = "2025-10-21T08:50:02.936Z" }, - { url = "https://files.pythonhosted.org/packages/af/8e/27bf8ef574c507fd984283acd8b33ff066c2ee4beea4b8af9eada23a20f4/arch-8.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82113ee290afac972f73ad6f61b4b4ebd57bf9a288c2df163f9b2bc1874f89f3", size = 967633, upload-time = "2025-10-21T09:25:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/22/11/8a3b956a532b26fe4f325d9829b09eba725eb25a3e89d568673e2015beca/arch-8.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:563bea8e594f712a38ec2186f6dcd0d55a73f1c203bd228958cad495d9d471f1", size = 983273, upload-time = "2025-10-21T09:25:17.695Z" }, - { url = "https://files.pythonhosted.org/packages/95/99/40ca7262d2cc5d74a7b8be10e8a254e0063969edb50959c489bad8d2adb4/arch-8.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:119766cdb3ac9ebdad4077dcf8238fb2a4554ba3c6503bd4431161f43923357e", size = 985658, upload-time = "2025-10-21T09:25:19.375Z" }, - { url = "https://files.pythonhosted.org/packages/0a/d1/14d3dab7283ea68a4e2d17be62d854af6df7e3d6f1b998a87d5be3ed8aed/arch-8.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:4849380bb831a1dc09891cd424f6623f163bde2403c66e376f9d0b5f8c1791c5", size = 934414, upload-time = "2025-10-21T08:44:28.636Z" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] @@ -81,21 +61,26 @@ wheels = [ ] [[package]] -name = "attrs" -version = "25.4.0" +name = "babel" +version = "2.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] [[package]] -name = "babel" -version = "2.17.0" +name = "backrefs" +version = "6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602, upload-time = "2026-02-16T19:10:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, ] [[package]] @@ -122,36 +107,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/1c/2a41f1c2c318639bfcd68fa28e231fa137fe077cd54624e1ebe6e51da78c/bcolz_zipline-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a0752e04702b35f548963c70f743732da3f8a5785046b0629de931d4cbc0e79", size = 479440, upload-time = "2025-05-31T18:23:58.363Z" }, ] -[[package]] -name = "beautifulsoup4" -version = "4.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, -] - -[[package]] -name = "bleach" -version = "6.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, -] - -[package.optional-dependencies] -css = [ - { name = "tinycss2" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -406,15 +361,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -689,15 +635,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - [[package]] name = "dill" version = "0.4.0" @@ -741,6 +678,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/54/23d8f6d36c66575b8d31e354c8bba2c857f9ca41e4b76388c6ca53938fd8/empyrical_reloaded-0.5.12-py3-none-any.whl", hash = "sha256:1bd8b53810c760ae5a12a03f2cefc6af0e905f854b71e7d99074b673abdd4fd6", size = 33048, upload-time = "2025-06-01T23:17:09.958Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "exchange-calendars" version = "4.11.3" @@ -776,15 +722,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] -[[package]] -name = "fastjsonschema" -version = "2.21.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, -] - [[package]] name = "filelock" version = "3.20.2" @@ -852,6 +789,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "greenlet" version = "3.3.0" @@ -887,6 +836,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "h5py" version = "3.15.1" @@ -922,6 +889,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544, upload-time = "2025-10-16T10:35:25.695Z" }, ] +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "hypothesis" version = "6.150.0" @@ -977,15 +985,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, ] -[[package]] -name = "imagesize" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, -] - [[package]] name = "importlib-metadata" version = "8.7.1" @@ -1170,71 +1169,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "jupyter-client" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/27/d10de45e8ad4ce872372c4a3a37b7b35b6b064f6f023a5c14ffcced4d59d/jupyter_client-8.7.0.tar.gz", hash = "sha256:3357212d9cbe01209e59190f67a3a7e1f387a4f4e88d1e0433ad84d7b262531d", size = 344691, upload-time = "2025-12-09T18:37:01.953Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/f5/fddaec430367be9d62a7ed125530e133bfd4a1c0350fe221149ee0f2b526/jupyter_client-8.7.0-py3-none-any.whl", hash = "sha256:3671a94fd25e62f5f2f554f5e95389c2294d89822378a5f2dd24353e1494a9e0", size = 106215, upload-time = "2025-12-09T18:37:00.024Z" }, -] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, -] - -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, -] - [[package]] name = "jupyterlab-widgets" version = "3.0.16" @@ -1422,6 +1356,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/a3/113410f7b2e61e9d6f13f1f17c584dbd08b5796e65d772ecd5b063fab3af/lru_dict-1.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff3af42922205620fdc920dcdf580c4c16b32c84a537a03b04b523e5c641a8a9", size = 15204, upload-time = "2025-11-02T10:01:56.06Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -1434,6 +1448,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1576,39 +1599,147 @@ wheels = [ ] [[package]] -name = "mdit-py-plugins" -version = "0.5.0" +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] -name = "mdurl" -version = "0.1.2" +name = "mkdocs-autorefs" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] [[package]] -name = "mistune" -version = "3.2.0" +name = "mkdocs-get-deps" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, ] [[package]] name = "ml4t-backtest" source = { editable = "." } dependencies = [ + { name = "ml4t-data" }, { name = "numpy" }, { name = "pandas" }, { name = "pandas-market-calendars" }, @@ -1627,9 +1758,9 @@ all = [ { name = "dash" }, { name = "hypothesis" }, { name = "matplotlib" }, - { name = "ml4t-diagnostic" }, - { name = "myst-parser" }, - { name = "nbsphinx" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, { name = "networkx" }, { name = "plotly" }, { name = "pre-commit" }, @@ -1640,9 +1771,6 @@ all = [ { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, - { name = "sphinx" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-rtd-theme" }, { name = "ty" }, ] comparison = [ @@ -1654,7 +1782,6 @@ comparison = [ ] dev = [ { name = "hypothesis" }, - { name = "ml4t-diagnostic" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1666,11 +1793,9 @@ dev = [ { name = "ty" }, ] docs = [ - { name = "myst-parser" }, - { name = "nbsphinx" }, - { name = "sphinx" }, - { name = "sphinx-autodoc-typehints" }, - { name = "sphinx-rtd-theme" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, ] viz = [ { name = "dash" }, @@ -1703,12 +1828,13 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.80.0" }, { name = "matplotlib", marker = "extra == 'all'", specifier = ">=3.7.0" }, { name = "matplotlib", marker = "extra == 'viz'", specifier = ">=3.7.0" }, - { name = "ml4t-diagnostic", marker = "extra == 'all'", editable = "../ml4t-diagnostic" }, - { name = "ml4t-diagnostic", marker = "extra == 'dev'", editable = "../ml4t-diagnostic" }, - { name = "myst-parser", marker = "extra == 'all'", specifier = ">=2.0.0" }, - { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0.0" }, - { name = "nbsphinx", marker = "extra == 'all'", specifier = ">=0.9.0" }, - { name = "nbsphinx", marker = "extra == 'docs'", specifier = ">=0.9.0" }, + { name = "mkdocs", marker = "extra == 'all'", specifier = ">=1.6,<2" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6,<2" }, + { name = "mkdocs-material", marker = "extra == 'all'", specifier = ">=9.5.0" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'all'", specifier = ">=0.24.0" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24.0" }, + { name = "ml4t-data", editable = "../ml4t-data" }, { name = "networkx", marker = "extra == 'advanced'", specifier = ">=3.0" }, { name = "networkx", marker = "extra == 'all'", specifier = ">=3.0" }, { name = "numpy", specifier = ">=1.24.0" }, @@ -1736,12 +1862,6 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.0" }, { name = "ruff", marker = "extra == 'all'", specifier = ">=0.8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, - { name = "sphinx", marker = "extra == 'all'", specifier = ">=7.0.0" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0.0" }, - { name = "sphinx-autodoc-typehints", marker = "extra == 'all'", specifier = ">=1.24.0" }, - { name = "sphinx-autodoc-typehints", marker = "extra == 'docs'", specifier = ">=1.24.0" }, - { name = "sphinx-rtd-theme", marker = "extra == 'all'", specifier = ">=1.3.0" }, - { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3.0" }, { name = "ty", marker = "extra == 'all'" }, { name = "ty", marker = "extra == 'dev'" }, { name = "vectorbt", marker = "extra == 'comparison'", specifier = ">=0.24.0" }, @@ -1764,102 +1884,117 @@ dev = [ ] [[package]] -name = "ml4t-diagnostic" -source = { editable = "../ml4t-diagnostic" } +name = "ml4t-data" +source = { editable = "../ml4t-data" } dependencies = [ - { name = "arch" }, - { name = "jinja2" }, - { name = "joblib" }, - { name = "numba" }, + { name = "aiofiles" }, + { name = "click" }, + { name = "filelock" }, + { name = "html5lib" }, + { name = "httpx" }, + { name = "lxml" }, { name = "numpy" }, + { name = "openpyxl" }, { name = "pandas" }, { name = "pandas-market-calendars" }, + { name = "platformdirs" }, { name = "polars" }, { name = "pyarrow" }, - { name = "pydantic" }, + { name = "pybreaker" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "pyyaml" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "shap" }, - { name = "statsmodels" }, - { name = "tqdm" }, + { name = "rich" }, + { name = "structlog" }, + { name = "tenacity" }, ] [package.metadata] requires-dist = [ - { name = "arch", specifier = ">=7.2.0" }, - { name = "cupy-cuda11x", marker = "extra == 'gpu'", specifier = ">=11.0.0" }, + { name = "aiofiles", specifier = ">=23.0.0" }, + { name = "click", specifier = ">=8.0.0" }, + { name = "cot-reports", marker = "extra == 'all'", specifier = ">=0.1.0" }, + { name = "cot-reports", marker = "extra == 'all-providers'", specifier = ">=0.1.0" }, + { name = "cot-reports", marker = "extra == 'cot'", specifier = ">=0.1.0" }, + { name = "databento", marker = "extra == 'all'", specifier = ">=0.38.0" }, + { name = "databento", marker = "extra == 'all-providers'", specifier = ">=0.38.0" }, + { name = "databento", marker = "extra == 'databento'", specifier = ">=0.38.0" }, + { name = "filelock", specifier = ">=3.19.1" }, + { name = "html5lib", specifier = ">=1.1" }, + { name = "httpx", specifier = ">=0.25.0" }, + { name = "hypothesis", marker = "extra == 'all'", specifier = ">=6.80.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.80.0" }, + { name = "ipdb", marker = "extra == 'all'", specifier = ">=0.13.0" }, { name = "ipdb", marker = "extra == 'dev'", specifier = ">=0.13.0" }, + { name = "ipython", marker = "extra == 'all'", specifier = ">=8.14.0" }, { name = "ipython", marker = "extra == 'dev'", specifier = ">=8.14.0" }, - { name = "jinja2", specifier = ">=3.1.0" }, - { name = "joblib", specifier = ">=1.3.0" }, - { name = "kaleido", marker = "extra == 'all'", specifier = ">=0.2.0" }, - { name = "kaleido", marker = "extra == 'viz'", specifier = ">=0.2.0" }, - { name = "lightgbm", marker = "extra == 'all'", specifier = ">=4.0.0" }, - { name = "lightgbm", marker = "extra == 'ml'", specifier = ">=4.0.0" }, - { name = "matplotlib", marker = "extra == 'all'", specifier = ">=3.7.0" }, - { name = "matplotlib", marker = "extra == 'viz'", specifier = ">=3.7.0" }, - { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.5.0" }, - { name = "mkdocs-gen-files", marker = "extra == 'docs'", specifier = ">=0.5.0" }, - { name = "mkdocs-literate-nav", marker = "extra == 'docs'", specifier = ">=0.6.0" }, + { name = "lxml", specifier = ">=6.0.2" }, + { name = "mkdocs", marker = "extra == 'all'", specifier = ">=1.6.0" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.0" }, + { name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'all'", specifier = ">=1.2.0" }, + { name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'docs'", specifier = ">=1.2.0" }, + { name = "mkdocs-material", marker = "extra == 'all'", specifier = ">=9.5.0" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'all'", specifier = ">=0.24.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24.0" }, - { name = "numba", specifier = ">=0.57.0" }, + { name = "mypy", marker = "extra == 'all'", specifier = ">=1.5.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.5.0" }, { name = "numpy", specifier = ">=1.24.0" }, + { name = "oandapyv20", marker = "extra == 'all'", specifier = ">=0.7.0" }, + { name = "oandapyv20", marker = "extra == 'all-providers'", specifier = ">=0.7.0" }, + { name = "oandapyv20", marker = "extra == 'dev'", specifier = ">=0.7.0" }, + { name = "oandapyv20", marker = "extra == 'oanda'", specifier = ">=0.7.0" }, + { name = "openpyxl", specifier = ">=3.1.5" }, { name = "pandas", specifier = ">=2.0.0" }, - { name = "pandas-market-calendars", specifier = ">=4.0.0" }, - { name = "plotly", marker = "extra == 'all'", specifier = ">=5.15.0" }, - { name = "plotly", marker = "extra == 'viz'", specifier = ">=5.15.0" }, + { name = "pandas-market-calendars", specifier = ">=4.3.0" }, + { name = "platformdirs", specifier = ">=4.0.0" }, { name = "polars", specifier = ">=0.20.0" }, + { name = "pre-commit", marker = "extra == 'all'", specifier = ">=3.3.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.3.0" }, { name = "pyarrow", specifier = ">=14.0.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pypdf", marker = "extra == 'all'", specifier = ">=5.0.0" }, - { name = "pypdf", marker = "extra == 'viz'", specifier = ">=5.0.0" }, + { name = "pybreaker", specifier = ">=1.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'all'", specifier = ">=7.4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, - { name = "pytest-benchmark", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'all'", specifier = ">=0.21.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'all'", specifier = ">=4.1.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-timeout", marker = "extra == 'all'", specifier = ">=2.1.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.1.0" }, + { name = "pytest-xdist", marker = "extra == 'all'", specifier = ">=3.3.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.3.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'all'", specifier = ">=0.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "scikit-learn", specifier = ">=1.3.0" }, - { name = "scipy", specifier = ">=1.10.0" }, - { name = "seaborn", marker = "extra == 'all'", specifier = ">=0.12.0" }, - { name = "seaborn", marker = "extra == 'viz'", specifier = ">=0.12.0" }, - { name = "shap", specifier = ">=0.41.0,<0.50.0" }, - { name = "statsmodels", specifier = ">=0.14.0" }, - { name = "streamlit", marker = "extra == 'all'", specifier = ">=1.28.0" }, - { name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.28.0" }, - { name = "tqdm", specifier = ">=4.66.0" }, - { name = "ty", marker = "extra == 'dev'" }, - { name = "wandb", marker = "extra == 'all'", specifier = ">=0.16.0" }, - { name = "wandb", marker = "extra == 'tracking'", specifier = ">=0.16.0" }, - { name = "xgboost", marker = "extra == 'all'", specifier = ">=2.0.0" }, - { name = "xgboost", marker = "extra == 'ml'", specifier = ">=2.0.0" }, + { name = "structlog", specifier = ">=23.0.0" }, + { name = "tenacity", specifier = ">=8.0.0" }, + { name = "xlsxwriter", marker = "extra == 'all'", specifier = ">=3.1.0" }, + { name = "xlsxwriter", marker = "extra == 'dev'", specifier = ">=3.1.0" }, + { name = "yfinance", marker = "extra == 'all'", specifier = ">=0.2.0" }, + { name = "yfinance", marker = "extra == 'all-providers'", specifier = ">=0.2.0" }, + { name = "yfinance", marker = "extra == 'yahoo'", specifier = ">=0.2.0" }, ] -provides-extras = ["all", "dashboard", "dev", "docs", "gpu", "ml", "tracking", "viz"] +provides-extras = ["all", "all-providers", "cot", "databento", "dev", "docs", "oanda", "yahoo"] [package.metadata.requires-dev] dev = [ + { name = "databento", specifier = ">=0.38.0" }, { name = "hypothesis", specifier = ">=6.80.0" }, - { name = "kaleido", specifier = ">=0.2.0" }, - { name = "lightgbm", specifier = ">=4.0.0" }, - { name = "matplotlib", specifier = ">=3.7.0" }, - { name = "plotly", specifier = ">=5.15.0" }, + { name = "oandapyv20", specifier = ">=0.7.0" }, { name = "pre-commit", specifier = ">=3.3.0" }, - { name = "pypdf", specifier = ">=5.0.0" }, { name = "pytest", specifier = ">=7.4.0" }, - { name = "pytest-benchmark", specifier = ">=4.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "pytest-timeout", specifier = ">=2.1.0" }, { name = "pytest-xdist", specifier = ">=3.3.0" }, { name = "ruff", specifier = ">=0.8.0" }, - { name = "seaborn", specifier = ">=0.12.0" }, { name = "twine", specifier = ">=6.0.0" }, { name = "ty" }, - { name = "xgboost", specifier = ">=2.0.0" }, + { name = "xlsxwriter", specifier = ">=3.1.0" }, + { name = "yfinance", specifier = ">=0.2.0" }, ] [[package]] @@ -1933,23 +2068,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "myst-parser" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "markdown-it-py" }, - { name = "mdit-py-plugins" }, - { name = "pyyaml" }, - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, -] - [[package]] name = "narwhals" version = "2.15.0" @@ -1959,78 +2077,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/2e/cf2ffeb386ac3763526151163ad7da9f1b586aac96d2b4f7de1eaebf0c61/narwhals-2.15.0-py3-none-any.whl", hash = "sha256:cbfe21ca19d260d9fd67f995ec75c44592d1f106933b03ddd375df7ac841f9d6", size = 432856, upload-time = "2026-01-06T08:10:11.511Z" }, ] -[[package]] -name = "nbclient" -version = "0.10.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "nbformat" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, -] - -[[package]] -name = "nbconvert" -version = "7.16.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bleach", extra = ["css"] }, - { name = "defusedxml" }, - { name = "jinja2" }, - { name = "jupyter-core" }, - { name = "jupyterlab-pygments" }, - { name = "markupsafe" }, - { name = "mistune" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "packaging" }, - { name = "pandocfilters" }, - { name = "pygments" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715, upload-time = "2025-01-28T09:29:14.724Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525, upload-time = "2025-01-28T09:29:12.551Z" }, -] - -[[package]] -name = "nbformat" -version = "5.10.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastjsonschema" }, - { name = "jsonschema" }, - { name = "jupyter-core" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, -] - -[[package]] -name = "nbsphinx" -version = "0.9.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "nbconvert" }, - { name = "nbformat" }, - { name = "sphinx" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e7/d1/82081750f8a78ad0399c6ed831d42623b891904e8e7b8a75878225cf1dce/nbsphinx-0.9.8.tar.gz", hash = "sha256:d0765908399a8ee2b57be7ae881cf2ea58d66db3af7bbf33e6eb48f83bea5495", size = 417469, upload-time = "2025-11-28T17:41:02.336Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/78/843bcf0cf31f88d2f8a9a063d2d80817b1901657d83d65b89b3aa835732e/nbsphinx-0.9.8-py3-none-any.whl", hash = "sha256:92d95ee91784e56bc633b60b767a6b6f23a0445f891e24641ce3c3f004759ccf", size = 31961, upload-time = "2025-11-28T17:41:00.796Z" }, -] - [[package]] name = "ndindex" version = "1.10.1" @@ -2231,6 +2277,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "osqp" version = "1.0.5" @@ -2265,6 +2323,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pandas" version = "2.3.3" @@ -2326,21 +2393,21 @@ wheels = [ ] [[package]] -name = "pandocfilters" -version = "1.5.1" +name = "parso" +version = "0.8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] [[package]] -name = "parso" -version = "0.8.5" +name = "pathspec" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] @@ -2597,6 +2664,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/03/f335d6c52b4a4761bcc83499789a1e2e16d9d201a58c327a9b5cc9a41bd9/pyarrow-22.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c34fe18094686194f204a3b1787a27456897d8a2d62caf84b61e8dfbc0252ae", size = 29185594, upload-time = "2025-10-24T10:09:53.111Z" }, ] +[[package]] +name = "pybreaker" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/89/fbf98e383f1ec6d117af2cd983efdb3eb7018b63834c427025764194cac2/pybreaker-1.4.1.tar.gz", hash = "sha256:8df2d245c73ba40c8242c56ffb4f12138fbadc23e296224740c2028ea9dc1178", size = 15555, upload-time = "2025-09-21T15:12:04.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/75/e64d3d40a741e2be21d69154f4e5c43a66f0c603c5ef11f49e01429a5932/pybreaker-1.4.1-py3-none-any.whl", hash = "sha256:b4dab4a05195b7f2a64a6c1a6c4ba7a96534ef56ea7210e6bcb59f28897160e0", size = 12915, upload-time = "2025-09-21T15:12:02.284Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -2692,6 +2768,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + [[package]] name = "pyfolio-reloaded" version = "0.9.9" @@ -2730,6 +2820,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/c8/f96208ade3ca4c23b372497d0788bcf0f2e0ff4310e5ee693366bc33fdf0/pyluach-2.3.0-py3-none-any.whl", hash = "sha256:4497b731aef59508b079dbf5f00bc5bf4329ac45090a6cd37b5a83756f0e69ab", size = 25914, upload-time = "2025-09-09T20:24:37.831Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, +] + [[package]] name = "pyparsing" version = "3.3.1" @@ -2832,6 +2935,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "python-interface" version = "1.6.1" @@ -2906,46 +3018,15 @@ wheels = [ ] [[package]] -name = "pyzmq" -version = "27.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "implementation_name == 'pypy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] @@ -2962,20 +3043,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - [[package]] name = "regex" version = "2025.11.3" @@ -3112,108 +3179,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] -[[package]] -name = "roman-numerals" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, -] - -[[package]] -name = "roman-numerals-py" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "roman-numerals" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, -] - [[package]] name = "ruff" version = "0.14.10" @@ -3399,38 +3364,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] -[[package]] -name = "shap" -version = "0.49.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cloudpickle" }, - { name = "numba" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "slicer" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/c6/9823a7f483aa9f3179fc359c10d22da9e418b1a7a3fc99a42b705d05e82a/shap-0.49.1.tar.gz", hash = "sha256:1114ecd804fff29f50d522ce6031082fcf42fe4a32fb1b5da233b2415d784c8c", size = 4084725, upload-time = "2025-10-14T10:04:49.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/7a/ccecf7a9158baa10bdc5146907c72dd5f85c762cb5f16cdc74d15cebb8a1/shap-0.49.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c652dc77f1fffe73f5a3def3356c5090e2e6401c261e4fe5329d83cb6251e772", size = 559663, upload-time = "2025-10-14T10:04:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c6/c43382d6c891fcf067d0a9f6d954351e3c7d330f4328c5816769b796aa27/shap-0.49.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23f1493205e648634680c8974e82e7f4b2e96ae3a7eca2251680172bd197ae9", size = 556265, upload-time = "2025-10-14T10:04:27.098Z" }, - { url = "https://files.pythonhosted.org/packages/c0/71/f7db7a5a2cedaa3ac52f58f453172d613be041bedd9509ce5b5cba2096a6/shap-0.49.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41147740c42821023e1b60185ce8be989656ccac266cc9490d7a8e3ad53c556a", size = 1022419, upload-time = "2025-10-14T10:04:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a4/96ca9a69dd669ff835ddef875c5dd8e07599103769417d3e9051fd97d470/shap-0.49.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef9952929d4a7e6763d2716938067bdad762217e3afb46cabfc15a62c012b364", size = 1027074, upload-time = "2025-10-14T10:04:30.2Z" }, - { url = "https://files.pythonhosted.org/packages/fc/9a/89ed1ac8beffe8ff8e09c12cb351bc3c79ddaadcc47ca6ee434d76e464d7/shap-0.49.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e823417eb0a01947cd9bd763bef2e534c5aef7a7c2952b1badfa969c7d59d3b3", size = 2088172, upload-time = "2025-10-14T10:04:31.725Z" }, - { url = "https://files.pythonhosted.org/packages/4a/28/11422c1c3aa022a06e76cbfa3267e1750cedc00c1e02ef1ccae9c88cd6f4/shap-0.49.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb28043decfec3f35f795421eb5a81545f629b7f60bbf7449cd2843a7f1c8cc6", size = 548036, upload-time = "2025-10-14T10:04:33.087Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5c/030bbfa19605ca4ad66a753d55e76aee5093be6748a6d33eda89e5613995/shap-0.49.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:333cd8e8c427badda92d5ada9e7aad1e3e1e8e7e0398da51a18b7ffb03514e45", size = 558604, upload-time = "2025-10-14T10:04:34.298Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7f/7e7b78e9fac6f891096fb6a59a6d4db23243b0af2369ae54e161f513c485/shap-0.49.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4faf61560f73a66f4f26bc027c91f8939201979c4db24949dca305ba0a2ad36", size = 555311, upload-time = "2025-10-14T10:04:35.582Z" }, - { url = "https://files.pythonhosted.org/packages/f2/be/25283a0f8c30deaf897b89a0dbfd490d330f6fc68caa6f19db6e130832e9/shap-0.49.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b440da658d9aee7711bf642c9b4826d81f588fb478cd9e90c068646e90f56669", size = 1016897, upload-time = "2025-10-14T10:04:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/91/a63e563f3dc8e134db12dd155a1a6ed5e0649f79fc8ac651aac1088e8652/shap-0.49.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8dfa5654eccf4d13dcb262a10314a4e0eb1060db842b2ef31e9fb0038168bc1", size = 1022476, upload-time = "2025-10-14T10:04:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/a2/89303c1f7eb206658bf9ec974dc6e69b0a6bd309cf5de0cfa8f92f5a8eb3/shap-0.49.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed3080030a6000d3737841c5770ed555b8a922b794fa0ba5aae1e45655eda1fa", size = 2087940, upload-time = "2025-10-14T10:04:39.497Z" }, - { url = "https://files.pythonhosted.org/packages/84/bd/0b9b3e19b9b8cda51463f8a749dc354eb9c87f42eddcbfdf742dceb3746b/shap-0.49.1-cp313-cp313-win_amd64.whl", hash = "sha256:6af779344c23b12a47063aab7fc135fefbdb5849233c1813f11dd8cf2fc73bea", size = 547806, upload-time = "2025-10-14T10:04:40.712Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -3440,24 +3373,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "slicer" -version = "0.0.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/f9/b4bce2825b39b57760b361e6131a3dacee3d8951c58cb97ad120abb90317/slicer-0.0.8.tar.gz", hash = "sha256:2e7553af73f0c0c2d355f4afcc3ecf97c6f2156fcf4593955c3f56cf6c4d6eb7", size = 14894, upload-time = "2024-03-09T23:35:26.826Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/81/9ef641ff4e12cbcca30e54e72fb0951a2ba195d0cda0ba4100e532d929db/slicer-0.0.8-py3-none-any.whl", hash = "sha256:6c206258543aecd010d497dc2eca9d2805860a0b3758673903456b7df7934dc3", size = 15251, upload-time = "2024-03-09T07:03:07.708Z" }, -] - -[[package]] -name = "snowballstemmer" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, -] - [[package]] name = "sortedcontainers" version = "2.4.0" @@ -3467,135 +3382,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] -[[package]] -name = "soupsieve" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, -] - -[[package]] -name = "sphinx" -version = "8.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, -] - -[[package]] -name = "sphinx-autodoc-typehints" -version = "3.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/34/4f/4fd5583678bb7dc8afa69e9b309e6a99ee8d79ad3a4728f4e52fd7cb37c7/sphinx_autodoc_typehints-3.5.2.tar.gz", hash = "sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb", size = 37839, upload-time = "2025-10-16T00:50:15.743Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/f2/9657c98a66973b7c35bfd48ba65d1922860de9598fbb535cd96e3f58a908/sphinx_autodoc_typehints-3.5.2-py3-none-any.whl", hash = "sha256:0accd043619f53c86705958e323b419e41667917045ac9215d7be1b493648d8c", size = 21184, upload-time = "2025-10-16T00:50:13.973Z" }, -] - -[[package]] -name = "sphinx-rtd-theme" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "sphinx" }, - { name = "sphinxcontrib-jquery" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, -] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, -] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, -] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, -] - -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, -] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, -] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, -] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.45" @@ -3678,6 +3464,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, ] +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + [[package]] name = "tables" version = "3.10.2" @@ -3705,24 +3500,21 @@ wheels = [ ] [[package]] -name = "threadpoolctl" -version = "3.6.0" +name = "tenacity" +version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] -name = "tinycss2" -version = "1.4.0" +name = "threadpoolctl" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] [[package]] @@ -3734,25 +3526,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, ] -[[package]] -name = "tornado" -version = "6.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, - { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, - { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, - { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, - { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, -] - [[package]] name = "tqdm" version = "4.67.1" @@ -3910,6 +3683,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" diff --git a/validation/benchmark_suite.py b/validation/benchmark_suite.py index 58fed9e0..2b6490d9 100644 --- a/validation/benchmark_suite.py +++ b/validation/benchmark_suite.py @@ -909,7 +909,7 @@ def benchmark_ml4t( Engine, Strategy, ) - from ml4t.backtest.config import CommissionModel, SlippageModel + from ml4t.backtest.config import CommissionType, SlippageType # Select profile by execution style default_profile = "backtrader" if execution_mode == "next_bar" else "vectorbt" @@ -1027,22 +1027,22 @@ def build_ml4t_config(no_costs: bool) -> BacktestConfig: cfg.initial_cash = config.initial_cash cfg.allow_short_selling = True if no_costs: - cfg.commission_model = CommissionModel.NONE + cfg.commission_type = CommissionType.NONE cfg.commission_rate = 0.0 - cfg.slippage_model = SlippageModel.NONE + cfg.slippage_type = SlippageType.NONE cfg.slippage_rate = 0.0 else: if config.commission_pct > 0: - cfg.commission_model = CommissionModel.PERCENTAGE + cfg.commission_type = CommissionType.PERCENTAGE cfg.commission_rate = config.commission_pct else: - cfg.commission_model = CommissionModel.NONE + cfg.commission_type = CommissionType.NONE cfg.commission_rate = 0.0 if config.slippage_pct > 0: - cfg.slippage_model = SlippageModel.PERCENTAGE + cfg.slippage_type = SlippageType.PERCENTAGE cfg.slippage_rate = config.slippage_pct else: - cfg.slippage_model = SlippageModel.NONE + cfg.slippage_type = SlippageType.NONE cfg.slippage_rate = 0.0 return cfg