Most Python pricers that need Greeks get them by bumping: price, move one input, price again, divide. It works, it costs one or two extra pricings per sensitivity, and its accuracy depends on a bump size somebody chose years ago.
Adjoint algorithmic differentiation (AAD) gives every first-order sensitivity of a price in one backward pass, exactly. The common worry is that it means rewriting the pricer around an AD framework. With an operator-overloading tool such as AADC it usually does not. The pricer keeps its structure, its loops and its data; the change is in what kind of number flows through it.
The pricer
A small book of three European options, in ordinary Python:
from math import exp, log, sqrt, erfc
def norm_cdf(x):
return 0.5 * erfc(-x / sqrt(2.0))
def bs_price(S, K, r, q, vol, T, is_call):
fwd = S * exp((r - q) * T)
d1 = (log(fwd / K) + 0.5 * vol * vol * T) / (vol * sqrt(T))
d2 = d1 - vol * sqrt(T)
if is_call:
return exp(-r * T) * (fwd * norm_cdf(d1) - K * norm_cdf(d2))
return exp(-r * T) * (K * norm_cdf(-d2) - fwd * norm_cdf(-d1))
BOOK = [dict(K=95.0, T=0.5, qty=10, call=True), dict(K=105.0, T=1.0, qty=-5, call=True),
dict(K=90.0, T=2.0, qty=8, call=False)]
def book_value(spot, rate, div, vol):
return sum(t["qty"] * bs_price(spot, t["K"], rate, div, vol, t["T"], t["call"]) for t in BOOK)
What has to change
One line. The math functions only accept plain floats, so a value passed through them loses its derivative. AADC’s maths functions accept both its active type and plain floats, so the pricer still works unchanged when called with floats:
import aadc
exp, log, sqrt, erfc = aadc.math.exp, aadc.math.log, aadc.math.sqrt, aadc.math.erfc
That replaces the first line of the file. Nothing else in it changes.
Record once, then ask
The recording is a separate script. It hands the pricer active values for the four market inputs, marks them as inputs and the book value as output, and records one run:
import numpy as np
import aadc
from pricer import book_value
base = dict(spot=100.0, rate=0.03, div=0.01, vol=0.20)
f = aadc.Functions()
f.start_recording()
x = {k: aadc.idouble(v) for k, v in base.items()}
h = {k: v.mark_as_input() for k, v in x.items()}
out = book_value(**x).mark_as_output()
f.stop_recording()
print("frozen decisions:", f.num_passive_warnings())
ws = f.create_workspace()
for k, v in base.items():
ws.set_val(h[k], v)
ws.forward()
ws.set_diff(out, 1.0)
ws.reverse()
The run printed:
| book value | 95.54477525088078 |
| d/dspot | 2.4977975158829056 |
| d/drate | −386.53172032367445 |
| d/ddiv | 295.78812996470356 |
| d/dvol | 406.8166017574855 |
| frozen decisions | 0 |
The value equals the original math pricer’s to the last digit. A central bump of the original pricer at h = 1e-5 gives a delta of 2.4977975215279002, agreeing with the AAD delta to about 6e-9, which is the bump’s own error. Four sensitivities came from one reverse pass; a fifth or a fiftieth input would cost almost nothing more.
Replay at new inputs
The record is the computation, not the Python. Setting a new spot on the same workspace and running it forward does not execute the pricer again:
ws.set_val(h["spot"], 101.0)
ws.forward()
It returned 98.15920151251808; the original pricer at spot 101 returns 98.15920151251808. For many scenarios at once, aadc.evaluate replays the record over arrays of inputs and returns values and derivatives together. Over five spots from 90 to 110 every replayed value matched a fresh pricing to the ten decimals printed, with delta and vega alongside each (at 90: value 84.4149871012, delta −0.4259021365, vega 476.9324954683).
What can stay, and what to look for
Stays as it is: the structure of the pricer, its functions, loops, lists and dictionaries, and any if on plain data such as is_call above. Branching on contract terms is not a problem, because those are not marked inputs.
Needs attention:
- Maths that leaves the active type.
math.*,float(), or a NumPy call that does not know the active type turn a value into a constant with zero derivative. Useaadc.mathfor scalars andaadc.arrayfor arrays. - An
ifon a marked input, such asif spot > barrier:. Python needs a plainbool, so the direction taken while recording is frozen, and a replay on the other side of the threshold follows the recorded branch. The engine counts every such case: reportf.num_passive_warnings()with every recording, and useaadc.iifwhere the decision must stay live. A separate page covers what AAD returns at a branch or a kink. - A new structure needs a new recording. A replay moves inputs, not the shape of the computation: a different book, a different number of time steps, a new trade means recording again.
When the counted decisions are zero and the replayed values match a fresh pricing, the record is the pricer at every input it can reach without crossing a decision.
Your agent can check this directly
aadc-mcp is a local MCP server that gives a coding agent the AADC engine and its how-tos, whose snippets run as written. pip install aadc-mcp, register it with your MCP client, and use the add_sensitivities prompt on your script: the agent makes a recording copy with marked inputs, leaves the pricing itself alone, runs it, and reports the price, each sensitivity and the frozen-decision count exactly as printed. open_session then keeps that recording alive so replay_session can answer what-ifs at new inputs from the record.
AADC runs here as Community Edition: free for non-commercial and academic use; commercial use, including evaluation inside a company, needs a licence from MatLogica.
Implemented using AADC, a commercial adjoint AD compiler (matlogica.com).