Skip to main content

moneyness

Options pricing, Greeks and implied volatility, in Python with no runtime dependencies.

The library prices European options under the generalised Black-Scholes-Merton model, differentiates them analytically, and recovers the volatility implied by a quoted price. It is written to be correct at the boundaries — zero time, zero volatility, deep wings, quotes sitting exactly on the arbitrage bound — because those are the inputs that break naive implementations, and they arrive in real data more often than the textbook middle does.

What is checked, and against what

tests/reference.py evaluates the same mathematics at fifty decimal digits in mpmath. It shares no code, no rearrangement and no branch structure with the library: where the implementation has a tail-safe grouping and a limit case, the reference has the textbook expression and enough precision that the difference does not matter. Agreement between them across a grid is therefore evidence about the implementation rather than about whoever typed the expected values.

The grid reaches the places a naive implementation breaks rather than the textbook middle — a spot at two fifths of the strike and at nearly four times it, three days and ten years, a negative carry, volatilities from 5% to 80%. Every figure below is produced by examples/worked.py, which runs in CI and exits non-zero if any of them moves:

Check Result
The price against the same formula at fifty digits in mpmath, 648 points worst relative error 1e-12, where the price is above 1e-8 of spot
The same, absolutely, including the quotes worth almost nothing worst absolute error 1e-13, on spots of 50 to 150
The normal distribution function against mpmath's erfc, 1143 points worst absolute error 1e-15, from z = -40 to z = +40
Delta, gamma, vega, theta and rho against derivatives of that price worst relative error 1e-11, against a numerical derivative
Put-call parity across the same grid worst relative error 1e-13
Volatility recovered from the price it implies, where vega exceeds 1 worst error 1e-13 over 342 quotes
The width of the set that reprices a quote worth 1.4e-18 every volatility from 0.001 to 0.899 reprices it

Four of those rows are shaped by what the measurement will actually support, and the shape is the interesting part.

The Greeks are checked against a differentiated reference, not a second closed form. A second closed form is another chance to make the same algebra mistake twice. A numerical derivative of the fifty-digit price shares nothing with the analytic Greek except the price itself, so an error in the Greek cannot hide behind an error in its derivation.

The price gets two rows because one figure would be a lie in one direction or the other. Relative error is the right measure for a price somebody would trade on and a meaningless one for a price of 1e-18, where both implementations are reporting the last bits of a number that is zero for every practical purpose and the ratio of two such disagreements can be anything at all. So the relative bound covers the prices above 1e-8 of spot and the absolute bound covers every point on the grid, including the ones below it.

Parity earns its place by being independent of everything above it. It is algebra on the two payoffs and does not know what a distribution function is, so a library whose call and put are both wrong in the same way passes the reference comparison and fails this.

And the implied volatility is a ladder rather than a figure, because a single round-trip error over this grid would describe the worst question rather than the solver. Vega is how much the price moves per unit of volatility, and the recovery error tracks it almost exactly:

vega above quotes worst error
1 342 1.1e-14
0.01 386 1.8e-12
0.0001 408 1.2e-10
1e-08 442 7.0e-07

206 of the 648 quotes on the grid have a vega of 1e-08 or less and are not questions a solver can answer at all. The worst of them is out by 0.8 in volatility, which is not a defect: the quote is worth 1.413e-18 on a spot of 50, and every volatility from 0.001 to 0.899 reprices it to within 5e-15. Eleven standard deviations out of the money an option is worth its intrinsic value to the same double whatever the volatility, so a monotone function is not a steep one and a solver always returns a number. Reporting the width of that set is the honest form of the answer, and it is the only form a test can check.

pip install -e ".[dev]"      # the reference needs mpmath
python examples/worked.py    # all seven, non-zero if a figure moves

Behind them: 6060 tests on Python 3.10 through 3.13.

Installing

pip install moneyness

Not on the package index yet. The pip install line above is what it will be; until the first release lands, install from source:

pip install "git+https://github.com/DaniyalMlk/moneyness.git"

Python 3.10 or newer. The library itself imports only the standard library, so there is nothing else to resolve and nothing to compile.

Working on it instead of with it:

pip install -e ".[dev]"

pytest, mypy, ruff and mpmath are used for development and testing. mpmath is not optional for the suite — see the decision below.

Releasing

The version lives in pyproject.toml and is mirrored by moneyness.__version__; a test asserts the two agree, and the release refuses to run if the tag disagrees with either.

# after the version bump has landed on main
git tag v0.1.0
git push origin v0.1.0

The tag builds the sdist and the wheel, installs each into a clean environment, runs the entry point out of both, and then publishes. Publishing uses the index's trusted-publishing flow, so there is no API token in this repository, in the workflow, or in the repository's secrets. Registering the publisher on the index is a one-time step done there, not here, and it names this repository, the release.yml workflow and the pypi environment.

Using it

from moneyness import Inputs, OptionType, price, delta, vega

option = Inputs(spot=100.0, strike=95.0, time=0.5, rate=0.04, vol=0.22)
price(option, OptionType.CALL)   # 10.043415628...
delta(option, OptionType.CALL)   #  0.704045742...
vega(option)                     # 24.433893679...

Recovering the volatility from a quoted price:

from moneyness.implied import Quote, solve

quote = Quote(spot=100.0, strike=95.0, time=0.5, rate=0.04, price=10.0434156286)
solve(quote, OptionType.CALL).vol   # 0.22

The four market conventions are reached by moving the cost of carry rather than by calling a different function:

Inputs(100.0, 95.0, 0.5, 0.04, 0.22)                          # non-dividend share
Inputs.with_dividend(100.0, 95.0, 0.5, 0.04, 0.22, 0.015)     # share paying a yield
Inputs.on_future(100.0, 95.0, 0.5, 0.04, 0.22)                # option on a future
Inputs(100.0, 95.0, 0.5, 0.04, 0.22, carry=0.04 - 0.01)       # currency

A smile generated by dynamics rather than fitted to quotes:

from moneyness import Contract, Heston, smile

model = Heston(v0=0.04, kappa=1.5768, theta=0.04, sigma=0.5751, rho=-0.5711)
contract = Contract(spot=100.0, strike=100.0, time=1.0, rate=0.0, carry=0.0)
[(p.strike, p.implied_vol) for p in smile(model, contract, [80, 100, 125])]
# [(80, 0.228815...), (100, 0.174341...), (125, 0.151918...)]

A variance swap's fair strike, replicated out of the same model's prices and checked against the closed form it has to agree with:

from moneyness import Contract, Heston, OptionType, fair_variance
from moneyness.heston import price

model = Heston(v0=0.04, kappa=1.5, theta=0.04, sigma=0.5, rho=-0.7)

def otm(strike: float) -> float:
    side = OptionType.CALL if strike >= 100.0 else OptionType.PUT
    return price(model, Contract(100.0, strike, 1.0, 0.0, 0.0), side)

result = fair_variance(otm, forward=100.0, time=1.0, discount=1.0, width=5.0)
result.fair_variance                              # 0.039999999999068...
model.expected_integrated_variance(1.0) / 1.0     # 0.04 exactly

The decision that mattered

The tests check the library against mpmath evaluating the same mathematics at fifty decimal digits, not against constants transcribed from a reference book.

The distinction is not pedantic. A transcribed constant tests the transcription: if a digit is fat-fingered the suite fails for a reason that has nothing to do with the code, and — worse — if the implementation and the constant are wrong in the same way, nothing fails at all. The high-precision oracle shares no code, no algebraic rearrangement and no branch structure with the implementation under test. When the two agree to 1e-13 across a grid of several hundred inputs, that is evidence about the implementation.

It also changes what the tests can assert. Because the oracle is available at any input, the suite checks relative accuracy in the far tail, where the true value is 1e-89 and an absolute tolerance would be satisfied by returning zero. That caught the specific reason norm_cdf is built on erfc rather than erf: the erf form loses the entire left tail to cancellation below about x = -8.

The same approach sets the tolerances honestly. The round trip norm_ppf(norm_cdf(x)) cannot be exact for x above about 4, because the probability is then within a few floating-point steps of 1 and the information is destroyed at storage rather than in the algorithm. Rather than loosening the tolerance until it passes, the suite asserts against the conditioning bound ulp(p) / phi(x) — which still fails if the upper branch regresses.

