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
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.
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 (spot → m.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:
| scenario | price | delta | vega |
|---|---|---|---|
| base | 9.400171 | 0.598570 | 38.591312 |
| crash (spot −30%, vol +10pt) | 1.717020 | 0.173033 | 17.900858 |
| rally (spot +30%) | 33.661546 | 0.940736 | 15.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.