Skip to content
Merged
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
31 changes: 18 additions & 13 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,36 @@ Event-driven backtesting engine with cross-framework parity validation.

| Directory | Purpose |
|-----------|---------|
| src/ml4t/backtest/ | Package root (~14.3k lines, 40 modules) |
| tests/ | 1,083 tests |
| src/ml4t/backtest/ | Package root (~13.8k lines, 40 modules) |
| tests/ | 1,367 tests |
| validation/ | Cross-framework parity (VBT, Backtrader, Zipline, LEAN) |

## Key Modules

| Module | Lines | Purpose |
|--------|-------|---------|
| engine.py | 491 | Event loop orchestration |
| broker.py | 1,438 | Order execution, positions |
| config.py | 937 | BacktestConfig (40+ knobs) |
| result.py | 1,025 | BacktestResult container |
| types.py | 578 | Order, Position, Fill, Trade |
| profiles.py | 375 | 6 core + 4 strict profiles |
| broker.py | 1,463 | Order execution, positions |
| result.py | 1,047 | BacktestResult container |
| config.py | 848 | BacktestConfig (40+ knobs) |
| calendar.py | 786 | Trading calendar, sessions |
| types.py | 625 | Order, Position, Fill, Trade, cost decomposition |
| engine.py | 419 | Event loop orchestration |
| profiles.py | 384 | 6 core + 4 strict profiles |
| export.py | 312 | Result export (Parquet, YAML, JSON) |
| sessions.py | 279 | Session handling |
| models.py | 248 | Commission/slippage models |
| datafeed.py | 224 | Price/signal iteration |
| strategy.py | 28 | Strategy base class |

## Subpackages

| Directory | Lines | Purpose |
|-----------|-------|---------|
| core/ | 1,365 | Order book, execution engine, fill engine, risk engine |
| accounting/ | 1,180 | Cash/margin policies, gatekeeper |
| analytics/ | 917 | Metrics, equity, trades, diagnostic bridge |
| execution/ | 1,328 | Fill executor, rebalancer, impact |
| risk/ | 1,876 | Position rules, portfolio limits |
| execution/ | 1,351 | Fill executor, rebalancer, impact |
| core/ | 1,314 | Order book, execution engine, fill engine, risk engine |
| accounting/ | 1,076 | Cash/margin policies, gatekeeper |
| analytics/ | 970 | Metrics, equity, trades, cost decomposition, diagnostic bridge |
| risk/ | 1,906 | Position rules, portfolio limits |
| strategies/ | 417 | Strategy templates |

## Entry Point
Expand Down
2 changes: 1 addition & 1 deletion LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ and 1500-bar stress tests across 9 market regimes.
3. Focus on relative performance, not absolute returns

### For Production Readiness
1. Validate with `Mode.REALISTIC` preset
1. Validate with `BacktestConfig.from_preset("realistic")`
2. Run with historical crisis periods (2008, 2020, 2022)
3. Test with varied slippage and commission assumptions
4. Paper trade before live deployment
210 changes: 190 additions & 20 deletions docs/user-guide/results.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,24 @@ print(f"Expectancy: ${m['expectancy']:.2f}")
print(f"Avg Win: ${m['avg_win']:.2f}")
print(f"Avg Loss: ${m['avg_loss']:.2f}")

# Per-trade returns (percentage-based, direction-aware)
print(f"Avg Trade: {m['avg_trade']:.2%}")
print(f"Avg Win: {m['avg_win']:.2%}")
print(f"Avg Loss: {m['avg_loss']:.2%}")
print(f"Best Trade: {m['largest_win']:.2%}")
print(f"Worst Trade: {m['largest_loss']:.2%}")
print(f"Payoff Ratio: {m['payoff_ratio']:.2f}")

# Costs
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%}")

