Saltar al contenido
Todos los documentos de la biblioteca

Composición de reglas de salida con límites de riesgo de cartera

Código Machine Learning for Trading

Resumen

Este cuaderno demuestra mediante ejemplos construidos las salidas por posición y los controles a nivel de cartera. Las reglas estáticas incluyen stop loss, objetivos de beneficio y salidas por tiempo; las dinámicas, stops dinámicos que siguen máximos anteriores, trailing stops que se ajustan y salidas parciales que cierran partes de una posición en objetivos sucesivos. Las cadenas de reglas aplican un orden de prioridad, mientras que las composiciones AND y OR combinan condiciones de activación. Los controles de cartera abarcan drawdown, pérdida diaria, exposición y límites de cantidad de posiciones.

Los ejemplos muestran cómo las acciones, los supuestos de ejecución y los motivos de activación dependen del comportamiento de las reglas. Si se coloca un stop antes que un objetivo, una vela que atraviese ambos se resuelve de forma conservadora; en cambio, una salida parcial con estado debe reiniciarse para cada posición. Las salidas de posiciones y los límites del libro abordan riesgos distintos, y la exposición bruta puede seguir siendo elevada aunque la exposición neta esté cerca de cero. El cuaderno es una demostración de comportamiento, no un estudio de rendimiento: usa estados construidos a mano, no cobra costes de transacción y comprueba los límites de cartera en una instantánea, no mediante una secuencia de infracciones e interrupciones de trading.

Ideas clave

  • Las reglas de salida de posiciones gestionan operaciones individuales, mientras que los límites de cartera rigen el libro en su conjunto.
  • El orden de las reglas determina qué acción prevalece cuando se activan varias condiciones a la vez.
  • Las salidas parciales con estado conservan información de objetivos anteriores y necesitan un estado independiente para cada posición.
  • Los trailing stops deben evaluarse con la información disponible antes de que termine la vela actual.
  • La exposición bruta y la neta reflejan formas distintas de riesgo de cartera.
  • Los ejemplos construidos de activación no indican con qué frecuencia se ejecutan las reglas ni si mejoran la rentabilidad neta.

Etiquetas

Texto completo
# 10_ml4t_backtest_risk_demo.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]
# # Composable Exit Rules and Portfolio Limits
# **Docker image**: `ml4t`
#
# **Chapter 19: Risk Management**
#
# ## Purpose
# Use the `ml4t.backtest.risk` library to evaluate position-level exit rules,
# compose rule priorities, and evaluate portfolio-level risk limits.
#
# ## Learning Objectives
# After completing this notebook, you will be able to:
# - Configure position-level exit rules (`StopLoss`, `TrailingStop`, `TakeProfit`)
# - Compose multiple rules with priority and boolean logic (`RuleChain`, `AllOf`, `AnyOf`)
# - Set up portfolio-wide risk limits (`MaxDrawdownLimit`, `DailyLossLimit`)
# - Interpret each rule's action, fill price, and reason
#
# ## Book reference
# Sections 19.4 (drawdowns and recovery), 19.7 (adaptive controls), and 19.8
# (kill switches and governance).
#
# ## Prerequisites
# - Comfort with the backtest framework introduced in Chapter 16
# - `02_exit_strategies` and `03_position_sizing_mae_mfe` for the rules'
#   analytical motivation

# %%
"""Configure and evaluate position rules and portfolio limits."""

import numpy as np
import plotly.graph_objects as go
import polars as pl
from ml4t.backtest.risk import (
    AllOf,
    AnyOf,
    DailyLossLimit,
    GrossExposureLimit,
    MaxDrawdownLimit,
    MaxPositionsLimit,
    NetExposureLimit,
    PortfolioState,
    PositionState,
    RuleChain,
    ScaledExit,
    StopLoss,
    TakeProfit,
    TighteningTrailingStop,
    TimeExit,
    TrailingStop,
)

from data import load_etfs
from utils.style import COLORS, ml4t_palette, show_plotly_with_alt

# %% tags=["parameters"]
N_BARS = 252

# %% [markdown]
# ## 1. Position-Level Exit Rules
#
# The `ml4t.backtest.risk.position` module provides exit rules that operate on
# individual positions. Each rule evaluates a `PositionState` and returns a
# `PositionAction` (HOLD, EXIT_FULL, EXIT_PARTIAL, or ADJUST_STOP).

# %% [markdown]
# ### Creating Position States
#
# A compact state factory keeps the examples focused on rule behavior. Long and
# short returns use the entry notional as the common denominator.


