Pular para o conteúdo
Todos os documentos da biblioteca

Mecânica de backtesting de futuros com multiplicadores de contrato e ressalvas sobre rolagem

Código Machine Learning for Trading

Resumo

Este notebook apresenta a mecânica do backtesting de futuros por meio de um exemplo de momentum long-short em várias classes de ativos. Explica como as especificações dos contratos convertem movimentos de preço em lucros e perdas em dólares por meio de multiplicadores, e como a exposição nocional determina o dimensionamento dos contratos. Compara taxas fixas por contrato com custos percentuais, aborda sessões e margem de futuros e usa a atribuição por setor para examinar a concentração. O exemplo usa dados diários alinhados às sessões e compara o impacto de aplicar ou omitir multiplicadores de contrato.

O backtest é ilustrativo, não fora da amostra: as mesmas datas orientam a regra, e especificações estáticas de contrato e margem são aplicadas ao período histórico. Preços contínuos ajustados por razão dão suporte aos sinais e às marcações, enquanto preços brutos orientam a quantidade de contratos; rolagens reais, custos de rolagem, financiamento e mudanças históricas de margem não são modelados. Portanto, o desempenho em dólares resultante é uma aproximação para pesquisa, não uma estimativa pronta para implantação. A principal lição do notebook é que metadados corretos dos contratos e unidades de custo são essenciais para a contabilidade, enquanto o desempenho de séries contínuas exige ressalvas explícitas sobre execução e rolagem.

Ideias principais

  • O resultado de futuros em dólares depende do multiplicador do contrato, além da variação de preço e da quantidade.
  • Dimensionar posições pelo preço multiplicado pelo multiplicador ajuda a igualar a exposição em dólares entre produtos.
  • Taxas por contrato e custos percentuais representam hipóteses diferentes e não devem ser confundidos.
  • Posições compradas e vendidas em futuros exigem margem, enquanto financiamento e compensações de margem precisam ser modelados separadamente.
  • Preços contínuos ajustados por razão podem apoiar sinais, mas omitir rolagens reais e seus custos limita o backtest.

Tags

Texto completo
# 02_futures_backtesting.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]
# # Futures Backtesting with Contract Specifications
#
# **Docker image**: `ml4t`
#
# This notebook introduces futures-specific backtesting mechanics using the
# **ml4t-backtest** engine. Every date it reports was also used to build the rule, so nothing
# here is an out-of-sample estimate, and the roll handling is illustrative rather than
# deployable.
#
# | Aspect | Equities | Futures |
# |--------|----------|---------|
# | **P&L Formula** | (exit - entry) × qty | (exit - entry) × qty × **multiplier** |
# | **Costs** | % of trade value | **$ per contract** (fixed) |
# | **Sessions** | Market hours (9:30-4 ET) | **Overnight sessions** (e.g., 5 PM CT) |
# | **Long-Short** | Short selling requires borrowing | No stock loan; both legs require margin |
# | **Position Sizing** | Shares × price | Contracts × price × **multiplier** |
#
# **Learning Objectives:**
# 1. Define `ContractSpec` and understand how multipliers convert points to dollars
# 2. Size positions correctly using notional value (price × multiplier)
# 3. Implement a long-short momentum strategy (natural for futures)
# 4. Compare per-contract vs percentage-based costs
# 5. Analyze sector attribution across diversified asset classes
#
# **Book Reference:** Chapter 16, Sections 16.2-16.3 - trading protocol and
# event-driven validation.
#
# **Prerequisites:**
# - Ch2 NB 04 ([`04_cme_futures_eda`](../02_financial_data_universe/04_cme_futures_eda.ipynb)): CME data structure
# - Ch2 NB 05 ([`05_futures_session_aggregation`](../02_financial_data_universe/05_futures_session_aggregation.ipynb)): Session boundaries (4 PM CT)
# - Ch2 NB 06 ([`06_futures_continuous`](../02_financial_data_universe/06_futures_continuous.ipynb)): Continuous contract construction
# - Ch16 NB 01-03: Backtesting first principles and ml4t-backtest basics

# %% [markdown]
# ## Setup

# %%
"""Futures backtesting with multiplier-aware simulation in ml4t-backtest."""

from pathlib import Path

import numpy as np
import plotly.graph_objects as go
import polars as pl
import yaml
from IPython.display import Markdown, display

# Data loading
# ml4t-backtest imports
from ml4t.backtest import (
    AssetClass,
    BacktestConfig,
    ContractSpec,
    DataFeed,
    Engine,
    ExecutionMode,
    OrderSide,
    Strategy,
)
from ml4t.backtest.config import CommissionType, SlippageType

from data import load_cme_futures
from utils.paths import REPO_ROOT
from utils.style import COLOR_CYCLER, COLORS, show_plotly_with_alt

# %% tags=["parameters"]
# Production defaults - Papermill injects overrides after this cell
START_DATE = "2018-01-01"
END_DATE = "2024-01-01"
INITIAL_CASH = 10_000_000
LOOKBACK = 63  # ~3 months momentum lookback
REBALANCE_EVERY = 21  # ~monthly rebalance (trading days)
LONG_N = 2  # Long top 2 by momentum
SHORT_N = 2  # Short bottom 2 by momentum
RUN_FULL_UNIVERSE = True