From the command line

moneyness price  --spot 100 --strike 95 --time 0.5 --rate 0.04 --vol 0.22
moneyness greeks --spot 100 --strike 95 --time 0.5 --rate 0.04 --vol 0.22
moneyness iv     --spot 100 --strike 95 --time 0.5 --rate 0.04 --price 10.0434156286
moneyness ladder --spot 100 --time 0.25 --vol 0.3 --rate 0.03 --low 80 --high 120 --steps 5
moneyness american --spot 100 --strike 100 --time 1 --rate 0.06 --vol 0.25 \
                   --dividend 0.06 --put --steps 400 --boundary

iv reports the method used, the iteration count and the residual alongside the volatility, and prints the no-arbitrage range when a quote cannot be inverted.

american prints the lattice value beside the European value on the same lattice and the European closed form, so the early-exercise premium and the discretisation error can be read as separate numbers rather than conflated. It also prints the Bjerksund-Stensland closed-form approximation and its trigger price, which is a fast independent check on the lattice rather than a second opinion from the same machinery. --boundary tabulates the early-exercise boundary, and says so plainly when the exercise region is empty.

A second decision: what the tolerances mean

Several tests assert against a conditioning bound rather than a fixed tolerance, and the distinction is what makes them worth having.

Recovering volatility from the price of a deep in-the-money option is limited by the quote, not by the solver. A price is a double, so it moves in steps of ulp(price); dividing that by dPrice/dVol gives the finest volatility the quote can distinguish at all. For a call struck at 20 against a spot of 100 the entire time value is 1.4e-9 and that ratio is 2.6e-7 relative — so no solver can do better, because the information is not in the input.

A fixed tolerance has to be either tight enough to fail on those quotes or loose enough to stop detecting regressions in the well-conditioned majority. Asserting against the bound itself avoids the choice, and documents the real guarantee.

The same reasoning fixed a genuine defect. The implied-volatility solver originally stopped when the price residual was small, which is the natural thing to write and is wrong: where the price is nearly flat in volatility, that threshold is met while the volatility is still incorrect in its sixth digit. Converging on the step in volatility instead — the quantity that is well posed — brought it to the precision the quote supports, and the disagreement with Brent that exposed the problem is now a standing test.

A third decision: why the obvious accuracy trick had to be earned

A lattice price carries an O(1/n) error in the layer count, so the textbook move is Richardson extrapolation: price at n and 2n, and take 2P(2n) - P(n) to cancel the leading term. Measured on a European call, doing that to a raw lattice makes the answer about twice worse, not four times better.

The premise is what fails. Richardson assumes the error is a smooth c/n, and a lattice error is not: it carries a large component that oscillates with n, because what the lattice gets wrong depends on where the strike falls between terminal nodes, and that position jumps around as n changes. Subtracting one price from twice another amplifies exactly that component.

The fix is to remove the cause rather than tune around it. The oscillation comes from the kink in the terminal payoff being sampled at nodes, so the layer before expiry is replaced with the closed-form European price — the Broadie-Detemple smoothing — which integrates across the kink exactly. The remaining error is smooth, and extrapolation then does what it promised:

lattice fine lattice + Richardson + smoothing + both
Cox-Ross-Rubinstein 2.9e-03 5.5e-03 1.4e-03 4.5e-05
Jarrow-Rudd 2.7e-03 6.8e-03 1.4e-03 5.0e-05
trinomial 1.6e-03 2.3e-03 7.2e-04 4.7e-06

Mean absolute error against the closed form over layer counts from 60 to 120. Smoothing and extrapolation are each worth a factor of two on their own; together they are worth 30x to 150x. Both remain switchable, and the failure above is itself a test, so the default cannot quietly stop being the right one.

A fourth decision: bracket the approximation, do not just measure it

The American price has no closed form, so there is nothing to check an American implementation against — which usually means settling for "the two methods agree to three decimals" and hoping that is enough.

There is something better available here. The Bjerksund-Stensland formula is the exact value of a flat-boundary exercise strategy: exercise the moment the underlying first crosses a fixed level. That strategy is one the holder could really follow, just not the best one, so its value is a genuine lower bound on the American price and an upper bound on the European price:

European <= Bjerksund-Stensland <= American

The suite asserts that inequality on every row of its grid, for both option types, rather than only asserting closeness. It is the sharper statement: a tolerance says two numbers are near each other, while the bracket says which side of the lattice the approximation has to fall on, and an implementation that drifted above the lattice would fail it while still looking accurate to three decimals. A companion tolerance stops the bracket being satisfied the lazy way, by returning the European value and sitting at the bottom of the band.

Puts are not implemented twice. They route through the call by the McDonald-Schroder transformation, P(S, K, T, r, b, v) = C(K, S, T, r - b, -b, v), so there is one exercise rule in the codebase rather than two that can drift apart — and the transformation itself is asserted, not assumed.

A fifth decision: let the arbitrage conditions do the work

A volatility surface has to satisfy two conditions to describe a possible market. Each slice must imply a non-negative probability density — no butterfly arbitrage — and total variance must not fall as maturity grows at fixed log-moneyness, or one could buy the longer option, sell the shorter, and collect a certain profit.

It is tempting to treat these as validation: fit the surface, then check it. They turn out to be more useful than that, because of what Dupire's identity looks like in these coordinates:

sigma_local^2(k, T) = (dw/dT) / g(k, T)

where w is total variance and g is the Durrleman function — the same g whose sign is the butterfly condition. The numerator is non-negative exactly when there is no calendar arbitrage. The denominator is positive exactly when there is no butterfly arbitrage. So a local volatility exists, as a real non-negative number, if and only if the surface admits neither.

The two conditions are not hygiene. They are precisely the conditions under which "what volatility would reproduce these prices" has an answer at all. So local_vol returns the numerator and the denominator alongside the result, because when the answer is unusable the caller needs to know which one failed: a negative numerator is slices crossing, which is a data problem, and a non-positive denominator is a density defect inside one slice, which is a fitting problem. They have different fixes.

The choice of interpolation follows from the same thinking. Linear in total variance at fixed log-moneyness makes the calendar condition automatic — a linear function between two ordered endpoints is monotone, so non-crossing slices cannot interpolate to crossing ones. The butterfly condition gets no such argument, since g is nonlinear in w. A randomised search over tens of thousands of admissible, non-crossing pairs failed to find an interpolated violation, which is suggestive and is not a proof, so the surface recomputes g on interpolated slices rather than assuming it, and checks the midpoints of the gaps by default.

Calibration exploits the shape of SVI rather than throwing five parameters at an optimiser. Substituting y = (k - m) / s makes the parametrisation linear in the three remaining coefficients, so the fit is a three-variable least squares solved in closed form inside a two-variable search. The constraint that keeps a slice non-negative everywhere turns out to be a second-order cone, which is projected onto exactly rather than clamped coordinate by coordinate.

from moneyness import Surface, calibrate

fit = calibrate(log_moneyness, total_variances)
fit.rmse, fit.slice_.wing_slopes

surface = Surface([(0.25, near), (1.0, mid), (2.0, far)])
[entry.free for entry in surface.calendar()]      # no slices crossing
surface.local_vol(k=-0.2, time=0.8).volatility

A sixth decision: keep the simulation exact, and say what the error is

The Monte Carlo does not run an Euler scheme. Geometric Brownian motion has lognormal increments in closed form, so a path can be drawn with exactly the right joint law at its monitoring dates, and there is no step-size bias to trade against sample count.

That separation earns its keep on barrier options. A barrier monitored weekly genuinely is a different contract from one monitored continuously — not a worse approximation to it — and if the simulation carried a discretisation error on top, the two effects would be impossible to tell apart. Here the only discretisation is the monitoring schedule, which is a term of the contract.

Standard errors are computed on the count of independent samples, not paths. Under antithetic sampling a path and its mirror are not independent, which is the entire point of drawing them, so the pair is one sample. Dividing by the path count instead understates the error by about sqrt(2) — silently, and in the direction that flatters the estimate. The suite checks the reported standard error against the spread actually observed across sixty seeds, which is the assertion that would catch it.