# %%
def create_position_state(
    symbol: str = "SPY",
    side: str = "long",
    entry_price: float = 100.0,
    current_price: float = 100.0,
    bars_held: int = 0,
    bar_open: float | None = None,
    bar_high: float | None = None,
    bar_low: float | None = None,
    high_water_mark: float | None = None,
    low_water_mark: float | None = None,
) -> PositionState:
    """Create a PositionState for rule evaluation."""
    if side not in {"long", "short"}:
        raise ValueError("side must be 'long' or 'short'")

    direction = 1.0 if side == "long" else -1.0
    unrealized_return = direction * (current_price - entry_price) / entry_price
    high_mark = max(entry_price, current_price) if high_water_mark is None else high_water_mark
    low_mark = min(entry_price, current_price) if low_water_mark is None else low_water_mark

    return PositionState(
        asset=symbol,
        side=side,
        entry_price=entry_price,
        current_price=current_price,
        bar_open=current_price if bar_open is None else bar_open,
        bar_high=current_price if bar_high is None else bar_high,
        bar_low=current_price if bar_low is None else bar_low,
        quantity=100.0,
        initial_quantity=100.0,
        unrealized_pnl=direction * (current_price - entry_price) * 100.0,
        unrealized_return=unrealized_return,
        bars_held=bars_held,
        high_water_mark=high_mark,
        low_water_mark=low_mark,
        max_favorable_excursion=max(0, unrealized_return),
        max_adverse_excursion=min(0, unrealized_return),
    )


# %% [markdown]
# ### Static Exit Rules
#
# Static rules have fixed thresholds that don't change during the position lifetime.

# %%
stop_loss = StopLoss(pct=0.05)
take_profit = TakeProfit(pct=0.10)
time_exit = TimeExit(max_bars=20)

# %% [markdown]
# **`StopLoss`** exits once the loss from the entry price reaches the percentage it was
# configured with. It measures against the entry, so the exit level never moves as the
# position gains.

# %%
scenarios = [
    ("Entry price", 100.0, 100.0),
    ("Down 3%", 100.0, 97.0),
    ("Down 5% (trigger)", 100.0, 95.0),
    ("Down 7%", 100.0, 93.0),
]

for name, entry, current in scenarios:
    state = create_position_state(entry_price=entry, current_price=current)
    action = stop_loss.evaluate(state)
    status = "TRIGGERED" if action.action.name != "HOLD" else "hold"
    print(f"  {name}: entry=${entry}, current=${current} -> {status}")
    if action.reason:
        print(f"    Reason: {action.reason}")

# %% [markdown]
# **`TakeProfit`** is the mirror image: it exits once the gain from entry reaches its target.

# %%
scenarios = [
    ("Entry price", 100.0, 100.0),
    ("Up 5%", 100.0, 105.0),
    ("Above 10% (trigger)", 100.0, 110.01),
    ("Up 15%", 100.0, 115.0),
]

for name, entry, current in scenarios:
    state = create_position_state(entry_price=entry, current_price=current)
    action = take_profit.evaluate(state)
    status = "TRIGGERED" if action.action.name != "HOLD" else "hold"
    print(f"  {name}: entry=${entry}, current=${current} -> {status}")

# %% [markdown]
# **`TimeExit(max_bars=20)`** exits after 20 bars regardless of P&L.

# %%
for bars in [5, 15, 19, 20, 25]:
    state = create_position_state(bars_held=bars)
    action = time_exit.evaluate(state)
    status = "TRIGGERED" if action.action.name != "HOLD" else "hold"
    print(f"  Bars held: {bars} -> {status}")

# %% [markdown]
# ### Dynamic Exit Rules
#
# Dynamic rules have thresholds that adapt to position performance.

# %% [markdown]
# **`TrailingStop`** measures from the highest price reached since entry rather than from the
# entry itself, so the exit level ratchets up with the position and never back down.
# The rule uses the high-water mark through the prior completed bar, then updates
# that state after the current bar is evaluated.

# %%
trailing_stop = TrailingStop(pct=0.05)

states = [
    create_position_state(entry_price=100, current_price=100),
    create_position_state(entry_price=100, current_price=110),
    create_position_state(entry_price=100, current_price=120),  # New high
    create_position_state(entry_price=100, current_price=116),  # Down 3.3%
    create_position_state(entry_price=100, current_price=114),  # Down 5% (trigger)
]

hwm = 100.0
for state in states:
    state.high_water_mark = hwm
    action = trailing_stop.evaluate(state)
    status = "TRIGGERED" if action.action.name != "HOLD" else "hold"
    trail_level = hwm * 0.95
    print(f"  Price ${state.current_price}: prior HWM=${hwm}, trail=${trail_level:.1f} -> {status}")
    hwm = max(hwm, state.current_price)

# %% [markdown]
# **`TighteningTrailingStop`** narrows the trail as the position gains, on a schedule of
# (return reached, trail width) pairs. The reasoning is that an unrealized gain is worth
# protecting more tightly than an unproven one, at the cost of being stopped out of a
# position that was going to keep running.

