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

Write your own payoff

Build a custom Java option payoff with NablaTensor, price it, and switch between call and put behavior without changing the engine.

Every payoff on this site so far came from Products or ExoticProducts. What does it actually cost to price something neither catalogue has?

The whole story

The Product interface is a single-method functional interface. Three lambdas from the built-in catalogue and one written inline all go through the same MonteCarlo.of(...).build() driver, each producing its own tape, node count, price, and Greeks. A capped call costs exactly two more nodes than a plain European call. Swapping the payoff from call to put is a one-line change to the payoff formula, and delta flips sign as it should.

Did you know?

Product<M> really is this small — @FunctionalInterface, one method, record(rec, in, grid). The doc comment above it says exactly what this page sets out to prove: "changing a payoff is a three-line diff, not a fork." Products and ExoticProducts wrap their lambdas in a private Named/Labelled record purely so toString() prints something nicer than Products$$Lambda@1a2b3c in a report — that wrapper is cosmetic. SwapThePayoff's inline payoff skips it entirely and hands MonteCarlo.of(...) a bare lambda, and nothing about the driver, the tape, or the reverse sweep notices the difference.

A payoff neither catalogue has

A capped call — min(max(S_T − K, 0), cap), discounted — isn't in Products or ExoticProducts. Here it is, written once, with nothing around it but a market and a loop you've already seen twice:

static Product<EquityMarket> cappedCall(double cap) {
  return (rec, in, grid) -> {
    // Market inputs stay differentiable throughout the recorded payoff.
    ADouble spot = in.of(EquityMarket::spot);
    ADouble strike = in.of(EquityMarket::strike);
    ADouble rate = in.of(EquityMarket::rate);
    ADouble vol = in.of(EquityMarket::vol);
    ADouble maturity = in.of(EquityMarket::maturity);

    // Build the path model once; each step appends nodes to the same tape.
    GbmPath model = GbmPath.of(rec, rate, vol, grid, maturity);
    ADouble terminal = spot;
    for (int t = 0; t < grid.steps(); t++) {
      // One random shock advances the symbolic terminal price by one step.
      terminal = model.step(terminal, rec.randn(), t);
    }
    // Apply the call payoff, floor it at zero, then cap it at the limit.
    ADouble payoff = terminal.sub(strike).max(0.0).min(rec.constant(cap));
    // Discount the capped payoff back to today.
    rec.output(payoff.mul(rate.neg().mul(maturity).exp()));
  };
}

Every line is something 1.3 or 3.1 already showed you: five named inputs, a GbmPath stepped forward, a max/min intrinsic, a discount factor. The only genuinely new idea is the .min(rec.constant(cap)) at the end — one extra ADouble method call.

Did you know?

Running cappedCall and Products.europeanCall() side by side (128 steps, 2,000,000 scenarios, seed 42) puts real numbers on "three-line diff": European call records to 661 nodes; Capped call (inline) records to 663 — exactly two more. One is rec.constant(cap), the other is the min op itself. Writing a payoff feature that isn't in the catalogue cost this tape precisely two nodes, not a new engine feature, a new backend, or a line changed anywhere outside the lambda.

Swap the payoff, literally

Turning this call into a put is the chapter's namesake move — one line, inside the lambda, nothing else:

// call: ADouble payoff = s.sub(strike).max(0.0).min(rec.constant(cap));
// put:
ADouble payoff = strike.sub(s).max(0.0).min(rec.constant(cap));

Three edits get you there: copy cappedCall to a cappedPut, swap that one line, and add it to SwapThePayoff's book map. A real run of the result:

Capped call (inline)   663    5.556285    0.272231    2.6029
Capped put (inline)    663   4.838204   -0.257065   15.9729

Same node count either way — sub() costs one node whichever operand comes first. Delta flips sign, as a put's should; nothing about MonteCarlo, Nabla, or the reverse sweep needed to know a put even exists.

Try it yourself

cappedCall(market.spot() * 0.15) caps the payoff at 15.0. Change that to market.spot() * 0.30 (30.0) and predict the direction before you run it: a looser cap binds less often, so the price should move up, toward the uncapped European call's 9.413115 — never past it, since a capped call can never be worth more than the option it caps.

▶️ Run it

The same three payoffs, live — European call, capped call, and its one-line put twin. The example below runs here; the capped product is the upstream SwapThePayoff typed Nabla.Inputs<EquityMarket> pattern on the portable cpu engine. Watch delta's chart flip sides between the capped call and the capped put:

Java · compile and run in this browser

Or run the real thing:

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

Prints the four-row table above. Add your cappedPut entry to the book map yourself to see the fifth row.

⚠️ What this doesn't do

Product<M> says right in its own doc comment that "any double-only record works" as the market type M — this page only ever used EquityMarket. A rates or FX payoff recording against its own market record through the same interface is a real capability, just not one any Learn page has exercised yet. This page also doesn't cover the label() default method or the Named/Labelled wrapper pattern — cosmetic, and optional, as the first sidenote already said.

What's next

→ Deeper: Swap the payoff — the seam in three lines is the technical version of this page; note its code sample predates the current GbmPath/TimeGrid constructor shape this page uses. → Next: Six backends, one tape — this whole book of payoffs can already run on all six, unchanged.


Questions or corrections? open an issue