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

Write your own payoff

Product<M> is a plain functional interface with one method. This page writes a payoff that isn't in either catalogue, runs it for real, and swaps call for put in one line to prove the seam holds.

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) -> {
    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);

    GbmPath model = new GbmPath(rec, rate, vol, grid, maturity);
    ADouble s = spot;
    for (int t = 0; t < grid.steps(); t++) {
      s = model.step(s, rec.randn(), t);
    }
    ADouble payoff = s.sub(strike).max(0.0).min(rec.constant(cap));
    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 module'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

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