# %%
tightening = TighteningTrailingStop(
    [
        (0.00, 0.05),
        (0.10, 0.03),
        (0.20, 0.02),
    ]
)

for peak_price in [108.0, 116.0, 128.0]:
    current_price = peak_price * 0.96
    state = create_position_state(
        entry_price=100,
        current_price=current_price,
        high_water_mark=peak_price,
    )
    action = tightening.evaluate(state)
    status = action.reason if action.reason else "hold"
    print(f"  Peak ${peak_price:.0f}, current ${current_price:.2f}: {status}")

# %% [markdown]
# **`ScaledExit`** sells part of the position at each of a series of profit targets, taking a
# stated fraction of what remains at each one. Unlike every other rule here it is stateful:
# it remembers which targets have already fired, so one instance serves a single position and
# must be reset before it is used for another.

# %%
scaled = ScaledExit(
    [
        (0.05, 0.25),
        (0.10, 0.33),
        (0.15, 0.50),
    ]
)

# Use prices just beyond each decimal threshold so binary floating-point
# representation cannot turn an intended crossing into an equality artifact.
for return_pct in [0.03, 0.0501, 0.08, 0.1001, 0.1501]:
    state = create_position_state(entry_price=100, current_price=100 * (1 + return_pct))
    action = scaled.evaluate(state)
    if action.action.name == "EXIT_PARTIAL":
        print(f"  At {return_pct:.2%}: EXIT {action.pct:.0%} of position ({action.reason})")
    else:
        print(f"  At {return_pct:.2%}: hold")

scaled.reset()

# %% [markdown]
# ## 2. Rule Composition
#
# The `ml4t.backtest.risk` module provides composition patterns to combine rules:
#
# - **RuleChain**: First non-HOLD wins (priority order)
# - **AllOf**: All must trigger (AND logic)
# - **AnyOf**: Any can trigger (OR logic, alias for RuleChain)

# %% [markdown]
# **`RuleChain`** evaluates its rules in order and returns the first one that does not say
# hold. Order is therefore policy: putting the stop first means a bar breaching both the stop
# and the target is recorded as a stop, which is the conservative reading of a bar whose
# internal sequence is unknown.

# %%
chain = RuleChain(
    [
        StopLoss(pct=0.05),
        TakeProfit(pct=0.10),
        TrailingStop(pct=0.03),
        TimeExit(max_bars=20),
    ]
)

# Test scenarios
test_cases = [
    ("Loss triggers stop", 100, 94, 5, 100),  # Stop loss
    ("Profit triggers TP", 100, 112, 5, 112),  # Take profit
    ("Trail triggers", 100, 108, 5, 115),  # Fell from 115 to 108 (>3%)
    ("Time triggers", 100, 102, 22, 105),  # Held 22 bars
    ("Nothing triggers", 100, 102, 5, 102),  # All hold
]

for name, entry, current, bars, hwm in test_cases:
    state = create_position_state(entry_price=entry, current_price=current, bars_held=bars)
    state.high_water_mark = hwm
    action = chain.evaluate(state)
    if action.action.name != "HOLD":
        print(f"  {name}: {action.reason}")
    else:
        print(f"  {name}: HOLD")

# %% [markdown]
# **`AllOf`** requires every rule to trigger before it exits. The example below combines a
# gain threshold with a minimum holding period, so neither a quick gain nor a long flat hold
# exits on its own.

# %%
all_of = AllOf(
    [
        TakeProfit(pct=0.01),
        TimeExit(max_bars=5),
    ]
)

test_cases = [
    ("Profitable, 3 bars", 0.05, 3),
    ("Profitable, 5 bars", 0.05, 5),
    ("Loss, 10 bars", -0.02, 10),
    ("Breakeven, 5 bars", 0.0, 5),
]

for name, ret, bars in test_cases:
    state = create_position_state(entry_price=100, current_price=100 * (1 + ret), bars_held=bars)
    state.unrealized_return = ret
    action = all_of.evaluate(state)
    status = "EXIT" if action.action.name != "HOLD" else "HOLD"
    print(f"  {name} (ret={ret:.0%}, bars={bars}): {status}")

# %% [markdown]
# **`AnyOf`** exits when any rule triggers, which makes it equivalent to `RuleChain`. Worth
# carrying over from `02_exit_strategies`: a rule that fires on almost every bar dominates
# any combination it is placed in.

# %%
any_of = AnyOf(
    [
        StopLoss(pct=0.05),
        TakeProfit(pct=0.10),
        TimeExit(max_bars=20),
    ]
)
{
    "AnyOf type": type(any_of).__name__,
    "rules in chain": len(any_of.rules),
    "doc": (AnyOf.__doc__ or "").strip().splitlines()[0] if AnyOf.__doc__ else "",
}