# %%
def format_usd(value: float) -> str:
    """Format signed dollar values with the sign before the currency symbol."""
    sign = "-" if value < 0 else ""
    return f"{sign}${abs(value):,.0f}"


# %%
# Six demo products - one per sector
PRODUCTS = ["ES", "CL", "GC", "ZN", "6E", "ZC"]

# %% [markdown]
# Contract specifications are current static metadata. The margin percentages in the YAML are
# anchored to 2025-12-31 prices and a 2026-05-16 CME rates snapshot; the simulation applies them
# across 2018-2023 and does not model historical specification or margin changes.

# %%
SPECS_PATH = REPO_ROOT / "data" / "futures" / "market" / "futures_specs.yaml"


def load_contract_specs_from_yaml(yaml_path: Path) -> dict[str, ContractSpec]:
    """Load point-value multipliers and margin percentages from the futures specification."""
    with yaml_path.open() as file:
        raw = yaml.safe_load(file)

    specs = {}
    for symbol, info in raw["products"].items():
        initial_margin = info.get("initial_margin_pct")
        maintenance_margin = info.get("maintenance_margin_pct")
        assert (initial_margin is None) == (maintenance_margin is None)
        margin_pct = (initial_margin, maintenance_margin) if initial_margin is not None else None
        specs[symbol] = ContractSpec(
            symbol=symbol,
            asset_class=AssetClass.FUTURE,
            multiplier=info["tick_value"] / info["tick_size"],
            tick_size=info["tick_size"],
            margin_pct=margin_pct,
        )
    return specs


FULL_SPECS = load_contract_specs_from_yaml(SPECS_PATH)
DEMO_SPECS = {product: FULL_SPECS[product] for product in PRODUCTS}

# %% [markdown]
# ## 1. Load CME Futures Data
#
# We load session-aligned daily bars via `load_cme_futures()`. These bars use
# **4 PM CT session boundaries** (not midnight UTC) - see Ch2 NB 05 for the
# aggregation methodology that converts hourly Databento bars to daily sessions.

# %%
cme_data = load_cme_futures(products=PRODUCTS, tenors=[0], start_date=START_DATE, end_date=END_DATE)

available_products = sorted(cme_data["product"].unique().to_list())
missing_products = [product for product in PRODUCTS if product not in available_products]
if missing_products:
    print(f"Unavailable in current data slice: {missing_products}")
PRODUCTS = [product for product in PRODUCTS if product in available_products]
if len(PRODUCTS) < LONG_N + SHORT_N:
    raise ValueError(
        "Need at least LONG_N + SHORT_N products after filtering for available futures data."
    )

print(f"Loaded {len(cme_data):,} daily bars for {cme_data['product'].n_unique()} products")
print(f"Date range: {cme_data['session_date'].min()} to {cme_data['session_date'].max()}")
print(f"Products: {sorted(cme_data['product'].unique().to_list())}")

# %% [markdown]
# Sample of the loaded session-daily bars (first six rows):

# %%
cme_data.head(6)

# %% [markdown]
# ## 2. Contract Specifications - Why Multipliers Matter
#
# Each futures contract has a **multiplier** (point value) that converts price
# changes to dollar P&L:
#
# $$\text{P\&L} = (\text{exit} - \text{entry}) \times \text{qty} \times \textbf{multiplier}$$
#
# Omitting the multiplier understates or overstates dollar P&L whenever the point value differs
# from one. The table below computes the effect from the current contract specifications.

# %%
spec_rows = []
for product in PRODUCTS:
    spec = DEMO_SPECS[product]
    spec_rows.append(
        {
            "product": product,
            "point_value_usd": float(spec.multiplier),
            "tick_size": float(spec.tick_size),
            "initial_margin_pct": float(spec.margin_pct[0]),
            "maintenance_margin_pct": float(spec.margin_pct[1]),
        }
    )
pl.DataFrame(spec_rows)

# %% [markdown]
# Multiplier range and approximate notional value per contract:

# %%
notional_rows = []
for product in PRODUCTS:
    spec = DEMO_SPECS[product]
    # Notional uses the contemporaneous traded level, not the ratio-adjusted history.
    last_price = cme_data.filter(pl.col("product") == product)["raw_close"][-1]
    notional_rows.append(
        {
            "product": product,
            "multiplier": float(spec.multiplier),
            "last_price": float(last_price),
            "notional_usd": float(last_price * spec.multiplier),
        }
    )
pl.DataFrame(notional_rows)

# %% [markdown]
# **P&L error without contract multipliers.** A representative price move is
# repriced as if every contract had multiplier = 1. The "correct" column shows
# actual dollar P&L per contract; the error grows with the multiplier.

# %%
examples = [
    ("ES", 10.0),  # 10 S&P index points
    ("CL", 0.50),  # 50 cents per barrel
    ("GC", 5.0),  # $5 per ounce
    ("ZN", 0.5),  # half a point
    ("6E", 0.0010),  # 10 pips
    ("ZC", 2.0),  # 2 cents per bushel
]
error_rows = []
for product, move in examples:
    mult = DEMO_SPECS[product].multiplier
    error_rows.append(
        {
            "product": product,
            "move": move,
            "wrong_pnl_usd": move * 1.0,
            "correct_pnl_usd": move * mult,
            "multiplier": mult,
        }
    )
