Saltar al contenido
Todos los documentos de la biblioteca

Controles de riesgo por niveles para trading real y modo sombra

Código Machine Learning for Trading

Resumen

Este cuaderno muestra cómo una capa intermediaria de bróker puede rechazar órdenes inseguras mediante límites de tamaño de orden y posición, topes de frecuencia de órdenes, restricciones de activos e interruptor de emergencia persistente. Un bróker sintético permite observar las decisiones: las órdenes intentadas se aceptan o bloquean con una explicación, lo que permite que los ejemplos aíslen los controles de riesgo del comportamiento de conectividad y datos de mercado. También muestra el modo sombra, en el que una cartera virtual registra ejecuciones y coste base sin enviar órdenes al bróker.

Los ejemplos distinguen las comprobaciones de órdenes individuales de los controles sobre la exposición acumulada y el estado operativo. El cuaderno describe otras salvaguardas configurables, como el filtrado de órdenes duplicadas, las comprobaciones de desviación de precios y el seguimiento de pérdidas diarias, pero no las pone a prueba aquí. La evidencia es el comportamiento de una demostración, no resultados de trading en vivo; un bróker simulado no permite determinar cómo funcionan los controles ante fallos reales del bróker o condiciones de mercado reales. Recomienda pasar del modo sombra al trading simulado antes de operar en vivo.

Ideas clave

  • Los límites de tamaño de orden pueden rechazar solicitudes que superen los topes de acciones o valor antes de enviarlas al bróker.
  • Los límites de posición, los topes de frecuencia y las reglas sobre activos restringen la exposición y el flujo de órdenes a nivel de cuenta.
  • Un interruptor de emergencia persistente puede mantener el trading detenido tras reiniciar un proceso hasta que se desactive.
  • El modo sombra puede registrar ejecuciones y coste base en una cartera virtual sin afectar al bróker.
  • Los ejemplos sintéticos ilustran las salvaguardas configuradas, pero no demuestran el rendimiento en mercados reales.

Etiquetas

Texto completo
# 10_safety_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]
# # SafeBroker Risk Controls Demo
#
# **Docker image**: `ml4t`
#
# **Section Reference**: 25.7 (Operational readiness)
#
# **Implementation Skills**:
# - ml4t.live.safety: SafeBroker, LiveRiskConfig, RiskState, RiskLimitError
# - ml4t.live.safety: VirtualPortfolio for shadow mode position tracking
# - Kill switch, position limits, order limits, drawdown monitoring
#
# **Key Learning**:
# This notebook drives six of SafeBroker's risk controls into their
# failure modes against a synthetic broker:
#
# 1. **Order Size Limits**: Max shares and max value per order
# 2. **Position Limits**: Max value, shares, and total exposure
# 3. **Rate Limiting**: Max orders per minute
# 4. **Asset Restrictions**: Allowed/blocked asset lists
# 5. **Kill Switch**: Emergency halt that persists across restarts
# 6. **Shadow Mode**: VirtualPortfolio tracks fills without touching the broker
#
# Three further controls, duplicate-order filtering, price-deviation checks
# and daily-loss monitoring, are available through `LiveRiskConfig` and are
# not demonstrated here. `13_runtime_safety_showcase` drives the daily-loss
# kill-switch trip and stale-data rejection.
#
# **Why This Matters**:
# Live trading can lose real money. These safeguards provide defense-in-depth
# against bugs, API errors, fat-finger mistakes, and runaway strategies.
#
# **Learning Objectives**
# - See how each protection layer fails closed when the strategy asks for something unsafe.
# - Distinguish order-level checks from portfolio-level checks and persistent kill switches.
# - Read the risk-control output as an operational checklist, not as a library feature tour.
#
# **Prerequisites**
# - Review Chapter 25.7 on operational readiness and the broker wrappers introduced earlier in the chapter.
# - Familiarity with shadow mode and why paper trading alone is not enough for release gating.

# %% [markdown]
# ## Setup
#
# The setup is intentionally minimal because the point of the notebook is to expose the risk engine's
# decisions. A lightweight mock broker is enough to make the failure modes and guardrails visible.

# %%
"""SafeBroker Risk Controls Demo: exercise six controls plus shadow accounting."""