# %% [markdown]
# ## 3. Portfolio-Level Limits
#
# Portfolio limits operate on the entire portfolio state, not individual positions.
# They implement **kill switches** and **guardrails** discussed in Section 19.8.

# %% [markdown]
# `create_portfolio_state` packages the inputs (equity, high-water mark,
# positions, daily P&L) into a `PortfolioState` so each limit check has a
# single object to reason about. We use it as a scaffold in the demos
# below; in production the backtester reconstructs `PortfolioState` from
# the broker on every bar.


# %%
def create_portfolio_state(
    equity: float = 100000,
    initial_equity: float = 100000,
    high_water_mark: float = 100000,
    num_positions: int = 5,
    positions: dict[str, float] | None = None,
    daily_pnl: float = 0,
) -> PortfolioState:
    """Create a PortfolioState for limit checks."""
    if positions is None:
        positions = {f"ASSET_{i}": equity / 10 for i in range(num_positions)}
    else:
        num_positions = len(positions)

    gross = sum(abs(v) for v in positions.values())
    net = sum(positions.values())

    drawdown = (
        (high_water_mark - equity) / high_water_mark
        if high_water_mark > 0 and equity < high_water_mark
        else 0.0
    )

    return PortfolioState(
        equity=equity,
        initial_equity=initial_equity,
        high_water_mark=high_water_mark,
        current_drawdown=drawdown,
        num_positions=num_positions,
        positions=positions,
        daily_pnl=daily_pnl,
        gross_exposure=gross,
        net_exposure=net,
    )


# %% [markdown]
# **`MaxDrawdownLimit`** carries two thresholds, a warning level and a liquidation level, so
# the escalation from "look at this" to "close everything" is written into the rule rather
# than decided under pressure.

# %%
dd_limit = MaxDrawdownLimit(max_drawdown=0.20, warn_threshold=0.15)

for dd_pct in [0.05, 0.10, 0.15, 0.18, 0.20, 0.25]:
    equity = 100000 * (1 - dd_pct)
    state = create_portfolio_state(equity=equity, high_water_mark=100000)
    result = dd_limit.check(state)
    if result.breached:
        print(f"  Drawdown {dd_pct:.0%}: {result.action.upper()} - {result.reason}")
    else:
        print(f"  Drawdown {dd_pct:.0%}: OK")

# %% [markdown]
# **`DailyLossLimit`** liquidates once the session's loss exceeds its threshold as a share of
# current equity. Measuring against current rather than starting equity means the limit
# tightens in absolute terms as the book shrinks.

# %%
daily_limit = DailyLossLimit(max_daily_loss_pct=0.02)

for daily_pnl in [500, 0, -1000, -2000, -2500]:
    state = create_portfolio_state(equity=100000, daily_pnl=daily_pnl)
    result = daily_limit.check(state)
    pct = daily_pnl / 100000 * 100
    if result.breached:
        print(f"  Daily P&L ${daily_pnl:+,} ({pct:+.1f}%): {result.action.upper()}")
    else:
        print(f"  Daily P&L ${daily_pnl:+,} ({pct:+.1f}%): OK")

# %% [markdown]
# **`MaxPositionsLimit`** halts when the open-position count reaches 10.

# %%
pos_limit = MaxPositionsLimit(max_positions=10)

for n_pos in [5, 8, 10, 12]:
    positions = {f"ASSET_{i}": 10000 for i in range(n_pos)}
    state = create_portfolio_state(num_positions=n_pos, positions=positions)
    result = pos_limit.check(state)
    if result.breached:
        print(f"  {n_pos} positions: {result.action.upper()} - {result.reason}")
    else:
        print(f"  {n_pos} positions: OK")

# %% [markdown]
# **`GrossExposureLimit`** halts new trading above its threshold. Gross exposure adds the
# absolute value of every position, so a long and a short of equal size count double rather
# than netting to nothing - which is the point, since both can lose at once.

# %%
gross_limit = GrossExposureLimit(max_gross_exposure=1.5)

for leverage in [0.8, 1.0, 1.3, 1.5, 2.0]:
    positions = {"LONG": 100000 * leverage / 2, "SHORT": -100000 * leverage / 2}
    state = create_portfolio_state(
        equity=100000,
        positions=positions,
        num_positions=2,
    )
    result = gross_limit.check(state)
    if result.breached:
        print(f"  {leverage:.0%} gross: {result.action.upper()} - {result.reason}")
    else:
        print(f"  {leverage:.0%} gross: OK")

# %% [markdown]
# **`NetExposureLimit`** bounds the signed sum instead, which is directional market exposure.
# A book can sit inside a tight net band while carrying large gross positions, so the two
# limits constrain different things and a portfolio needs both.

# %%
net_limit = NetExposureLimit(max_net_exposure=0.10, min_net_exposure=-0.10)