Three claims here are measured rather than asserted:

claim measured
convergence at n^-1/2 RMS error over 40 seeds falls by 2.02x, 2.13x, 2.34x per 4x sample increase
the geometric-average control on an arithmetic Asian standard error falls by more than 10x
the Brownian bridge on a barrier step-dependence falls from 1.03 to 0.07 across an 80x sweep of monitoring frequency, about 15x

Knock-ins are priced as the vanilla minus the knock-out on the same paths, so in-out parity holds exactly rather than to within two simulations' noise. The barrier prices are validated against a Reiner-Rubinstein formula implemented separately in the test suite — the bridge is essentially unbiased at ten monitoring dates, where the naive estimate is off by many standard errors.

from moneyness import Inputs, OptionType, Settings, Barrier, barrier

option = Inputs(100.0, 100.0, 1.0, 0.05, 0.2)
estimate = barrier(option, OptionType.CALL, 90.0, Barrier.DOWN_AND_OUT, 50)
estimate.value, estimate.standard_error, estimate.interval(0.95)

From the command line, on a whole surface

surface reads a CSV of maturity,strike,vol — a file, or - for standard input — fits an SVI slice per maturity, and reports what the result implies.

moneyness surface --quotes quotes.csv --spot 100 --rate 0.05 --local-vol
 maturity  quotes        rmse     max err         a        b      rho        m        s    left   right    worst g
------------------------------------------------------------------------------------------------------------------------
   0.2500       9   4.504e-12   9.550e-12   0.02000  0.15000 -0.35000 -0.00000  0.12000  -0.203   0.097    0.24751
   1.0000       9   1.420e-11   2.529e-11   0.05000  0.22000 -0.30000 -0.00000  0.18000  -0.286   0.154    0.24879
   2.0000       9   1.808e-11   3.465e-11   0.10000  0.30000 -0.25000  0.00000  0.25000  -0.375   0.225    0.25048

calendar (total variance must not fall as maturity grows)
  0.2500 -> 1.0000  clear, closest approach 0.05052
  1.0000 -> 2.0000  clear, closest approach 0.08452

verdict: no arbitrage found

The wing slopes are printed next to the fit quality on purpose: a slope near Lee's bound of 2 is a warning about the tails whatever the residual says.

It exits 1 when the fitted surface admits arbitrage, so it can be used as a check in a pipeline. That is a finding about the data rather than a failure of the request, which is why it is an exit status and not an exception.

A seventh decision: take the maximum of admissible bounds

The Bjerksund-Stensland approximation is the exact value of a flat-boundary exercise strategy — admissible but not optimal — which is what makes European <= approximation <= American hold by construction rather than by luck. The 2002 refinement lets the boundary step once partway through the option's life, which needs a bivariate normal distribution function: the joint event of not having crossed the first level before the step and finishing in the money is inherently two-dimensional.

It is tempting to argue the two-step value can never be worse than the flat one, since a flat boundary is the special case of the two levels coinciding. That argument is about the optimal two-step boundary. The levels here come from a closed-form heuristic optimised for neither, and measured over 240 random markets the raw 2002 formula falls below the 1993 value on nine of them — and in one case below the European price, which no American value may do, since holding to expiry is always available.

So bjerksund_stensland_2002 returns the largest of the European price, the 1993 value and the two-step value. Each is the value of a strategy the holder could actually follow, so each is a lower bound, and the largest of several lower bounds is both the sharpest available and still a bound:

European <= max(European, 1993, 2002) <= American

That is not a patch over a numerical problem. It is the right way to combine admissible strategies when none dominates everywhere. It binds on about four per cent of that grid, and the suite asserts it binds rather than sitting unused — with the specific market that triggers it written into a test.

mean absolute error vs. a converged lattice
1993 single boundary 0.0666
2002, guarded 0.0420

The bivariate normal underneath is built from Sheppard's identity with the substitution t = sin(theta), which removes the 1/sqrt(1 - t^2) singularity exactly where it would otherwise bite — at correlations near one, which is where the two-step boundary evaluates it. Worst absolute error against fifty-digit integration over 810 points, correlations from -0.9999 to 0.9999 and arguments to eight standard deviations: 2.2e-16.

Its Gauss-Legendre nodes are computed from the Legendre recurrence at import rather than transcribed. Forty table entries all look equally plausible, and a single wrong digit gives a rule that is slightly wrong everywhere — accurate enough to look fine and never exactly right. The tests check the rule by the property that defines it: exact on every polynomial up to degree 2n - 1, and not beyond.

An eighth decision: keep two pricing routes, and a broken one

Heston's model has no closed-form price, but it has a closed-form characteristic function, and a European option is an integral against it. The integral has been written down more than once. This package evaluates two of them — Lewis's single integral and Heston's original pair of probabilities — and the tests require them to agree, to 2e-11 across six maturities, nine strikes and both option types.

That is not belt and braces. It is the only check available that is sensitive to the thing most likely to be wrong.

The obvious test — that the price collapses to Black-Scholes as the volatility of variance goes to zero — passes for every strike even with the sign of the log-moneyness reversed in the integrand. At zero volatility of variance the transform on the pricing line is real, so exp(i u k) and exp(-i u k) contribute identically and the error is invisible. It stays invisible at the money for any parameters, because there k is zero. It shows up only off the forward and only once the variance is genuinely random — where it prices the mirror image of the smile the model generates, which for a correlation of -0.57 put a one-year 200-strike call at 0.2478 against a true 0.0013 — a hundred and thirty times the lognormal price of 0.0018, for an underlying whose upside tail the model makes thinner than lognormal, not fatter.

A symmetric special case cannot validate a sign that only matters under asymmetry. Two independent derivations can, and did.

check what it catches
zero volatility of variance equals Black-Scholes, to 1e-12 the transform's algebra
Lewis and Gil-Pelaez agree, to 2e-11 the pricing formulas, including signs
the Lewis integral re-evaluated at thirty digits in mpmath the quadrature
butterflies non-negative, prices monotone in strike the shape

Lewis's route is also the faster of the two, by a factor of 1.45 on a one-year option, because it is one integral rather than two — so it is what price calls, and the second route stays available for callers who want it and for the suite.

A ninth decision: never subtract two things that are about to agree

The characteristic function needs a - d, where d = sqrt(a^2 + sigma^2 m). Those two agree to leading order as the volatility of variance goes to zero, and their difference is the whole answer: computed directly it is noise below a sigma of about 1e-4, and the integrand stops being smooth enough to integrate at all — the adaptive rule bisects every panel to its depth limit and never returns.

(a - d)(a + d) = -sigma^2 m gives the same quantity as a division, which cancels nothing, and it takes sigma^2 out of the numerator where it was going to be divided straight back out. The transform is then accurate down to a volatility of variance of exactly zero, which is the case with an independent answer, and the gap to Black-Scholes behaves the way theory says it should:

gap to Black-Scholes as sigma halves
correlated shocks, rho = -0.5 halves — leading term is the skew, linear in rho sigma
uncorrelated shocks, rho = 0 quarters — the skew term is absent and the gap is quadratic

Measured: -0.18779 per unit of sigma in the first case, -2.9578 per unit of sigma^2 in the second, each stable to better than one per cent over a sixteen-fold range.

The logarithm in the transform gets the same treatment, since its argument sits within sigma^2 of one.

A tenth decision: implement the broken branch on purpose

Heston's 1993 grouping of the characteristic function takes a complex logarithm whose argument circles the origin as maturity grows. The principal branch then jumps by 2 pi i, and the price is wrong by an amount nobody would call a rounding error. Albrecher and co-authors' observation is that the algebraically identical grouping reached by the other square root keeps that argument inside a disc around 1, where it cannot wind.

Both are implemented, selected by Branch, and branch_discrepancy measures the gap. The reason to keep the broken one is that the shape of its failure is worth seeing:

transform argument u maturity the textbook grouping survives to
1 8.65 years
3 2.88 years
10 1.55 years

The failure maturity falls as the frequency rises. So the naive form breaks in the tail of the pricing integrand — the part that shapes the wings — while the low frequencies that carry most of the price are still correct. A price computed with it does not look broken. It looks slightly off in the wings, which is exactly how a model that has been fitted to the wrong wings looks.