import logging
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from async_utils import run_async
from ml4t.backtest.types import Order, OrderSide, OrderStatus, OrderType, Position
from ml4t.live import LiveRiskConfig, RiskLimitError, SafeBroker, VirtualPortfolio
from ml4t.live.protocols import ExecutionCapability

from utils.paths import get_output_dir

print("[OK] ml4t.live risk controls imported")

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    stream=sys.stdout,
)
logger = logging.getLogger(__name__)


# %% tags=["parameters"]
# Production defaults; papermill injects overrides for CI
RATE_LIMIT_PER_MINUTE = 3
STATE_DIR = get_output_dir(25, "safety_risk_demo") / "temporary_state"
STATE_DIR.mkdir(parents=True, exist_ok=True)
MANAGED_STATE_PATHS: list[Path] = []


# %% [markdown]
# Each risk-control scenario receives a unique state path so persistence is testable without shared files.


# %%
def _temporary_state_path(prefix: str = "nb10_state_") -> str:
    """Return one isolated state path and track its state and journal files."""
    state_path = STATE_DIR / f"{prefix}{len(MANAGED_STATE_PATHS) // 2 + 1:02d}.json"
    journal_path = state_path.with_name(f"{state_path.stem}-journal{state_path.suffix}l")
    for path in (state_path, journal_path):
        path.unlink(missing_ok=True)
        MANAGED_STATE_PATHS.append(path)
    return str(state_path)


# %% [markdown]
# ## 1. Mock Broker for Testing
#
# The query surface separates broker state from fill mechanics. Every blocked order can therefore be
# attributed to the risk layer rather than connectivity or market-data noise.


# %%
class MockBrokerQueries:
    """Read-only and connection methods required by `SafeBroker`."""

    async def connect(self) -> None:
        self._connected = True
        logger.info("MockBroker: Connected")

    async def disconnect(self) -> None:
        self._connected = False
        logger.info("MockBroker: Disconnected")

    async def is_connected_async(self) -> bool:
        return self._connected

    @property
    def execution_capabilities(self):
        return frozenset({ExecutionCapability.LIMIT})

    def assert_paper_trading(self) -> None:
        return None

    @property
    def positions(self) -> dict[str, Position]:
        return self._positions.copy()

    @property
    def pending_orders(self) -> list[Order]:
        return self._pending_orders.copy()

    def get_position(self, asset: str) -> Position | None:
        return self._positions.get(asset)

    async def get_positions_async(self) -> dict[str, Position]:
        return self._positions.copy()

    async def get_pending_orders_async(self) -> list[Order]:
        return self._pending_orders.copy()

    async def get_position_async(self, asset: str) -> Position | None:
        return self._positions.get(asset)

    async def get_account_value_async(self) -> float:
        position_value = sum(
            abs(p.quantity) * (p.current_price or p.entry_price) for p in self._positions.values()
        )
        return self._cash + position_value

    async def get_cash_async(self) -> float:
        return self._cash


# %% [markdown]
# Order construction is pure apart from the broker-local sequence number.


# %%
def make_filled_order(
    broker: Any,
    asset: str,
    quantity: int,
    side: OrderSide,
    order_type: OrderType,
    limit_price: float | None,
    stop_price: float | None,
) -> Order:
    """Construct one immediately filled synthetic order."""
    broker._order_counter += 1
    price = limit_price or 100.0
    return Order(
        asset=asset,
        side=side,
        quantity=quantity,
        order_type=order_type,
        limit_price=limit_price,
        stop_price=stop_price,
        order_id=f"MOCK-{broker._order_counter}",
        status=OrderStatus.FILLED,
        filled_quantity=quantity,
        filled_price=price,
        filled_at=datetime.now(UTC),
    )


# %% [markdown]
# Fill accounting updates position quantity and cash from the same signed transaction.


# %%
def apply_mock_fill(broker: Any, order: Order) -> None:
    """Apply one filled order to synthetic broker state."""
    asset, quantity, side = order.asset, order.quantity, order.side
    price = float(order.filled_price)
    signed_qty = quantity if side == OrderSide.BUY else -quantity
    current = broker._positions.get(asset)
    if current:
        new_qty = current.quantity + signed_qty
        if new_qty == 0:
            del broker._positions[asset]
        else:
            current.quantity = new_qty
    else:
        broker._positions[asset] = Position(
            asset=asset,
            quantity=signed_qty,
            entry_price=price,
            entry_time=datetime.now(UTC),
            current_price=price,
        )
    transaction = quantity * price
    broker._cash += transaction if side == OrderSide.SELL else -transaction


