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 installline 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, fourrhoand 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_floorsearches 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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| moneyness-0.1.0.tar.gz | 383.8 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| moneyness-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
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 logRelease 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 logSecurity 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.
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 logA 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.