An eleventh decision: simulate the variance, do not repair it

The variance in Heston's model follows a square-root process, and an Euler step can take it negative. Every repair for that biases the price, and the bias is not small. Measured against the transform on a one-year at-the-money call worth 6.809, full truncation — the mildest repair — is out by 0.718 at four steps a year, halving with each doubling of the grid: 0.287 at eight, 0.106 at sixteen, 0.039 at thirty-two.

Andersen's scheme instead fits a non-negative law to the exact conditional mean and variance of the process, choosing between a squared normal and a point mass plus an exponential by the ratio of the second to the square of the first. It is inside its own Monte Carlo error at four steps, and stays there. Reaching that with an Euler grid costs upwards of sixty-four times the work.

The failure is sharpest at the origin, and that is where it matters. Started at exactly zero, a full-truncation Euler step has no diffusion at all — the volatility of the increment is the square root of a variance that is zero — so the path has to be lifted off the origin by drift before any randomness enters, and the simulated conditional variance comes back below three-quarters of the true one. A model whose Feller condition fails, which is the usual outcome of fitting one to index quotes, spends time at exactly that point.

What the martingale correction is actually worth

The drift constant can be set per step so the simulated forward is exactly right rather than approximately right, using the proposal's own moment generating function. It is often presented as the point of the scheme. It is not, and the numbers are worth stating because the honest version is more useful than the impressive one.

The uncorrected constant is chosen so the defect nearly cancels when the variance sits at its long-run level, and what is left there is third order in the step. On the reference parameters, per step:

variance four steps a year twenty steps a year
0.0025 +2.06 bp +0.02 bp
0.04 (= theta) -0.23 bp -0.002 bp
0.25 -12.75 bp -0.12 bp

Two things to read off that. The defect changes sign as the variance crosses theta, so a path that wanders on both sides of it partly cancels its own error — which is why the correction is worth less than a glance at the worst cell suggests. And dividing the step by five divides the defect by about a hundred and twenty-five, not by five.

So it earns its place on coarse grids and in the wings of a simulated distribution, where the variance is far from theta and stays there, and does nothing measurable in the middle. It is on by default and can be switched off.

Two bugs the tests found

Both in the zero-volatility-of-variance limit, which is the case with an independent answer and therefore the case worth testing.

The proposal divided by a conditional variance that was exactly zero. And more interestingly, the log price lost its diffusion entirely. The correlated part of the price's shock is normally carried by the variance's own increment, through a substitution that divides by the volatility of variance — which is why the diffusion coefficients hold only the orthogonal fraction 1 - rho^2. As that volatility falls, the variance increment becomes less random and supplies proportionally less, while the coefficients in front of it grow like its reciprocal and the total stays right. At exactly zero the increment is not random at all, the substitution does not exist, and the correlated fraction has to be put back by hand. The limit is discontinuous in the decomposition though not in the law.

A twelfth decision: difference the cheap option, not the dear one

SABR is the smile the interest rate and commodity markets actually quote — four parameters per expiry, turned into volatilities by Hagan's asymptotic expansion. moneyness.sabr has both of its forms, the displaced variant for negative forwards, and the Bachelier pricer this package previously had no equivalent of.

$ moneyness sabr --forward 0.02 --time 10 --alpha 0.004 --beta 0.5 \
      --rho -0.3 --nu 0.8 --low 0.01 --high 0.035 --steps 7 --floor
forward            0.020000
at-the-money sd    0.00259849
backbone exponent  0.5

    strike    lognormal       normal          black      bachelier      density
-------------------------------------------------------------------------------
  0.010000   24.623602%   0.00355128   0.0010752317   0.0011477077      -7.5881
  0.014167   15.054426%   0.00254592   0.0010808367   0.0011032233      -6.6868
  0.018333    6.941488%   0.00132929   0.0009710369   0.0009737722       9.5840
  0.022500    6.184832%   0.00131248   0.0006954914   0.0006973904      11.4515
  0.026667   10.489246%   0.00243027   0.0008097671   0.0008189376      -1.6095
  ...

the density turns negative below 0.017935, which is 0.79 at-the-money standard
deviations below the forward; ten per cent further down it is -7.1184

Three of the degenerate limits are closed forms sharing no code with the expansion, and they are what the rest rests on. No vol-of-vol with a unit exponent is Black exactly — measured gap 0.0, because every correction term carries a factor of nu or 1 - beta. No vol-of-vol with a zero exponent is Bachelier exactly. No vol-of-vol with a general exponent is CEV, where the formula is approximate and wrong by the amount its own next term predicts: 1.704e-03 measured against the 1.713e-03 the (1-beta)²log²(F/K)/24 term carries.

The decision in the heading

The density column above is the second derivative of an option price in the strike, and the first version of it was differenced from call prices throughout. That is catastrophic cancellation below the forward. A call at a 1% strike against a 2% forward is worth 0.0094; its last bit is 1.7e-18; four of those divided by a squared step of 1e-06 is 6e-06 of pure noise — and the density down there is smaller than that.

The result was a confident, written-up, four-parameter-set finding: "the density turns negative below 1.0649%, sixteen standard deviations below the forward, where it is nine orders of magnitude below its peak — so the defect is real, always there, and harmless." Every number in that sentence was rounding.

Pricing the out-of-the-money option instead costs nothing — put-call parity is linear in the strike, so the two second differences are the same number in exact arithmetic and only one of them is in double precision — and the finding inverts:

  • No one-year smile tested has a negative density at all down to 1e-04 of the forward, at any vol-of-vol up to 1.2 and any correlation.
  • The defect needs maturity or vol-of-vol: across five nu, four rho and four maturities, 61 of 80 combinations have one, and all 19 that do not are short-dated.
  • Where it appears it is not a tail curiosity. At ten years it is negative 0.795 standard deviations below the forward — inside the range anyone quotes — reaching -7.118 against a peak of 749.7, which is stable to four digits across three decades of differencing step. At thirty years the boundary reaches 0.15 standard deviations.
  • And it is on both wings at ten years, not only the low one: -1.61 at a 2.67% strike. density_floor searches downwards only, which its name says.

So a short-dated SABR smile is a distribution and a long-dated one is not. That is a more useful statement than "the formula admits arbitrage", and it was only available after the arithmetic stopped being dominated by its own rounding.

Two more things worth stating

The z / x(z) factor is zero over zero at the money, and its series is 1 - rho z/2 + (2 - 3rho²)z²/12. The minus is not a detail: a plus leaves the series wrong by rho z, 3e-03 at a z of 1e-02, so it never beats the ratio at any threshold — which reads as a badly tuned threshold rather than as a sign error. With the sign right they cross at |z| ≈ 2e-04 where each is wrong by about 5e-13, and below that the ratio is wrong by 8.3e-08 at 1e-10 and 8.9e-05 at 1e-12, however small z gets.

And beta is an argument rather than a fitted parameter because a single smile carries no information about it. Fitting the same five quotes at four exponents moves alpha by a factor of 15.5 and rho from -0.263 to -0.389, while the fitted smile moves by at most 6.1 basis points of volatility.

A thirteenth decision: report the truncation, do not pick a width

A variance swap's fair strike is the one number in this package that needs no model at all. The identity

-log(S/F) + S/F - 1 = ∫₀^F (K-S)⁺/K² dK + ∫_F^∞ (S-K)⁺/K² dK

holds pointwise, so its expectation turns realised variance into a portfolio of the options already quoted. fair_variance integrates that in log-moneyness, where both wings are the same integrand.

Being model-free means there are answers known in advance. A flat smile must give back sigma², and does, to two or three units in the last place. A Heston smile must give back E[∫V]/T, which Heston.expected_integrated_variance already supplies, and does to 1e-8 relative — once the strikes reach far enough, which is the whole of the difficulty.

