Instruments¶
fast-vollib's core is functional. fast_black_scholes(flag, S, K, t, r, sigma)
and its neighbours take arrays and return arrays, and that stays the canonical,
fastest API. What it has no vocabulary for is what is being priced: nothing in
it is an option, an underlier, or a forward.
fast_vollib.instruments adds that nominal layer without moving any
mathematics into it. Every number an adapter returns comes from the same kernel
you would have called yourself — the test suite asserts exact array equality
against direct calls — so using the instruments layer costs you nothing numerically
and cannot drift from the functional API as either side changes.
Quick start¶
import numpy as np
from fast_vollib.instruments import (
Asset, AssetClass, EuropeanOption, VanillaMarketInputs, price_instrument,
)
spx = Asset(identifier="SPX", asset_class=AssetClass.INDEX, currency="USD")
option = EuropeanOption(
instrument_id="SPX-C-5000",
underlier=spx.ref(),
option_type="call", # "c" and "p" work too
strike=5000.0,
maturity=0.75, # a year fraction, never a date
notional=100.0,
)
market = VanillaMarketInputs(underlying=5100.0, rate=0.04, volatility=0.18)
price = price_instrument(option, market, model="black_scholes")
The contract holds terms. The market holds observations. They meet at the call site and nowhere else — a contract never stores a spot price, so it is never stale, and two identical options are equal whenever they have identical terms.
A book at a time¶
A batch is the execution unit: one batch is one fused kernel call, never a Python loop over contracts.
from fast_vollib.instruments import EuropeanOptionBatch, greeks_instrument
chain = EuropeanOptionBatch.from_arrays(
option_type=np.where(np.arange(20_000) % 2 == 0, "c", "p"),
strike=np.linspace(4_000.0, 6_000.0, 20_000),
maturity=0.75,
underlier="SPX",
)
market = VanillaMarketInputs(underlying=5100.0, rate=0.04, volatility=0.18)
prices = price_instrument(chain, market, model="black_scholes")
greeks = greeks_instrument(chain, market, model="black_scholes") # dict of arrays
from_arrays builds no per-row Python object — not a contract, not even an
underlier reference. At 100k rows it is roughly 9x faster than building the
scalar objects and handing them to from_instruments, before you count the
cost of building them.
Implied volatility, with the solver named¶
from fast_vollib.instruments import implied_volatility_instrument
quotes = VanillaMarketInputs(underlying=5100.0, rate=0.04, price=prices)
iv = implied_volatility_instrument(chain, quotes, model="black_scholes") # Jäckel
iv = implied_volatility_instrument(chain, quotes, model="black_scholes",
solver="halley")
market.price is the price of the position: it is divided by the contract's
notional before inversion, so a quote for 100 contracts inverts to the same
volatility as a quote for one.
Path-dependent contracts¶
An average-rate, barrier, lookback, or variance contract needs the whole
trajectory rather than its last point, so its payoff takes a
Scenario:
from fast_vollib.instruments import AsianOption
from fast_vollib.processes import GBM
from fast_vollib.simulation import MonteCarloEngine, simulate
asian = AsianOption(
underlier="SPX", option_type="call", strike=5000.0,
averaging_method="arithmetic", strike_convention="fixed", maturity=0.75,
)
result = MonteCarloEngine().price(
asian, VanillaMarketInputs(underlying=5100.0, rate=0.04),
process=GBM.risk_neutral(rate=0.04, volatility=0.18),
n_paths=100_000, n_steps=64, rng=0,
)
result.price, result.stderr
payoff_requirement(instrument) says which kind of state a contract needs, and
a bare array handed to a path payoff is refused rather than interpreted — it
carries no underlier, no observation times, and no way to tell whether its
horizon is the contract's maturity. See Simulation for the
monitoring, fixing, and variance conventions, which are contract meaning rather
than numerical settings.
Payoffs keep your array type¶
import torch
from fast_vollib.instruments import payoff
terminal = torch.tensor([4_900.0, 5_200.0], dtype=torch.float64, requires_grad=True)
cashflow = payoff(option, terminal) # torch tensor, same device, tape intact
cashflow.sum().backward()
The five layers¶
| Layer | What it holds | Key names |
|---|---|---|
| Contracts | Terms only: frozen, slotted, keyword-only dataclasses | Asset, Forward, Future, EuropeanOption, BinaryOption, AsianOption, BarrierOption, LookbackOption, VarianceSwap, InstrumentRef |
| Batches | Columnar host arrays; the unit of execution | EuropeanOptionBatch, moneyness, log_moneyness, time_to_maturity |
| Market inputs | Valuation-time observations, never stored on a contract | VanillaMarketInputs |
| Payoffs & adapters | Backend-native payoffs; thin wrappers over the kernels | payoff, price_instrument, greeks_instrument, implied_volatility_instrument |
| Discovery | What exists and what can be done with it | instrument_types, capabilities, the error hierarchy |
What each type supports¶
Generated from instrument_types(), so it cannot claim a capability the
registry does not have.
| Type | type_id |
Payoff | Payoff needs | Price | Greeks | Implied volatility | Monte Carlo | Cashflows | Present value |
|---|---|---|---|---|---|---|---|---|---|
Asset |
asset |
no | — | — | — | — | no | no | no |
Forward |
forward |
yes | terminal | — | — | — | yes | no | no |
Future |
future |
yes | terminal | — | — | — | no | no | no |
EuropeanOption |
european_option |
yes | terminal | black, bs, bsm | black, bs, bsm | black: halley, jackel; bs: halley, jackel; bsm: halley, jackel | yes | no | no |
BinaryOption |
binary_option |
yes | terminal | — | — | — | yes | no | no |
AsianOption |
asian_option |
yes | path | — | — | — | yes | no | no |
BarrierOption |
barrier_option |
yes | path | — | — | — | yes | no | no |
LookbackOption |
lookback_option |
yes | path | — | — | — | yes | no | no |
VarianceSwap |
variance_swap |
yes | path | — | — | — | yes | no | no |
FixedRateBond |
fixed_rate_bond |
no | — | — | — | — | no | yes | yes |
ZeroCouponBond |
zero_coupon_bond |
no | — | — | — | — | no | yes | yes |
Model abbreviations: black = Black-76, bs = Black-Scholes, bsm = Black-Scholes-Merton. A dash means the operation is not available for that type, and asking for it raises rather than returning an approximation. Monte Carlo is a type-level answer: an individual contract is additionally eligible only with a strictly positive maturity, which MonteCarloEngine.supports(instrument) applies and is authoritative for an actual request. Cashflows and present value are the fixed-income route: a security with dated payments has no payoff and no option-pricing model, so cashflows() reads its schedule and fast_vollib.pricing.present_value() values it against a DiscountCurve.
Regenerate with uv run python scripts/generate_instrument_docs.py; a test
fails if the table is stale.
Forward and Future are recognized and have terminal payoffs, but the
instruments layer provides no analytic pricing adapter for them — pricing a
forward requires discounting rather than a Black formula, and the adapters add
no mathematics of their own.
Asking for a price returns an error that says exactly that, rather than an
option value.
The Monte Carlo column is a different question from Price. Price means
an analytic kernel; Monte Carlo means
MonteCarloEngine can simulate the underlier and average the
payoff. The two never stand in for each other: an analytic request for a type
with no kernel raises rather than quietly simulating, and a simulated price is
only ever produced by asking for one by name.
Future is the one type with a payoff and no valuation route at all. Its
terminal formula coincides with a forward's, so simulating it would return a
plausible number — but a future's economics are a stream of daily variation
margin, which this library does not model.
American and Bermudan exercise are not represented. The registry contains only instrument types with supported behaviour.
Differentiability¶
A native tensor return is not evidence that gradients survived. The table
below is the whole claim; capabilities(...).native_autodiff records the same
thing as machine-readable (model, solver, backend) triples, computed against
the backends you actually have installed.
| Path | Differentiable? | Why |
|---|---|---|
payoff() on torch/jax input |
Yes, tested | Pure array-namespace operations; the tape is preserved and never staged through host memory. A digital or barrier indicator keeps the graph and differentiates to exactly zero, which is the correct pathwise answer rather than a useful Greek |
MonteCarloEngine.price(..., return_native=True) |
Yes, tested | Gradients reach spot, discount rate, process drift, and process volatility on smooth routes; see Simulation for which routes those are |
implied_volatility_instrument(solver="jackel"), native torch/jax input, return_native=True |
Yes, tested | Routes to jackel.implied_volatility_autograd[_jax]; implicit-function-theorem gradients w.r.t. price, spot or forward, strike, maturity, rate, and (BSM) dividend yield |
| The same request with host or formatted output | No | Formatting terminates the gradient path — a documented boundary, not an accident |
implied_volatility_instrument(solver="halley"), any form |
No claim | Inherits the existing backend's host-staging contract |
price_instrument, greeks_instrument |
No — do not rely on it | The fused kernels stage host-to-device by design; return_native=True changes the container, not the tape |
The differentiable route preserves the Jäckel wrappers' own edge behaviour:
below-intrinsic and other invalid-domain inputs return NaN in both the forward
and the backward pass, and low-vega rows follow the wrappers' documented guard.
That behaviour is not normalized to another solver's policy, and on_error is
not consulted on that route.
Two rules¶
Nothing is inferred. model is always an explicit argument. An equity
underlier does not imply Black-Scholes over Black-Scholes-Merton — that turns on
whether a dividend yield is being modelled, which is a modelling decision, not a
property of the asset. The asset class may validate a choice; it never makes
one. The same holds for the IV solver and the backend.
Consequently market.underlying is neutrally named: under "black" it is read
as a forward, under the other two models as a spot. The model at the call site
fixes which.
Nothing falls back. A request that cannot be served exactly raises.
| Error | Raised when |
|---|---|
InstrumentValidationError |
A contract term is missing, malformed, or out of range. Batch messages name the row. |
UnsupportedInstrumentError |
The operation has no implementation for this type. The message says whether the type is recognized at all. |
UnsupportedModelError |
The model is unknown or unavailable here; the message lists the ones that work. |
UnsupportedSolverError |
The solver cannot serve the requested backend, or a differentiable route's optional dependency is missing. |
MissingMarketInputError |
A needed market field was not supplied; the message names it. |
SerializationError |
A record is structurally undecodable — unknown version, type, or field. |
All six subclass InstrumentError, and each also subclasses the builtin you
would reach for anyway (ValueError or NotImplementedError).
There is no case in which requesting machine-precision gradients yields a host-staged approximation, or in which a barrier is priced as a European.
Implied-volatility routing¶
solver="jackel" is the default: it is machine-precision and the only route
with gradient support. It reaches the full-model Jäckel wrappers for the
numpy, torch, and jax backends. Requesting it with numba raises and
names solver="halley" as the alternative.
solver="halley" is deprecated. It remains available, tested, and
unchanged, it serves every backend, and it raises no runtime warning — but it
carries no gradient support and is less accurate than the Jäckel route at
comparable cost. Reach for it to reproduce existing results, not for new work.
The raw jackel_iv_black solver consumes an undiscounted Black-76 price and a
forward. Market prices are discounted, so handing one to the raw solver produces
a plausible and wrong volatility. The adapter never reaches it: the full-model
wrappers perform the discount and forward conversion for the model actually
requested, and a test spies on the raw solver to confirm what it receives.
Maturity is a year fraction¶
maturity: float is measured in years from valuation and is never a date, and
never a date-or-float union. Calendars, day counts, and schedule resolution
belong to a layer above this one; admitting them here would make every
contract's meaning depend on an evaluation date that contracts do not carry.
Convert before constructing:
Serialization¶
from fast_vollib.instruments import instrument_to_json, instrument_from_json
record = instrument_to_json(option)
assert instrument_from_json(record) == option
{
"schema_version": 1,
"instrument_type": "european_option",
"instrument_id": "SPX-C-5000",
"underlier": {"identifier": "SPX", "asset_class": "index", "currency": "USD"},
"option_type": "call",
"strike": 5000.0,
"maturity": 0.75,
"settlement": "cash",
"notional": 100.0
}
The codec is strict: unknown schema versions, unknown instrument types, unknown
or missing fields, and non-canonical enum spellings are all errors. The
constructors accept "c" and "p"; the wire format has one spelling per value.
Records carry no engines, arrays, market data, or model parameters, and pickle
is not a wire format for these objects.
A JSON Schema is checked in at
docs/schemas/instrument-v1.schema.json.
It is generated from the same field table the codec reads
(uv run python scripts/generate_instrument_schema.py), regenerated
byte-for-byte by a test, and closed (additionalProperties: false) with the
runtime numeric constraints mirrored. Nothing validates against it at runtime;
jsonschema is not a dependency of the library.
Experimental format
Backward compatibility is not guaranteed until the format is declared
stable. Incompatible record-shape changes increment schema_version.
The DataFrame boundary¶
from_frame maps every column explicitly. Nothing is inferred from column
names — guessing is how a book gets priced against the wrong column and nobody
finds out until the P&L is wrong.
batch = EuropeanOptionBatch.from_frame(
chain_df,
option_type_col="cp", strike_col="K",
maturity_col="T", underlier_col="symbol",
notional_col="contracts",
)
A contract frame holds contract terms only. Spot, rate, volatility, and observed
prices are market observations; naming one of them as a contract column is
refused by name, and they go to the pricing call as VanillaMarketInputs.
Batches are homogeneous, so a frame mixing instrument types is rejected rather
than silently split — group it first. to_frame() round-trips, preserving row
order.
Coordinates come from pure functions rather than attributes, and take an
explicit model, because the forward is S, S·e^{rT}, or S·e^{(r−q)T}
depending on it:
from fast_vollib.instruments import log_moneyness, time_to_maturity
k = log_moneyness(chain, market, model="black_scholes") # log(K/F)
T = time_to_maturity(chain)
log_moneyness is the coordinate
fast_vollib.surface is parametrized in, so a batch feeds a
surface without a sign flip.
Batch equality¶
Batches deliberately have no __eq__. For a container of arrays, a == b has
two defensible meanings — one bool, or an elementwise mask — and silently
picking either produces wrong control flow in code that expected the other. Use
batch.equals(other) for the whole-batch question. Batches are
identity-hashable, and their columns are read-only.
Scope¶
The instruments layer does not represent exercise descriptors beyond European
exercise, calibration, or direct construction of an IVSurface from an
instrument batch. Stochastic processes and simulation live in
fast_vollib.processes and
fast_vollib.simulation rather than here: a contract holds
terms, and neither a process nor a scenario is ever attached to one. The
registry lists only the operations that are supported.