رفتن به محتوا
همه اسناد کتابخانه

بک‌تست رویدادمحور بازگشت به میانگین RSI برای بیت‌کوین

نوت‌بوک یادگیری ماشین برای معامله‌گری

خلاصه

این دفترچه قاعده بازگشت به میانگینِ فقط خریدِ RSI را برای قراردادهای دائمی BTC/USDT با استفاده از موتور بک‌تست رویدادمحور پیاده‌سازی می‌کند. کندل‌های درون‌روزی را به داده روزانه OHLCV بر مبنای UTC تجمیع می‌کند، شاخص سود و زیان غلتان RSI را محاسبه می‌کند، هنگام افت شاخص به زیر آستانه پایین وارد موقعیت می‌شود و بالاتر از آستانه بالا خارج می‌شود. سفارش‌ها سهم مشخصی از ارزش پرتفوی را هدف می‌گیرند و کمیسیون و لغزش قیمت را لحاظ می‌کنند.

استراتژی از طریق فراخوانی‌های هر کندل، سفارش‌های در انتظار، وضعیت کارگزار و پرشدن در قیمت آغاز کندل بعدی بیان می‌شود. دفترچه این نمایش ترتیبی را با رویکرد برداری مقایسه می‌کند و تأکید دارد که هیچ‌یک بدون فرض‌های صریح درباره زمان‌بندی اطلاعات، اندازه موقعیت، هزینه‌ها و حسابداری معتبر نیست. نتایج معاملات را با سود و زیان قیمت مرجع و معیار پایه‌ای همسو با پروتکل تطبیق می‌دهد. این مثال درسی درباره روش‌شناسی است، نه اثبات یک مزیت پایدار؛ مقایسه درون‌نمونه‌ای و تشخیص مسیر معاملات، قواعد خروج جدید را اعتبارسنجی نمی‌کنند و عملکرد برون‌نمونه‌ای را اثبات نمی‌کنند.

ایده‌های کلیدی

  • قاعده RSI پس از خوانش اشباع فروش وارد موقعیت خرید می‌شود و پس از خوانش اشباع خرید از آن خارج می‌شود.
  • سیگنال‌های محاسبه‌شده از قیمت بسته‌شدن، در صف قرار می‌گیرند و در قیمت آغاز کندل بعدی اجرا می‌شوند.
  • اندازه موقعیت هنگام صدور سیگنال می‌تواند پس از شکاف قیمت شبانه با مواجهه تحقق‌یافته تفاوت داشته باشد.
  • موتورهای رویدادمحور، سفارش‌ها، اجراها، وجه نقد و موقعیت‌ها را برای منطق وابسته به مسیر صریح می‌کنند.
  • اعتبار بک‌تست به هم‌خوانی فرض‌های زمان‌بندی، هزینه، اندازه موقعیت و حسابداری با پروتکل معامله وابسته است.

برچسب‌ها

متن کامل
# Single Asset Backtest with ml4t-backtest


# 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

## Setup

```python
"""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
```

```python
# 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
```

```python
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**."
    )
)
```

## 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.

```python
_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
```

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

```python
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()}")
```

## 2. RSI Indicator Calculation

Compute the same simple rolling gain/loss RSI used by VectorBT's default indicator.

```python
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}")
```

```python
# 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)
```

```python
# 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."
    ),
)
```

## 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)

```python
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)
```

## 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.

`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.

```python
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)
```

```python
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
```

**Portfolio performance summary** (RSI mean reversion via ml4t-backtest):

```python
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),
        ],
    }
)
```

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.

```python
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."
    )
)
```

## 5. Daily Returns Artifact (Calendar vs Session Alignment)

```python
# 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}")
```

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.

```python
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"),
)
```

CME session-aligned daily returns (3-bar demo crossing the 17:00 CT boundary):

```python
session_demo.select(["session_date", "return_pct"])
```

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.

## 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.

```python
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,
        ],
    }
)
```

## 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.

```python
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)
```

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.

```python
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],
    }
)
```

```python
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}**."
    )
)
```

## 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.

```python
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."
    ),
)
```

```python
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."
    ),
)
```

## 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.

```python
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
```

```python
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"
```

**Protocol-matched strategy comparison** (daily returns annualized with 365 crypto periods):

```python
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))
```

```python
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."
    )
)
```

```python
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."
    ),
)
```

## 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.

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

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.

```python
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."
    ),
)
```

```python
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."
    )
)
```

## 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.

**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 |

## 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.

## Key Takeaways

The synthesis below is generated from the current run so its result statements remain aligned
with the executed notebook.

```python
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.
"""
    )
)
```
![notebook output](figures/p1_1.png)
![notebook output](figures/p1_2.png)
![notebook output](figures/p1_3.png)
![notebook output](figures/p1_4.png)
![notebook output](figures/p1_5.png)

با ذکر منبع و مطابق مجوز اثر، به‌طور کامل نمایش داده می‌شود. مجوز: MIT

این خلاصه را عامل پژوهشی Stratmill بر پایه متن اصلی نوشته است؛ نسخه‌ای از اثر منبع نیست.