Use case · Scenarios
Explain a portfolio scenario with attribution
A ScenarioDefinition carries a named, ordered list of typed ScenarioShock variants. explain_portfolio_scenario_pnl applies them left-to-right, repricing every position by full revaluation, and returns exact PnL attribution per position, per book, per expiry / strike bucket, and per strategy group.
When to use it
- You hold a Portfolio and need exact, fully-reconciling PnL attribution under a named what-if shock set — not just a single aggregate number.
- You want per-position, per-bucket, and per-strategy breakdowns (use
explain_*_scenario_pnlrather than the rawevaluate_*_scenariorepricing API). - You are ingesting upstream regime / vol / jump signals and want them expanded deterministically into shocks via
FerroWaveScenarioAdapter.
Example
use ferro_risk::{
Book, BookId, ExerciseStyle, OptionType, Portfolio, Position, PositionId,
PricingInputs, PricingModel, ScenarioDefinition, ScenarioShock,
explain_portfolio_scenario_pnl,
};
let position = Position::new(
PositionId::new(1),
10.0, // quantity
PricingInputs {
option_type: OptionType::Call,
exercise_style: ExerciseStyle::European,
spot: 100.0,
strike: 105.0,
time_to_expiry: 0.5,
rate: 0.03,
dividend_yield: 0.01,
volatility: 0.20,
},
PricingModel::BlackScholesMerton,
)?;
let book = Book::new(BookId::new(1), vec![position], vec![])?;
let portfolio = Portfolio::new(vec![book])?;
// Shocks compose left-to-right (non-commutative).
let scenario = ScenarioDefinition::new(
"regime_shift",
vec![
ScenarioShock::SpotRelative { relative: 0.05 }, // spot up 5%
ScenarioShock::ParallelVol { shift: 0.02 }, // +2 vol points
ScenarioShock::Skew { slope: -0.10 }, // skew tilt
ScenarioShock::Rate { shift: 0.0025 }, // +25 bps
ScenarioShock::RollDown { years: 0.05 }, // time roll
],
)?;
let report = explain_portfolio_scenario_pnl(&portfolio, &scenario)?;
println!("portfolio pnl = {}", report.pnl);
for book in &report.books {
println!("book {:?} pnl = {}", book.book_id, book.pnl);
for pos in &book.positions {
println!(" position {:?} pnl = {}", pos.position_id, pos.pnl);
}
}
for bucket in &report.expiry_buckets {
println!("expiry {} pnl = {}", bucket.time_to_expiry, bucket.pnl);
}
# Ok::<(), ferro_risk::FerroRiskError>(())The shocks
| ScenarioShock | Description |
|---|---|
| SpotRelative { relative } | Multiply spot by (1 + relative); relative must be > -1.0. |
| ParallelVol { shift } | Add a flat absolute shift to annualized volatility (must stay ≥ 0). |
| Skew { slope } | Add a model-coordinate skew shift to vol (lognormal uses k = ln(K/F); Bachelier uses K − F). Uses the current shocked forward. |
| Rate { shift } | Add a flat absolute shift to the continuously-compounded rate. |
| RollDown { years } | Subtract years from time_to_expiry, floored at 0.0 (exact expiry). This is the time-decay shock. |
The attribution report
PortfolioScenarioPnlReport has public fields (no constructor):
| Field / method | Description |
|---|---|
| pnl | Total PnL = shocked_net_value − base_net_value. |
| base_net_value / shocked_net_value | Portfolio value before / after the shocks. |
| books | Per-book reports, each with its own positions, strategies, buckets, and pnl. |
| expiry_buckets / strike_buckets | Portfolio-level attribution by expiry and by strike. |
| .position_rows() / .strategy_rows() | Flattened, deterministically-ordered position / strategy rows. |
What attribution means
Attribution here is dimensional, not factor-based: the report slices full-revaluation PnL (shocked − base) across positions, strategy groups, and expiry / strike buckets. It is not a Greek decomposition into spot / vol / rate / time contributions.
Notes
evaluate_*_scenarioreturns the raw repricing (base / shocked values);explain_*_scenario_pnlis a thin layer that addspnland expiry / strike bucketing. Repricing is identical.- Book and portfolio variants are symmetric —
_book_takes a&Book;_portfolio_takes a&Portfolioand rolls up per-book reports. - Repricing is deterministic and order-preserving; positions and expiry / strike buckets reconcile exactly to the total. Strategy groups are a membership view and are not additive when memberships overlap.
FerroWaveScenarioAdapter::new(name, steps).to_scenario_definition()expands regime / volatility / jump signal steps into the same ordered shock list, routed throughScenarioDefinition::new(same validation).