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_pnl rather than the raw evaluate_*_scenario repricing 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

ScenarioShockDescription
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 / methodDescription
pnlTotal PnL = shocked_net_value − base_net_value.
base_net_value / shocked_net_valuePortfolio value before / after the shocks.
booksPer-book reports, each with its own positions, strategies, buckets, and pnl.
expiry_buckets / strike_bucketsPortfolio-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_*_scenario returns the raw repricing (base / shocked values); explain_*_scenario_pnl is a thin layer that adds pnl and expiry / strike bucketing. Repricing is identical.
  • Book and portfolio variants are symmetric — _book_ takes a &Book; _portfolio_ takes a &Portfolio and 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 through ScenarioDefinition::new (same validation).