The rule of thumb is a few standard deviations of log-moneyness, and under a flat smile it holds: five leaves 4.4e-08 relative. Under Heston at the same equivalent volatility it does not. Ten standard deviations leaves 5.7e-07 at a vol-of-vol of 0.3, 5.3e-05 at 0.5 and 1.3e-03 at 0.8; reaching 1e-09 at 0.8 takes thirty, which is strikes from a quarter of a per cent of the forward to four hundred times it. The 1/K² weight is exactly the weight that keeps a fat tail relevant.

So the width is an argument, and truncation_error says in closed form what it costs. Beyond the last strike the portfolio pays the tangent to the log payoff rather than the payoff, because there is nothing further out to buy, so the error is the expected excess of one over the other — right to 1e-14 relative against the measurement at one standard deviation. At equal log-distance the put wing is 1.32 times dearer to lose at one standard deviation and 2.32 times at four, so a desk one strike short should buy the low one.

The unbounded version was tried first and does not work, for a reason that belongs to the prices rather than to the quadrature. A transform price has an absolute floor: asking for 1e-13 returns 1.48e-12 at log-moneyness 2 and 3.02e-12 at 5 — not monotone — and 1.381e-03 at 40, where the true price is zero to hundreds of digits. Dividing by the strike makes it harmless to the integral, but a walk looking for a panel that contributes nothing is looking for a property the integrand does not have.

The centring term, exactly

A desk sums over listed strikes and splits puts from calls at K0, the largest listed strike at or below the forward. That replicates the log contract centred on K0, and the identity says what it costs:

2 (F/K0 - 1 - log(F/K0)) / T

The market convention subtracts (F/K0 - 1)² / T, the first term of that. It overstates the true correction by 0.67% at a one per cent gap, 6.6% at ten per cent and 32% at a half. Centring offers both, and either restores second-order convergence in the strike spacing — halving ratios of 4.00 — against an uncorrected sum whose ratios run 6.64, 10.71, 2.65, 1.71, 7.23 and 1.46, because its error is set by where the forward falls on the lattice rather than by the spacing.

And a volatility swap is not its square root

integrated_variance_variance gives Var(∫V) in closed form for the square-root process, from Cov(V_s, V_t) = e^{-κ(t-s)} Var(V_s) integrated twice; it agrees with an independent double quadrature of the same covariance to 1e-14. volatility_swap_strike turns that into the second-order fair volatility — and the docstring says what the measurement says, which is that the correction has the right sign and is a quarter to a half too large. Against 60,000 simulated paths the predicted discount is 0.0195 in volatility against 0.0153 realised at a vol-of-vol of 0.5 and 0.0343 against 0.0245 at 0.8: 13 and 22 standard errors, so not sampling noise.

moneyness variance --forward 102.37 --time 1 --rate 0.03 --vol 0.2 \
  --width 2 --step 5

prints the continuous replication with its exact answer and predicted truncation, then the listed-strip sum at all three centrings side by side.

A fourteenth decision: three derivations for one price

A barrier option is the first contract here that depends on where the path went rather than only on where it ended, so the reflection principle has to come in. moneyness.barrier gives the eight standard single-barrier Europeans in closed form, and the table they come from is self-consistent: in + out = vanilla holds algebraically for all eight, so asserting it to 7.1e-15 catches a term copied with the wrong sign and nothing more. It cannot catch the table being the wrong table.

So the same contract is priced two other ways, and neither shares a derivation with the formula. price_pde now takes a knock-out and marches a grid backwards with a Dirichlet condition at the barrier; across seven contracts it converges on the closed forms at ratios of 4.00 under doubling of both mesh dimensions, which is the order the scheme supports, so the two agree at the mesh's own accuracy rather than to a chosen tolerance. And monte_carlo.barrier with its Brownian bridge reaches them from paths, within 0.8 standard errors across all four styles. A formula, a grid and a simulation.

moneyness barrier --spot 100 --strike 100 --time 1 --rate 0.05   --dividend 0.03 --vol 0.20 --level 120 --barrier up-and-out

The barrier outranks the strike on the grid, and it is measured

_space_nodes has always anchored the lattice on the strike, because the payoff kink must sit on a node, and deliberately not on the spot — demanding both collapsed the spacing, and a strike 1% from the spot once produced 1781 nodes where 600 were asked for. A barrier is a third special level and it wins: the domain ends at the barrier, so a barrier between nodes is a barrier moved by up to half a spacing, which is first order in a quantity the price is very sensitive to.

Measured on an up-and-out call, displacing the barrier by half a spacing moves the price by 35, 69, 137 and 273 times the on-node error as the mesh refines from 200-by-100 to 1600-by-800. The ratio grows, which is the signature of the lower-order term taking over. Both levels keep their nodes where they are more than a step apart, by choosing the number of steps between them rather than the spacing; closer than that the node count is capped at twice the request and the strike is given up, with MeshPrice.strike_on_node saying which happened. That cap was eight times the request first, and let a barrier five basis points from the strike build 2442 nodes for a request of 400 — the same collapse the strike anchoring exists to avoid, under a guard meant to prevent it.

The bug worth recording was in the domain, not the spacing. Its reach was sized from the barrier rather than from the spot, so a barrier at ten times the spot put the whole grid above the spot: the quadratic read extrapolated off the nearest three nodes and returned -1183.8 for a call worth 8.65. A wrong answer with a plausible shape is the worst outcome this code can produce. The only test that would have caught it was the one comparing a distant barrier against the unbarriered solve — every comparison against the closed form uses a barrier within 20% of the spot, where the mis-sized domain still happened to contain it. The containment is now swept directly over both sides, barriers from 1% away to ten times the spot, four widths and four node counts.

A knock-out is the one price here that falls as volatility rises

Everything else in this package is increasing in volatility. An up-and-out call peaks at a volatility of 7.51% and falls away on both sides: on a spot of 100, a strike of 100, a barrier of 120 and a year to run it is worth 3.435 at the peak, then 3.117, 1.923, 1.107, 0.662, 0.418 and 0.191 at 10% through 40%. From the peak to 40% it loses 94% of its value for more volatility, because the volatility that pays for the optionality also pays for the knock-out. Vega is +31.0 at 5% and -11.9 at 20%, so it changes sign inside the range anybody quotes, and an implied-volatility solve has two roots or none at almost every price. None is offered, and the command line says so where someone would look for one.

The continuity correction's accuracy is simulated, not assumed

A barrier checked at the close is harder to breach than one watched continuously, so a knock-out is worth more. monitoring_shift applies the Broadie-Glasserman-Kou correction — move the level away from the spot by exp(0.5826 sigma sqrt(dt)) and price the continuous contract there — and the shift is under a per cent while what it is worth is not: the price is levered about eighteen times to the barrier level.

monte_carlo.barrier with bridge=False prices the genuinely discrete contract, which is a different contract and not a worse estimate of the continuous one, so it says what the correction is actually worth. The real effect is +13.3%, +28.3% and +54.7% over continuous monitoring for daily, weekly and monthly checks. And the correction itself is -0.33% off daily, +0.92% weekly and +6.82% monthly — excellent daily, fine weekly, and at monthly monitoring it overstates the contract by seven per cent. Still far better than ignoring the effect, which understates the monthly contract by thirty-five, but not a number to quote. Simulate that one, and the docstring and the command line both say so.

A fifteenth decision: bound it, because it cannot be priced

Every price above is a formula, a grid or a simulation of something whose distribution is known. The arithmetic average of lognormals has no tractable distribution, which is why monte_carlo.asian simulated it and nothing computed it. The gap that leaves is not speed. A simulation cannot bound anything — it returns an estimate and a standard error, so it can say a price is probably near a number and never that it is certainly above or below one.

moneyness.asian gives two bounds that are inequalities rather than approximations, each resting on a statement about the payoff that holds path by path. AM-GM: G <= A, so the geometric call is cheaper. Convexity: the positive part of an average is at most the average of the positive parts, so an Asian is worth at most the average of ordinary options on its monitoring dates. Between them sits Curran's bound, which conditions on the geometric average instead of discarding it.

from moneyness import Inputs, OptionType, price_bounds, turnbull_wakeman

contract = Inputs(spot=100.0, strike=100.0, time=1.0, rate=0.05, vol=0.20)
bounds = price_bounds(contract, OptionType.CALL, 12)

