RSI اوسط کی واپسی اسٹریٹیجی کا واقعہ محور بیک ٹیسٹ
خلاصہ
یہ نوٹ بک واقعات پر مبنی بیک ٹیسٹنگ انجن کے ذریعے BTC/USDT کے پرپیچوئل بارز کے لیے صرف لانگ RSI اوسط کی جانب واپسی کا قاعدہ نافذ کرتی ہے۔ یہ انٹرا ڈے مشاہدات کو UTC روزانہ بارز میں جمع کرتی ہے، منافع اور نقصان کے سادہ متحرک RSI کا حساب لگاتی ہے، اشاریہ حدِ فروخت سے نیچے ہونے پر داخل ہوتی ہے اور حدِ خرید سے اوپر ہونے پر نکلتی ہے۔ اسٹریٹیجی سرمایہ کے مقررہ حصے کو ہدف بناتی ہے اور کمیشن و سلپیج کا حساب رکھتی ہے۔ مسلسل انجن فیصلوں، زیرِ التوا آرڈرز، تکمیل، پوزیشنوں اور نقدی کو واضح طور پر ظاہر کرتا ہے، جس سے راستے پر منحصر منطق ممکن ہوتی ہے۔
نوٹ بک دکھاتی ہے کہ واقعات کی ترتیب بیک ٹیسٹ کو کیسے بدلتی ہے: سگنلز مکمل دن کی اختتامی قیمت استعمال کرتے ہیں، جبکہ قطار میں لگے آرڈرز اگلے آغاز پر مکمل ہوتے ہیں۔ آرڈر کی اکائیاں سگنل کے وقت کی اختتامی قیمت سے طے ہوتی ہیں، اس لیے رات بھر قیمت کا خلا حقیقی ایکسپوژر بدل سکتا ہے۔ یہ ٹریڈ حسابات میں تطبیق کرتی ہے اور نتائج کا پروٹوکول سے ہم آہنگ بینچ مارک سے موازنہ کرتی ہے، جبکہ واقعہ محور طریقے کو ویکٹرائزڈ تجزیے سے الگ دکھاتی ہے۔ یہ نمونے کے اندر کے نتائج ہیں؛ دستاویز زور دیتی ہے کہ انجن کے واضح میکینکس سے مفروضے خود بخود حقیقت پسندانہ نہیں ہوتے، اور ٹریڈ کی حرکت کی تشخیص اخراج کے قواعد کی توثیق نہیں کرتی۔
اہم خیالات
- واقعہ محور انجن فیصلوں، زیرِ التوا آرڈرز، تکمیل، نقدی اور پوزیشنوں کو واضح بناتا ہے۔
- اسٹریٹیجی حدِ فروخت سے نیچے RSI کی قرأت پر لانگ پوزیشن لیتی ہے اور حدِ خرید سے اوپر کی قرأت پر نکلتی ہے۔
- اختتامی قیمت پر مبنی سگنل اگلے آغاز پر مکمل ہو تو رات بھر کے خلا کے بعد ایکسپوژر مختلف ہو سکتا ہے۔
- بیک ٹیسٹ کی ساکھ اس بات پر منحصر ہے کہ معلومات کے وقت، عمل درآمد، سائز، اخراجات اور حسابات مطلوبہ پروٹوکول سے مطابقت رکھتے ہوں۔
- ٹریڈ کے راستے کی تشخیص مشاہدہ شدہ نتائج بیان کرتی ہے مگر اخراج کے قاعدے کی درستگی ثابت نہیں کرتی۔
ٹیگز
مکمل متن
# 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.
"""
)
)
```ماخذ کا حوالہ دیتے ہوئے مکمل متن دکھایا گیا ہے، ماخذ کے لائسنس کے تحت۔ لائسنس: MIT
یہ خلاصہ اصل ماخذ سے Stratmill کے تحقیقی ایجنٹ نے لکھا ہے؛ یہ ماخذ کی نقل نہیں۔