# %% [markdown]
# The mutable broker adds immediate-fill submission to the query surface.


# %%
class MockBroker(MockBrokerQueries):
    """Synthetic broker for exercising `SafeBroker` controls."""

    def __init__(self, initial_cash: float = 100_000.0):
        self._cash = initial_cash
        self._positions: dict[str, Position] = {}
        self._pending_orders: list[Order] = []
        self._connected = False
        self._order_counter = 0

    async def submit_order_async(
        self,
        asset: str,
        quantity: int,
        side: OrderSide | None = None,
        order_type: OrderType = OrderType.MARKET,
        limit_price: float | None = None,
        stop_price: float | None = None,
        **_: Any,
    ) -> Order:
        """Fill one order immediately and update synthetic account state."""
        if side is None:
            side = OrderSide.BUY if quantity > 0 else OrderSide.SELL
            quantity = abs(quantity)
        order = make_filled_order(self, asset, quantity, side, order_type, limit_price, stop_price)
        apply_mock_fill(self, order)
        logger.info("MockBroker: %s %s %s @ $%.2f", side.value, quantity, asset, order.filled_price)
        return order

    async def cancel_order_async(self, order_id: str) -> bool:
        return False

    async def close_position_async(self, asset: str) -> Order | None:
        pos = self._positions.get(asset)
        if pos and pos.quantity != 0:
            side = OrderSide.SELL if pos.quantity > 0 else OrderSide.BUY
            return await self.submit_order_async(asset, abs(pos.quantity), side)
        return None


# %% [markdown]
# ## 2. Demo Helper Function
#
# One helper runs every attempted order and prints its outcome, so the notebook reads like an
# operator console: either the request is allowed, or the control layer explains why it is
# blocked. Each expected allow or block becomes an executable assertion.


# %%
def run_demo(coro, expect_error: bool = False):
    """Run an async demo and fail the notebook on an unexpected outcome."""
    try:
        result = run_async(coro)
    except RiskLimitError as e:
        if not expect_error:
            raise AssertionError(f"Unexpected risk block: {e}") from e
        print(f"   [OK] Blocked as expected: {e}")
        return None
    if expect_error:
        raise AssertionError("Expected RiskLimitError, but the order was accepted")
    print("   [OK] Order accepted by control layer")
    return result


# %% [markdown]
# Every demo below reports its outcome through the same helper, so an allowed order and a
# blocked one are distinguishable at a glance. A production risk layer needs the same property:
# inconsistent error reporting is what makes a real incident hard to diagnose under time pressure.

# %% [markdown]
# ## 3. Demonstration: Order Size Limits
#
# SafeBroker enforces maximum order size (shares and value).
#
# This first demo answers the simplest production question: can one buggy order wipe out the session before
# anything else has a chance to react?

# %%
print("\n" + "=" * 70)
print("DEMO 1: Order Size Limits")
print("=" * 70)

# Use temp file for state to avoid conflicts
state_file = _temporary_state_path()

broker = MockBroker()
run_async(broker.connect())

config = LiveRiskConfig(
    execution_mode="paper",
    max_order_shares=50,  # Max 50 shares per order
    max_order_value=5_000.0,  # Max $5,000 per order
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
# Prime the staleness guard with reference quotes for every asset this demo touches.
safe_broker.record_market_snapshot("AAPL", 100.0)
safe_broker.record_market_snapshot("TSLA", 600.0)

print("\n1. Order within limits (10 shares @ $100 = $1,000):")
run_demo(safe_broker.submit_order_async("AAPL", 10, OrderSide.BUY))

print("\n2. Order exceeds share limit (100 shares, limit is 50):")
run_demo(
    safe_broker.submit_order_async("AAPL", 100, OrderSide.BUY),
    expect_error=True,
)

print("\n3. Order exceeds value limit (10 shares @ $600 = $6,000):")
# First set a price by creating a position
broker._positions["TSLA"] = Position(
    asset="TSLA",
    quantity=1,
    entry_price=600.0,
    entry_time=datetime.now(UTC),
    current_price=600.0,
)
run_demo(
    safe_broker.submit_order_async(
        "TSLA", 10, OrderSide.BUY, order_type=OrderType.LIMIT, limit_price=600.0
    ),
    expect_error=True,
)

# %% [markdown]
# Order-size controls stop both an oversized share count and an oversized notional before the
# broker sees the request, which is what keeps a single mistyped instruction too small to matter.

# %% [markdown]
# ## 4. Demonstration: Position Limits
#
# SafeBroker enforces maximum position size and total exposure. This answers the portfolio-level question of
# whether a sequence of valid orders can still push the account into an unsafe aggregate state.

# %%
print("\n" + "=" * 70)
print("DEMO 2: Position Limits")
print("=" * 70)

# Isolate the share cap by setting the dollar limits well above the attempted position.
state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())