bounds.lower       # 6.1556 -- Curran's, within 6e-05 relative of the true price
bounds.upper       # 6.8370
bounds.geometric   # 5.9402 -- below, by AM-GM, for a call
turnbull_wakeman(contract, OptionType.CALL, 12)   # 6.1742, and high

Which side AM-GM falls on depends on the payoff

The inequality that makes the geometric call cheaper makes the geometric put dearer, since max(K - G, 0) >= max(K - A, 0). At the money with 20% volatility and twelve fixings: a geometric call of 5.9402 under a true 6.1571, and a geometric put of 3.6517 over a true 3.5355. Treating it as a lower bound for both sides would have looked right on every call that was tried.

For a put it is therefore an upper bound, and a much better one than convexity — which allows the fixings to be independent, where averaging destroys far more variance than that. The put's interval comes out 0.118 wide against the call's 0.681, so it is the one used.

Conditioning is worth a factor of six hundred

Curran's bound exercises when E[A | G] > K. That is a strategy a holder could follow, so its value is below the option's, and because the two averages move together it is nearly exact. Against two million paths it is low by 6.0e-05 relative at 20% volatility, 3.2e-04 at 40% and 1.5e-03 at 80% — against the geometric bound's 0.2154 at the money, a factor of about six hundred. At 10% volatility the shortfall sits at 0.3 standard errors of that run, so it is reported as unresolved rather than as a number.

The moments are exact, and they say why an Asian is cheap

E[S_i S_j] = S^2 exp(b(t_i + t_j) + v^2 min(t_i, t_j)) makes both moments finite sums. Twelve monthly fixings leave the average with a matched log-variance of 0.01526 against the terminal price's 0.04 at 20% volatility — an effective volatility of 12.35%. The dense-fixing limit is the textbook v^2 T / 3, approached as 1/3 + 1/(2m): at three thousand fixings that predicts 0.333500 against a measured 0.333503.

That third is a zero-carry statement. At a 5% cost of carry the limit sits at 0.3377, because exp(b t) weights the later and more variable part of the path more heavily. The test asserted a third with a carry in force and failed by eight times its tolerance.

Moment matching's error changes sign twice

At the money it reads high — 6.1e-04, 1.4e-03, 3.0e-03, 8.5e-03 and 3.0e-02 relative at 5%, 10%, 20%, 40% and 80% volatility over a year, two to three times worse per doubling. Out of the money it reads low enough to break the lower bound: a call struck at 120 against a spot of 100 comes out 9.2% below Curran's bound at 10% volatility, 3.4% below at 20% and 0.79% below at 40%.

Then it crosses: at 60% the same option is 0.9% above the bound and at 80% it is 2.7% above. So the shrinking violations at moderate volatility point the wrong way about what happens next. The 40% figure was first written down as 7.9%, from misreading -7.86e-03 as a percentage, which made the trend look monotone and hid the crossing.

An error that is positive at one strike and negative at another, and that reverses again in volatility, cannot be quoted as a tolerance — so none is. The bounds say when it has gone wrong instead, which nothing else here could: there is no closed form to compare against, and at low volatility the simulation's standard error is wider than the error being looked for.

moneyness asian --spot 100 --strike 120 --time 1 --rate 0.05 --vol 0.20 \
  --fixings 12 --fixing-table

A sixteenth decision: let a bound with no correlation in it be the check

An option on S1 - S2 has no closed form, so the module carries three routes: Margrabe's exchange option, which is exact at a zero strike; Kirk's approximation, which is what desks quote; and an exact conditional quadrature. Conditioning is the one that does the work. Given the variate driving the second asset, the first is still lognormal with a shifted forward and a reduced variance, so the conditional payoff is a Black-76 call struck at S2(z) + K and the price is one integral against a normal density.

moneyness spread --forward1 100 --forward2 95 --strike 1 \
    --time 1 --rate 0.03 --vol1 0.30 --vol2 0.25 --rho 0.9
              contract  call on S1 - S2 struck at 1
        forward spread  5.0000000000
            spread vol  0.1322875656

                 price  7.2080907571
           resolves to  2.155e-10  (absolute)

    lower (half-space)  7.2080631333
      upper (vanillas)  7.7857973177
        interval width  0.5777341844
                        0.0802 of the price

        exchange (K=0)  7.7857973177  (exact, Margrabe)
                  Kirk  7.2079860954
                        -1.452e-05 relative
                        outside the bounds, below the lower bound by 7.704e-05

The last line is the decision in the heading. Kirk's error here is 1.5e-05 relative, which anybody would accept — and the number is nonetheless below a price that can be sub-replicated, so it is not a slightly-off price but an arbitrage. Nothing except a rigorous bound can say that, and the same bound says it at 18 of the 84 strike-correlation points swept: fifteen below the lower bound, and three above the upper one by up to 1.9e-02 at a correlation of -0.99.

The upper bound is a correlation reading

For any level a, max(S1 - S2 - K, 0) <= max(S1 - a, 0) + max(a - S2 - K, 0) path by path — the split either hands the whole shortfall to one leg or divides it exactly. So two vanilla options bound the spread, with no correlation in them at all.

That is also the limit of what they can do. A bound that holds for every coupling of the two marginals is the price under the worst one, and for lognormals the worst coupling is attainable. So the optimised portfolio does not approximate the upper extreme; it equals the spread price at a correlation of minus one, measured at 4.2e-14 to 6.7e-14 relative across five strikes. Two routes with nothing in common — two Black formulas against a conditional quadrature — agreeing to machine precision is worth more than either agreeing with a tolerance.

Which explains the width above. The bracket is 1.1% of the price near a correlation of minus one and 484% wide at plus 0.9, because the width measures distance from the worst case rather than the quality of the method. At a zero strike it closes entirely: S1 > S2 is already a linear condition on the driving Gaussian, so the best sub-replicating half-space is the exercise region and the dominating exchange option is the same price.

The kink is worth eight orders of magnitude, and missing it looks like divergence

With no volatility on the first asset the integrand is genuinely kinked at the point where the conditional option passes through the money. Uniform panels at 24, 48, 96 and 192 panels give relative errors of 9.6e-06, 1.8e-05, 3.3e-06 and 1.2e-06 against the closed form. The first refinement makes it worse, so a two-point convergence check would have reported the method diverging rather than crawling. With the root of F1(z) - S2(z) - K inserted as a panel edge, the same quadrature is exact to 4.5e-14.

Two monotonicity claims that measurement contradicted

Kirk's error grows with the strike — at every one of the six non-positive correlations swept, where it reads high throughout. At correlations of 0.5 and 0.95 the sign change lands inside the strike range and the ordering fails. The first version of that test was tried on negative correlations and passed on a claim that is false.

And the half-space lower bound's gap is not monotone in the correlation. It is exact at both ends, since a single driving variate makes the exercise region a genuine half-space, and peaks in between: 3.1e-10, 1.0e-06, 5.9e-06, 2.4e-05, 6.9e-05, 1.2e-04, then back to 1.0e-06 as the correlation runs -0.99, -0.9, 0, 0.5, 0.8, 0.95, 0.999. Any sweep stopping at 0.9 would have shown it growing throughout.

A seventeenth decision: one tail integral, not four closed forms

A lookback pays on where the path got to. There are four of them — the extreme against a strike, and the terminal price against the extreme, each way round — and the usual treatment is four closed forms copied out of a table. They are all the same integral. Write the payoff as a layer cake,

(M_T - L)^+ = int_L^inf 1{M_T > y} dy

and the expectation is an integral of the running maximum's tail, which the reflection principle gives directly. So moneyness.lookback has two functions that matter, maximum_excess and minimum_shortfall, and the four contracts, the expected extremes and the relations between them are rearrangements of those.

moneyness lookback --spot 100 --strike 100 --time 1 \
    --rate 0.05 --carry 0.05 --vol 0.2 --monitorings 252 52 12
              contract  fixed-strike call, extreme so far the spot
                 price  19.1676252573
               vanilla  10.4505835722
               forward  105.1271096376
            E[maximum]  120.1503704192
            E[minimum]  87.0275830734
        P(max <= spot)  0.0
        P(min <= spot)  1.0

  per year        price      std err   vs continuous
       252    18.372059     0.033563           -4.15%
        52    17.416833     0.033243           -9.13%
        12    15.812022     0.032355          -17.51%

