کنترلهای چندلایه ریسک برای معامله زنده و حالت سایه
خلاصه
این دفترچه نشان میدهد که یک پوشش کارگزاری چگونه میتواند سفارشهای ناامن را با استفاده از سقف اندازه سفارش و موقعیت، محدودیت نرخ سفارش، محدودیت دارایی و کلید توقف دائمی رد کند. یک کارگزار مصنوعی تصمیمها را قابل مشاهده میکند: سفارشهای آزمایشی یا پذیرفته میشوند یا با توضیح مسدود میشوند؛ به این ترتیب مثالها بررسیهای ریسک را از رفتار اتصال و داده بازار جدا میکنند. همچنین حالت سایه را نشان میدهد که در آن یک پرتفوی مجازی، بدون ارسال سفارش به کارگزار، اجراها و بهای تمامشده را ثبت میکند.
مثالها بررسی سفارشهای منفرد را از کنترل مواجهه انباشته و وضعیت عملیاتی متمایز میکنند. دفترچه تدابیر حفاظتی قابل پیکربندی دیگری، از جمله فیلتر سفارشهای تکراری، بررسی انحراف قیمت و پایش زیان روزانه را شرح میدهد، اما آنها را در اینجا اجرا نمیکند. شواهد آن رفتار نمایشی است، نه نتایج معامله زنده؛ یک کارگزار شبیهسازیشده نمیتواند نشان دهد کنترلها در برابر خرابی واقعی کارگزار یا شرایط بازار چگونه عمل میکنند. توصیه میکند پیش از استفاده زنده، از حالت سایه به معامله آزمایشی بروید.
ایدههای کلیدی
- سقف اندازه سفارش میتواند پیش از ارسال به کارگزار، درخواستهای فراتر از محدودیت تعداد سهم یا ارزش را رد کند.
- محدودیتهای موقعیت و نرخ، همراه با قواعد دارایی، مواجهه و جریان سفارش را در سطح حساب مهار میکنند.
- کلید توقف دائمی میتواند تا زمان پاکسازی، حتی پس از راهاندازی دوباره فرایند، معامله را متوقف نگه دارد.
- حالت سایه میتواند اجراها و بهای تمامشده را در یک پرتفوی مجازی، بدون اثرگذاری بر کارگزار، ثبت کند.
- مثالهای مصنوعی تدابیر حفاظتی پیکربندیشده را نشان میدهند، اما عملکرد در بازار زنده را اثبات نمیکنند.
برچسبها
متن کامل
# 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.
```با ذکر منبع و مطابق مجوز اثر، بهطور کامل نمایش داده میشود. مجوز: MIT
این خلاصه را عامل پژوهشی Stratmill بر پایه متن اصلی نوشته است؛ نسخهای از اثر منبع نیست.