When to Use
Invoke this skill when the question is "what does this book lose if this happens?" and the answer has to be reproducible, auditable and independent of whatever the recent return distribution happens to look like. VaR and Expected Shortfall are calibrated to a historical window; a deterministic scenario is not, which is the whole point of running one alongside them.
The engine is factor-based: each position declares one risk factor and one sensitivity to it, and each scenario declares a shock per factor. Two shock types exist because the two sensitivities are different quantities with different signs:
| Shock type | Units of the shock | P&L | beta_to_factor carries |
|---|---|---|---|
RELATIVE_RETURN |
fractional return (-0.35 = −35%) |
$V \cdot \beta \cdot \Delta$ | return elasticity to the factor |
YIELD_BPS |
basis points (200.0 = +200bp) |
$-V \cdot D \cdot \Delta/10^4$ | modified or spread duration, in years |
A fall in a price-like factor is a loss for a long; a rise in a yield-like factor is a loss for a long. The minus sign lives in the engine, not in your beta.
When NOT to Use
- As a capital requirement or a regulatory stress test. No regulator-set methodology
is implemented. MiFID II RTS 6 Article 10 is titled "Stress testing" but mandates a
systems capacity test — message and trade volumes at twice the previous six months'
peak — not a portfolio P&L scenario. BCBS d450 is guidance addressed to banks. See
references/standards.mdfor what binds whom. - On an options book, without knowing what you are losing. Every position is one
linear beta or duration. There is no gamma, no vega convexity, no bond convexity and no
cross-factor term, so a large shock on a convex book is wrong in the direction that
matters — a short option loses far more than the linear estimate. Aggregate the Greeks
with
options-greeks-real-time-portfolio-aggregationand shock those instead. - To model an instrument that can trade through zero. A
RELATIVE_RETURNshock is bounded at −1.0. The May 2020 WTI contract settled at −$37.63 on 20 April 2020 (CFTC interim staff report); no percentage shock reaches a negative price. Shock the position value directly. - To ask what it costs to get out. This is a revaluation at shocked prices. The
liquidation cost and the days-to-liquidate horizon are
portfolio-stress-test-including-liquidity-crunch-scenarios. - To replay per-symbol crash returns. If your scenario is "SPY −34%, QQQ −28%" rather
than "equity factor −34%", use
stress-testing-against-historical-crash-scenarios. - To find the scenario that breaks you.
StressScenarioCategory.REVERSE_STRESSis a label you may attach to a scenario you constructed; the engine does not solve for the shock that breaches a limit. - As a path-dependent drawdown model. One instantaneous revaluation, not a path. The limit is expressed as a drawdown percentage but what is measured is a single-period scenario loss.
Prerequisites
- Positions as
AssetPosition:asset_id,factor_name, signedcurrent_value_usd(a short is negative),beta_to_factor. One instrument may occupy several rows, one per factor it is exposed to — a convertible carries an equity row and a credit row. - A capital base.
capital_base_usdis the denominator for every percentage in the report and is required whenever the book contains a short; on a long-only book it defaults to the sum of position values. max_allowed_drawdown_pct(default $20.0$) — a library default, not a regulatory limit. Calibrate it to the capital the book must not lose and record why.- Scenarios: the three predefined
HISTORICAL_CRISISreplays, plus anyStressScenarioDefinitionyou supply. Scenario ids must be unique across both.
Workflow
-
Map every position to a factor and a sensitivity.
- Decision point — the factor name is a join key, and a typo is silent. A position
whose
factor_namematches no shock contributes exactly zero. Version 1.0.0 reported such a book as a $0 loss, no breach, and an empty worst-case scenario — an all-clear on a book it never stressed. Coverage is now explicit: readreport.status,report.factors_never_shockedand each result'sunshocked_asset_idsbefore quoting any number. A run in which no scenario touches any position raises. - Partial coverage is normal and is not an error: the 2022 rate scenario legitimately leaves an equity book untouched. It is reported, not rejected. BCBS d450 Principle 4: "If certain material and relevant risks are excluded from the scenarios, their exclusion should be explained and documented."
- Decision point — the factor name is a join key, and a typo is silent. A position
whose
-
Choose the shock type per factor, not per scenario.
- Decision point — a rate or spread shock is
YIELD_BPS, and its sign is the engine's. $\Delta P/P \approx -D_{\text{mod}},\Delta y$. Version 1.0.0's absolute-change branch computed $+V\beta,\Delta y$, so a +200bp hike against a duration-7 book reported a +14% gain; and its 2008 credit shock was written3.00with the relative flag — "+300bps" in the comment, +300% in the arithmetic, a +$3,000,000 gain on a $1M book in a spread blow-out. If you are migrating from 1.0.0 and worked around either bug with a negative beta, remove the workaround: pass duration as a positive number. - The legacy
is_absolute_change=Truestill constructs and now selectsYIELD_BPS. Its numbers change sign. Prefershock_type=ShockType.YIELD_BPS.
- Decision point — a rate or spread shock is
-
Set the denominator deliberately.
- Decision point — net exposure is not a capital base. A market-neutral book nets
to near zero and any loss over it explodes; a $1M/−$999k pair reported a −35% loss
against a $1,000 denominator in 1.0.0. The engine now refuses to infer a base from a
book containing shorts and requires
capital_base_usd.
- Decision point — net exposure is not a capital base. A market-neutral book nets
to near zero and any loss over it explodes; a $1M/−$999k pair reported a −35% loss
against a $1,000 denominator in 1.0.0. The engine now refuses to infer a base from a
book containing shorts and requires
-
Read the breach flag, and know what it compares.
- The flag is
loss_pct < -max_allowed_drawdown_pct, evaluated before display rounding and strictly — a loss of exactly the limit passes, and −20.004% against a 20.0% limit breaches. Version 1.0.0 compared the two-decimal display figure and cleared that loss. breached_scenario_idscollects every breach; breaches and incomplete coverage are logged at WARNING.
- The flag is
-
Report and archive.
resultscarries the per-scenario breakdown — P&L, percentage, the value actually shocked, and the value that was not — so any aggregate traces to its drivers and a risk committee can see the scenario's reach, not just its number.
Full procedure: see
references/workflows.md. Standards reference: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Reporting a gain on a rate hike. The duration sensitivity is signed: $\Delta P/P \approx -D,\Delta y$. Dropping the minus turns the single most common fixed-income stress scenario into good news, and nothing in the output looks wrong — the number is the right magnitude with the wrong sign.
- Writing basis points into a relative shock.
3.00means +300% to aRELATIVE_RETURNfactor and +300bp to aYIELD_BPSone. The comment beside the constant is not what runs. - Treating a $0 scenario loss as a pass. Zero P&L means either nothing moved or
nothing matched. Check
unshocked_asset_idsbefore believing it, especially after a reference-data change renames a factor. - Reading a NaN as a pass. Every comparison against NaN is False, so an unguarded NaN
value clears
loss_pct < -limitand lands in a report whose breach flag readsFalse. Non-finite inputs now raise. - Deciding a risk limit on a rounded number. Display rounding belongs in the report, not in the comparison.
- Measuring a hedged book against its own net exposure. Drawdown limits are set against capital. State the capital base.
- Duplicating a factor inside one scenario. A
-0.30shock beside a-0.05typo used to apply the-0.05silently, because the later key won. Both now raise. - Assuming diversification survives the scenario. Shocks are applied independently per
factor from a vector you supplied; there is no correlation model and no forced-seller
feedback. If you do not believe two legs offset in a crunch, shock them apart — see
tail-correlation-between-strategies-under-stress. - Applying spot shocks to options without Greeks. A linear beta prices neither gamma nor vega; the error grows with exactly the shock sizes a stress test is for.
- Mistaking the defaults for the episodes. −35% equity is materially milder than the −56.8% peak-to-trough decline of the 2007–2009 bear market, and +150% vol is milder than the rise that carried VIX to its 80.86 close on 20 November 2008. They are library defaults. An uncalibrated default nobody has questioned is a scenario nobody chose.
Verification
Run python -m unittest discover -s skills/scenario-based-stress-testing-custom-shocks/scripts
and confirm a 100% pass rate. The suite pins the behaviour below; every item in the first
group fails against version 1.0.0.
- Rate shock sign. $1,000,000 at modified duration 7 under +200bp $\Rightarrow$ $-7 \times 0.02 \times $1\text{M} = -$140{,}000$ (−14.0%), not $+$140{,}000$. A −150bp cut on $2,000,000 at duration 5 $\Rightarrow +$150{,}000$.
- Credit shock units. $2,000,000 at spread duration 4 under the 2008 scenario's +300bp $\Rightarrow -$240{,}000$ (−12.0%), not $+$24{,}000{,}000$.
- Threshold. A loss of exactly −20.0% against a 20.0% limit passes; −20.004% breaches despite rounding to −20.00 for display.
- Coverage. A book whose only factor no scenario shocks raises
ValueError. A book that is 80%EQUITY_SPOTand 20%GOLDreturnsSTRESS_TEST_INCOMPLETE_FACTOR_COVERAGE,factors_never_shocked == ["GOLD"], and a −$280,000 Lehman loss withGLDlisted as unshocked. - Equity worked example. $500k at β1.1 plus $500k at β0.9 under −35% $\Rightarrow -$350{,}000$ (−35.0%), limit breached; under a custom −15% $\Rightarrow -$150{,}000$, not breached.
- Sign convention. A −$100,000 short-vol position under a +150% vol shock loses $150,000; the mirror long gains it.
- Negative checks. NaN/±Inf/numeric-string/bool/None values or betas, a blank
asset_idorfactor_name, empty positions, a non-positive or non-finitecapital_base_usd, a zero-value long-only book, a non-positivemax_allowed_drawdown_pct, a duplicatescenario_id, a duplicate factor within one scenario, an empty shock list, a relative shock below −1.0, a short book with nocapital_base_usd, a bareStressScenarioDefinitionpassed where a sequence is expected, a P&L that overflows to infinity, and wrongly typed positions or scenarios must each raiseValueError. - Positions given as a generator are materialised, not consumed by the first scenario and reported as $0 for the rest.
Related Skills
portfolio-stress-test-including-liquidity-crunch-scenariosstress-testing-against-historical-crash-scenariosrisk-limit-calibration-against-historical-drawdownsrisk-model-backtesting-against-realized-outcomestail-correlation-between-strategies-under-stresscorrelation-aware-exposure-limitsoptions-greeks-real-time-portfolio-aggregationvalue-at-risk-var-live-monitoringkill-switch-and-drawdown-circuit-breakers