test_cases = [
    ("Neutral", {"LONG": 50000, "SHORT": -50000}),
    ("+5% net", {"LONG": 55000, "SHORT": -50000}),
    ("+15% net", {"LONG": 60000, "SHORT": -45000}),
    ("-12% net", {"LONG": 44000, "SHORT": -56000}),
]

for name, positions in test_cases:
    state = create_portfolio_state(equity=100000, positions=positions, num_positions=2)
    result = net_limit.check(state)
    if result.breached:
        print(f"  {name}: {result.action.upper()} - {result.reason}")
    else:
        print(f"  {name}: OK")

# %% [markdown]
# ## 4. Practical Example: Layered Rule Configuration
#
# Position rules and portfolio limits address different decisions. The configuration
# below illustrates both layers; governance still requires escalation, approval, and
# reinstatement procedures outside these classes.

# %%
# Define position-level rules
position_rules = RuleChain(
    [
        StopLoss(pct=0.03),  # 3% hard stop
        TighteningTrailingStop(
            [
                (0.00, 0.05),  # 5% trail initially
                (0.10, 0.03),  # Tighten to 3% at +10%
                (0.20, 0.02),  # Tighten to 2% at +20%
            ]
        ),
        TakeProfit(pct=0.30),  # 30% take profit
        TimeExit(max_bars=60),  # Exit after 60 bars
    ]
)

# Define portfolio-level limits
portfolio_limits = [
    MaxDrawdownLimit(max_drawdown=0.15, warn_threshold=0.10),
    DailyLossLimit(max_daily_loss_pct=0.02),
    MaxPositionsLimit(max_positions=20),
    GrossExposureLimit(max_gross_exposure=1.0),
]

# %% [markdown]
# The two layers answer different questions and both are needed. The position rules decide what to
# do about one trade going wrong, in priority order: a hard stop measured from entry, a trail that
# tightens as the gain grows, a final target, and a holding-period cap so nothing is carried
# indefinitely. The portfolio limits decide when to stop trading altogether regardless of which
# individual position is responsible - a drawdown from the equity high, a single session's loss,
# a count of open positions, and total gross exposure.
#
# A position rule cannot see the portfolio and a portfolio limit cannot see which trade to close.
# The configured values are in the cells above; the cell below prints them back so the
# configuration a reader is looking at is the one being evaluated.

# %%
print("Position rules, in priority order:")
for rule in position_rules.rules:
    print(f"  {type(rule).__name__}")
print("\nPortfolio limits:")
for limit in portfolio_limits:
    print(f"  {type(limit).__name__}")

# %% [markdown]
# ### Evaluate Illustrative Position Paths

# %%
positions_sim = [
    {
        "symbol": "AAPL",
        "entry_price": 150.0,
        "prices": [150, 148, 146, 145.5, 145],  # Declining -> stop loss
    },
    {
        "symbol": "GOOGL",
        "entry_price": 100.0,
        "prices": [100, 108, 115, 118, 113],  # Rise then trail triggers
    },
    {
        "symbol": "MSFT",
        "entry_price": 300.0,
        "prices": [300, 305, 310, 308, 312],  # Steady rise, hold
    },
]

# %%
for pos in positions_sim:
    print(f"\n{pos['symbol']} (entry: ${pos['entry_price']})")

    hwm = pos["entry_price"]
    for i, price in enumerate(pos["prices"]):
        state = create_position_state(
            symbol=pos["symbol"],
            entry_price=pos["entry_price"],
            current_price=price,
            bars_held=i,
            high_water_mark=hwm,
        )

        action = position_rules.evaluate(state)
        ret = (price / pos["entry_price"] - 1) * 100

        if action.action.name != "HOLD":
            print(f"  Bar {i}: ${price} ({ret:+.1f}%) HWM=${hwm:.0f} -> EXIT: {action.reason}")
            break
        else:
            print(f"  Bar {i}: ${price} ({ret:+.1f}%) HWM=${hwm:.0f} -> hold")
        hwm = max(hwm, price)

# %% [markdown]
# ### Portfolio Limit Check

# %%
portfolio = create_portfolio_state(
    equity=92000,  # Down from 100k
    initial_equity=100000,
    high_water_mark=105000,  # Was up 5% at peak
    daily_pnl=-1800,  # Down $1,800 today
    num_positions=8,
    positions={f"POS_{i}": 11500 for i in range(8)},  # 100% gross exposure
)

print("\nPortfolio State:")
print(f"  Equity: ${portfolio.equity:,.0f}")
print(f"  High Water Mark: ${portfolio.high_water_mark:,.0f}")
print(f"  Current Drawdown: {portfolio.current_drawdown:.1%}")
print(f"  Daily P&L: ${portfolio.daily_pnl:+,.0f} ({portfolio.daily_pnl / portfolio.equity:.1%})")
print(f"  Positions: {portfolio.num_positions}")
print(f"  Gross Exposure: {portfolio.gross_exposure / portfolio.equity:.0%}")