pl.DataFrame(error_rows)

# %%
# Visualize multiplier range across products
fig = go.Figure()
fig.add_trace(
    go.Bar(
        x=[DEMO_SPECS[p].symbol for p in PRODUCTS],
        y=[DEMO_SPECS[p].multiplier for p in PRODUCTS],
        text=[f"${DEMO_SPECS[p].multiplier:,.0f}" for p in PRODUCTS],
        textposition="outside",
        marker_color=COLORS["blue"],
    )
)
fig.update_layout(
    title="Contract multiplier by product, US dollars per point",
    xaxis_title="Product",
    yaxis_title="Multiplier ($)",
    yaxis_type="log",
    height=400,
)
show_plotly_with_alt(
    fig,
    (
        "Bar chart of contract multiplier by product, one bar per product, on a logarithmic "
        "dollar axis with each bar labelled by its own multiplier. The axis is logarithmic "
        "because the multipliers are set by each contract's specification rather than by "
        "anything comparable across products, and a linear axis would put most of them on the "
        "baseline. Drawn to establish what has to be applied before a point move in any of "
        "these products can be read as a dollar move."
    ),
)

# %% [markdown]
# ## 3. Prepare Data for DataFeed
#
# The ml4t-backtest `DataFeed` requires: `timestamp` (Datetime), `symbol`,
# `open`, `high`, `low`, `close`, `volume`. CME data keeps the canonical `product` identifier; the
# feed-boundary alias to `symbol` is required by the generic engine. The timestamp is a session
# label for a 4 PM CT close, not a midnight observation.
#
# This example uses ratio-adjusted continuous OHLC for signals and engine marks, while the raw
# front-contract close determines contract counts. That avoids splice jumps in momentum but does
# not represent explicit rolls. The reported dollar P&L is a continuous-series research
# approximation that excludes roll orders, roll costs, and contract-specific fill history.

# %%
feed_source = cme_data.with_columns(
    pl.col("session_date").cast(pl.Datetime).alias("timestamp"),
    pl.col("product").alias("symbol"),
)

prices_df = feed_source.select(
    "timestamp",
    "symbol",
    pl.col("adj_open").alias("open"),
    pl.col("adj_high").alias("high"),
    pl.col("adj_low").alias("low"),
    pl.col("adj_close").alias("close"),
    "volume",
).sort(["timestamp", "symbol"])

roll_transitions = (
    cme_data.sort(["product", "session_date"])
    .with_columns(
        (pl.col("cum_ratio") != pl.col("cum_ratio").shift(1).over("product"))
        .fill_null(False)
        .alias("roll_transition")
    )["roll_transition"]
    .sum()
)

print(f"DataFeed prices: {prices_df.shape}")
print(f"Schema: {dict(prices_df.schema)}")
print(f"Detected ratio-adjustment transitions in the demo panel: {roll_transitions:,}")

# %% [markdown]
# Sample DataFeed bar (first timestamp, all assets):

# %%
first_ts = prices_df["timestamp"][0]
prices_df.filter(pl.col("timestamp") == first_ts)

# %% [markdown]
# ## 4. Momentum Signal
#
# We compute a 63-session trailing return for each product. This signal drives
# cross-sectional ranking in the strategy.

# %%
# Compute trailing momentum per product
momentum_df = feed_source.sort(["symbol", "timestamp"]).with_columns(
    (pl.col("adj_close") / pl.col("adj_close").shift(LOOKBACK).over("symbol") - 1).alias("momentum")
)

# The raw close enters only the contemporaneous notional-sizing denominator.
signals_df = momentum_df.select(["timestamp", "symbol", "raw_close", "momentum"])

valid_count = signals_df.filter(pl.col("momentum").is_not_null()).shape[0]
print(f"Signal rows: {len(signals_df):,} ({valid_count:,} with valid momentum)")

# %%
# Visualize momentum by product over time
fig = go.Figure()
for product in PRODUCTS:
    prod_data = momentum_df.filter((pl.col("symbol") == product) & pl.col("momentum").is_not_null())
    fig.add_trace(
        go.Scatter(
            x=prod_data["timestamp"].to_list(),
            y=prod_data["momentum"].to_list(),
            name=product,
            mode="lines",
            line={"color": COLOR_CYCLER[PRODUCTS.index(product)]},
        )
    )

fig.update_layout(
    title=f"Trailing {LOOKBACK}-session return by product",
    xaxis_title="Date",
    yaxis_title="Trailing return",
    yaxis_tickformat=".0%",
    height=450,
    legend=dict(orientation="h", yanchor="bottom", y=1.02),
)
# The widest product is named from the data rather than asserted: START_DATE is a papermill
# parameter, so a shorter window need not contain the 2020 crude-oil episode at all.
_ranges = {
    product: (
        momentum_df.filter((pl.col("symbol") == product) & pl.col("momentum").is_not_null())[
            "momentum"
        ].min(),
        momentum_df.filter((pl.col("symbol") == product) & pl.col("momentum").is_not_null())[
            "momentum"
        ].max(),
    )
    for product in PRODUCTS
}
_widest = max(_ranges, key=lambda k: (_ranges[k][1] or 0) - (_ranges[k][0] or 0))
_lo, _hi = _ranges[_widest]
show_plotly_with_alt(
    fig,
    (
        f"Line chart of each product's trailing {LOOKBACK}-session return over the sample, one "
        "line per product on a shared date axis. Returns rather than prices, so products "
        "whose price levels differ by orders of magnitude can share an axis at all. Drawn "
        "because this is the quantity a cross-sectional ranking sorts on: what the rule "
        "compares each month is these lines' values on one date."
    ),
)