Two of those probabilities are exact rather than tight. A Brownian path leaves its starting point in both directions immediately, so the maximum exceeds the spot and the minimum falls below it with probability one — and the reflection formula evaluated at that point is a difference of two equal terms, which returns something near 1e-17 rather than nothing. Harmless on its own, and not harmless inside a tail integral.

The textbook form divides by zero on an option on a future

Integrating the tail analytically leaves a factor of sigma^2 / (2b) against a difference of two terms that become equal as the carry vanishes. The direct transcription therefore divides by zero when b is exactly zero — which is what Inputs.on_future sets it to, so the contract that breaks it is one of the conventions this package advertises in its first paragraph.

Pulling the shared exponential out instead leaves an expm1 and a band of the normal law between two symmetric arguments, both of which survive the limit. The two groupings then fail in opposite regimes, and that is the finding: the direct one is accurate to 2.0e-15 at a 1% carry and degrades to 2.4e-12 at 1e-6 and to 5.7e-10 at a level 2.7 times the spot, while the regrouped one holds those same cases to 1e-14 or better and is wrong by a factor of 4e+24 once 2b / sigma^2 reaches 100, because the exponential it factors out then dwarfs the difference it multiplies.

Over 7,840 combinations of level, carry, volatility and maturity — 6,765 of them above a floor of 1e-8 times the spot, below which a relative error measures round-off rather than method — switching at |2b / sigma^2| = 1 gives a worst relative error of 3.3e-13 against 8.4e-09 for the direct form alone. The crossover is not tuned: the same sweep gives 3.3e-13 at 0.5 and at 2.

Monitoring is worth more than the correction that would represent it

moneyness.barrier can tabulate discrete monitoring in closed form, because a barrier checked at the close is representable as a barrier at a shifted level. A lookback cannot: the correction is to the extreme itself and there is no level to move. So the monitoring table above is simulated, and the simulation is of the discrete contract rather than of a correction to the continuous one.

The gaps are large and they point the other way from a barrier's. A maximum taken at the close is smaller, so every lookback is worth less: 4.2% less daily, 9.2% weekly and 17.8% monthly for the fixed-strike call struck at the spot, and 3.4%, 7.5% and 14.7% for the floating-strike call. Quoting the continuous price for a monthly fix is an 18% error.

That simulation is also the only check here that owes nothing to the reflection principle, so it has to be unbiased, and a path observed on a grid is not. Its maximum is smaller than the maximum of the path it was drawn from, and the bias dies like one over the square root of the step count rather than fast enough to spend steps on. Conditional on its two endpoints a Brownian path over a step is a bridge, whose maximum has an invertible distribution function, so one extra uniform per step draws the true extreme. Over five seeds at 40 steps the simulated prices land between -1.2 and +1.7 standard errors of the closed form and scatter either side; the same run with the bridge off is 101 standard errors low.

A property that was true and the wrong property

Inputs.is_degenerate asks whether the terminal payoff carries any uncertainty, and a zero strike is one of its cases: a call struck at nothing is worth the forward whatever the path does. A lookback's strike says nothing about the path — a floating-strike one does not read the strike at all — so reusing that property priced a zero-strike floating-strike call off a deterministic path and returned 4.98 where the answer is 17.56. What matters here is whether the path is deterministic, which is no time or no volatility and nothing else. A test asserting that the floating styles ignore the strike is what caught it, and that test exists because the docstring claims they do.

An eighteenth decision: let the decomposition be the implementation's judge

Every payoff above is fixed when the trade is struck. Two are not: at a date before maturity the holder decides something, and what they hold afterwards depends on the decision. A chooser defers the direction. A compound option defers the purchase.

from moneyness import Compound, Inputs, OptionType, chooser_legs, chooser_price, compound_price

inputs = Inputs(100.0, 100.0, 1.0, 0.05, 0.25, carry=0.05)

chooser_price(inputs, 0.5)            # 17.041176  -- declare call or put at six months
chooser_legs(inputs, 0.5).total       # 17.041176  -- the same, from two vanilla prices

bought = compound_price(inputs, Compound(OptionType.CALL, OptionType.CALL, 5.0, 0.5))
bought.value                          #  8.381446
bought.forward_purchase               #  7.459449  -- buying it forward instead
bought.deferral_value                 #  0.921997  -- the right to decline
bought.critical_spot                  # 93.720886  -- where the inner call is worth 5.00

The chooser's decomposition is the contract, not a check bolted on

At the decision date the holder has max(C, P), which is C + max(P - C, 0), and put-call parity makes P - C a known linear function of the spot. So the choice is worth a call struck at K to maturity plus exp((b - r)(T - t)) puts struck at K exp(-b (T - t)) expiring at the decision date — two bsm.price calls. Rubinstein's closed form is the same thing written as four normal integrals, and the two agree to 1.8e-14 at worst over strikes from 80 to 120, carries from -0.03 to 0.08 and volatilities from 15% to 60%.

Both are kept, because a closed form with an independent derivation to check it against is worth more than either alone. The decomposition also hands over the two limits: at a decision date equal to the maturity the chooser is a straddle, and at a decision date of zero it is the larger of the two vanillas — reached exactly, since the closed form's y divides by the square root of zero there and the branch has to be right rather than nearly right.

One parity validates both halves of a compound pair at once

A call-on-call pays max(C - K1, 0) at the decision date and a put-on-call pays max(K1 - C, 0), so their difference is C - K1 pathwise and the discounted difference is the inner option less the discounted premium. Nothing from this module is on the right-hand side. The residual is at worst 7.1e-15 across four parameter sets per pair, and it constrains four bivariate arguments and two correlation signs for each pair at once — which is what makes the put-side formulas trustworthy, since an error in either member breaks it.

An antithetic simulation agrees independently: only the spot at the decision date is drawn and the inner option is priced in closed form there, so the estimator has no discretisation error at all. Across five seeds for each of the four pairs the twenty deviations run from -1.52 to +0.93 standard errors and take both signs within every pair, which one seed inside its interval could not have shown.

The payoff algebra does not reduce to one option in every case

With the decision date at the maturity the inner option is worth its intrinsic, and three of the four compounds become a single vanilla on a shifted strike. The fourth does not. Capping max(K1 - (S - K)^+, 0) truncates the payoff at both ends — flat at the premium below the inner strike, zero above K + K1 — so it is a put struck at K + K1 less a put struck at K. Writing it as the single shifted put overpays by the whole flat region: 9.881 against a correct 2.422, four times the price, with the overpayment concentrated exactly where the contract is most likely to pay.

A compound option is a forward purchase plus an option on the premium

$ moneyness deferred --spot 100 --strike 100 --time 1 --rate 0.05 --vol 0.25 --premium 5
...
  compound options on a premium of 5 paid at the decision
            contract          price    forward buy      decline  P(exercise)
        call-on-call       8.381446       7.459449     0.921997     0.662712
         put-on-call       0.921997       7.459449            -     0.337288
    on a call: parity residual -2.665e-15, critical spot 93.720886

The right to decline, 0.921997, is the put-on-call to the last digit printed, which is what the parity residual beside it is asserting. It is also why a compound option is not a cheap way to buy an option: it costs more than buying the thing forward, by exactly the value of the choice.

A nineteenth decision: let the correlation disagree with itself

An option on a share quoted abroad has four common forms, and they are not variants of one price. Writing X for the exchange rate in domestic units per one foreign unit:

contract payoff correlation
foreign struck X_T (S_T - K_f)+ absent
quanto X_fix (S_T - K_f)+ lowers a call
composite (S_T X_T - K_d)+ raises a call
equity-linked FX S_T (X_T - K_x)+ raises a call
moneyness quanto --spot 100 --exchange 1.25 --strike 105 --time 1 \
  --domestic-rate 0.04 --foreign-rate 0.02 --dividend 0.01 \
  --asset-vol 0.22 --exchange-vol 0.14 --rho 0.35
          foreign strike  105.0000000000
         domestic strike  133.9014258785
             rate strike  1.2752516750
            quanto drift  -0.0107800000
           composite vol  0.2992657682

          foreign struck    8.7775022095   d/drho    +0.000000
                  quanto    8.0004023524   d/drho    -1.677126
               composite   12.6889317588   d/drho    +5.071480
        equity linked fx    7.5531410400   d/drho    +1.897304