# Gross vs Net
print(f"Gross P&L: ${m['total_gross_pnl']:.2f}")
print(f"Gross PF: {m['gross_profit_factor']:.2f}")
print(f"Net PF: {m['profit_factor']:.2f}")
```

### Available Metrics
Expand All @@ -51,17 +66,77 @@ print(f"Slippage: ${m['total_slippage']:.2f}")
| `winning_trades` | Number of winning trades |
| `losing_trades` | Number of losing trades |
| `win_rate` | Win rate (0 to 1) |
| `profit_factor` | Gross profits / gross losses |
| `expectancy` | Average $ per trade |
| `avg_trade` | Average trade P&L |
| `avg_win` | Average winning trade |
| `avg_loss` | Average losing trade |
| `largest_win` | Largest single win |
| `largest_loss` | Largest single loss |
| `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) |
| `payoff_ratio` | avg_win / \|avg_loss\| (size-normalized reward-to-risk) |
| `total_commission` | Total commission paid |
| `total_slippage` | Total slippage cost |
| `total_slippage` | Total slippage cost (entry + exit) |
| `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 |

### Cost Decomposition

Every trade carries a full cost breakdown, letting you separate strategy edge from execution costs:

```python
for trade in result.trades:
print(f"{trade.symbol}: gross={trade.gross_pnl:+.2f}, "
f"net={trade.pnl:+.2f}, drag={trade.cost_drag:.4%}")
```

| Property | Description |
|----------|-------------|
| `trade.gross_pnl` | Price-move P&L: `(exit - entry) * qty * multiplier` |
| `trade.pnl` | Net P&L after all costs |
| `trade.gross_return` | Direction-aware gross return (same as `pnl_percent`) |
| `trade.net_return` | Direction-aware net return including fees |
| `trade.total_slippage_cost` | Entry + exit slippage in dollars |
| `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.multiplier` | Contract multiplier (1.0 for equities, 50.0 for ES futures) |

`pnl_percent` is direction-aware: positive means profitable for both long and short trades.

## Trade Analyzer

`result.trade_analyzer` provides aggregate statistics on closed trades:

```python
ta = result.trade_analyzer

# Standard metrics
print(f"Win Rate: {ta.win_rate:.1%}")
print(f"Profit Factor: {ta.profit_factor:.2f}")
print(f"Avg MFE: {ta.avg_mfe:.4f}")
print(f"MFE Capture: {ta.mfe_capture_ratio:.2f}")

# Cost decomposition
print(f"Gross P&L: ${ta.total_gross_pnl:.2f}")
print(f"Net Profit: ${ta.net_profit:.2f}")
print(f"Total Costs: ${ta.total_costs:.2f}")
print(f"Avg Cost Drag: {ta.avg_cost_drag:.4%}")
print(f"Gross Profit Factor:{ta.gross_profit_factor:.2f}")

# Filter by side
long_stats = ta.by_side("long")
short_stats = ta.by_side("short")
print(f"Long win rate: {long_stats.win_rate:.1%}")
print(f"Short win rate: {short_stats.win_rate:.1%}")

# Export all stats
stats_dict = ta.to_dict()
```

## Trades DataFrame

```python
Expand All @@ -78,15 +153,21 @@ Returns a Polars DataFrame with columns:
| `exit_time` | Datetime | Exit timestamp |
| `entry_price` | Float | Entry fill price |
| `exit_price` | Float | Exit fill price |
| `quantity` | Float | Position size |
| `quantity` | Float | Position size (negative for shorts) |
| `direction` | String | "long" or "short" |
| `pnl` | Float | Dollar P&L |
| `pnl_percent` | Float | Percentage return |
| `pnl` | Float | Net P&L after costs |
| `pnl_percent` | Float | Direction-aware percentage return |
| `bars_held` | Int | Holding period |
| `fees` | Float | Total commission |
| `slippage` | Float | Total slippage |
| `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) |
| `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 |
| `cost_drag` | Float | Total cost as fraction of notional |
| `exit_reason` | String | Why the trade exited |
| `status` | String | "closed" or "open" |