# %% [markdown]
# ## 5. Futures Momentum Strategy
#
# Cross-sectional momentum ranks products by recent performance. We go **long**
# the top performers and **short** the bottom performers. This long-short
# structure is natural for futures:
#
# - **No stock-loan step**: Short futures require margin rather than borrowed shares
# - **Symmetric P&L**: Long and short have identical cost structure
# - **Explicit gross exposure**: Positive and negative notionals can be budgeted together
#
# ### Position Sizing for Futures
#
# Each position targets equal notional exposure:
#
# $$\text{contracts} = \left\lfloor \frac{\text{allocation}}{\text{price} \times \text{multiplier}} \right\rfloor$$
#
# The integer floor keeps each selected product at or below its allocation. If one contract exceeds
# that allocation, the target is not traded rather than being forced into an oversized position.


# %%
def compute_target_contracts(
    data,
    specs: dict[str, ContractSpec],
    long_n: int,
    short_n: int,
    capital_base: float,
) -> tuple[dict[str, int], int] | None:
    """Rank current signals and convert equal notionals to signed integer contracts."""
    momentum = {
        asset: value
        for asset, bar in data.items()
        if (value := bar.get("signals", {}).get("momentum")) is not None and np.isfinite(value)
    }
    if len(momentum) < long_n + short_n:
        return None

    ranked = sorted(momentum, key=momentum.get, reverse=True)
    directions = {asset: 1 for asset in ranked[:long_n]}
    directions.update({asset: -1 for asset in ranked[-short_n:]})
    allocation = capital_base / (long_n + short_n)
    targets = {}
    skipped = 0
    for asset, direction in directions.items():
        raw_close = data[asset].get("signals", {}).get("raw_close")
        if raw_close is None or not np.isfinite(raw_close) or raw_close <= 0:
            continue
        quantity = int(allocation / (raw_close * specs[asset].multiplier))
        if quantity == 0:
            skipped += 1
        else:
            targets[asset] = direction * quantity
    return targets, skipped


# %%
def submit_contract_deltas(target_quantities: dict[str, int], broker) -> None:
    """Submit signed order deltas from current to target contract quantities."""
    for asset in sorted(set(broker.positions) | set(target_quantities)):
        position = broker.get_position(asset)
        current_quantity = position.quantity if position is not None else 0.0
        delta = target_quantities.get(asset, 0.0) - current_quantity
        if abs(delta) < 1e-12:
            continue
        side = OrderSide.BUY if delta > 0 else OrderSide.SELL
        broker.submit_order(asset, abs(delta), side)


# %%
class FuturesMomentumStrategy(Strategy):
    """Cross-sectional momentum on futures with proper contract sizing.

    Rebalances every N bars: long top performers, short bottom performers.
    Position sizing uses notional value (price x multiplier) to ensure
    equal dollar exposure across products with different contract sizes.
    """

    def __init__(
        self,
        contract_specs: dict[str, ContractSpec],
        long_n: int = 2,
        short_n: int = 2,
        rebalance_every: int = 21,
    ):
        self.specs = contract_specs
        self.long_n = long_n
        self.short_n = short_n
        self.rebalance_every = rebalance_every
        self.bar_count = 0
        self.target_history = []
        self.zero_contract_targets = 0

    def on_data(self, timestamp, data, context, broker):
        self.bar_count += 1
        if self.bar_count % self.rebalance_every != 0:
            return
        current_equity = broker.get_account_value()
        if current_equity <= 0:
            return
        target_result = compute_target_contracts(
            data, self.specs, self.long_n, self.short_n, current_equity
        )
        if target_result is None:
            return
        target_quantities, skipped = target_result
        self.zero_contract_targets += skipped
        self.target_history.append((timestamp, dict(sorted(target_quantities.items()))))
        submit_contract_deltas(target_quantities, broker)


# %% [markdown]
# ## 6. Configure and Run Backtest
#
# The `BacktestConfig` controls all behavioral settings. For futures:
#
# - `CommissionType.PER_CONTRACT` charges an illustrative **two dollars per contract per fill
#   side**, which is a round teaching number rather than a broker quote
# - `allow_short_selling=True` enables short positions
# - `allow_leverage=True` activates the per-product margin percentages in `ContractSpec`
# - 5 bps percentage slippage is a simplifying common-unit assumption; a production futures
#   simulator would use product-specific ticks, spread, liquidity, and roll costs
#
# Momentum and the raw sizing price are observed at session close. `ExecutionMode.NEXT_BAR` holds
# the submitted integer quantity fixed and fills it at the following session's adjusted open, so the
# signal does not receive the return into its own fill.
#
# `ContractSpec` is **product metadata** (static per instrument), not a behavioral
# knob. Pass `contract_specs=` to the `Engine` constructor - the Engine threads
# it through to the `Broker` automatically.