config = LiveRiskConfig(
    execution_mode="paper",
    max_position_value=100_000.0,
    max_position_shares=100,
    max_total_exposure=200_000.0,
    max_order_value=20_000.0,
    max_order_shares=200,
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
safe_broker.record_market_snapshot("AAPL", 100.0)

print("\n1. Share cap: start with 50 shares @ $100:")
run_demo(safe_broker.submit_order_async("AAPL", 50, OrderSide.BUY))
print("\n2. Share cap: adding 60 would reach 110 shares:")
run_demo(
    safe_broker.submit_order_async("AAPL", 60, OrderSide.BUY),
    expect_error=True,
)

# %% [markdown]
# A separate broker isolates the per-position dollar cap from the share and total-exposure gates.

# %%
state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())
config = LiveRiskConfig(
    execution_mode="paper",
    max_position_value=10_000.0,
    max_position_shares=200,
    max_total_exposure=200_000.0,
    max_order_value=20_000.0,
    max_order_shares=200,
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
safe_broker.record_market_snapshot("MSFT", 150.0)

print("\n3. Position-value cap: 100 MSFT @ $150 would be $15,000:")
run_demo(
    safe_broker.submit_order_async(
        "MSFT", 100, OrderSide.BUY, order_type=OrderType.LIMIT, limit_price=150.0
    ),
    expect_error=True,
)

# %% [markdown]
# A third broker builds two valid positions before a new order crosses only the total-exposure cap.

# %%
state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())
config = LiveRiskConfig(
    execution_mode="paper",
    max_position_value=20_000.0,
    max_position_shares=200,
    max_total_exposure=15_000.0,
    max_order_value=20_000.0,
    max_order_shares=200,
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
for symbol in ("AAPL", "MSFT", "GOOGL"):
    safe_broker.record_market_snapshot(symbol, 100.0)

print("\n4. Total exposure: build valid $5,000 and $8,000 positions:")
run_demo(safe_broker.submit_order_async("AAPL", 50, OrderSide.BUY))
run_demo(safe_broker.submit_order_async("MSFT", 80, OrderSide.BUY))
print("\n5. Total exposure: another $3,000 would raise exposure to $16,000:")
run_demo(
    safe_broker.submit_order_async("GOOGL", 30, OrderSide.BUY),
    expect_error=True,
)

# %% [markdown]
# Each scenario above uses its own broker so the share cap, the per-position value cap and the
# total-exposure cap each raise on their own account, with no earlier control masking the
# rejection under test. Portfolio-level caps exist because a failure often arrives as a sequence
# of individually reasonable orders that add up to an unreasonable position.

# %% [markdown]
# ## 5. Demonstration: Rate Limiting
#
# SafeBroker limits the number of orders per minute. Rate limiting matters because repeated small errors can
# be just as destructive as one oversized order when the loop is running unattended.

# %%
print("\n" + "=" * 70)
print("DEMO 3: Rate Limiting")
print("=" * 70)

state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())

config = LiveRiskConfig(
    execution_mode="paper",
    max_orders_per_minute=RATE_LIMIT_PER_MINUTE,
    max_order_value=50_000.0,
    max_position_value=100_000.0,
    dedup_window_seconds=0.0,  # Disable dedup for this demo
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)

print("\nSubmitting 5 orders rapidly (limit is 3/minute):")
symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "META"]  # Different symbols to avoid dedup
for sym in symbols:
    safe_broker.record_market_snapshot(sym, 100.0)
