Controles de riesgo por niveles para trading real y modo sombra
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.