Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,13 @@ jobs:
run: uv sync --dev

- name: Run tests
run: uv run pytest tests/ -v --tb=short -x --no-cov
run: uv run pytest tests/ -v --tb=short -x --no-cov -m "not benchmark"

- name: Run runtime regression benchmark
run: >-
uv run pytest
tests/benchmark/test_hotpath_benchmarks.py::test_optimized_feed_runtime_vs_legacy_baseline
-v --tb=short --no-cov

contracts:
name: Cross-Engine Contracts
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.10
rev: v0.14.10
hooks:
- id: ruff-format
- id: ruff
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,12 @@ python validation/run_all_correctness.py --framework zipline --scenarios 01,03,0

## Performance

Run the instrument-free runtime regression check with:

```bash
uv run pytest tests/benchmark/test_hotpath_benchmarks.py::test_optimized_feed_runtime_vs_legacy_baseline --no-cov
```

Benchmark on 250 assets x 20 years daily data (1.26M bars):

| Metric | Value |
Expand Down
29 changes: 29 additions & 0 deletions docs/user-guide/execution-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@ config = BacktestConfig(execution_mode=ExecutionMode.NEXT_BAR) # default
`OrderType.MOC` is the exception. In `NEXT_BAR` mode, `MOC` orders submitted during
`on_data()` still fill on the current bar, at the close, after strategy logic runs.

### Pre-Risk Callback State

`Strategy.on_before_risk()` runs after the current bar has been registered and immediately before
position rules are evaluated. The state visible to the callback depends on execution mode:

| Mode | Positions visible to `on_before_risk()` | Ordinary orders submitted there |
|------|------------------------------------------|----------------------------------|
| `NEXT_BAR` | All open positions, plus any fills from priced, policy-valid prior market entries submitted by this callback | Pending until the next bar |
| `SAME_BAR` | State before regular pending-order processing | Processed during the current bar |

In `SAME_BAR`, set `immediate_fill=True` when a position opened in `on_before_risk()` must receive
stop or trailing-rule evaluation on that same bar. In `NEXT_BAR`, newly opened positions start risk
evaluation on the following bar, matching ordinary next-bar entry timing. A prior market entry
fills before the callback only when a current price is available and policy and execution limits
permit it. Partial fills are visible to the callback while the remaining quantity stays pending.
Limit and stop orders can remain pending, so a guarded entry checks both position and pending intent:

```python
def on_before_risk(self, timestamp, data, context, broker):
if broker.get_position("SPY") is None and not broker.get_pending_orders("SPY"):
broker.submit_order("SPY", 10)
```

Orders submitted by `on_data()` retain the configured within-bar fill ordering with risk exits. A
pre-risk market entry that lacks buying power remains pending during the callback and
then participates in the normal ordered batch, so a same-bar exit can fund it. Limit and stop
orders stay in the normal ordered batch. Explicit pyramiding remains available by submitting an
additional order without the flat-position and pending-order guard.

### SAME_BAR

Orders fill at the **current bar's close** price, in the same bar they are submitted.
Expand Down
74 changes: 59 additions & 15 deletions docs/user-guide/results.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ For reproducibility, `BacktestResult` also exposes:
- `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.rejected_orders` and `result.to_rejected_orders_dataframe()` for orders that reached
the rejected terminal state
- `result.to_parquet(...)`, which writes `config.yaml`, `spec.yaml`, and `predictions.parquet`
when available