# %%
config = BacktestConfig(
    initial_cash=INITIAL_CASH,
    execution_mode=ExecutionMode.NEXT_BAR,
    # Illustrative per-contract commission
    commission_type=CommissionType.PER_CONTRACT,
    commission_per_share=2.00,
    commission_minimum=2.00,
    # Slippage: 5 bps of price
    slippage_type=SlippageType.PERCENTAGE,
    slippage_rate=0.0005,
    # Futures require short selling and leverage
    allow_short_selling=True,
    allow_leverage=True,
)

# Create DataFeed with prices and momentum signals
feed = DataFeed(prices_df=prices_df, signals_df=signals_df)

# Create strategy
strategy = FuturesMomentumStrategy(
    contract_specs=DEMO_SPECS,
    long_n=LONG_N,
    short_n=SHORT_N,
    rebalance_every=REBALANCE_EVERY,
)

# Create engine with contract specs - Engine threads them to the Broker
engine = Engine(feed=feed, strategy=strategy, config=config, contract_specs=DEMO_SPECS)

# Run backtest
result = engine.run()

print(f"Completed target rebalances: {len(strategy.target_history):,}")
print(f"Targets below one-contract granularity: {strategy.zero_contract_targets:,}")

# %% [markdown]
# **Futures momentum backtest - with contract specs.**

# %%
pl.DataFrame(
    {
        "metric": [
            "Initial capital ($)",
            "Final value ($)",
            "Total return (%)",
            "Sharpe ratio",
            "Max drawdown (%)",
            "Total trades",
            "Win rate (%)",
        ],
        "value": [
            float(INITIAL_CASH),
            float(result.metrics["final_value"]),
            float(result.metrics["total_return_pct"]),
            float(result.metrics["sharpe"]),
            float(result.metrics["max_drawdown_pct"]),
            float(result.metrics["num_trades"]),
            float(result.metrics["win_rate"] * 100),
        ],
    }
)

# %% [markdown]
#
# %%
display(
    Markdown(
        f"The six-product teaching run returns **{result.metrics['total_return_pct']:.2f}%** with "
        f"a **{result.metrics['sharpe']:.2f} Sharpe** and "
        f"**{result.metrics['max_drawdown_pct']:.2f}% maximum drawdown**. The result is descriptive "
        "and in-sample. It motivates volatility targeting and portfolio-level risk limits rather "
        "than supporting a performance claim."
    )
)

# %% [markdown]
# ## 7. Analyze Trades
#
# Verify that the broker applied the correct multiplier per product.
# Each trade's P&L should reflect the contract multiplier.

# %%
trades_df = result.to_trades_dataframe()
closed_trades = trades_df.filter(pl.col("status") == "closed")

print(f"Total trades: {len(trades_df)} ({len(closed_trades)} closed)")

# %% [markdown]
# Sample of closed trades with multiplier-correct P&L and cost decomposition:

# %%
closed_trades.select(
    [
        "symbol",
        "direction",
        "quantity",
        "entry_price",
        "exit_price",
        "pnl",
        "pnl_percent",
        "gross_pnl",
        "cost_drag",
        "bars_held",
    ]
).head(10)

# %% [markdown]
# Trade summary by product (P&L, win rate):

# %%
trade_summary = (
    closed_trades.group_by("symbol")
    .agg(
        pl.len().alias("trades"),
        pl.col("pnl").sum().alias("total_pnl"),
        pl.col("pnl").mean().alias("avg_pnl"),
        (pl.col("pnl") > 0).mean().alias("win_rate"),
    )
    .sort("total_pnl", descending=True)
)
trade_summary

# %%
best_product = trade_summary.row(0, named=True)
worst_product = trade_summary.row(-1, named=True)
display(
    Markdown(
        f"P&L is concentrated: **{best_product['symbol']}** contributes "
        f"**{format_usd(best_product['total_pnl'])}**, while "
        f"**{worst_product['symbol']}** contributes "
        f"**{format_usd(worst_product['total_pnl'])}**. This small-universe result motivates the broader "
        "universe comparison, but does not establish diversification."
    )
)

# %% [markdown]
# ## 8. Without Multipliers - The Error
#
# A controlled counterfactual replaces every broker multiplier with one while replaying the exact
# target-contract schedule produced by the multiplier-aware run. This prevents the counterfactual's
# different equity path from feeding back into sizing. The counterfactual changes the engine's
# position valuation, margin basis, and point-to-dollar P&L conversion, not the intended orders.

# %%
UNIT_MULTIPLIER_SPECS = {
    product: ContractSpec(
        symbol=product,
        asset_class=AssetClass.FUTURE,
        multiplier=1.0,
        tick_size=spec.tick_size,
        margin_pct=spec.margin_pct,
    )
    for product, spec in DEMO_SPECS.items()
}


