Skip to content
All library documents

Event-Driven Backtesting of an RSI Mean-Reversion Strategy

Code Machine Learning for Trading

Summary

This notebook implements a long-only RSI mean-reversion rule for BTC/USDT perpetual bars using an event-driven backtesting engine. It aggregates intraday observations into UTC daily bars, calculates a simple rolling gain-and-loss RSI, enters when the indicator is below its oversold threshold, and exits above its overbought threshold. The strategy targets a stated fraction of capital and models commissions and slippage. A sequential engine represents decisions, pending orders, fills, positions, and cash explicitly, supporting path-dependent logic.

The notebook highlights how event ordering changes a backtest: signals use the completed day’s close, while queued orders fill at the next open. Order units are determined using the signal-time close, so an overnight price gap can shift actual exposure. It reconciles trade accounting and compares results with a protocol-matched benchmark, while contrasting the event-driven approach with vectorized analysis. These are in-sample results; the document stresses that explicit engine mechanics do not make assumptions realistic by themselves, and trade excursion diagnostics do not validate exit rules.

Key ideas

  • An event-driven engine makes decisions, pending orders, fills, cash, and positions explicit.
  • The strategy enters long on an oversold RSI reading and exits on an overbought reading.
  • A close-based signal filled at the next open can produce a different exposure after an overnight gap.
  • Backtest credibility depends on matching information timing, execution, sizing, costs, and accounting to the intended protocol.
  • Trade-path diagnostics describe observed outcomes but do not establish that an exit rule is valid.

Tags

Full text
# 04_single_asset_ml4t_backtest.py