Expand Down Expand Up @@ -85,20 +87,22 @@ print(f"Net PF: {m['profit_factor']:.2f}")
| `sharpe` | Sharpe ratio |
| `sortino` | Sortino ratio |
| `calmar` | Calmar ratio |
| `num_trades` | Total completed trades |
| `num_trades` | Realized exit legs, including partial reductions and full closes |
| `num_orders` | Total submitted orders |
| `num_rejected_orders` | Orders that reached the rejected terminal state |
| `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) |
| `profit_factor` | Net profit factor (winning P&L / losing P&L) |
| `expectancy` | Expected return per trade (decimal) |
| `avg_trade` | Average trade return (decimal) |
| `avg_win` | Average winning trade return (decimal) |
| `avg_loss` | Average losing trade return (decimal, negative) |
| `largest_win` | Best single trade return (decimal) |
| `largest_loss` | Worst single trade return (decimal, negative) |
| `winning_trades` | Winning realized exit legs |
| `losing_trades` | Losing realized exit legs |
| `win_rate` | Winning fraction across realized exit legs (0 to 1) |
| `profit_factor` | Winning P&L / losing P&L across realized exit legs |
| `expectancy` | Expected return per realized exit leg (decimal) |
| `avg_trade` | Average realized exit-leg return (decimal) |
| `avg_win` | Average winning realized exit-leg return (decimal) |
| `avg_loss` | Average losing realized exit-leg return (decimal, negative) |
| `largest_win` | Best realized exit-leg return (decimal) |
| `largest_loss` | Worst realized exit-leg return (decimal, negative) |
| `payoff_ratio` | avg_win / \|avg_loss\| (size-normalized reward-to-risk) |
| `total_commission` | Total commission paid |
| `total_slippage` | Total slippage cost in dollars (entry + exit) |
Expand All @@ -117,7 +121,7 @@ print(f"Net PF: {m['profit_factor']:.2f}")

`BacktestResult` now exposes three distinct raw reporting surfaces:

- `trades`: flat-to-flat lifecycle summaries
- `trades`: realized exit legs plus end-of-backtest open-position marks
- `fills`: execution blotter rows
- `portfolio_state`: end-of-bar portfolio snapshots

Expand Down Expand Up @@ -179,7 +183,9 @@ Quote-aware backtests therefore leave an explicit audit trail:

## Trade Analyzer

`result.trade_analyzer` provides aggregate statistics on closed trades:
`result.trade_analyzer` computes P&L statistics from realized exit legs, including partial
reductions. Holding-period and MAE/MFE statistics use fully closed position lifecycles. Those
lifecycle values are `NaN` when partial realizations exist but no position has fully closed.

```python
ta = result.trade_analyzer
Expand Down Expand Up @@ -249,9 +255,13 @@ Returns a Polars DataFrame with columns:
| `total_slippage_cost` | Float | Entry + exit slippage in dollars |
| `cost_drag` | Float | Total cost as fraction of notional |
| `exit_reason` | String | Why the trade exited |
| `status` | String | "closed" or "open" |
| `exit_reason_detail` | String | Detailed risk or liquidation cause, when available |
| `status` | String | "closed", "partial", or "open" |

Open positions at the end of the backtest are included with `status="open"` and mark-to-market values.
Partial reductions use `status="partial"`. Realized-P&L metrics include partial and fully closed
records, while holding-period and excursion metrics use only fully closed records to avoid counting
one position lifecycle more than once. Open positions at the end of the backtest use `status="open"`
and mark-to-market values.

## Equity DataFrame

Expand Down Expand Up @@ -352,6 +362,8 @@ Fill objects carry order-type metadata for audit:
| `fill.spread` | Bid-ask spread |
| `fill.bid_size` / `fill.ask_size` | Quote sizes |
| `fill.available_size` | Side-aware size used for the fill context |
| `fill.exit_reason` | Typed exit category; empty for entry fills |
| `fill.exit_reason_detail` | Detailed risk or liquidation cause, when available |

For quote-aware backtests, `fills.parquet` is the first place to look when you want
to verify whether a result difference came from:
Expand Down Expand Up @@ -380,18 +392,50 @@ result.to_parquet("./results/my_backtest")
# Creates:
# trades.parquet
# fills.parquet
# rejected_orders.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
# spec.yaml # when config is attached
# manifest.json

# Reload later
from ml4t.backtest.result import BacktestResult
result = BacktestResult.from_parquet("./results/my_backtest")
```

`manifest.json` identifies artifact schema version 2 and every component written. Loading is
strict by default. An empty directory, missing manifest or required component, interrupted write,
malformed file, or unsupported schema version raises a specific `ArtifactError` subclass before a
result is returned. Selective exports are valid component exports, but they are not complete result
artifacts unless they contain all required components. Schema 1 was an unreleased development
format whose metrics used non-standard bare JSON constants for `NaN` and infinity; current readers
reject it rather than silently changing metric types. The stored `daily_pnl` component must decode
and equal the value recomputed from the stored equity curve.

Manifest-free beta artifacts require explicit recovery:

```python
result = BacktestResult.from_parquet("./results/beta_backtest", recovery=True)