print("\nLimit Checks:")
for limit in portfolio_limits:
    result = limit.check(portfolio)
    limit_name = limit.__class__.__name__
    if result.breached:
        print(f"  {limit_name}: {result.action.upper()} - {result.reason}")
    else:
        print(f"  {limit_name}: OK")

# %% [markdown]
# ## 5. Rule Diagnostics
#
# The figures below visualize rule mechanics, not strategy performance. Thresholds
# are fixed before evaluation, and every surface calls the library on controlled
# states rather than clipping or rewriting realized returns.

# %% [markdown]
# ### Trigger Timing on a Real Price Path
#
# The position is entered after the first SPY close of 2020. Each subsequent bar
# supplies its observed OHLC range. The trailing rule receives the high-water mark
# through the previous completed bar, which preserves the library's default lagged
# timing and avoids using the current bar's high before evaluating its low.

# %%
spy = (
    load_etfs(symbols=["SPY"], start_date="2020-01-01", end_date="2020-12-31")
    .sort("timestamp")
    .head(N_BARS)
)
entry_date = spy.item(0, "timestamp")
entry_price = float(spy.item(0, "close"))
print(f"Entry after {entry_date}: SPY close ${entry_price:.2f}; bars loaded: {spy.height}")

# %% [markdown]
# `first_trigger` makes the event order explicit. The entry bar is never tested,
# current-bar OHLC is observable to active stop orders, and water marks advance only
# after a bar completes without an exit.


# %%
def first_trigger(frame: pl.DataFrame, rule_name: str, rule: object) -> dict[str, object]:
    """Return the first action from a rule on a long position entered at the first close."""
    entry = float(frame.item(0, "close"))
    high_water_mark = entry
    low_water_mark = entry

    for bars_held, bar in enumerate(frame.iter_rows(named=True), start=0):
        if bars_held == 0:
            continue
        state = create_position_state(
            entry_price=entry,
            current_price=float(bar["close"]),
            bars_held=bars_held,
            bar_open=float(bar["open"]),
            bar_high=float(bar["high"]),
            bar_low=float(bar["low"]),
            high_water_mark=high_water_mark,
            low_water_mark=low_water_mark,
        )
        action = rule.evaluate(state)
        if action.action.name != "HOLD":
            return {
                "rule": rule_name,
                "timestamp": bar["timestamp"],
                "bars_held": bars_held,
                "close": float(bar["close"]),
                "fill_price": action.fill_price,
                "reason": action.reason,
            }
        high_water_mark = max(high_water_mark, float(bar["high"]))
        low_water_mark = min(low_water_mark, float(bar["low"]))

    raise RuntimeError(f"{rule_name} did not trigger within the observed path")


# %% [markdown]
# The same entry and bars feed three independent rules. Their first actions retain
# the library's fill-price convention and human-readable reason.

# %%
timeline_rules = {
    "Stop loss 5%": StopLoss(pct=0.05),
    "Trailing stop 3%": TrailingStop(pct=0.03),
    "Take profit 15%": TakeProfit(pct=0.15),
}
trigger_results = pl.DataFrame(
    [first_trigger(spy, rule_name, rule) for rule_name, rule in timeline_rules.items()]
).sort("timestamp")
trigger_results

# %% [markdown]
# The trigger markers use fill prices, while the line shows daily closes. A shared
# date axis prevents nearby selloff triggers from collapsing into overlapping text.

# %%
fig = go.Figure()
fig.add_trace(
    go.Scatter(
        x=spy["timestamp"].to_list(),
        y=spy["close"].to_list(),
        name="SPY close",
        line=dict(color=COLORS["neutral"], width=2),
    )
)
for result, color in zip(
    trigger_results.iter_rows(named=True),
    ml4t_palette(trigger_results.height, categorical=True),
    strict=True,
):
    fig.add_trace(
        go.Scatter(
            x=[result["timestamp"]],
            y=[result["fill_price"]],
            mode="markers",
            name=result["rule"],
            marker=dict(color=color, size=11, line=dict(color=COLORS["bg_light"], width=1)),
            hovertemplate=(
                f"{result['rule']}<br>%{{x|%Y-%m-%d}}<br>Fill $%{{y:.2f}}<extra></extra>"
            ),
        )
    )
fig.update_layout(
    title=(
        "SPY Trigger Dates Differ Across Exit Rules"
        "<br><sup>Long entry after the first 2020 close; default stop-price fills</sup>"
    ),
    xaxis_title="Date",
    yaxis_title="SPY price ($)",
    height=430,
    legend_title_text="First action",
)
show_plotly_with_alt(
    fig,
    "A price path with markers showing where each rule in the chain would have triggered, so the order in which they fire is visible against the same series.",
)