Expand All @@ -110,6 +191,29 @@ Returns a Polars DataFrame with columns:
| `drawdown` | Float | Current drawdown from HWM |
| `high_water_mark` | Float | Running maximum equity |

## Fills

Access every individual order fill:

```python
for fill in result.fills:
print(f"{fill.asset}: {fill.quantity} @ ${fill.price:.2f}")
print(f" Type: {fill.order_type}")
print(f" Commission: ${fill.commission:.2f}")
print(f" Slippage: ${fill.slippage:.4f}")
```

Fill objects carry order-type metadata for audit:

| Field | Description |
|-------|-------------|
| `fill.order_type` | `"market"`, `"limit"`, or `"stop"` |
| `fill.limit_price` | Limit price (for limit orders) |
| `fill.stop_price` | Stop price (for stop orders) |
| `fill.price` | Actual fill price |
| `fill.commission` | Commission charged |
| `fill.slippage` | Slippage applied |

## Dictionary Output

For backward compatibility:
Expand All @@ -135,6 +239,47 @@ result = BacktestResult.from_parquet("./results/my_backtest")

## Integration with ml4t-diagnostic

### Portfolio Analysis (Recommended)

The simplest way to bridge backtest results into ml4t-diagnostic is `to_portfolio_analysis()`:

```python
from ml4t.backtest import Engine

result = engine.run()

# One-liner bridge to ml4t-diagnostic
analysis = result.to_portfolio_analysis(calendar="NYSE")

# Now use PortfolioAnalysis methods
print(f"Sharpe: {analysis.sharpe_ratio():.2f}")
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.

```python
# Crypto backtest
analysis = result.to_portfolio_analysis(calendar="crypto")

# With benchmark
analysis = result.to_portfolio_analysis(
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")
```

!!! note "Requires ml4t-diagnostic"
Install with `pip install ml4t-diagnostic`. The import is deferred so ml4t-backtest
works standalone without ml4t-diagnostic installed.

### Trade Records

Convert trades to TradeRecord format for the diagnostic library:

```python
Expand All @@ -146,17 +291,42 @@ from ml4t.backtest.analytics.bridge import to_trade_records
records = to_trade_records(result.trades)
```

## Fills
The bridge exports all cost decomposition fields (`gross_pnl`, `net_return`, `total_slippage_cost`, `cost_drag`) for diagnostic analysis.

Access every individual order fill:
### Full Tearsheet

Pass all result data for the richest tearsheet (up to 24 sections):

```python
for fill in result.fills:
print(f"{fill.asset}: {fill.quantity} @ ${fill.price:.2f}")
print(f" Commission: ${fill.commission:.2f}")
print(f" Slippage: ${fill.slippage:.4f}")
from ml4t.diagnostic.visualization.backtest import generate_backtest_tearsheet

html = generate_backtest_tearsheet(
trades=result.to_trades_dataframe(),
returns=analysis.returns,
equity_curve=result.to_equity_dataframe(),
metrics=result.metrics,
template="full",
title="My Strategy — Full Report",
output_path="tearsheet.html",
)
```

#### Metrics Keys That Enable Tearsheet Sections

The `metrics` dict controls which tearsheet sections render. Sections gracefully degrade when keys are missing.

| Section | Required Metrics Keys |
|---------|----------------------|
| Executive Summary | `sharpe_ratio`, `max_drawdown`, `win_rate`, `profit_factor`, `n_trades`, `cagr`, `volatility`, `expectancy` |
| Cost Attribution | `gross_pnl`, `commission`, `slippage` |
| Statistical Validity (DSR) | `dsr_probability`, `dsr_significant`, `min_trl`, `current_trl`, `trl_sufficient` |
| RAS Adjustment | `ras_adjusted_ic`, `ras_significant`, `original_ic`, `rademacher_complexity` |
| Confidence Intervals | `sharpe_ratio`, `sharpe_ratio_lower_95`, `sharpe_ratio_upper_95` (similarly for other metrics) |
| Haircut Sharpe | `sharpe`, `n_periods` or `n_observations` |
| Expected Max Sharpe | `expected_max_sharpe` |

Sections that depend only on `trades` (trade analysis, MFE/MAE, exit reasons) or `returns` (drawdown, monthly heatmap, rolling Sharpe) require no special metrics keys.

## Config Preservation

The config used for the backtest is preserved in the result:
Expand All @@ -170,8 +340,8 @@ 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`) — comprehensive metrics extraction, equity curve visualization, trade analysis
- **Ch16 case studies** — all cases call `result.to_daily_returns(calendar="NYSE")` for integration with ml4t-diagnostic signal analysis
- **Ch16 / NB05** (`performance_reporting`) — `to_portfolio_analysis()`, 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