```py
# ---
# jupyter:
#   jupytext:
#     cell_metadata_filter: tags,-all
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Single Asset Backtest with ml4t-backtest
#
# **Docker image**: `ml4t`
#
# This notebook introduces the **ml4t-backtest** event-driven backtesting engine.
# We implement the RSI mean-reversion rule from notebook 03 (VectorBT) as a
# sequence of decisions, pending orders, fills, and portfolio-state updates.
#
# ## When to Use ml4t-backtest vs VectorBT
#
# | Question | ml4t-backtest | VectorBT |
# |----------|---------------|----------|
# | **Natural representation** | Sequential decisions and state transitions | Timestamped arrays and portfolio signals |
# | **State exposure** | Orders, fills, cash, and positions are explicit | State is expressed through vectorized portfolio rules |
# | **Research strength** | Path-dependent strategy logic | Fast parameter and signal analysis |
# | **Credibility condition** | Engine settings match the trading protocol | Array semantics match the trading protocol |
#
# Neither representation is realistic by default. Credibility comes from explicit,
# tested assumptions about information timing, fills, sizing, costs, and accounting.
#
# **Learning Objectives:**
# 1. Implement a production-style `Strategy` class with `on_data` callbacks.
# 2. Run an event-driven backtest with next-open execution and explicit costs.
# 3. Reconcile fills, closed-trade P&L, and a protocol-matched benchmark.
#
# **Book Reference:** Chapter 16, Sections 16.3-16.5 (simulation workflow and reporting).
#
# **Prerequisites:** Complete [`03_single_asset_vectorbt`](03_single_asset_vectorbt.ipynb) first.
#
# ## Key Concepts Introduced
#
# 1. **Strategy class**: Encapsulates trading logic in `on_data()` method
# 2. **DataFeed**: Combines OHLCV prices with context data (indicators)
# 3. **Engine**: Orchestrates backtest execution with commission/slippage
# 4. **Broker**: Manages orders, positions, and portfolio state
# 5. **ExecutionMode**: Controls order fill timing (same-bar vs next-bar)
#
# ## Strategy: RSI Mean Reversion on BTC
#
# - **Long Entry**: RSI < 30 (oversold)
# - **Exit**: RSI > 70 (overbought)
# - **Position Size**: 95 percent of capital per trade
# - **Transaction Costs**: 10 bps commission + 5 bps slippage

# %% [markdown]
# ## Setup

# %%
"""Single-asset event-driven backtest with explicit timing, sizing, and cost accounting."""

from datetime import datetime

import numpy as np
import plotly.graph_objects as go
import polars as pl
from IPython.display import Markdown, display
from ml4t.backtest import (
    BacktestConfig,
    DataFeed,
    Engine,
    ExecutionMode,
    Strategy,
)
from ml4t.backtest.analytics import MAEMFEAnalyzer, TradeAnalyzer
from ml4t.backtest.config import ShareType
from ml4t.backtest.sessions import SessionConfig, compute_session_pnl
from plotly.subplots import make_subplots

from data import load_crypto_perps
from utils.paths import get_output_dir
from utils.style import COLORS, show_plotly_with_alt

# %% tags=["parameters"]
# Production defaults - Papermill injects overrides for CI
START_DATE = "2020-01-01"
END_DATE = "2024-01-01"
INITIAL_CASH = 100_000
FEES = 0.001  # 10 bps per trade
SLIPPAGE = 0.0005  # 5 bps slippage
RSI_PERIOD = 14
RSI_LOWER = 30  # Oversold threshold
RSI_UPPER = 70  # Overbought threshold
POSITION_SIZE = 0.95  # 95% of capital per trade

# %%
display(
    Markdown(
        f"The rule uses a **{RSI_PERIOD}-day RSI**, enters below **{RSI_LOWER}**, "
        f"exits above **{RSI_UPPER}**, and targets **{POSITION_SIZE:.0%}** exposure. "
        f"Each fill pays a **{FEES:.2%} fee** plus **{SLIPPAGE:.2%} slippage**."
    )
)

# %% [markdown]
# ## 1. Data Acquisition
#
# Load BTC/USDT perpetual bars, aggregate the three 8-hour observations in each UTC day, and
# retain the canonical `symbol` and `timestamp` keys. UTC-day aggregation is a calendar convention
# for a continuously traded market, not an exchange session close.

# %%
_crypto = load_crypto_perps(
    symbols=["BTCUSDT"],
    start_date=START_DATE,
    end_date=END_DATE,
)
btc_df = (
    _crypto.filter(
        (pl.col("symbol") == "BTCUSDT")
        & (pl.col("timestamp") >= pl.lit(START_DATE).str.to_datetime().dt.replace_time_zone("UTC"))
        & (pl.col("timestamp") < pl.lit(END_DATE).str.to_datetime().dt.replace_time_zone("UTC"))
    )
    .sort("timestamp")
    .with_columns(pl.col("timestamp").dt.replace_time_zone(None))
    .group_by_dynamic("timestamp", every="1d", group_by="symbol")
    .agg(
        pl.col("open").first(),
        pl.col("high").max(),
        pl.col("low").min(),
        pl.col("close").last(),
        pl.col("volume").sum(),
    )
    .sort(["symbol", "timestamp"])
)

prices_df = btc_df

# %% [markdown]
# Canonical keys must remain unique after aggregation. Positive, internally consistent OHLC bars
# protect both the indicator and the broker from malformed inputs.

# %%
assert prices_df.n_unique(["symbol", "timestamp"]) == len(prices_df)
assert prices_df["symbol"].unique().to_list() == ["BTCUSDT"]
price_columns = ["open", "high", "low", "close"]
assert prices_df.select(pl.col(price_columns).is_not_null().all()).row(0) == (True,) * 4
assert prices_df.select((pl.col(price_columns) > 0).all()).row(0) == (True,) * 4
assert prices_df.select((pl.col("high") >= pl.max_horizontal("open", "close", "low")).all()).item()
assert prices_df.select((pl.col("low") <= pl.min_horizontal("open", "close", "high")).all()).item()
assert len(prices_df) > RSI_PERIOD

print(f"Loaded {len(prices_df):,} daily bars from local crypto dataset")
print(f"Date range: {prices_df['timestamp'].min()} to {prices_df['timestamp'].max()}")

# %% [markdown]
# ## 2. RSI Indicator Calculation
#
# Compute the same simple rolling gain/loss RSI used by VectorBT's default indicator.


# %%
def compute_rsi(close: pl.Series, period: int = 14) -> pl.Series:
    """Compute RSI from simple rolling mean gains and losses."""
    delta = close.diff()
    gain = delta.clip(lower_bound=0.0)
    loss = (-delta).clip(lower_bound=0.0)
    avg_gain = gain.rolling_mean(window_size=period, min_samples=period)
    avg_loss = loss.rolling_mean(window_size=period, min_samples=period)

    rs = avg_gain / avg_loss
    return 100 - (100 / (1 + rs))


# Compute RSI and add to context
rsi_values = compute_rsi(prices_df["close"], RSI_PERIOD)

# Create context DataFrame with RSI
context_df = prices_df.select(["timestamp"]).with_columns(rsi_values.alias("rsi"))

rsi_valid = context_df["rsi"].drop_nulls().drop_nans()
assert len(rsi_valid) <= len(context_df) - RSI_PERIOD
assert rsi_valid.is_between(0, 100, closed="both").all()
print(f"RSI Statistics ({len(rsi_valid)}/{len(context_df)} valid):")
print(f"  Mean: {rsi_valid.mean():.1f}")
print(f"  Std:  {rsi_valid.std():.1f}")
print(f"  Min:  {rsi_valid.min():.1f}")
print(f"  Max:  {rsi_valid.max():.1f}")

# %%
# Visualize price and RSI
fig = make_subplots(
    rows=2,
    cols=1,
    shared_xaxes=True,
    vertical_spacing=0.05,
    row_heights=[0.7, 0.3],
    subplot_titles=["BTC/USDT Price", "RSI (14)"],
)

fig.add_trace(
    go.Scatter(
        x=prices_df["timestamp"].to_list(),
        y=prices_df["close"].to_list(),
        name="BTC",
        line=dict(color=COLORS["blue"]),
    ),
    row=1,
    col=1,
)

fig.add_trace(
    go.Scatter(
        x=context_df["timestamp"].to_list(),
        y=context_df["rsi"].to_list(),
        name="RSI",
        line=dict(color=COLORS["amber"]),
    ),
    row=2,
    col=1,
)

# Add RSI threshold lines
fig.add_hline(y=RSI_LOWER, line_dash="dash", line_color=COLORS["positive"], row=2, col=1)
# Assigned, not bare: add_hline returns the figure, and a bare call as the cell's last
# statement makes Jupyter render it as an extra output with no alt text.
_ = fig.add_hline(y=RSI_UPPER, line_dash="dash", line_color=COLORS["negative"], row=2, col=1)

# %%
# Layout and display
fig.update_layout(
    height=600,
    title=(
        "BTC/USDT close and its RSI, with the entry and exit thresholds marked"
        "<br><sup>14-day simple rolling gain/loss means; UTC daily bars</sup>"
    ),
    showlegend=True,
    xaxis2_title="Date",
    yaxis_title="Price (USDT)",
    yaxis2_title="RSI",
)
show_plotly_with_alt(
    fig,
    (
        "Two stacked panels on a shared date axis. The upper panel is the BTC/USDT close in "
        f"USDT. The lower panel is the {RSI_PERIOD}-period RSI computed from those closes, in "
        "amber, with dashed horizontal lines at the entry and exit thresholds. Same "
        "construction as the vectorized notebook's first figure, drawn here so the two "
        "engines can be compared on an identical starting point."
    ),
)

# %% [markdown]
# ## 3. Define Strategy Class
#
# The `Strategy` class is the heart of ml4t-backtest. You subclass it and implement
# the `on_data()` method, which is called for each bar during the backtest.
#
# ### Two conventions the subclass has to follow
#
# 1. **Take the parameters as constructor arguments.** Thresholds and lookbacks are passed in
#    when the strategy is constructed, so the same class can be run at another setting without
#    editing it. A value written directly into `on_data()` can only be changed by editing code.
#
# 2. **Use `broker.get_position()` for position checks**: Returns `None` if no position,
#    or a `Position` object with `quantity`, `entry_price`, `unrealized_pnl`.
#
# 3. **Access context data via `context.get()`**: Indicators precomputed and passed
#    via `context_df` are available here. Always check for `None`/`NaN`.
#
# 4. **Express sizing explicitly**: `broker.order_target_percent()` converts the target
#    portfolio weight into an order using information available on the signal bar.
#
# 5. **Handle data availability gracefully**: Early bars may lack history for
#    indicators. Check before trading.
#
# ### RSI Mean Reversion Logic
#
# - **Enter Long**: RSI < 30 (oversold, expect mean reversion bounce)
# - **Exit Long**: RSI > 70 (overbought, take profit)


# %%
class RSIMeanReversionStrategy(Strategy):
    """Long-only RSI threshold strategy."""

    def __init__(
        self,
        rsi_lower: float = 30,
        rsi_upper: float = 70,
        position_size: float = 0.95,
    ):
        """Store entry, exit, and target-weight parameters."""
        self.rsi_lower = rsi_lower
        self.rsi_upper = rsi_upper
        self.position_size = position_size

    def on_data(self, timestamp, data, context, broker):
        """Submit target-weight orders from the close-based RSI state."""
        rsi = context.get("rsi")
        if rsi is None or np.isnan(rsi):
            return

        asset = "BTCUSDT"
        if data.get(asset) is None:
            return

        position = broker.get_position(asset)
        is_in_position = position is not None and position.quantity > 0

        if not is_in_position and rsi < self.rsi_lower:
            broker.order_target_percent(asset, self.position_size)
        elif is_in_position and rsi > self.rsi_upper:
            broker.order_target_percent(asset, 0.0)


# %% [markdown]
# ## 4. Run Backtest
#
# To run a backtest, we need three components:
#
# 1. **DataFeed**: Provides OHLCV data and context (indicators) bar-by-bar
# 2. **Strategy**: Our trading logic (implemented above)
# 3. **Engine**: Orchestrates execution, applies costs, tracks portfolio
#
# ### Execution Mode
#
# The `execution_mode` parameter controls order fill timing:
#
# - `SAME_BAR`: Orders are eligible on the signal bar and need a valid intrabar timing argument.
# - `NEXT_BAR`: Orders submitted after the decision fill at the next bar's open.
#
# For this close-derived daily signal, `NEXT_BAR` establishes a valid event order: observe RSI after
# the UTC close, submit the order, and fill at the next UTC day's open. Other strategies require
# their own latency argument rather than a universal mode choice.

# %% [markdown]
# `DataFeed` combines the OHLCV prices with the context frame the strategy reads its indicators
# from. The price frame must carry `timestamp`, `symbol`, `open`, `high`, `low`, `close` and
# `volume`; the context frame is optional, its columns reach the strategy as a context dictionary,
# and its `timestamp` column has to align with the price frame's.

# %%
feed = DataFeed(
    prices_df=prices_df,
    context_df=context_df,
)

strategy = RSIMeanReversionStrategy(
    rsi_lower=RSI_LOWER,
    rsi_upper=RSI_UPPER,
    position_size=POSITION_SIZE,
)

config = BacktestConfig(
    initial_cash=INITIAL_CASH,
    execution_mode=ExecutionMode.NEXT_BAR,
    commission_rate=FEES,
    slippage_rate=SLIPPAGE,
    share_type=ShareType.FRACTIONAL,
    calendar="crypto",
)

engine = Engine(feed=feed, strategy=strategy, config=config)

# %%
print("Running backtest...")
results = engine.run()
assert results.equity is not None
assert results.equity.periods_per_year == 365
assert all(fill.asset == "BTCUSDT" for fill in results.fills)

closed_trades = [trade for trade in results.trades if trade.status == "closed"]
open_trades = [trade for trade in results.trades if trade.status == "open"]
assert len(closed_trades) == int(results["num_trades"])
assert len(open_trades) <= 1

# %% [markdown]
# **Portfolio performance summary** (RSI mean reversion via ml4t-backtest):

# %%
pl.DataFrame(
    {
        "metric": [
            "Final value ($)",
            "Total return (%)",
            "Sharpe ratio",
            "Max drawdown (%)",
            "Closed round trips",
            "Win rate (%)",
        ],
        "value": [
            float(results["final_value"]),
            float(results["total_return_pct"]),
            float(results["sharpe"]),
            float(results["max_drawdown_pct"]),
            float(results["num_trades"]),
            float(results.get("win_rate", 0) * 100),
        ],
    }
)

# %% [markdown]
# The full sample is descriptive: every date it reports was also used to choose the rule. The
# next cell injects the current
# result so the interpretation cannot drift when data or engine semantics change.

# %%
display(
    Markdown(
        f"The event-driven RSI rule finishes at **${results['final_value']:,.0f}** "
        f"(**{results['total_return_pct']:.2f}%**) with a **{results['sharpe']:.2f} Sharpe ratio** "
        f"and **-{results['max_drawdown_pct']:.2f}% maximum drawdown**. It completes "
        f"**{len(closed_trades)} closed round trips**"
        + (f" and has **{len(open_trades)} open trade** at the sample end." if open_trades else ".")
        + " These in-sample results describe the configured simulation; they do not establish a "
        "persistent mean-reversion premium."
    )
)

# %% [markdown]
# ## 5. Daily Returns Artifact (Calendar vs Session Alignment)

# %%
# Build daily return artifact from equity curve (calendar-day aggregation)
ec = results.equity
equity_df = pl.DataFrame(
    {
        "timestamp": pl.Series("timestamp", ec.timestamps, dtype=pl.Datetime, strict=False),
        "equity": pl.Series("equity", ec.values, dtype=pl.Float64, strict=False),
    }
).sort("timestamp")

calendar_daily = (
    equity_df.with_columns(pl.col("timestamp").dt.truncate("1d").alias("timestamp"))
    .group_by("timestamp")
    .agg(
        open_equity=pl.col("equity").first(),
        close_equity=pl.col("equity").last(),
    )
    .sort("timestamp")
)
calendar_daily = calendar_daily.with_columns(
    pl.lit("BTCUSDT").alias("symbol"),
    ((pl.col("close_equity") - pl.col("close_equity").shift(1)) / pl.col("close_equity").shift(1))
    .fill_null(0.0)
    .alias("daily_return"),
)
assert calendar_daily.n_unique(["symbol", "timestamp"]) == len(calendar_daily)
assert calendar_daily.schema["timestamp"].base_type() == pl.Datetime

OUTPUT_DIR = get_output_dir(16, "single_asset_ml4t_backtest")
daily_returns_path = OUTPUT_DIR / "daily_returns_calendar.parquet"
calendar_daily.select(["symbol", "timestamp", "daily_return"]).write_parquet(daily_returns_path)
print(f"Saved daily returns artifact: {daily_returns_path.name}")

# %% [markdown]
# A three-observation CME example shows why an overnight session boundary differs from a calendar
# boundary. The observation after 17:00 CT carries the following session's label.

# %%
cme_equity_demo = [
    (datetime(2024, 1, 8, 16, 30), 100_000.0),  # Monday session (before 17:00)
    (datetime(2024, 1, 8, 17, 30), 100_400.0),  # Tuesday session (after 17:00)
    (datetime(2024, 1, 9, 16, 0), 100_900.0),  # Tuesday close
]
session_demo = compute_session_pnl(
    cme_equity_demo,
    SessionConfig(calendar="CME_Equity", timezone="America/Chicago"),
)
# %% [markdown]
# CME session-aligned daily returns (3-bar demo crossing the 17:00 CT boundary):

# %%
session_demo.select(["session_date", "return_pct"])

# %% [markdown]
# Session-aligned aggregation is required for futures because trading sessions cross calendar
# boundaries, such as the CME evening open. This notebook's BTC artifact instead uses an explicit
# UTC-day convention for a continuously traded market.

# %% [markdown]
# ## 6. Analyze Trade Statistics
#
# Portfolio metrics describe the path; closed-trade metrics describe the realized round trips.
# Open positions are marked to market in portfolio equity but are excluded from win-rate and
# payoff statistics.

# %%
analyzer = TradeAnalyzer(closed_trades)
assert analyzer.num_trades > 0
net_trade_returns = np.array([trade.net_return for trade in closed_trades])
net_wins = net_trade_returns[net_trade_returns > 0]
net_losses = net_trade_returns[net_trade_returns < 0]
assert len(net_wins) > 0 and len(net_losses) > 0
net_payoff_ratio = float(net_wins.mean() / abs(net_losses.mean()))

pl.DataFrame(
    {
        "metric": [
            "Closed round trips",
            "Win rate (%)",
            "Profit factor",
            "Net payoff ratio",
            "Average net trade (%)",
            "Average bars held",
        ],
        "value": [
            float(analyzer.num_trades),
            analyzer.win_rate * 100,
            analyzer.profit_factor,
            net_payoff_ratio,
            float(net_trade_returns.mean() * 100),
            analyzer.avg_bars_held,
        ],
    }
)

# %% [markdown]
# ## Reconcile the Trade Log and Execution Costs
#
# The engine records slipped entry and exit prices. To recover P&L before modeled execution costs,
# remove entry and exit slippage from those fill prices, then subtract slippage dollars and
# commissions explicitly. The per-trade identity is
#
# $$\text{net P\&L}=\text{reference-price P\&L}-\text{slippage cost}-\text{commission}. $$
#
# The assertion below independently reconciles that identity for every closed round trip.

# %%
trades_df = results.to_trades_dataframe().filter(pl.col("status") == "closed")
assert trades_df["direction"].unique().to_list() == ["long"]

trades_df = (
    trades_df.with_columns(
        entry_reference_price=pl.col("entry_price") - pl.col("entry_slippage"),
        exit_reference_price=pl.col("exit_price") + pl.col("exit_slippage"),
    )
    .with_columns(
        reference_gross_pnl=(pl.col("exit_reference_price") - pl.col("entry_reference_price"))
        * pl.col("quantity")
        * pl.col("multiplier"),
        execution_costs=pl.col("fees")
        + (pl.col("entry_slippage") + pl.col("exit_slippage"))
        * pl.col("quantity").abs()
        * pl.col("multiplier"),
    )
    .with_columns(reconciled_net_pnl=pl.col("reference_gross_pnl") - pl.col("execution_costs"))
)
assert np.allclose(trades_df["reconciled_net_pnl"], trades_df["pnl"], rtol=0, atol=1e-8)

trades_df.select(
    "symbol",
    "entry_time",
    "exit_time",
    "quantity",
    "reference_gross_pnl",
    "execution_costs",
    "pnl",
    "pnl_percent",
).head(10)

# %% [markdown]
# The aggregate below covers closed trades only, so it reconciles realized trade P&L without mixing
# in an end-of-sample open position or its unrealized return.

# %%
closed_gross_pnl = float(trades_df["reference_gross_pnl"].sum())
closed_execution_costs = float(trades_df["execution_costs"].sum())
closed_net_pnl = float(trades_df["pnl"].sum())
assert np.isclose(closed_gross_pnl - closed_execution_costs, closed_net_pnl, atol=1e-8)

pl.DataFrame(
    {
        "component": ["Reference-price P&L", "Execution costs", "Net closed-trade P&L"],
        "value_usdt": [closed_gross_pnl, -closed_execution_costs, closed_net_pnl],
    }
)

# %%
round_trip_cost_bps = 2 * (FEES + SLIPPAGE) * 10_000
display(
    Markdown(
        f"At the configured rates, a completed entry and exit costs approximately "
        f"**{round_trip_cost_bps:.0f} bps of fixed notional** before gap and sizing effects. "
        f"Across the closed trades here, the independently reconciled execution cost is "
        f"**${closed_execution_costs:,.0f}**."
    )
)

# %% [markdown]
# ## 7. Performance Visualization
#
# Portfolio value shows when the strategy is exposed or in cash. Drawdown measures the percentage
# loss from the running equity peak, with zero fixed at the top of the underwater chart.

# %%
ec = results.equity
fig = go.Figure(
    go.Scatter(
        x=ec.timestamps,
        y=ec.values,
        name="Portfolio Value",
        line=dict(color=COLORS["blue"], width=2),
    )
)
fig.update_layout(
    title=(
        "Portfolio value under the RSI rule"
        "<br><sup>Portfolio value net of configured fees and slippage; full sample</sup>"
    ),
    xaxis_title="Date",
    yaxis_title="Portfolio Value (USDT)",
    height=450,
    hovermode="x unified",
)
show_plotly_with_alt(
    fig,
    (
        "Line chart of the RSI rule's portfolio value in USDT over the sample, starting from "
        "the configured initial cash, as the event-driven engine computes it. The rule holds "
        "cash between an exit and the next entry, so the series is flat on every session it "
        "is not invested."
    ),
)

# %%
drawdown_pct = ec.drawdown_series() * 100
drawdown_floor = min(float(drawdown_pct.min()) * 1.05, -1.0)
fig_dd = go.Figure(
    go.Scatter(
        x=ec.timestamps,
        y=drawdown_pct,
        name="Drawdown",
        fill="tozeroy",
        fillcolor=COLORS["silver_muted"],
        line=dict(color=COLORS["negative"]),
    )
)
fig_dd.update_layout(
    title=(
        "Drawdown of the RSI portfolio from its running peak"
        "<br><sup>Close-to-close peak-to-trough loss; net, full sample</sup>"
    ),
    xaxis_title="Date",
    yaxis_title="Drawdown (%)",
    yaxis_range=[drawdown_floor, 0],
    height=400,
    hovermode="x unified",
)
show_plotly_with_alt(
    fig_dd,
    (
        "Filled drawdown chart measured from the portfolio's own running peak, zero at the "
        "top and losses below, in percent. Each point is the distance from the highest value "
        "reached up to that date."
    ),
)

# %% [markdown]
# ## 8. Compare to Buy-and-Hold Benchmark
#
# A benchmark is useful only under a comparable protocol. The buy-and-hold strategy below uses the
# same engine, the same signal-time target weight, next-open execution, fractional units, fee and
# slippage rates, crypto annualization, and sample. Its single entry remains open at the sample
# end.


# %%
class BuyAndHoldStrategy(Strategy):
    """Submit one target-weight entry and retain it through the sample."""

    def __init__(self, position_size: float = 0.95):
        self.position_size = position_size
        self.submitted = False

    def on_data(self, timestamp, data, context, broker):
        """Submit the benchmark entry on the first available bar."""
        asset = "BTCUSDT"
        if not self.submitted and data.get(asset) is not None:
            broker.order_target_percent(asset, self.position_size)
            self.submitted = True


# %%
benchmark_feed = DataFeed(prices_df=prices_df)
benchmark_engine = Engine(
    feed=benchmark_feed,
    strategy=BuyAndHoldStrategy(position_size=POSITION_SIZE),
    config=config,
)
benchmark_results = benchmark_engine.run()
assert benchmark_results.equity is not None
assert benchmark_results.equity.periods_per_year == 365
assert len(benchmark_results.fills) == 1
assert len(benchmark_results.trades) == 1
assert benchmark_results.trades[0].status == "open"

# %% [markdown]
# **Protocol-matched strategy comparison** (daily returns annualized with 365 crypto periods):

# %%
comparison = pl.DataFrame(
    {
        "metric": ["Total return (%)", "Sharpe ratio", "Max drawdown (%)", "Fill count"],
        "rsi_strategy": [
            float(results["total_return_pct"]),
            float(results["sharpe"]),
            float(results["max_drawdown_pct"]),
            float(len(results.fills)),
        ],
        "buy_and_hold": [
            float(benchmark_results["total_return_pct"]),
            float(benchmark_results["sharpe"]),
            float(benchmark_results["max_drawdown_pct"]),
            float(len(benchmark_results.fills)),
        ],
    }
)
comparison.with_columns(pl.col(["rsi_strategy", "buy_and_hold"]).round(3))

# %%
strategy_return = float(results["total_return_pct"])
benchmark_return = float(benchmark_results["total_return_pct"])
return_leader = "Buy-and-hold" if benchmark_return > strategy_return else "The RSI strategy"
display(
    Markdown(
        f"**{return_leader}** leads total return in this sample: **{benchmark_return:.2f}%** "
        f"for buy-and-hold versus **{strategy_return:.2f}%** for RSI. Their Sharpe ratios are "
        f"**{benchmark_results['sharpe']:.2f}** and **{results['sharpe']:.2f}**; maximum "
        f"drawdowns are **-{benchmark_results['max_drawdown_pct']:.2f}%** and "
        f"**-{results['max_drawdown_pct']:.2f}%**, respectively. This is an in-sample exposure "
        "comparison, not evidence that either rule will dominate out of sample."
    )
)

# %%
benchmark_ec = benchmark_results.equity
fig = go.Figure()
fig.add_trace(
    go.Scatter(
        x=ec.timestamps,
        y=ec.values,
        name="RSI Strategy",
        line=dict(color=COLORS["blue"]),
    )
)
fig.add_trace(
    go.Scatter(
        x=benchmark_ec.timestamps,
        y=benchmark_ec.values,
        name="Buy & Hold",
        line=dict(color=COLORS["neutral"], dash="dash"),
    )
)
fig.update_layout(
    title=(
        "Portfolio value, RSI rule against buy-and-hold on one protocol"
        "<br><sup>Portfolio value; same engine, allocation, next-open fills, and costs</sup>"
    ),
    xaxis_title="Date",
    yaxis_title="Portfolio Value (USDT, log scale)",
    yaxis_type="log",
    yaxis_tickformat="~s",
    height=500,
    legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
    hovermode="x unified",
)
show_plotly_with_alt(
    fig,
    (
        "Portfolio value for the RSI rule in solid navy and buy-and-hold in dashed grey, on a "
        "logarithmic axis. Both are run through the same engine with the same allocation, the "
        "same next-open fills and the same costs, from the same starting capital, so the only "
        "difference between them is the entry and exit rule. The axis is logarithmic so that "
        "equal proportional moves are equal distances on the page whatever each series' "
        "level, which is what lets two curves of different magnitude share a panel."
    ),
)

# %% [markdown]
# ## 9. MFE/MAE Analysis
#
# Maximum favorable excursion (MFE) and maximum adverse excursion (MAE) expose each closed trade's
# path between entry and exit. The full-sample levels below are retrospective diagnostics: they
# are read off the same trades that produced them, so they are not stop-loss or take-profit
# recommendations.

# %%
mfe_analyzer = MAEMFEAnalyzer(closed_trades)
levels = mfe_analyzer.optimal_exit_levels()
excursion_df = trades_df.with_row_index("trade_id", offset=1)

# %% [markdown]
# Bars show the highest and lowest marked return reached during each trade; diamonds show the
# terminal realized return. The chart keeps adverse excursions below zero.

# %%
fig = go.Figure()
fig.add_trace(
    go.Bar(
        x=excursion_df["trade_id"],
        y=excursion_df["mfe"] * 100,
        name="Maximum favorable excursion",
        marker_color=COLORS["positive"],
    )
)
fig.add_trace(
    go.Bar(
        x=excursion_df["trade_id"],
        y=excursion_df["mae"] * 100,
        name="Maximum adverse excursion",
        marker_color=COLORS["negative"],
    )
)
fig.add_trace(
    go.Scatter(
        x=excursion_df["trade_id"],
        y=excursion_df["pnl_percent"] * 100,
        name="Realized return",
        mode="markers",
        marker=dict(color=COLORS["blue"], size=8, symbol="diamond"),
    )
)
fig.add_hline(y=0, line_dash="dash", line_color=COLORS["neutral"], line_width=1)
fig.update_layout(
    title=(
        "Favorable and adverse excursion against realized return, by closed trade"
        "<br><sup>Closed RSI round trips; full-sample retrospective diagnostic</sup>"
    ),
    xaxis_title="Closed Trade Number",
    yaxis_title="Return from Entry (%)",
    barmode="relative",
    height=450,
)
show_plotly_with_alt(
    fig,
    (
        "Bar chart over the closed round trips, one pair of bars per trade in the order they "
        "closed: maximum favorable excursion above zero, maximum adverse excursion below, "
        "with the realized return marked as a diamond. The two excursions are the best and "
        "worst unrealized returns reached while the trade was open, so each pair brackets the "
        "diamond between them. Drawn to separate what a trade did while it was open from what "
        "it settled at."
    ),
)

# %%
display(
    Markdown(
        f"The closed trades have an **{mfe_analyzer.edge_ratio:.2f} MFE-to-|MAE| edge ratio**. "
        f"The analyzer's full-sample percentile references are **{levels['stop_loss']:.2%}** for "
        f"adverse excursion and **{levels['take_profit']:.2%}** for favorable excursion, with a "
        f"**{levels['risk_reward']:.2f}** ratio. These levels require separate validation before "
        "they can enter a trading rule."
    )
)

# %% [markdown]
# ## 10. Relating the Event-Driven and Vectorized Implementations
#
# Notebook 03 and this notebook now share the same BTC input, UTC-day aggregation, simple rolling
# RSI, prior-close decision timing, next-open fill convention, fractional units, and percentage
# costs. One deliberate sizing detail remains visible:
#
# - **VectorBT** resolves the percentage size at the execution row's open.
# - **ml4t-backtest** converts the same signal-time target into units at the decision close, queues
#   those units, and fills them at the next open. An overnight gap therefore moves the realized
#   fill-time weight away from the target.
#
# Headline differences alone cannot identify a cause. Notebook 06 isolates engine conventions under
# a dedicated parity contract rather than attributing any residual gap after the fact.

# %% [markdown]
# **Execution approach: VectorBT vs ml4t-backtest.**
#
# | Aspect              | VectorBT (NB 03)     | ml4t-backtest (NB 04)    |
# |---------------------|----------------------|--------------------------|
# | Execution model     | Vectorized           | Event-driven             |
# | Order representation | Timestamped signals | Pending order objects    |
# | Position state      | Portfolio simulation | Explicit broker state    |
# | Parameter analysis  | Natural array workflow | Repeated sequential runs |
# | Trade path records  | Portfolio records    | Per-trade state and excursions |

# %% [markdown]
# ## See Also
#
# - **`03_single_asset_vectorbt`** implements the common RSI rule with an array-oriented portfolio.
# - **`06_framework_parity`** aligns engine conventions under a dedicated parity contract.
# - **`09_performance_reporting`** extends the result into a diagnostic report.

# %% [markdown]
# ## Key Takeaways
#
# The synthesis below is generated from the current run so its result statements remain aligned
# with the executed notebook.

# %%
display(
    Markdown(
        f"""
### 1. Sequential state is the reason to use the event loop

`DataFeed`, `Strategy`, `Engine`, and `Broker` make decisions, pending orders, fills, cash, and
positions explicit. That visibility supports path-dependent logic, but does not make unspecified
assumptions realistic.

### 2. Event ordering is part of the strategy definition

The {RSI_PERIOD}-day close-derived RSI is observed after the UTC day closes. `NEXT_BAR` then fills
the queued fractional order at the next open. The target weight is converted to units at signal
time, so an overnight gap can move the realized fill-time weight.

### 3. Accounting should reconcile independently

The run completes **{len(closed_trades)} closed round trips**. Reference-price P&L less modeled
slippage and commission reconciles to closed-trade net P&L within the asserted tolerance.

### 4. Benchmarks and diagnostics need the same protocol boundary

**{return_leader}** leads this matched in-sample comparison. MFE/MAE levels describe the observed
trade paths; they are not validated exit rules.

**Next:** `06_framework_parity` isolates engine semantics, and `09_performance_reporting` builds the
reporting layer described in Chapter 16, Sections 16.3-16.5.
"""
    )
)

```

Shown in full with attribution under the source's licence. Licence: MIT

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.