class TargetScheduleStrategy(Strategy):
    """Replay a precomputed signed-contract schedule without recalculating size."""

    def __init__(self, schedule):
        self.schedule = schedule
        self.target_history = []

    def on_data(self, timestamp, data, context, broker):
        if timestamp not in self.schedule:
            return
        target_quantities = self.schedule[timestamp]
        self.target_history.append((timestamp, target_quantities))
        submit_contract_deltas(target_quantities, broker)


# %%
target_schedule = dict(strategy.target_history)
feed_nomult = DataFeed(prices_df=prices_df, signals_df=signals_df)
strategy_nomult = TargetScheduleStrategy(target_schedule)
engine_nomult = Engine(
    feed=feed_nomult,
    strategy=strategy_nomult,
    config=config,
    contract_specs=UNIT_MULTIPLIER_SPECS,
)
result_nomult = engine_nomult.run()

assert strategy.target_history == strategy_nomult.target_history

# %%
pl.DataFrame(
    {
        "metric": ["Final value ($)", "Total return (%)", "Max drawdown (%)"],
        "with_specs": [
            result.metrics["final_value"],
            result.metrics["total_return_pct"],
            result.metrics["max_drawdown_pct"],
        ],
        "without_multipliers": [
            result_nomult.metrics["final_value"],
            result_nomult.metrics["total_return_pct"],
            result_nomult.metrics["max_drawdown_pct"],
        ],
    }
)

# %%
# Overlay equity curves
fig = go.Figure()
ec_with = result.equity
ec_without = result_nomult.equity

fig.add_trace(
    go.Scatter(
        x=ec_with.timestamps,
        y=ec_with.values,
        name="Multiplier-aware",
        line=dict(color=COLORS["blue"], width=2),
    )
)
fig.add_trace(
    go.Scatter(
        x=ec_without.timestamps,
        y=ec_without.values,
        name="Unit-multiplier counterfactual",
        line=dict(color=COLORS["neutral"], width=2, dash="dash"),
    )
)
fig.add_hline(y=INITIAL_CASH, line_dash="dot", line_color=COLORS["neutral"])

fig.update_layout(
    title="Equity under broker multipliers and under unit multipliers",
    xaxis_title="Date",
    yaxis_title="Portfolio Value ($)",
    yaxis_tickformat="$,.0f",
    height=450,
    legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
)
# The description reads its numbers off the plotted series rather than naming the ones this
# run happened to produce: INITIAL_CASH is a papermill parameter, so a test-mode run plots a
# different capital base and any hard-coded figure here would contradict its own chart.
show_plotly_with_alt(
    fig,
    (
        "Line chart of portfolio value in dollars for two runs of the same strategy on the "
        "same signals. The solid navy line applies each contract's real multiplier; the "
        "dashed grey line is a counterfactual in which every multiplier is replaced by one "
        "and nothing else is changed. Both start from the same capital and share an axis. "
        "Drawn as a pair because the multiplier is the only difference between them, so the "
        "panel isolates what that one field does to a futures backtest."
    ),
)

# %% [markdown]
#
# %%
display(
    Markdown(
        f"With the same intended contract targets, the multiplier-aware run returns "
        f"**{result.metrics['total_return_pct']:.2f}%**, versus "
        f"**{result_nomult.metrics['total_return_pct']:.2f}%** under unit multipliers. The gap "
        "shows why point values are part of the accounting contract; neither curve should be read "
        "as a deployable rolled-futures performance estimate."
    )
)

# %% [markdown]
# ## 9. Sector Attribution
#
# Futures span multiple asset classes. Each product is mapped to its sector and its realized P&L
# summed within the sector. In this six-product demonstration every sector holds exactly one
# product, so the chart below relabels per-product P&L rather than aggregating across a sector;
# the grouping earns its keep only on a universe wide enough for a sector to hold several
# products. Nothing here assumes the sectors are uncorrelated.

# %%
SECTOR_MAP = {
    "ES": "Equity Index",
    "CL": "Energy",
    "GC": "Metals",
    "ZN": "Rates",
    "6E": "FX",
    "ZC": "Agriculture",
}

sector_pnl = (
    closed_trades.with_columns(
        pl.col("symbol").replace_strict(SECTOR_MAP, default="Other").alias("sector")
    )
    .group_by("sector")
    .agg(
        pl.col("pnl").sum().alias("total_pnl"),
        pl.len().alias("trades"),
        (pl.col("pnl") > 0).mean().alias("win_rate"),
    )
    .sort("total_pnl", descending=True)
)
sector_pnl

# %%
# Visualize sector P&L
fig = go.Figure()
fig.add_trace(
    go.Bar(
        x=sector_pnl["sector"].to_list(),
        y=sector_pnl["total_pnl"].to_list(),
        marker_color=[
            COLORS["positive"] if pnl > 0 else COLORS["negative"]
            for pnl in sector_pnl["total_pnl"].to_list()
        ],
        text=[format_usd(p) for p in sector_pnl["total_pnl"].to_list()],
        textposition="outside",
    )
)
fig.update_layout(
    title="Realized P&L by sector",
    xaxis_title="Sector",
    yaxis_title="Total P&L ($)",
    yaxis_tickformat="$,.0f",
    height=400,
)
show_plotly_with_alt(
    fig,
    (
        "Bar chart of realized profit and loss by sector, one bar per sector, each labelled "
        "with its dollar value and coloured green above zero and red below. Sectors are "
        "ordered by their value rather than alphabetically. Each sector holds a single "
        "product in this demonstration, so the chart relabels per-product profit and loss by "
        "the sector its product belongs to rather than aggregating several products within a "
        "sector."
    ),
)

