Results & Analysis¶
The result export tutorial joins a time-zone-aware feed to fills, equity, predictions, and funding, then checks a Parquet round trip. The diagnostic handoff uses the optional analysis package.
Engine.run() returns a BacktestResult containing trades, equity curve, fills,
portfolio state, and computed metrics. Everything is accessible as Python objects,
Polars DataFrames, or Parquet files.
The trades, fills, equity_curve, portfolio_state, and rejected_orders lists are
snapshots: adding to or removing from engine or broker state after run() does not change a
completed result.
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 payloadresult.to_spec_dict()for a richer runtime snapshot including library version and realized windowresult.to_predictions_dataframe()for the raw prediction/input surface passed into the backtest, when availableresult.rejected_ordersandresult.to_rejected_orders_dataframe()for orders that reached the rejected terminal stateresult.to_parquet(...), which writesconfig.yaml,spec.yaml, andpredictions.parquetwhen available
Snippets using result assume an existing BacktestResult from Engine.run(). The linked export tutorial supplies the complete run and checks each exported table.
Metrics¶
result = engine.run()
m = result.metrics
# Returns
print(f"Total Return: {m['total_return_pct']:.1f}%")
print(f"CAGR: {m['cagr']:.2%}")
print(f"Volatility: {m['volatility']:.2%}")
# Risk
print(f"Max Drawdown: {m['max_drawdown_pct']:.1f}%")
print(f"Sharpe: {m['sharpe']:.2f}")
print(f"Sortino: {m['sortino']:.2f}")
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}")
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%}")
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}")
print(f"Gross PF: {m['gross_profit_factor']:.2f}")
print(f"Net PF: {m['profit_factor']:.2f}")
Available Metrics¶
| Metric | Description |
|---|---|
initial_cash |
Starting cash |
final_value |
Final portfolio value |
total_return |
Total return as decimal |
total_return_pct |
Total return as percentage |
cagr |
Compound annual growth rate |
volatility |
Annualized volatility |
max_drawdown |
Maximum drawdown (positive number) |
max_drawdown_pct |
Maximum drawdown as percentage |
sharpe |
Sharpe ratio |
sortino |
Sortino ratio |
calmar |
Calmar ratio |
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 |
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) |
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: realized exit legs plus end-of-backtest open-position marksfills: execution blotter rowsportfolio_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:
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:
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.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 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.
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¶
Returns a Polars DataFrame with columns:
| Column | Type | Description |
|---|---|---|
symbol |
String | Asset identifier |
entry_time |
Datetime | Entry timestamp |
exit_time |
Datetime | Exit timestamp |
entry_price |
Float | Entry fill price |
exit_price |
Float | Exit fill price |
quantity |
Float | Position size (negative for shorts) |
direction |
String | "long" or "short" |
pnl |
Float | Net P&L after costs |
pnl_percent |
Float | Direction-aware percentage return |
bars_held |
Int | Holding period |
fees |
Float | Total commission |
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 |
cost_drag |
Float | Total cost as fraction of notional |
exit_reason |
String | Why the trade exited |
exit_reason_detail |
String | Detailed risk or liquidation cause, when available |
status |
String | "closed", "partial", or "open" |
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¶
Returns a Polars DataFrame with columns:
| Column | Type | Description |
|---|---|---|
timestamp |
Datetime | Bar timestamp |
equity |
Float | Portfolio value |
return |
Float | Bar-to-bar return |
cumulative_return |
Float | Cumulative return from start |
drawdown |
Float | Current drawdown from HWM |
high_water_mark |
Float | Running maximum equity |
Portfolio State DataFrame¶
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:
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:
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}")
Or convert them directly to a Polars DataFrame:
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 |
|---|---|
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 |
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 |
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:
- quote-side execution
- synthetic slippage
- commission
- the configured mark source
Dictionary Output¶
For backward compatibility:
Parquet Export¶
Save results for later analysis or integration with ml4t-diagnostic:
# Export all result components to Parquet / JSON / YAML
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:
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)¶
The simplest way to bridge backtest results into ml4t-diagnostic is
portfolio_analysis_from_result():
from ml4t.backtest import Engine
from ml4t.diagnostic.integration import portfolio_analysis_from_result
result = engine.run()
# One-liner bridge to ml4t-diagnostic
analysis = portfolio_analysis_from_result(result, calendar="NYSE")
# Compute PortfolioAnalysis summary metrics
stats = analysis.compute_summary_stats()
print(f"Sharpe: {stats.sharpe_ratio:.2f}")
print(f"Max DD: {stats.max_drawdown:.2%}")
monthly = analysis.compute_monthly_returns()
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()
# Crypto backtest
analysis = portfolio_analysis_from_result(result, calendar="crypto")
# With benchmark
analysis = portfolio_analysis_from_result(
result,
calendar="NYSE",
benchmark=spy_returns, # numpy array or Polars Series
)
# Gross vs net comparison
analysis_gross = portfolio_analysis_from_result(results_gross, calendar="crypto")
analysis_net = portfolio_analysis_from_result(results_net, calendar="crypto")
Requires ml4t-diagnostic
Add ml4t-diagnostic==0.1.4 for portfolio analysis. The full HTML
tearsheet also requires the visualization extra:
uv add 'ml4t-diagnostic[viz]==0.1.4'. Backtest imports and runs
without this optional package.
Trade Records¶
Convert trades to TradeRecord format for the diagnostic library:
# Bridge to ml4t-diagnostic
trade_records = result.to_trade_records()
# Or use the bridge function directly
from ml4t.backtest.analytics.bridge import to_trade_records
records = to_trade_records(result.trades)
The bridge exports all cost decomposition fields (gross_pnl, net_return, total_slippage_cost, cost_drag) for diagnostic analysis.
Full Tearsheet¶
Use the diagnostic integration function to build the HTML report from a
complete Backtest result. This example uses the bundled synthetic equity panel
and writes tearsheet.html in the current directory.
from pathlib import Path
import polars as pl
from ml4t.backtest import BacktestConfig, DataFeed, Engine
from ml4t.backtest.example_data import ExampleRoundTrip, load_example_prices
from ml4t.diagnostic.integration import generate_tearsheet_from_result
prices = load_example_prices("equity").filter(pl.col("asset") == "AAPL")
result = Engine(
DataFeed(prices_df=prices),
ExampleRoundTrip("AAPL", 100),
BacktestConfig(initial_cash=100_000),
).run()
html = generate_tearsheet_from_result(
result, title="AAPL example", template="full", output_path="tearsheet.html"
)
assert "<html" in html.lower()
print(f"html_saved={Path('tearsheet.html').is_file()} fills={len(result.fills)}")
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:
In the book¶
Chapter 16, Section 16.5, Performance reporting develops return and drawdown interpretation. This page defines the result frames and artifact format used to reproduce those reports.
Next Steps¶
- Book Guide -- notebook and case-study map for reporting workflows
- Quickstart -- end-to-end examples
- Profiles -- compare results across framework profiles