← learnModule 3 · Payoffs that depend on the whole path4 min read

Barriers and digitals — payoffs with a kink

Why a discontinuous payoff breaks both bump-and-revalue and a reverse sweep, and how nablatensor-ops' Smooth.step trades a sharp corner for a curve steep enough to matter and smooth enough to differentiate.

Every payoff so far bends smoothly. A knock-out barrier doesn't: the option is worth something, then the instant a level is touched, it's worth nothing. What does a reverse sweep even do with a slope that jumps from flat to flat with nothing sensible in between?

The whole story

The indicator 1S > barrier is flat with slope zero on both sides and undefined exactly at the jump. Smooth.step replaces it with a logistic curve, 1/(1+exp(-x/width)), that converges to the same jump as width shrinks but stays differentiable everywhere. Three real checks confirm it: in/out parity holds exactly, the adjoint delta matches a central bump of the same smoothed payoff, and the digital's price matches the Black-Scholes closed form.

Did you know?

Smooth.java's own doc comment states the property that makes this whole approach work, not just plausible: "as width -> 0 the smoothed value converges to the discontinuous limit; its derivative concentrates into a spike, so a barrier delta computed this way is a genuine (mollified) delta, not a bumped one." The smoothing isn't a hack that happens to give a usable number — it's built entirely from the engine's own primitive ops (div, neg, exp, add), so its adjoint is the exact derivative of the smoothed function, no special-cased reverse rule required.

The smoothed indicator

nablatensor-ops' Smooth.step is a logistic curve, built from four ADouble calls:

public static ADouble step(AadRecorder rec, ADouble x, double width) {
  requireWidth(width);
  ADouble e = x.div(width).neg().exp();          // exp(-x/width)
  return rec.constant(1.0).div(e.add(1.0));      // 1 / (1 + exp(-x/width))
}

gt/lt/between all reduce to step on a shifted argument. A knock-out barrier multiplies one of these per monitored step — "still alive so far," smoothed — instead of checking a hard boundary:

ADouble survival = rec.constant(1.0);
for (int t = 0; t < grid.steps(); t++) {
  path = sim.model.step(path, rec.randn(), t);
  ADouble notBreached = up
      ? Smooth.lt(rec, path, barrier, width)   // still below an up-barrier
      : Smooth.gt(rec, path, barrier, width);  // still above a down-barrier
  survival = survival.mul(notBreached);
}

ExoticProducts.barrier(...) records exactly this loop; digitalCash and digitalAsset use one Smooth.gt/lt at expiry instead of a running product across every step.

Checks that actually ran

ExoticsTest verifies this construction three separate ways; running the same market (atmOneYear, 64 steps, 200,000 scenarios, seed 12345) gives the same real numbers:

In/out parity. UP_OUT and UP_IN at the same level and width are built as vanilla × survival and vanilla × (1 − survival) — they have to sum back to the plain European, and they do, to six decimals:

vanilla=9.400362  UP_OUT=3.409376  UP_IN=5.990986  OUT+IN=9.400362

Digital vs. the closed form. A cash-or-nothing digital's fair value is e^{-rT} N(d2) — the same d2 Black-Scholes uses:

digital mc=0.504659  closedForm=0.504572  diff=8.70e-05
Did you know?

The barrier's adjoint delta is checked against a central bump — but of the same smoothed payoff, not the original discontinuous contract:

barrier adjointDelta=0.006051  bumpDelta=0.005933  diff=1.18e-04

That's a deliberate, easy-to-miss choice. Bumping the actual discontinuous barrier is exactly the "notoriously noisy" estimate this page opened with — smoothing doesn't make that noise go away for free on the raw contract; it replaces the contract itself with a differentiable stand-in, and then both bump-and-revalue and adjoint AD get a clean answer on that stand-in. Adjoint AD is the cheap way to differentiate the smoothed payoff, not a way to avoid smoothing it in the first place.

Try it yourself

ExoticsTest.barrierConvergesAsSmoothingWidthShrinks runs the same UP_OUT barrier at width = 4.0, 2.0, 1.0, 0.5, and 0.25, in sequence. Run it yourself and watch the price move less between each successive pair — the test's own assertion is exactly that: the gap between the last two widths is smaller than the gap between the first two, because the sequence is settling onto the sharp-barrier limit rather than wandering.

▶️ Run it

mvn -o -q install
mvn -o -q -pl nablatensor-examples exec:java \
  -Dexec.mainClass=com.nablatensor.bench.ProductBench \
  -Dscenarios=1000000 -Dsteps=128

Prints price, delta, and the adjoint-vs-bump timing from 2.1 for a whole book of products at once — European, Asian, lookback, barrier, digital, cliquet, and autocallable, all through the same .greeks()/.priceOnly() pattern.

⚠️ What this doesn't do

This page covers exactly two of ExoticProducts' five payoffs — barrier and digital, the two the plan calls "payoffs with a kink." It doesn't touch cliquets or autocallables, which use the same Smooth primitives for a different reason (a ratchet's local/global clamps, an early-redemption trigger) rather than a barrier's knock-in/out kink. It also doesn't say how to choose width beyond the rule of thumb already in Smooth.java's own doc comment: too small re-introduces the variance a discontinuity brings, too large biases the price.

What's next

→ Deeper: Barriers, digitals and the smoothed indicator covers MultiMetric — pricing several of these payoffs off one market and seed at once — which this page left out entirely. → Next: Write your own payoff, the "swap the payoff" exercise, turned into three lines you change yourself.


Questions or corrections? open an issue