# %% [markdown]
#
# %%
best_sector = sector_pnl.row(0, named=True)
worst_sector = sector_pnl.row(-1, named=True)
display(
    Markdown(
        f"With one product per sector, sector and product attribution are identical. "
        f"**{best_sector['sector']}** contributes the most P&L "
        f"(**{format_usd(best_sector['total_pnl'])}**), while "
        f"**{worst_sector['sector']}** contributes the least "
        f"(**{format_usd(worst_sector['total_pnl'])}**). The wider universe tests whether "
        "adding eligible products changes this concentration."
    )
)

# %% [markdown]
# ## 10. Full Available Universe
#
# Scale from six demo products to every product present in both the current CME data snapshot and
# the current specifications file. This is a fixed, current teaching universe, not historical
# membership or a survivorship-free universe estimate. Product eligibility on each bar still
# depends on data availability at that timestamp.

# %%
print(f"Loaded {len(FULL_SPECS)} product specifications from YAML")

# %% [markdown]
# Cross-check the instantiated point values against the independent tick-value identity in the raw
# YAML: point value equals tick value divided by tick size.

# %%
with SPECS_PATH.open() as file:
    raw_specification = yaml.safe_load(file)["products"]

verify_rows = []
for product in PRODUCTS:
    engine_multiplier = DEMO_SPECS[product].multiplier
    tick_identity = (
        raw_specification[product]["tick_value"] / raw_specification[product]["tick_size"]
    )
    verify_rows.append(
        {
            "product": product,
            "engine_multiplier": float(engine_multiplier),
            "tick_value_over_tick_size": float(tick_identity),
            "match": abs(engine_multiplier - tick_identity) < 1e-12,
        }
    )
multiplier_check = pl.DataFrame(verify_rows)
assert multiplier_check["match"].all()
multiplier_check

# %%
if RUN_FULL_UNIVERSE:
    cme_all = load_cme_futures(tenors=[0], start_date=START_DATE, end_date=END_DATE)
    available_products = set(cme_all["product"].unique().to_list())
    universe = sorted(available_products & set(FULL_SPECS.keys()))

    print(f"\nAvailable in data: {len(available_products)} products")
    print(f"Have specs for:    {len(FULL_SPECS)} products")
    print(f"Universe:          {len(universe)} products")
    print(f"Products: {universe}")

    cme_all = cme_all.filter(pl.col("product").is_in(universe))

# %%
if RUN_FULL_UNIVERSE:
    feed_full_source = cme_all.with_columns(
        pl.col("session_date").cast(pl.Datetime).alias("timestamp"),
        pl.col("product").alias("symbol"),
    )

    prices_full = feed_full_source.select(
        "timestamp",
        "symbol",
        pl.col("adj_open").alias("open"),
        pl.col("adj_high").alias("high"),
        pl.col("adj_low").alias("low"),
        pl.col("adj_close").alias("close"),
        "volume",
    ).sort(["timestamp", "symbol"])

    signals_full = (
        feed_full_source.sort(["symbol", "timestamp"])
        .with_columns(
            (pl.col("adj_close") / pl.col("adj_close").shift(LOOKBACK).over("symbol") - 1).alias(
                "momentum"
            )
        )
        .select(["timestamp", "symbol", "raw_close", "momentum"])
    )

# %%
if RUN_FULL_UNIVERSE:
    universe_specs = {p: FULL_SPECS[p] for p in universe}

    feed_full = DataFeed(prices_df=prices_full, signals_df=signals_full)
    strategy_full = FuturesMomentumStrategy(
        contract_specs=universe_specs,
        long_n=6,
        short_n=6,
        rebalance_every=REBALANCE_EVERY,
    )
    engine_full = Engine(
        feed=feed_full, strategy=strategy_full, config=config, contract_specs=universe_specs
    )

    result_full = engine_full.run()
    print(f"Full-universe target rebalances: {len(strategy_full.target_history):,}")
    print(f"Full-universe targets below one contract: {strategy_full.zero_contract_targets:,}")

# %% [markdown]
# Six-product baseline vs full CME universe - same momentum rule, same costs:

# %%
if RUN_FULL_UNIVERSE:
    comparison = pl.DataFrame(
        {
            "metric": [
                "Products",
                "Final value ($)",
                "Sharpe ratio",
                "Max drawdown (%)",
                "Total trades",
            ],
            "six_products": [
                float(len(PRODUCTS)),
                float(result.metrics["final_value"]),
                float(result.metrics["sharpe"]),
                float(result.metrics["max_drawdown_pct"]),
                float(result.metrics["num_trades"]),
            ],
            "full_universe": [
                float(len(universe)),
                float(result_full.metrics["final_value"]),
                float(result_full.metrics["sharpe"]),
                float(result_full.metrics["max_drawdown_pct"]),
                float(result_full.metrics["num_trades"]),
            ],
        }
    )
else:
    comparison = pl.DataFrame({"note": ["Skipped full-universe scaling (RUN_FULL_UNIVERSE=False)"]})