# %% [markdown]
# ### Rule Priority Across Position States
#
# A close-only grid isolates composition semantics. When the 20-bar time exit
# overlaps a price rule, `RuleChain` returns the earlier rule in its declared order.

# %%
return_grid = np.arange(-10, 16, 1)
bars_grid = np.arange(0, 31, 2)
exit_chain = RuleChain([StopLoss(pct=0.05), TakeProfit(pct=0.10), TimeExit(max_bars=20)])
exit_code = {"HOLD": 0, "stop_loss": 1, "take_profit": 2, "time_exit": 3}
exit_surface = np.zeros((len(bars_grid), len(return_grid)), dtype=int)

for row_idx, bars_held in enumerate(bars_grid):
    for col_idx, return_pct in enumerate(return_grid):
        state = create_position_state(
            entry_price=100.0,
            current_price=100.0 * (1 + return_pct / 100),
            bars_held=int(bars_held),
        )
        action = exit_chain.evaluate(state)
        reason_key = next((key for key in exit_code if action.reason.startswith(key)), "HOLD")
        exit_surface[row_idx, col_idx] = exit_code[reason_key]

# %% [markdown]
# The categorical map separates `HOLD`, entry stop, profit target, and time exit.
# Its colors encode actions rather than performance.

# %%
exit_colors = [
    COLORS["silver_muted"],
    COLORS["negative"],
    COLORS["positive"],
    COLORS["amber"],
]
exit_scale = [
    [0.0, exit_colors[0]],
    [1 / 6, exit_colors[0]],
    [1 / 6, exit_colors[1]],
    [0.5, exit_colors[1]],
    [0.5, exit_colors[2]],
    [5 / 6, exit_colors[2]],
    [5 / 6, exit_colors[3]],
    [1.0, exit_colors[3]],
]

# %% [markdown]
# The colorbar names each action directly so the categorical codes never require
# interpretation from the reader.

# %%
fig = go.Figure(
    go.Heatmap(
        x=return_grid,
        y=bars_grid,
        z=exit_surface,
        zmin=0,
        zmax=3,
        colorscale=exit_scale,
        colorbar=dict(
            title="First action",
            tickmode="array",
            tickvals=[0, 1, 2, 3],
            ticktext=["HOLD", "STOP", "PROFIT", "TIME"],
        ),
        hovertemplate="Return %{x}%<br>Bars %{y}<br>Action code %{z}<extra></extra>",
    )
)
fig.update_layout(
    title=(
        "Rule Priority Resolves Overlapping Exit Conditions"
        "<br><sup>Close-only state grid; stop loss precedes take profit and time exit</sup>"
    ),
    xaxis_title="Return from entry (%)",
    yaxis_title="Bars held",
    height=430,
)
show_plotly_with_alt(
    fig,
    "A grid of position states by rule, coloured by the action each rule returns, showing which rule claims each region of the state space under the chain's priority order.",
)

# %% [markdown]
# ### Portfolio Escalation Surface
#
# The portfolio map combines two independent checks without inventing realized
# returns. Each cell constructs a `PortfolioState`, calls both limits, and reports
# the more severe action if both are breached.

# %%
drawdown_grid = np.arange(0, 26, 2.5)
daily_loss_grid = np.arange(0, 4.1, 0.5)
portfolio_surface = np.zeros((len(drawdown_grid), len(daily_loss_grid)), dtype=int)

for row_idx, drawdown_pct in enumerate(drawdown_grid):
    for col_idx, daily_loss_pct in enumerate(daily_loss_grid):
        equity = 100000 * (1 - drawdown_pct / 100)
        state = create_portfolio_state(
            equity=equity,
            high_water_mark=100000,
            daily_pnl=-equity * daily_loss_pct / 100,
            num_positions=0,
            positions={},
        )
        actions = {
            result.action
            for result in (dd_limit.check(state), daily_limit.check(state))
            if result.breached
        }
        portfolio_surface[row_idx, col_idx] = (
            2 if "liquidate" in actions else 1 if "warn" in actions else 0
        )

# %% [markdown]
# The boundaries in the surface are the class contracts, not approximations of them: each limit
# warns at one configured level and liquidates at another, and the daily-loss limit escalates
# straight to liquidation with no warning band at all.