for i in range(5):
    print(f"\nOrder {i + 1} ({symbols[i]}):")
    run_demo(
        safe_broker.submit_order_async(symbols[i], 10, OrderSide.BUY),
        expect_error=(i >= 3),  # Expect error after 3rd order
    )

# %% [markdown]
# The burst is rejected on the fourth order even though every order in it passes every other
# check. A throughput cap is what stands between the account and a runaway loop, a duplicated
# signal, or a broker rate-limit ban earned by chatty execution code.

# %% [markdown]
# ## 6. Demonstration: Asset Restrictions
#
# SafeBroker can restrict trading to allowed assets or block specific assets. This is how a live system keeps
# a strategy inside its approved mandate even if symbol selection logic goes wrong.

# %%
print("\n" + "=" * 70)
print("DEMO 4: Asset Restrictions")
print("=" * 70)

state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())

# Only allow specific ETFs
config = LiveRiskConfig(
    execution_mode="paper",
    allowed_assets={"SPY", "QQQ", "IWM"},  # Whitelist
    max_order_value=50_000.0,
    max_position_value=100_000.0,
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
# Only prime SPY: AAPL is blocked at the asset-restriction check before staleness ever runs.
safe_broker.record_market_snapshot("SPY", 100.0)

print("\n1. Trading SPY (allowed):")
run_demo(safe_broker.submit_order_async("SPY", 10, OrderSide.BUY))

print("\n2. Trading AAPL (not in allowed list):")
run_demo(
    safe_broker.submit_order_async("AAPL", 10, OrderSide.BUY),
    expect_error=True,
)

# %% [markdown]
# Now exercise the blocklist path: trades to non-blocked assets are
# accepted, trades to any listed symbol are rejected before they reach
# the broker.

# %%
print("\n--- Testing Blocked Assets ---")
state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())

config = LiveRiskConfig(
    execution_mode="paper",
    blocked_assets={"TSLA", "GME", "AMC"},  # Blacklist volatile stocks
    max_order_value=50_000.0,
    max_position_value=100_000.0,
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
# Only prime AAPL: GME is blocked at the asset-restriction check before staleness runs.
safe_broker.record_market_snapshot("AAPL", 100.0)

print("\n3. Trading AAPL (not blocked):")
run_demo(safe_broker.submit_order_async("AAPL", 10, OrderSide.BUY))

print("\n4. Trading GME (blocked):")
run_demo(
    safe_broker.submit_order_async("GME", 10, OrderSide.BUY),
    expect_error=True,
)

# %% [markdown]
# The allowlist and the blocklist put a hard boundary around what the strategy may touch. That
# boundary is an operational safeguard rather than a research convenience: it is what stops a
# symbol-selection bug from routing an order into an unsupported or explicitly banned instrument.

# %% [markdown]
# ## 7. Demonstration: Kill Switch
#
# The kill switch is an emergency halt that persists across restarts. It exists for the scenarios where the
# safest action is to stop every new order until a human explicitly clears the system.

# %%
print("\n" + "=" * 70)
print("DEMO 5: Kill Switch")
print("=" * 70)

state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())