One strike cannot serve four contracts, so the command converts the foreign strike at the forward rate — the conversion under which the three struck contracts become one contract when the exchange rate stops moving. The slopes are why they are printed together.

The correlation enters with a minus in one place and a plus in another

quanto_drift is -rho sigma_S sigma_X and composite_volatility is sqrt(sigma_S^2 + sigma_X^2 + 2 rho sigma_S sigma_X), so one estimate moves two of these prices in opposite directions. Over correlations from -0.8 to +0.8 the quanto loses 28.1% of its value, the composite gains 162.5% and the equity-linked option gains 56.1% — and the foreign-struck price does not change by a single bit, because converting a foreign payoff at whatever rate prevails is holding the foreign claim. That invariance is asserted rather than assumed; it is the one contract here where a correlation estimate cannot be wrong.

Which way up the rate is quoted decides the sign

The other quotation negates the correlation and leaves its magnitude alone, so a figure taken from the wrong convention is exactly as plausible and exactly wrong. The quanto drift then moves by 2 rho sigma_S sigma_X instead of vanishing: a quanto call reads 8.6% too high at a correlation of 0.2, 15.5% at 0.35 and 41.9% at 0.85, with nothing in the price to say so. CrossCurrency.inverted changes quotation properly.

Neither quote identifies the pair; the two of them do

A quanto price depends on (sigma_X, rho) only through the product rho sigma_X. Five pairs along that curve, sigma_X from 0.05 to 0.50, give quanto prices equal in every bit while their composite prices differ by 42%. A composite price depends only on sigma_X^2 + 2 rho sigma_S sigma_X; three pairs along that curve agree to 1e-13 while their quanto prices differ by 19%.

Two different functions of the same pair, and together linear in (rho sigma_X, sigma_X^2), so implied_fx_pair inverts them with no search. The closed form is also what makes the failure legible: inconsistent quotes produce a negative variance and are refused, where a two-dimensional search would have stopped at a plausible corner with a poor residual.

A degenerate case is the only check on the strike conventions

At zero exchange rate volatility, with the foreign strike converted at the forward rate and the quanto's fixed rate set to that forward, all three struck contracts equal today's rate times the foreign-currency option to within six parts in 1e15. A simulation cannot catch a strike written in the wrong currency, because the simulation would be wrong the same way; this collapse can.

Running the tests

pytest                 # test suite
mypy                   # strict type checking, sources and tests
ruff check .           # lint

The same three run on every push and pull request, and the suite runs on each Python version the package claims to support rather than only the newest. That matters more here than it would in most projects: there are no dependencies to resolve differently, so an interpreter difference surfaces directly as a difference in floating-point or math behaviour — which is precisely what a single-version matrix is the wrong shape to catch.

A fourth job builds the wheel, installs it into an empty virtualenv with no source tree in sight, and runs the console entry point. An entry point can be perfectly healthy in a source checkout and broken the moment it is installed, and nothing short of installing it will say so.

Status

Phases 1 to 3 of the roadmap are in place — the pricing core, the Greeks, and implied-volatility solving — along with phase 4, which adds American exercise on binomial and trinomial lattices with the early-exercise boundary and the Bjerksund-Stensland closed form, and phase 5, the volatility surface: SVI slices and their calibration, both arbitrage conditions, and Dupire local volatility. Most of the command-line interface from phase 7 is there too.

Phase 6 is in place too: an exact Monte Carlo with antithetic and control variates, Asian and barrier payoffs, and the Brownian bridge correction.

Phase 7 is complete: the command line covers pricing, Greeks, implied volatility, a strike ladder, a term structure, American valuation and surface fitting with arbitrage reporting.

Phase 9 adds a second model: Heston's stochastic volatility, its characteristic function in the branch-stable grouping, two independent pricing routes, and the quadrature they run on — all still in the standard library, so the package remains dependency-free.

Phase 10 simulates the model rather than transforming it: Andersen's quadratic-exponential scheme, Asian and barrier payoffs under stochastic volatility, and an independent check on the transform that shares no code with it.

Phase 11 adds SABR: both of Hagan's expansions, the displaced variant, a Bachelier price the package lacked, calibration, and a search for where the formula's own density turns negative.

Phase 12 adds variance and volatility swaps: the log-contract replication with its two exact targets, the closed-form cost of a finite strike range, the listed-strip sum with the centring term computed exactly rather than to leading order, and the variance of integrated variance that separates a volatility swap from the square root of a variance one.

Phase 13 closes the gap the surface left: the package computed a Dupire local volatility and could not price with it. There is now a finite-difference solver in log-spot for a general local volatility, European and American, with the early-exercise boundary; a bridge from the surface that does the forward-log-moneyness mapping in one place; and a --reprice flag on the surface command that solves every quote back out of the surface fitted to it. The round trip is the point: a surface's own local volatility reproduces the surface to within 1.5e-03 in volatility, and the two ways of getting it wrong — reading the moneyness from the spot, and taking the surface literally below its first quote — are both measured rather than warned about.

Phase 14 adds barrier options, the first contract here that depends on where the path went: the eight closed forms, a knock-out in the solver, and the existing simulation, all three agreeing.

Phase 15 is the first price in the package that cannot be computed at all, only bracketed. The arithmetic average of lognormals has no tractable distribution, so moneyness.asian supplies the exact moments, Curran's conditioning lower bound, and rigorous bounds from AM-GM and convexity — which is what lets it show that the moment-matched price every trading system uses falls below a rigorous lower bound out of the money, by 3.4% at 20% volatility.

Phase 16 adds the first contract on two underlyings. moneyness.spread prices an option on S1 - S2 by conditioning — exact up to the quadrature, pinned against Margrabe at a zero strike — alongside Kirk's approximation and a rigorous interval. The upper side of that interval is built from two vanilla options and carries no correlation at all, which turns out to equal the price at a correlation of minus one to 1e-13; and inside it, Kirk sits outside the bounds at 18 of 84 points swept.

Phase 17 adds the other half of what the reflection principle gives. Where phase 14 asked whether the running extreme crossed a level, moneyness.lookback prices contracts on where it got to — all four from one analytic integral of the extreme's tail, with the law of that extreme public because a stop-loss or a high-water-mark fee is written on it and neither is an option. The carry appears as a sigma^2 / (2b) that the textbook form divides by, so an option on a future breaks it; two groupings that fail in opposite regimes hold it to 3.3e-13 across the sweep. And the monitoring table has to be simulated rather than corrected, which is where the 17.8% gap at a monthly fix comes from.

Every item on the roadmap is now done except the first release on the package index, which waits on the publisher being registered there.

Metadata

Release files for moneyness 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for moneyness 0.1.0
File Size Uploaded Provenance Actions
moneyness-0.1.0.tar.gz 383.8 kB With provenance Details for moneyness-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for moneyness 0.1.0
File Interpreter ABI Platform Actions
moneyness-0.1.0-py3-none-any.whl Python 3 none any Details for moneyness-0.1.0-py3-none-any.whl

Total release size: 590.5 kB

Release files / moneyness-0.1.0.tar.gz

Download URL moneyness-0.1.0.tar.gz
Size 383.8 kB
Tags Source
SHA-256 checksum
How to use checksums
219b56c2577424f0b471e8a7da33d2a4bfe6cae1b03dc62558cdb0789d9602bc
BLAKE2b-256 checksum
How to use checksums
a866c0821014b6656c508fe486cfc07b8d9deace216260c25e35a7f320cf76c7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / moneyness-0.1.0-py3-none-any.whl

Download URL moneyness-0.1.0-py3-none-any.whl
Size 206.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0c5cce9644c938ac93cbfd84cb7116cf7cb08caa6d4cdebc6140eed6cfd7e39d
BLAKE2b-256 checksum
How to use checksums
2f403107eecf8cb7a32d2784745e2d17a07b07a6967a1c57f5b722dd7470316d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for moneyness 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for moneyness-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page