## Next Steps
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ markers = [
"unit: marks unit tests",
"private: requires commercial dependencies (vectorbtpro) - excluded by default",
"requires_comparison: requires optional comparison frameworks (vectorbt, backtrader, zipline)",
"no_invariant_check: skip autouse accounting invariant check for this test",
]
filterwarnings = [
"ignore::DeprecationWarning",
Expand Down Expand Up @@ -212,7 +213,7 @@ ignore = [
]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["ARG001", "ARG002", "F841", "SIM102"] # Test patterns
"tests/*" = ["ARG001", "ARG002", "F841", "SIM102", "SIM108"] # Test patterns

[tool.ty.environment]
python-version = "3.11"
Expand Down
28 changes: 14 additions & 14 deletions src/ml4t/backtest/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,28 @@

| File | Lines | Purpose |
|------|-------|---------|
| engine.py | 491 | Event loop orchestration |
| broker.py | 1,438 | Order execution, positions, risk eval |
| config.py | 937 | BacktestConfig, 40+ behavioral knobs |
| result.py | 1,025 | BacktestResult container |
| types.py | 578 | Order, Position, Fill, Trade |
| profiles.py | 375 | 6 core + 4 strict framework profiles |
| broker.py | 1,463 | Order execution, positions, risk eval |
| result.py | 1,047 | BacktestResult container |
| config.py | 848 | BacktestConfig, 40+ behavioral knobs |
| calendar.py | 786 | Trading calendar, overnight sessions |
| types.py | 625 | Order, Position, Fill, Trade, cost decomposition |
| engine.py | 419 | Event loop orchestration |
| profiles.py | 384 | 6 core + 4 strict framework profiles |
| export.py | 312 | Result export (Parquet, YAML, JSON) |
| sessions.py | 279 | Session handling |
| models.py | 248 | Commission/slippage models |
| datafeed.py | 224 | Price/signal iteration |
| strategy.py | 28 | Strategy base class |
| models.py | 245 | Commission/slippage models |
| sessions.py | 279 | Session handling |
| export.py | 312 | Result export (Parquet, YAML, JSON) |

## Subpackages

| Directory | Lines | Purpose |
|-----------|-------|---------|
| core/ | 1,365 | Order book, execution engine, fill engine, risk engine |
| accounting/ | 1,180 | Cash/margin/crypto policies, gatekeeper |
| analytics/ | 917 | Metrics, equity, trades, diagnostic bridge |
| execution/ | 1,328 | Fill executor, rebalancer, impact, limits |
| risk/ | 1,876 | Position rules (stop/trail/TP), portfolio limits |
| execution/ | 1,351 | Fill executor, rebalancer, impact, limits |
| core/ | 1,314 | Order book, execution engine, fill engine, risk engine |
| accounting/ | 1,076 | Unified account policy, gatekeeper |
| analytics/ | 970 | Metrics, equity, trades, cost decomposition, diagnostic bridge |
| risk/ | 1,906 | Position rules (stop/trail/TP), portfolio limits |
| strategies/ | 417 | Strategy templates |

## Key
Expand Down
Loading