for diagnostic in result.artifact_diagnostics:
print(diagnostic.code, diagnostic.component, diagnostic.message)
```

Recovery reads supported components in a deterministic order and reports every missing, malformed,
or ignored component. Unsupported manifest schema versions still fail because their interpretation
is not defined. Missing `config` or `spec` data raises only when an explicit `include` requests it;
the default export records unavailable optional components in the manifest. A serialization failure
raises `ArtifactWriteError` whether the component came from the default set or an explicit
`include`. Component payloads are serialized before output files are created.
Non-finite metric floats use a tagged JSON object and are restored on load, so `metrics.json`
remains standards-compliant without losing `NaN` or infinity. NumPy arrays and Polars Series are
stored and loaded as JSON lists. The returned path mapping includes `manifest`; passing its keys
back through `include` treats that key as a no-op because the manifest is always written.

## Integration with ml4t-diagnostic

### Portfolio Analysis (Recommended)
Expand Down
20 changes: 19 additions & 1 deletion src/ml4t/backtest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@
# Execution: rebalancing
from .execution.rebalancer import RebalanceConfig, TargetWeightExecutor
from .execution.schedule import RebalanceCadence, RebalanceSchedule, resolve_rebalance_timestamps
from .result import BacktestResult
from .result import (
ArtifactDiagnostic,
ArtifactError,
ArtifactIncompleteError,
ArtifactManifestError,
ArtifactNotFoundError,
ArtifactReadError,
ArtifactWriteError,
BacktestResult,
UnsupportedArtifactVersionError,
)

# Risk management rules (position-level)
from .risk.position.composite import RuleChain
Expand Down Expand Up @@ -53,6 +63,14 @@
"run_backtest",
"BacktestConfig",
"BacktestResult",
"ArtifactDiagnostic",
"ArtifactError",
"ArtifactNotFoundError",
"ArtifactManifestError",
"ArtifactIncompleteError",
"ArtifactReadError",
"ArtifactWriteError",
"UnsupportedArtifactVersionError",
"CommissionType",
# Canonical domain types
"OrderType",
Expand Down
27 changes: 24 additions & 3 deletions src/ml4t/backtest/accounting/gatekeeper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from collections.abc import Callable

from ..models import CommissionModel
from ..models import CommissionModel, calculate_commission
from ..types import Order, OrderSide
from .account import AccountState

Expand Down Expand Up @@ -137,7 +137,9 @@ def validate_order(self, order: Order, price: float) -> tuple[bool, str]:
# Check for position reversal (long→short or short→long)
# Delegate to policy's handle_reversal() method
if self._is_reversal(current_qty, order_qty_delta):
commission = self.commission_model.calculate(order.asset, order.quantity, price)
commission = calculate_commission(
self.commission_model, order.asset, order.quantity, price
)
return self.account.policy.handle_reversal(
asset=order.asset,
current_quantity=current_qty,
Expand All @@ -156,7 +158,7 @@ def validate_order(self, order: Order, price: float) -> tuple[bool, str]:

# This is an opening order (new position or adding to existing)
# Calculate commission to include in cost
commission = self.commission_model.calculate(order.asset, order.quantity, price)
commission = calculate_commission(self.commission_model, order.asset, order.quantity, price)

# Use buffered cash (reserves cash_buffer_pct for safety margin)
available = self._available_cash()
Expand Down Expand Up @@ -185,6 +187,25 @@ def validate_order(self, order: Order, price: float) -> tuple[bool, str]:
multiplier=multiplier,
)

def validate_order_with_code(self, order: Order, price: float) -> tuple[bool, str, str | None]:
"""Validate an order and return a stable rejection code when invalid."""
valid, reason = self.validate_order(order, price)
if valid:
return True, reason, None

current_qty = self.account.get_position_quantity(order.asset)
quantity_delta = self._calculate_quantity_delta(order.side, order.quantity)
resulting_qty = current_qty + quantity_delta
return False, reason, self.classify_rejection(resulting_qty)

def classify_rejection(self, resulting_quantity: float) -> str:
"""Classify a policy rejection independently of its display text."""
if resulting_quantity < 0 and not self.account.policy.allows_short_selling():
return "account_restriction"
if getattr(self.account.policy, "allow_leverage", False):
return "insufficient_buying_power"
return "insufficient_cash"

def _is_reversal(self, current_qty: float, order_qty_delta: float) -> bool:
"""Check if order reverses position (long → short or short → long).

Expand Down
2 changes: 2 additions & 0 deletions src/ml4t/backtest/analytics/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ def to_trade_record(trade: Trade) -> dict[str, Any]:

With the aligned schema (v0.1.0a6+), field names now match between
backtest Trade and diagnostic TradeRecord, simplifying this conversion.
``exit_reason_detail`` remains on the backtest record until the diagnostic
TradeRecord schema accepts that optional field.

Args:
trade: A completed Trade from backtest
Expand Down
Loading