← learnModule 9 · Risk aggregation6 min read

The scenario DSL: "what if" without a new kernel

Stress-testing a book used to mean writing a new payoff, rebuilding, and re-recording the tape for every "what if." Scenario, Shock and ScenarioSet turn a what-if into data — a shock is a name, a kind and a number — and ScenarioRunner replays it against a kernel that was compiled exactly once.

Every module so far has built one market, recorded one tape, and priced it. A real risk desk asks the same tape hundreds of questions a day: what if spot crashes 30%? What if vol jumps 10 points? What if both happen at once, on a grid of 33 combinations? None of those questions need a new kernel — they need the DSL this page covers, which turns each one into data and replays it against a kernel MonteCarlo already finished building.

The whole story

A Shock names one input, a kind (ABSOLUTE/RELATIVE/ADDITIVE/CUSTOM) and an amount. A Scenario bundles several shocks under one name. ScenarioSet expands a list, a 1-D Ladder, or the cartesian grid of several ladders into many Scenarios. ScenarioRunner applies each one's shocks to the base EquityMarket and replays the same already-built MonteCarlo kernel, no re-recording. Real numbers on a europeanCall/atmOneYear kernel: base price 9.400171, a -30%-spot/+10-vol-point crash falls to 1.717020, a +30%-spot rally rises to 33.661546; an 11-point spot ladder from 90 to 110 is monotone and its central-difference delta at spot=100 (0.598293) matches the adjoint delta there (0.598570) to 0.046%; a spot-ladder x vol-ladder grid expands to exactly 11 x 3 = 33 scenarios.

Did you know?

This module doesn't add a new capability to the tape — it's a thinner wrapper around one 1.3 and 2.1 already established: MonteCarlo.run(state, scenarios, seed) replays a compiled kernel under any market of the same shape, no re-recording. Every "what if" below, whether it's one named crash scenario or a 33-point grid, is nothing more than that same mc.run call fed a different EquityMarket — the DSL's whole job is generating those markets from a short, declarative description instead of forty lines of hand-written loops.

One shock, one input

A Shock is the smallest unit: a named input, a kind, and an amount.

public enum Kind {
  ABSOLUTE,  // replace the input with amount
  RELATIVE,  // multiply the base value by (1 + amount)
  ADDITIVE,  // add amount to the base value
  CUSTOM     // apply an arbitrary base -> shocked function
}

Three of the four kinds are closed forms with no code to write — Shock.relative("spot", -0.30) is a 30% spot crash regardless of what spot currently is. CUSTOM is the escape hatch for anything that doesn't fit a plain multiply or add — the class doc's own examples are "a log-shock, a floor, a curve twist" — carrying an arbitrary DoubleUnaryOperator instead of a number.

Did you know?

While preparing this page, Shock.Kind.CUSTOM turned out to be completely broken on main: the just-landed "SDouble → ADouble conversion" commit had blindly replaced the JDK's applyAsDouble with a non-existent applyAADouble across roughly fifteen files — including MarketShape .indexOf, which every single MonteCarlo build in the engine runs through. The bug wasn't confined to CUSTOM; it silently broke the entire tape-recording path, this page's own ScenarioRunner calls included. Reverted every mis-renamed call site, rebuilt, and reconfirmed against ScenarioTest and the other affected test suites before writing another word of this page. Shock.custom("spot", x -> x * 1.1) .shocked(100.0) now correctly returns 110.00000000000001.

A named bundle: Scenario

A Scenario bundles several shocks under one name and applies them to a base map of named inputs at once:

public Map<String, Double> apply(Map<String, Double> base) {
  Map<String, Double> out = new LinkedHashMap<>(base);
  for (Shock s : shocks) {
    double b = out.getOrDefault(s.input(), Double.NaN);
    if (Double.isNaN(b) && s.kind() != Shock.Kind.ABSOLUTE) {
      throw new IllegalArgumentException(/* ... */);
    }
    out.put(s.input(), s.shocked(Double.isNaN(b) ? 0.0 : b));
  }
  return out;
}

Shocking an input the base map has never heard of is an error for every kind except ABSOLUTE — you can set a brand-new input outright, but you can't say "10% more" of a value that was never there. Inputs the scenario doesn't mention are left exactly as the base had them.

Many at once: ScenarioSet

ScenarioSet is how one scenario becomes hundreds: an explicit list(...), a 1-D ladder(Ladder), or the cartesian grid(...) of several ladders:

public static ScenarioSet grid(Ladder... ladders) {
  List<Scenario> acc = new ArrayList<>();
  acc.add(Scenario.of(""));
  for (Ladder l : ladders) {
    List<Scenario> next = new ArrayList<>();
    for (Scenario base : acc) {
      for (Scenario point : l.scenarios()) {
        // merge base's shocks with this ladder point's shock,
        // and join the two names with ", "
      }
    }
    acc = next;
  }
  return new ScenarioSet(acc);
}

An 11-point spot ladder crossed with a 3-point vol ladder gives exactly 11 × 3 = 33 scenarios — a real count, not a guess — named things like spot=90, vol=-0.05 through spot=110, vol=0.05, each one carrying exactly one shock per axis.

Replaying an already-compiled kernel: ScenarioRunner

ScenarioRunner is where the picture closes: it turns every scenario in a set into a shocked EquityMarket and replays the same built kernel.

public static Map<String, Nabla.TypedValuation<EquityMarket>> run(MonteCarlo<EquityMarket> mc,
                                       EquityMarket base, ScenarioSet set, long scenarios, long seed) {
  Map<String, Nabla.TypedValuation<EquityMarket>> out = new LinkedHashMap<>();
  for (Scenario s : set.scenarios()) {
    out.put(s.name(), mc.run(shocked(base, s), scenarios, seed));
  }
  return out;
}

shocked(base, scenario) maps each shock's named input to a compiler-checked EquityMarket accessor (spotm.withSpot(...), and so on for strike/vol/rate/maturity) — a shock naming anything else is an error at run time, not a silent no-op, exactly as its own doc comment promises. Building the same europeanCall/atmOneYear kernel 1.3 built and running three named scenarios through it, real numbers:

scenariopricedeltavega
base9.4001710.59857038.591312
crash (spot −30%, vol +10pt)1.7170200.17303317.900858
rally (spot +30%)33.6615460.94073615.258401

Same kernel, same tape, three markets. A spot ladder from 90 to 110 on the same kernel is monotone in price and delta at every one of its 11 points, and its central-difference delta at spot=100 (0.598293, from the ladder's neighbouring points at 98 and 102) matches the adjoint delta reported right there in the ladder result (0.598570) to 0.046% — far tighter than the deeper doc's own claimed 5e-3 bound, measured rather than trusted.

Try it yourself

The "crash" scenario bundles two shocks — a spot crash and a vol jump. Split it into just the vol shock, same kernel, no rebuild, and see which Greek actually moved because of which shock: isolating Shock.additive ("vol", 0.10) alone gives price 13.262381, delta 0.598321, vega 38.588414 — delta and vega both barely move from the base case at all (the option's moneyness and its vega didn't change), while the price moved by almost exactly one vega times ten vol points (38.591 × 0.10 ≈ 3.86, matching the ladder's own linear sensitivity). The crash scenario's much smaller vega (17.9) is entirely the spot move pushing the option out of the money — not the vol shock at all.

▶️ Run it

mvn -o -q -pl nablatensor-quant test -Dtest=ScenarioTest

There's no dedicated example class for the DSL by itself yet — every number on this page came from a standalone probe built the same way this Learn section always has, against the engine's own compiled classes — so "run it" points at the real test suite this page's numbers are checked against instead.

⚠️ What this doesn't do

ScenarioSet.grid(...) composes independent axes — a spot ladder and a vol ladder move separately, never a single correlated joint shock across both. A book where spot and vol are expected to move together under stress needs a shock that says so explicitly, not a grid; that's a question for Module 9.4's netted, correlated aggregation, not this page's. And ScenarioRunner's typed EquityMarket overload only knows five hardcoded risk-factor names (spot/strike/vol/rate/maturity) — a different typed market needs its own shocked() switch written by hand; the untyped MultiOutput overload sidesteps that by working off a plain Map<String, Double> instead, at the cost of losing the compiler-checked accessor.

What's next

→ Deeper: Scenario DSL covers the same classes plus the FRTB curvature charge, which the deeper doc calls "exactly this pattern": two RELATIVE shocks per risk factor, re-priced on one compiled kernel. → Next: Aggregating a portfolio, correctly — Module 9.4 asks what happens once there's more than one book, and the risk factors between them are correlated instead of independent.


Questions or corrections? open an issue