comparison

# %% [markdown]
#
# %%
if RUN_FULL_UNIVERSE:
    sharpe_relation = (
        "higher" if result_full.metrics["sharpe"] > result.metrics["sharpe"] else "lower"
    )
    drawdown_relation = (
        "higher"
        if result_full.metrics["max_drawdown_pct"] > result.metrics["max_drawdown_pct"]
        else "lower"
    )
    display(
        Markdown(
            f"Expanding from **{len(PRODUCTS)} to {len(universe)} products** changes closed trades "
            f"from **{result.metrics['num_trades']:.0f} to {result_full.metrics['num_trades']:.0f}**. "
            f"The broader run has a **{sharpe_relation} Sharpe** "
            f"(**{result_full.metrics['sharpe']:.2f}** versus **{result.metrics['sharpe']:.2f}**) "
            f"and **{drawdown_relation} maximum drawdown** "
            f"(**{result_full.metrics['max_drawdown_pct']:.2f}%** versus "
            f"**{result.metrics['max_drawdown_pct']:.2f}%**). Universe breadth alone does not "
            "establish diversification; Ch17-19 add risk-targeted sizing and sector constraints."
        )
    )

# %% [markdown]
# ## 11. Cost Analysis - Per-Contract vs Percentage
#
# A single percentage-of-notional commission cannot reproduce a constant per-contract fee across
# products with different unit notionals. We compare the configured illustrative $2-per-contract
# per-side fee with current notionals; this is not a broker quote.

# %% [markdown]
# Notional value per contract by product, and a flat \$2 commission expressed as
# a percentage of notional. The cost percentage varies materially across
# products because their multipliers and prices differ; the printed min/max
# below quantifies the spread for the six demo contracts.

# %%
cost_rows = []
for product in PRODUCTS:
    spec = DEMO_SPECS[product]
    price = float(cme_data.filter(pl.col("product") == product)["raw_close"][-1])
    notional = price * spec.multiplier
    cost_rows.append(
        {
            "product": product,
            "price": price,
            "multiplier": float(spec.multiplier),
            "notional_usd": notional,
            "two_dollar_pct_of_notional": 2.0 / notional * 100,
        }
    )
cost_table = pl.DataFrame(cost_rows)
_cost_min = cost_table["two_dollar_pct_of_notional"].min()
_cost_max = cost_table["two_dollar_pct_of_notional"].max()
print(
    f"$2/contract cost as % of notional: min {_cost_min:.4f}%, "
    f"max {_cost_max:.4f}% (ratio {_cost_max / _cost_min:.1f}×)"
)
cost_table

# %%
smallest_notional = cost_table.sort("notional_usd").row(0, named=True)
largest_notional = cost_table.sort("notional_usd").row(-1, named=True)
display(
    Markdown(
        f"The current demo notionals range from **${smallest_notional['notional_usd']:,.0f}** "
        f"({smallest_notional['product']}) to **${largest_notional['notional_usd']:,.0f}** "
        f"({largest_notional['product']}), a **{largest_notional['notional_usd'] / smallest_notional['notional_usd']:.1f}x** "
        f"spread. A flat $2 fee therefore ranges from **{_cost_min:.4f}%** to "
        f"**{_cost_max:.4f}%** of notional. Per-contract and percentage cost models encode "
        "different assumptions rather than interchangeable units."
    )
)

# %% [markdown]
# ## Key Takeaways
#
# 1. **Contract metadata is part of accounting.** Point-value multipliers convert price moves to
#    dollars, while tick size and margin fields define execution and admissibility.
#
# 2. **Position sizing uses notional value**: $q = \lfloor \text{alloc} / (\text{price} \times \text{multiplier}) \rfloor$.
#    This ensures equal dollar exposure across products with different multipliers.
#
# 3. **Long-short does not require a stock loan**, but both legs require margin. This notebook does
#    not model financing, portfolio-margin offsets, or time-varying margin schedules.
#
# 4. **Cost units must match the protocol.** The example charges an illustrative $2 per contract
#    per side and 5 bps slippage; production estimates should be product- and time-specific.
#
# 5. **Sector attribution exposes concentration**, not diversification by itself. Section 10
#    computes the broader-universe comparison from the current run; Ch17-19 test risk-targeted
#    sizing and sector constraints.
#
# 6. **Continuous-series output is an approximation.** Ratio-adjusted prices support stable signals,
#    but this notebook does not execute individual contract rolls or charge roll-specific costs.
#
# ## Next Steps
#
# - **CME case study**: `case_studies/cme_futures/strategy/backtest.py`
# - **Ch17**: Portfolio construction with margin-based position sizing
# - **Ch18**: Transaction cost decomposition for futures
#
# ## Cross-References
#
# - **Ch2 NB 05**: Session boundary aggregation (4 PM CT → daily bars)
# - **Ch2 NB 06**: Continuous contract construction (Panama/ratio adjustment)
# - `ml4t.backtest.types.ContractSpec`: Full API reference

```

Exibido na íntegra, com atribuição conforme a licença da fonte. Licença: MIT

Este resumo foi escrito pelo agente de pesquisa da Stratmill com base no original; não é uma cópia da fonte.