config = LiveRiskConfig(
    execution_mode="paper",
    max_order_value=50_000.0,
    max_position_value=100_000.0,
    dedup_window_seconds=0.0,  # Disable for demo
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
safe_broker.record_market_snapshot("AAPL", 100.0)

print("\n1. Normal trading before kill switch:")
run_demo(safe_broker.submit_order_async("AAPL", 10, OrderSide.BUY))

print("\n2. Activating kill switch (manual emergency halt):")
safe_broker.enable_kill_switch("Manual test - simulating emergency")
print("   Kill switch activated!")

print("\n3. Attempting to trade with kill switch active:")
run_demo(
    safe_broker.submit_order_async("AAPL", 10, OrderSide.BUY),
    expect_error=True,
)

# %% [markdown]
# Reconstruct `SafeBroker` from the same state file. The in-memory toggle is
# irrelevant if the latch does not come back with the new instance.

# %%
print("\n4. Checking state persistence...")
safe_broker.close_persistence()
new_safe_broker = SafeBroker(MockBroker(), config)
print(f"   Kill switch still active: {new_safe_broker._state.kill_switch_activated}")
print(f"   Reason: {new_safe_broker._state.kill_switch_reason}")
assert new_safe_broker._state.kill_switch_activated

print("\n5. Disabling kill switch (manual recovery):")
new_safe_broker.disable_kill_switch()
print("   Kill switch disabled!")

print("\n6. Trading after recovery:")
new_safe_broker.record_market_snapshot("AAPL", 100.0)
_ = run_demo(new_safe_broker.submit_order_async("AAPL", 10, OrderSide.BUY))
new_safe_broker.close_persistence()

# %% [markdown]
# The second `SafeBroker` reads the latch from the state file, so the halt outlives the process
# that set it rather than disappearing with the kernel. A halt that did not outlive its process
# would let a restart quietly reactivate a strategy that was stopped for a real risk event.

# %% [markdown]
# ## 8. Demonstration: Shadow Mode
#
# Shadow mode logs orders but doesn't execute them. Uses VirtualPortfolio
# for realistic position tracking to prevent infinite buy loops.

# %%
print("\n" + "=" * 70)
print("DEMO 6: Shadow Mode (No Broker Submission)")
print("=" * 70)

state_file = _temporary_state_path()
broker = MockBroker()
run_async(broker.connect())

config = LiveRiskConfig(
    shadow_mode=True,  # Enable shadow mode
    max_order_value=50_000.0,
    max_position_value=100_000.0,
    state_file=state_file,
)
safe_broker = SafeBroker(broker, config)
safe_broker.record_market_snapshot("AAPL", 100.0)

print("\n1. Submitting order in shadow mode:")
order = run_demo(safe_broker.submit_order_async("AAPL", 100, OrderSide.BUY))

print("\n2. Checking virtual position (shadows real broker):")
pos = safe_broker.get_position("AAPL")
if pos:
    print(f"   Virtual position: {pos.quantity} shares @ ${pos.entry_price:.2f}")
else:
    raise AssertionError("Shadow order did not update the virtual position")

print("\n3. Checking real broker position (should be empty):")
real_pos = broker.get_position("AAPL")
if real_pos:
    raise AssertionError(f"Shadow order reached the real broker: {real_pos.quantity} shares")
else:
    print("   Real broker has NO position (correct - shadow mode)")

print("\n4. Virtual portfolio account value:")
value = run_async(safe_broker.get_account_value_async())
print(f"   Virtual account value: ${value:,.2f}")

# %% [markdown]
# Shadow mode updates the virtual portfolio while the underlying broker stays flat, so the whole
# routing path is observable without any change to real inventory. That is what makes it the
# first place to validate end-to-end routing, ahead of paper and well ahead of live.

# %% [markdown]
# ## 9. Demonstration: VirtualPortfolio Details
#
# VirtualPortfolio handles position tracking for shadow mode,
# including weighted average cost basis and position flipping.

# %%
print("\n" + "=" * 70)
print("DEMO 7: VirtualPortfolio Position Tracking")
print("=" * 70)

portfolio = VirtualPortfolio(initial_cash=100_000.0)

print("\n1. Initial state:")
print(f"   Cash: ${portfolio.cash:,.2f}")
print(f"   Positions: {len(portfolio.positions)}")

# Simulate buy order
buy_order = Order(
    asset="AAPL",
    side=OrderSide.BUY,
    quantity=100,
    filled_quantity=100,
    filled_price=150.0,
    status=OrderStatus.FILLED,
)
portfolio.process_fill(buy_order)

print("\n2. After buying 100 AAPL @ $150:")
pos = portfolio.positions.get("AAPL")
print(f"   Position: {pos.quantity} shares @ ${pos.entry_price:.2f}")
print(f"   Cash: ${portfolio.cash:,.2f}")
print(f"   Account value: ${portfolio.account_value:,.2f}")
assert pos.quantity == 100
assert pos.entry_price == 150.0
assert portfolio.account_value == 100_000.0

# %%
# Add to position at higher price
buy_order2 = Order(
    asset="AAPL",
    side=OrderSide.BUY,
    quantity=100,
    filled_quantity=100,
    filled_price=160.0,
    status=OrderStatus.FILLED,
)
portfolio.process_fill(buy_order2)

print("\n3. After buying 100 more AAPL @ $160 (weighted avg cost):")
pos = portfolio.positions.get("AAPL")
print(f"   Position: {pos.quantity} shares @ ${pos.entry_price:.2f}")
print(f"   Cash: ${portfolio.cash:,.2f}")
assert pos.quantity == 200
assert pos.entry_price == 155.0

# Partial sell
sell_order = Order(
    asset="AAPL",
    side=OrderSide.SELL,
    quantity=50,
    filled_quantity=50,
    filled_price=170.0,
    status=OrderStatus.FILLED,
)
portfolio.process_fill(sell_order)

print("\n4. After selling 50 AAPL @ $170:")
pos = portfolio.positions.get("AAPL")
print(f"   Position: {pos.quantity} shares @ ${pos.entry_price:.2f}")
print(f"   Cash: ${portfolio.cash:,.2f}")
print(f"   Account value: ${portfolio.account_value:,.2f}")
assert pos.quantity == 150
assert pos.entry_price == 155.0
assert portfolio.account_value == 103_000.0

# %%
for managed_path in MANAGED_STATE_PATHS:
    for candidate in (
        managed_path,
        managed_path.with_name(f"{managed_path.name}.lock"),
        managed_path.with_name(f"{managed_path.name}.head"),
    ):
        candidate.unlink(missing_ok=True)

unexpected_state_files = list(STATE_DIR.iterdir())
assert not unexpected_state_files, f"Temporary state residue: {unexpected_state_files}"
STATE_DIR.rmdir()
print(f"[OK] Cleaned {len(MANAGED_STATE_PATHS)} managed state and journal paths")

# %% [markdown]
# The virtual portfolio carries cost basis and partial exits explicitly rather than leaving them
# to an implementation detail. A shadow or paper environment without that accounting hides the
# state-management bugs it exists to surface, and those are the bugs that cost money live.

# %% [markdown]
# ## Summary: Controls Exercised Above
#
# | Control | What this notebook showed |
# |---------|---------------------------|
# | **Order Size Limits** | Max shares and value per order rejected before the broker sees them |
# | **Position Limits** | Per-position value + share caps blocked unsafe accumulation |
# | **Rate Limiting** | Burst of valid orders blocked at the per-minute cap |
# | **Asset Restrictions** | Allowed / blocked asset lists held the universe boundary |
# | **Kill Switch** | Manual halt, latch persisted across `SafeBroker` reconstruction |
# | **Shadow Mode** | `VirtualPortfolio` tracked fills while the underlying broker stayed flat |
#
# **Controls covered elsewhere**:
# - **Duplicate Order Filter** and **Price Deviation** are configured through
#   `LiveRiskConfig.dedup_window_seconds` and `LiveRiskConfig.max_price_deviation_pct`;
#   this notebook does not present them as executed results.
# - **Daily-Loss Drawdown Monitor** + **Stale-Data Rejection** are demonstrated in
#   `13_runtime_safety_showcase`, which drives the same `SafeBroker` instance
#   through both failure modes with full kill-switch latch.
#
# **Additional features the demos rely on**:
# - **State Persistence**: kill switch and daily counters survive restarts.
# - **Atomic Writes**: the state file uses atomic JSON writes to prevent corruption.
#
# **Best Practices**:
# 1. Always start with `shadow_mode=True`.
# 2. Graduate to paper trading.
# 3. Use conservative limits when going live.
# 4. Monitor the state file for kill-switch activations.

# %% [markdown]
# ## Key Takeaways
#
# 1. **Defense-in-depth**: SafeBroker layers independent controls so that no
#    single misconfiguration can produce an unchecked order. Six of those
#    controls are exercised above; the remaining duplicate-order, fat-finger,
#    and drawdown gates are wired through the same `LiveRiskConfig` surface
#    without being claimed as outputs of this notebook.
# 2. **Configurable via LiveRiskConfig**: order size, position exposure, rate
#    caps and drawdown thresholds are all parameters rather than hard-coded
#    logic.
# 3. **Kill switch persists across restarts**: Emergency halts survive process
#    restarts and must be manually cleared, preventing accidental
#    reactivation of a halted strategy.
# 4. **Shadow mode with VirtualPortfolio**: Orders are logged and tracked with
#    realistic cost-basis accounting without touching the broker, making it
#    the safest first deployment step.
#
# **Next**: Combine these controls with the parity checks in `08_pipeline_verification` and then keep the
# same SafeBroker configuration when moving from shadow mode to paper trading.

```

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.