# %%
portfolio_colors = [COLORS["silver_muted"], COLORS["amber"], COLORS["negative"]]
portfolio_scale = [
    [0.0, portfolio_colors[0]],
    [0.25, portfolio_colors[0]],
    [0.25, portfolio_colors[1]],
    [0.75, portfolio_colors[1]],
    [0.75, portfolio_colors[2]],
    [1.0, portfolio_colors[2]],
]
fig = go.Figure(
    go.Heatmap(
        x=daily_loss_grid,
        y=drawdown_grid,
        z=portfolio_surface,
        zmin=0,
        zmax=2,
        colorscale=portfolio_scale,
        colorbar=dict(
            title="Action",
            tickmode="array",
            tickvals=[0, 1, 2],
            ticktext=["OK", "WARN", "LIQUIDATE"],
        ),
        hovertemplate="Daily loss %{x}%<br>Drawdown %{y}%<extra></extra>",
    )
)
fig.update_layout(
    title=(
        "Drawdown and Daily-Loss Limits Form an Escalation Surface"
        "<br><sup>MaxDrawdownLimit(20%, warn 15%) and DailyLossLimit(2%)</sup>"
    ),
    xaxis_title="Daily loss (% of current equity)",
    yaxis_title="Drawdown from high-water mark (%)",
    height=430,
)
show_plotly_with_alt(
    fig,
    "A surface of portfolio state by limit outcome in three bands - no action, warn, and liquidate - with the warning band visible only for the limits that define one.",
)

# %% [markdown]
# ## 6. Demonstrated API Coverage
#
# This inventory summarizes only the classes exercised above. The counts derive
# from the names so they cannot drift from the displayed lists.

# %%
demonstrated_api = {
    "Position rules": (
        "StopLoss",
        "TakeProfit",
        "TimeExit",
        "TrailingStop",
        "TighteningTrailingStop",
        "ScaledExit",
    ),
    "Composition patterns": ("RuleChain", "AllOf", "AnyOf"),
    "Portfolio limits": (
        "MaxDrawdownLimit",
        "DailyLossLimit",
        "MaxPositionsLimit",
        "GrossExposureLimit",
        "NetExposureLimit",
    ),
}
library_coverage = pl.DataFrame(
    {
        "category": list(demonstrated_api),
        "count": [len(names) for names in demonstrated_api.values()],
        "examples": [", ".join(names) for names in demonstrated_api.values()],
    }
)
library_coverage

# %% [markdown]
# **Section integration**:
#
# - §19.4 references `StopLoss`, `TrailingStop`, and `TighteningTrailingStop`
#   for drawdown and recovery management.
# - §19.7 references `RuleChain` for adaptive controls.
# - §19.8 references `MaxDrawdownLimit`, `DailyLossLimit` for kill switches.

# %% [markdown]
# ## Key Takeaways
#
# 1. **Exit rules and portfolio limits are different controls and neither substitutes for the
#    other.** A position rule sees one trade and decides whether to close it. A portfolio limit
#    sees the book and decides whether to keep trading at all. A book can be full of individually
#    healthy positions and still be over its drawdown limit, and a single position can need closing
#    while the book is fine.
#
# 2. **Order inside a rule chain is policy, not implementation.** The first rule that fires wins,
#    so a chain that evaluates the target before the stop will record a bar that breached both as a
#    profitable exit. Decide the order deliberately and state it, as `02_exit_strategies` had to
#    for the same reason.
#
# 3. **Know which rules carry state.** Most of these evaluate a position afresh each bar. The
#    scaled exit does not: it remembers which targets have already fired, so reusing one instance
#    across positions silently skips targets for the second position onward.
#
# 4. **Check what each rule is allowed to see on the current bar.** A trailing stop that reads the
#    current bar's own high to set the water mark it then tests against is using information the
#    order did not have. The default here takes the water mark from completed bars.
#
# 5. **Distinguish gross from net exposure and limit both.** Gross sums absolute positions and net
#    sums signed ones. A market-neutral book sits near zero net while carrying substantial gross,
#    and only one of the two limits would catch it levering up.
#
# 6. **A limit class enforces a threshold; it does not decide one.** The value comes from a risk
#    mandate, and the surrounding procedure - who is told, who may override, what has to be true to
#    resume - is not in the library and is what makes a kill switch usable.
#
# ### Known limitations
#
# - Every example here is an evaluation of a constructed state, not a backtest. Nothing measures
#   what applying these rules would have earned or cost, and the diagnostics show where rules fire
#   rather than whether firing there was right.
# - The position states are built by hand to sit either side of each threshold. That is the right
#   way to demonstrate a contract and says nothing about how often real paths reach those states.
# - No cost is charged on any exit. The rules differ mainly in how often they close positions,
#   which is exactly what costs price.
# - The portfolio limits are checked against a single snapshot of a book rather than a sequence, so
#   nothing here exercises what happens after a limit breaches and trading halts.
#
# API reference: `ml4t.backtest.risk`.
#
# **Next**: [`11_systematic_risk_sweep`](11_systematic_risk_sweep.ipynb) applies these position
# rules across grids of thresholds and reads the resulting surfaces.

```

Se muestra íntegramente con atribución según la licencia de la fuente. Licencia: MIT

Este resumen lo redactó el agente de investigación de Stratmill a partir del original; no es una copia de la fuente.