When to Use
Use this skill when deploying multi-strategy or multi-asset trading systems where sub-strategies maintain their own internal stop-loss rules. During systemic market shocks, correlation spikes, or liquidity crunches, individual strategy stops may fail to trigger simultaneously or suffer severe slippage. An independent portfolio-level stop-loss continuously calculates Net Asset Value (NAV) across all strategies and enforces daily ($\text{DD}{\text{daily}} \ge \text{MaxDailyDD}$) and peak-to-trough ($\text{DD}{\text{peak}} \ge \text{MaxPeakDD}$) limits, flattening all active positions and locking out new orders when limits are breached.
When NOT to Use
- As a source of regulatory thresholds. The 5% / 10% defaults in
scripts/are your risk policy. Nothing surveyed inreferences/standards.mdimposes a drawdown or daily-loss number on a trading firm: MiFID II RTS 6 Art. 15(4) requires a firm to set market and credit risk limits based on its own capital base and risk tolerance, and SEC Rule 15c3-5 binds broker-dealers with market access, not the end trading firm. Never present these numbers to an auditor as regulatory minimums — calibrate them withrisk-limit-calibration-against-historical-drawdowns. - As the order gate itself. This engine decides whether the portfolio must stop; it does not inspect individual orders. Enforcing reduce-only flow while locked — so the lockout does not veto its own liquidation — belongs to
kill-switch-and-drawdown-circuit-breakers. - As a replacement for per-strategy stops. A portfolio stop is a last-resort backstop that halts the whole book. If it fires routinely, the strategy-level controls in
strategy-level-kill-switch-vs-portfolio-level-kill-switchare too loose. - Inside a backtest. A latching portfolio stop applied to a simulated equity curve truncates the drawdown tail and flatters the result. Model it as an explicit strategy rule instead — see
lookahead-bias-elimination. - On a multi-currency book that has not been normalized. The engine sums cash, prices and equity baselines arithmetically and performs no FX conversion; convert to one reporting currency first via
multi-currency-pnl-and-fx-conversion.
Prerequisites
- Sub-strategy position states (
strategy_id,symbol,quantity,current_price,unrealized_pnl), sourced from the broker/custodian's account state rather than the bot's internal bookkeeping — RTS 6 Art. 17(3) frames that reconciliation duty for EU investment firms, and a fill-tracking bug otherwise hides a real breach from the one control meant to catch it. - Portfolio equity metrics (
start_of_day_equity,peak_equity,current_cash), all positive and all in one reporting currency. - A record of every settled deposit and withdrawal (
capital_flow_since_sod,capital_flow_since_peak), so a cash movement is not read as P&L. - The correct NAV valuation mode for the account type:
CASH_PLUS_MARKET_VALUEfor cash-funded equity/spot books,CASH_PLUS_UNREALIZED_PNLfor margined derivatives books. - Risk policy config (
max_daily_drawdown_pct: default 0.05,max_peak_drawdown_pct: default 0.10), expressed as fractions, not percentage points.
Workflow
- Aggregated Portfolio NAV Calculation:
- Choose the valuation mode from the account type, then compute total NAV: $\text{NAV} = \text{CurrentCash} + \sum (\text{Qty}_i \cdot \text{Price}_i)$ for a cash-funded book, or $\text{NAV} = \text{CurrentCash} + \sum \text{UnrealizedPnL}_i$ for a margined one. On a futures or CFD account
Qty · Priceis notional, not equity: adding it to cash reports a leveraged, losing book as hugely profitable and the stop never fires.
- Choose the valuation mode from the account type, then compute total NAV: $\text{NAV} = \text{CurrentCash} + \sum (\text{Qty}_i \cdot \text{Price}_i)$ for a cash-funded book, or $\text{NAV} = \text{CurrentCash} + \sum \text{UnrealizedPnL}_i$ for a margined one. On a futures or CFD account
- Fail Closed Before Measuring Anything:
- Treat any input you cannot evaluate as a halt condition, not as a passed check. A
NaNmark makes every threshold comparison false, so both limits go quiet at once with no outward signal; a zero or negativestart_of_day_equitymakes the daily drawdown denominator undefined. Halt (HALTED_INVALID_INPUT), and where marks carry timestamps, halt on staleness too (HALTED_STALE_PRICES). - A fail-closed halt blocks new risk but must not auto-liquidate: the engine has no evidence the portfolio is actually down, and market-flattening a book on one bad tick is itself the loss event. Escalate to a human instead — and when usable data returns and shows a genuine breach, the flatten must still be requested then, rather than being swallowed by the lock the halt already set.
- Treat any input you cannot evaluate as a halt condition, not as a passed check. A
- Drawdown Evaluation, Net of Capital Flows:
- Remove settled flows from NAV before comparing against the baselines — a withdrawal lowers NAV without being a loss, a deposit raises it without being a gain: $$\text{DD}{\text{daily}} = \frac{E{\text{SOD}} - (\text{NAV} - F_{\text{SOD}})}{E_{\text{SOD}}}, \qquad \text{DD}{\text{peak}} = \frac{E{\text{peak}} - (\text{NAV} - F_{\text{peak}})}{E_{\text{peak}}}$$
- Clamp both at zero (a portfolio above its baseline is not in drawdown), and decide the breach on the unrounded value so a reported
0.0500cannot disagree with its own breach flag.
- Emergency Circuit Breaker Trigger:
- If $\text{DD}{\text{daily}} \ge \text{MaxDailyDD}$ OR $\text{DD}{\text{peak}} \ge \text{MaxPeakDD}$, trigger the emergency global flatten, override all sub-strategies, and lock the trading session.
- Emit the flatten request once, on the transition into the breach — not on every subsequent poll, or a 1-second risk loop fires a fresh liquidation cascade every second while the first one is still working. Guard the transition with a lock if a strategy loop, a risk poller and an operator endpoint can all reach it.
- Latch the Lockout Across Evaluations:
- The lock must survive NAV recovery, a now-flat book, and the next day's start-of-day reset. Recomputing it from the current state each call silently re-enables trading at the next daily rollover, when $\text{DD}_{\text{daily}}$ mechanically returns to zero — exactly the auto-resume this control exists to prevent.
- Require an Audited Human Re-Enable:
- Refuse a blank operator identity and a blank reason, refuse an operator outside the configured roster, return a boolean the caller must check, and append every attempt — granted and refused — to the audit trail.
- Re-enabling clears the latch, not the breach. After a peak-drawdown halt the breached high-water mark survives, so the next evaluation re-latches immediately; resuming requires the operator to deliberately re-baseline
peak_equityin their own state store. Never re-baseline it automatically — that erases the peak limit entirely.
- Audit Report Generation: Output structured
PortfolioStopReport, including the NAV the drawdown was actually measured on and whether the lock came from this evaluation or a prior latch.
Full procedure: see
references/workflows.md. Standards reference: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Relying Solely on Strategy-Level Stops: Assuming strategy-level stops provide adequate portfolio-level protection during correlation breakdown.
- Manual Intervention Delays: Leaving portfolio drawdown response to manual human decision-making during fast market crashes.
- Failing to Lock Trading Session: Flattening positions upon stop-loss breach but allowing sub-strategies to immediately re-open new positions on subsequent signal cycles — or recomputing the lock from live NAV, so the lockout evaporates the moment prices bounce or the next trading day resets the daily baseline.
- Passing a Limit as Percentage Points:
max_daily_drawdown_pct=5meaning "5%" reads as 500%. The breaker can never fire, and the first evidence is the loss it was supposed to prevent. Validate at construction. - Trusting an Unchecked
NaN: A stale mark or a zero-denominator P&L makesdd >= limitfalse for both limits simultaneously, so the engine reportsPORTFOLIO_NAV_HEALTHYwhile checking nothing. - Auto-Liquidating on Bad Data: Force-flattening because NAV could not be computed converts a data outage into a realized loss at market. Halt new risk, alert a human, do not sell.
- Valuing a Margined Book as Cash + Notional: A $500,000 notional futures position on $1,000,000 cash reports NAV of $1,500,000 rather than the true $940,000 account equity, and a 6% drawdown reads as a 50% gain.
- Reading a Settled Withdrawal as Drawdown: A scheduled cash transfer trips the stop and market-flattens a book that was never in trouble; symmetrically, a deposit masks a real loss.
- Re-Firing the Flatten Every Poll: A latch-free breach path re-issues the full liquidation on every evaluation cycle, duplicating orders into a falling market.
- Auto-Baselining the Peak on Re-Enable: Resetting
peak_equityto current NAV to get the engine to unlock deletes the peak-drawdown limit while appearing to satisfy it. - Comparing Un-normalized Currencies: Summing USD cash with JPY position values produces a NAV that is not a number in any currency, and a drawdown limit measured against it is meaningless.
Verification
- Instantiate
PortfolioLevelStopLossIndependentOfStrategyStops. Input portfolio with $$1,000,000$ start-of-day equity. Submit position drawdowns resulting in NAV of $$940,000$ ($6%$ daily drawdown vs $5%$ max limit) $\implies$ verifyDAILY_DRAWDOWN_BREACH_FLATTENstatus andis_trading_locked = True. - Feed a
NaNmark and a zerostart_of_day_equityand confirm each returnsHALTED_INVALID_INPUTwithis_trading_locked = Trueandpositions_to_flatten_count = 0— notPORTFOLIO_NAV_HEALTHY. - Construct the config with
max_daily_drawdown_pct=5and confirm it raisesValueErrorrather than accepting a 500% limit. - Evaluate a breach, then evaluate a fully recovered portfolio, and confirm the report still reports the latched breach status with
is_latched = True. - Confirm the flatten request is emitted on the first breaching evaluation only, and that eight concurrent evaluations of the same breaching state produce exactly one liquidation request.
- Confirm
human_re_enable()refuses a blank identity, a blank reason and an unlisted operator, that every attempt lands inre_enable_log, and that re-enabling while the portfolio still breaches re-latches on the very next evaluation. - Confirm a $$100,000$ settled withdrawal from a flat $$1,000,000$ book reports
PORTFOLIO_NAV_HEALTHY, and that a $$200,000$ deposit does not mask a $$60,000$ loss. - Confirm the same losing futures book breaches under
CASH_PLUS_UNREALIZED_PNLand does not underCASH_PLUS_MARKET_VALUE, and that the configured mode matches the account type. - Run
python -m unittest discover -s skills/portfolio-level-stop-loss-independent-of-strategy-stops/scriptsand confirm all tests pass.