A ground-up port of QuantLib — the
quantitative-finance library — into idiomatic, memory-safe Rust. The deliverable
is a core library, libitofin, with thin language bindings on top (Python
first, then a C ABI for everything else).
The name nods to Kiyosi Itô, whose stochastic calculus underpins modern derivatives pricing.
⚠️ Pre-1.0, under active development. The core already prices European options, Hull-White swaptions (with model calibration), and Heston options end-to-end, but the API will change until 1.0 and parts of the pricing surface are still being filled in. The Python bindings (itofin) are published on PyPI; a C ABI is planned. See Status.
cargo add libitofinuse libitofin::time::Date;
// dates, calendars, day counters, curves, vol surfaces, quotes, indexes,
// cashflows, swaps/swaptions, short-rate + Heston models with calibration,
// and analytic option engines are available today - see docs.rs.The same engine is reachable from Python via itofin
(Python 3.13+). The API mirrors QuantLib's ql/ layout: types live in submodules
(itofin.time, itofin.instruments, itofin.processes, ...), while Settings
and ItofinError stay at the top level. Real market data is first-class - yield
curves (bootstrapped from a deposit/swap strip via PiecewiseYieldCurve, or
interpolated) and Black-vol surfaces - so you price against a market snapshot, not
just flat inputs. Type stubs ship in the wheel, so editors and mypy see the full API.
pip install itofinfrom itofin import Settings
from itofin.instruments import OptionType, VanillaOption
from itofin.processes import BlackScholesProcess
from itofin.time import Date, DayCounter
s = Settings()
s.set_evaluation_date(Date(15, 6, 2026))
dc = DayCounter.actual360()
process = BlackScholesProcess(60.0, 0.08, 0.0, 0.30, Date(15, 6, 2026), dc)
option = VanillaOption(OptionType.Call, 65.0, Date(15, 6, 2026) + 90, s)
option.set_engine(process)
print(f"NPV {option.npv():.10f}") # 2.1333684449
print(f"delta {option.delta():.10f}") # 0.3724827980…or build a real vol surface (strike x expiry) and query it:
from itofin.termstructures import BlackVarianceSurface
from itofin.time import Date, DayCounter
ref = Date(15, 6, 2026)
vols = BlackVarianceSurface(
ref,
[ref + 365, ref + 730], # expiries
[90.0, 100.0, 110.0], # strikes
[[0.20, 0.25], # one row per strike,
[0.18, 0.22], # one column per expiry
[0.16, 0.20]],
DayCounter.actual365_fixed(),
)
print(f"{vols.black_vol(1.0, 100.0):.2f}") # 0.18QuantLib is ~470k lines of mature, battle-tested C++ across 16 modules. This project re-expresses that core in safe, idiomatic Rust:
- Memory-safe by construction — no manual
shared_ptrcycles or use-after-free. The core is single-threaded-mutable during setup, then frozen into immutable snapshots for data-race-free parallel compute (rayon). - A clean FFI story — a single core crate with Python (PyO3) and C-ABI (cbindgen) bindings layered on top, so the same engine is reachable from Python, C, C++, Julia, R, and more.
- Faithful numerics — QuantLib's
test-suite/(186.cppfiles) is the porting oracle: a feature is "done" only when the matching tests are ported and the Rust output matches the C++ numbers within tolerance. - Usability at the edges — where C++ leans on runtime casts, silent fallbacks, or clock magic, the core prefers compile-time typing and explicit errors; ergonomic conveniences live in the binding crates.
The port proceeds bottom-up through dependency layers L0→L11; each layer depends only on lower-numbered layers. The live backlog is the GitHub Project board and the repository's issues (the board is the source of truth, not a checked-in file).
| Layer | Epic | Scope | State |
|---|---|---|---|
| L0 | core | types, errors, patterns, settings, handle, utilities | ✅ done |
| L1 | math | array/matrix, distributions, interpolation, integrals, solvers, optimization, statistics, RNG, ODE, copulas, decompositions | ✅ done |
| L2 | time | Date, Period, Calendar, DayCounter, Schedule, IMM/ASX/ECB |
✅ done |
| L3 | quotes | Quote, SimpleQuote, derived quotes, InterestRate, compounding |
✅ done |
| L4 | term structures | interpolated yield curves, Black-vol curves/surfaces, local vol | ✅ done |
| L5 | processes | multi-factor StochasticProcess / StochasticProcess1D, Black-Scholes, Heston (analytic surface), Ornstein-Uhlenbeck, correlated process array |
🚧 in progress |
| L6 | indexes | InterestRateIndex, Ibor family (Euribor / Eonia / €STR / SOFR), SwapIndex |
🚧 in progress |
| L7 | cashflows | fixed / floating / Ibor / overnight coupons and legs, coupon pricers, duration, capped-floored coupons | 🚧 in progress |
| L8 | instruments | fixed-rate bonds, vanilla / OIS swaps, swaptions, caps & floors, vanilla options | 🚧 in progress |
| L9 | methods | lattices, trees (trinomial + Hull-White), Monte Carlo (path generators + antithetic); finite differences pending | 🚧 in progress |
| L10 | models | CalibratedModel + calibrate(), short-rate (Vasicek, CIR, Hull-White), Heston, calibration helpers |
🚧 in progress |
| L11 | engines | analytic European & Heston (Fourier), swaption (Black / Bachelier / Jamshidian), discounting swap / bond, Black cap/floor | 🚧 in progress |
Milestone 1 (done): a European option prices end-to-end — quote → flat
yield/vol curves → generalized Black-Scholes process → analytic engine → lazy
instrument greeks — matching QuantLib's europeanoption.cpp value and greeks to
double-rounding precision, with the full observer/invalidation graph exercised.
Verified end-to-end since then, each against the matching test-suite/ oracle:
- Hull-White calibration — a Hull-White model calibrates to a swaption strip
(Jamshidian decomposition + Levenberg-Marquardt), reproducing
shortratemodels.cpp's cached mean-reversion and volatility. - Heston analytic + calibration — the Fourier
AnalyticHestonEngineprices European options tohestonmodel.cpp's cached values, and a Heston model calibrates to a DAX volatility surface (reproducing the reference SSE).
types/errors— QuantLib's numeric aliases andQlError/QlResultwithfail!/require!macros (the analogue ofQL_FAIL/QL_REQUIRE).patterns/handle/settings— the observer/observable graph,LazyObject,Handle/RelinkableHandle, and the evaluation-date context.math— arrays and matrices (with SVD/QR/Cholesky/…), the distribution family, interpolation (linear → bicubic), integrals (incl. Gauss quadratures), 1-D solvers, optimizers, statistics, RNGs (MT/Sobol/…), ODEs, copulas.time— dates, periods, 50+ calendars, day counters, schedules, IMM/ASX/ECB.quotes/interestrate— simple and derived quotes, interest-rate and compounding conversions.termstructures— flat and interpolated yield curves (zero/discount/ forward), implied and spreaded curves, Black-variance curves/surfaces, local vol.indexes—InterestRateIndex, the Ibor family (Euribor / Eonia / €STR / SOFR) andSwapIndex, with fixings threaded throughSettings(D11).cashflows— fixed, floating, Ibor and overnight coupons and legs, coupon pricers, duration, and capped-floored coupons.processes— the multi-factorStochasticProcessbase, generalized Black-Scholes, Heston (analytic surface), Ornstein-Uhlenbeck, and a correlated process array.models—CalibratedModelwithcalibrate(), the short-rate family (Vasicek, CIR, Hull-White), the Heston model, and calibration helpers (swaption, Heston).instruments/pricingengines— vanilla payoffs and exercise,EuropeanOption, fixed-rate bonds, vanilla / OIS swaps, swaptions, caps & floors; the analytic European and Heston (Fourier) engines, the swaption engines (Black / Bachelier / Jamshidian), and discounting swap / bond engines.
Requires the toolchain pinned in rust-toolchain.toml
(Rust 1.96.0, edition 2024); plain cargo picks it up automatically.
cargo build # whole workspace
cargo build -p libitofin # core crate only
cargo test # the porting oracle
cargo test -p libitofin patterns:: # one module
cargo fmt
cargo clippy --all-targetsA pre-commit config runs fmt, check, clippy,
test, and conventional-commit linting on every commit:
pre-commit run --all-filescrates/libitofin/ the core library — FFI-agnostic, idiomatic Rust
crates/libitofin-ffi/ extern "C" + cbindgen → C header (planned)
crates/itofin-py/ PyO3 + maturin → the `itofin` package (on PyPI)
QuantLib/ reference C++ tree + test oracle (git-ignored symlink)
The QuantLib/ entry is a git-ignored local symlink, not committed — point
it at a QuantLib checkout to have the reference source and test-suite oracle
available locally: ln -s /path/to/QuantLib QuantLib.
- Bottom-up, layer by layer — never port a module before its dependencies.
- The C++ test-suite is the oracle — match the numbers, not just the shape.
- Small PRs — ≤350 LOC target, 400 hard cap; large source files split across tickets.
- Single-threaded-mutable core, snapshot-and-fan-out for parallelism — the
observable graph is mutated single-threaded during setup, then frozen into
immutable snapshots for
rayoncompute. Noasyncin the core (QuantLib does no I/O; market data is user input). - Fidelity in numerics, usability at API boundaries — QuantLib is the oracle
for every number, but the core favours compile-time typing and explicit
Resulterrors over runtime casts and silent fallbacks; convenience lives in the bindings.
The port targets semantic faithfulness (matching QuantLib's results), not bit-for-bit reproduction of its implementation. A small, deliberate set of divergences is catalogued here. Each is an intentional, reviewed decision (not an oversight) and is documented at the point of divergence in the source.
Time / calendars (EPIC-2):
- Calendar holiday overrides are per-value, not process-global. QuantLib
shares one global
Implper market, soaddHolidayon anyTARGET()handle is visible through every other. This port shares added/removed holidays only among clones of aCalendarvalue, matching the "explicit state, no hidden singletons" decision. The built-in holiday rules are identical; only the reach ofadd_holiday/remove_holidaydiffers. holiday_listfilters weekends by a date-aware rule. QuantLib'sholidayListexcludes weekends using the weekday-onlyisWeekend, which misclassifies holidays for markets whose weekend changed over time (Saudi Arabia's Thu/Fri→Fri/Sat in 2013, Israel/TASE's Fri/Sat→Sat/Sun in 2026). This port filters with a date-awareis_weekend_on; fixed-weekend calendars are unaffected (the default equals the weekday rule).- Table-backed calendars fail loudly past their data horizon. Where QuantLib tabulates lunar / religious / observed holidays only up to a fixed year and then silently returns "business day" for later dates, this port panics with a clear message once a query passes the last fully-tabulated year. QuantLib's tables are kept verbatim; we never fabricate future dates.
Periodcomparison is a partial order, and fixes a negative-period bug. QuantLib'soperator</operator==throw when two periods have overlapping day ranges (e.g.1 Monthvs30 Days); this port returnsNonefrompartial_cmpinstead, so comparison never panics. It also orders the day boundsmin <= maxbefore comparing, which QuantLib omits: for negative lengths QuantLib's inverted bounds make overlapping periods (like-1 Monthvs-30 Days) look decidably ordered, whereas this port correctly reports them as undecidable. Positive comparisons are unaffected.DayCounteris always valid; there is no empty placeholder. QuantLib's default-constructedDayCounterholds a nullimpl_andQL_REQUIREs a non-null one on every call. This port omits the empty state, so aDayCounteralways wraps a concrete convention and its accessors never trip that null check. The "not yet set" placeholder used by higher layers is anOption<DayCounter>at those call sites instead.Business/252counts directly instead of via a process-global cache. QuantLib memoizes per-month and per-year business-day totals in globalstd::maps keyed by calendar name; this port counts withCalendar::business_days_betweendirectly. With a calendar's built-in schedule the results are identical; once holidays are overridden they can differ, since QuantLib's name-keyed cache goes stale while this port always reflects the current holiday set.Actual/Actual (ISMA)uses the reference-date algorithm. QuantLib picks a schedule-driven implementation when aScheduleis supplied and a reference-date one otherwise; the reference-date path is ported, with the schedule-driven overload following as needed.
Core (EPIC-0):
- An unset evaluation date is an explicit error, not a system-clock fallback.
QuantLib's
Settingssingleton falls back to the machine clock; this core has no clock (for determinism and FFI), so operations that need the evaluation date returnErrwhen it is unset rather than silently pricing a possibly-expired instrument as live. - Past index fixings live on
Settings, not in a globalIndexManagersingleton (D11). QuantLib stores fixing history inIndexManager::instance(), a global-singletonmap<string, TimeSeries<Real>>; D5 forbids that singleton, so the history moves ontoSettingsas afixing_store, exactly like the evaluation date. It stays shared across every handle to an index (aSettingsis shared, and the store is keyed by the case-insensitive index name), so two handles to "the same" Euribor observe one history, as C++'s global map guarantees. Each index name owns anObservable, so adding or clearing a fixing notifies exactly that index's observers, and those notifiers outlive aclear_fixings. A conflicting fixing on an existing date returnsErr(D4) where QuantLib notifies and then throws. Theenforces_todays_historic_fixingsflag's today's-fixing rule is applied by the index layer that reads the store, asInterestRateIndex::fixingdoes in C++, not by the store itself.
Cash flows (EPIC-7):
-
Couponis not a subtrait ofCashFlow; one blanket impl derives the cash-flow face from the coupon. C++ derivesCouponfromCashFlow, and its base class supplieshasOccurred,exCouponDate()andisCoupon()so that no coupon can forget them. Rust has no specialization, so under the subtrait shape aCouponcannot supply its supertrait's methods on its implementors' behalf. All three wrong answers compile and are plausible numbers rather than diagnostics: the plain-eventhas_occurredignoresSettings::includeTodaysCashFlowson the evaluation date; aNoneex_coupon_dateaccrues ex-coupon while reportingtrading_ex_couponasfalse; aNoneas_couponcontributes nothing tobps, has its amount subtracted fromatm_rate's target, and reports its payment date tomaturity_datein place of its accrual end.So
Couponrestates its own surface (coupon_base,amount,rate,day_counter,accrued_amount, andAsObservable), andimpl<T: Coupon> CashFlow for Twrites the three exactly once, readingCouponBase. A coupon cannot get them wrong because it cannot supply them.amountmoves ontoCouponfor the same reason it must: a blanket that providedamountwould giveFixedRateCoupona generic body in place of its compounded one, and a competingimpl CashFlow for FixedRateCouponrestoring it is a conflicting implementation. The three methods stay required onCashFlowandEventfor the flows that are not coupons, which still forward explicitly toevent_has_occurredorcash_flow_has_occurred.Two costs.
dyn Coupondoes not implementCashFlow: theSome(self)ofas_couponis an unsizing coercion, so the blanket takes aSizedT, and+ ?Sizedwould restore the impl and forbid theSome(self). Nothing needs it - theCashFlowsanalytics hold a&dyn CashFlowalready and read onlyCoupon's own methods through the coupon view. Andamountnow sits on both traits, so on a concrete coupon with both in scope an unqualifiedcoupon.amount()is ambiguous; name the trait. -
The
IborCouponpar/indexed forecast switch is threaded onSettings, not a global singleton. QuantLib'sIborCoupon::indexFixing()forecast branch selects between a par-coupon approximation (rollsfixingEndDateoff the coupon's own accrual end) and an indexed coupon (the index's natural maturity) through theIborCoupon::Settingssingleton (usingAtParCoupons, defaulttrue). That process-global switch is what D5 forbids, so the port drops the singleton and threads the flag explicitly onSettings(using_at_par_coupons, defaulttrue) beside the evaluation date and the D11 fixing store. The behaviour it governs is fully reproduced:index_fixingcomputes the cachedfixingValueDate/fixingEndDate/spanningTime(couponpricer.cpp:56-88) and reads the specialized 3-argforecastFixingoff the curve; a determined fixing is mode-independent and returns the same required past fixing as the C++ override.BlackIborCouponPricerportsswapletRate = gearing * indexFixing + spread; since a non-in-arrearsBlack76coupon needs no convexity adjustment it needs no volatility, and since onlyswapletPrice(notswapletRate) reads the forwarding-curve discount, the pricer captures no curve. The caplet/floorlet optionlet path, the*Pricemethods, and the in-arrears convexity adjustment belong to the cap/floor slice; an in-arrears coupon is refused with anErrrather than priced with a missing convexity term. -
IborLegbuilds the plain-vanilla coupons only; caps, floors and the in-arrears feature are omitted, not silently dropped. QuantLib'sIborLegcarrieswithCaps/withFloors/inArrearsbuilder methods that route the leg throughCappedFlooredIborCouponand the optionlet pricer. Those belong to the cap/floor slice, so this port omits the methods entirely rather than accepting the vectors and building plain coupons. The defaultBlackIborCouponPriceris attached incoupons()(C++ attaches it inoperator Leg()under the same no-cap/no-floor/no-arrears condition, which here always holds);build()only erases the concrete coupons into aLeg, and the freeset_coupon_priceroverrides the default on the concrete coupons, the erasedLegcarrying no downcast. The stub reference dates follow theFloatingLegtemplate (calendar.adjust(end - tenor, bdc), no end-of-month flag), which agrees with the fixed leg on every multi-coupon schedule. A zero gearing, which the C++ template collapses to aFixedRateCoupon, is not special-cased:IborCouponrejects it, sowith_gearing(0.0)surfaces thatErrrather than a silent fixed coupon.
Indexes (EPIC-6):
Currencyis always valid; there is no empty placeholder. QuantLib's default-constructedCurrencyholds a nulldata_andQL_REQUIREs a non-null one on every inspector; itsoperator==treats two empty currencies as equal andoperator<<prints"null currency". This port omits the empty state, so aCurrencyalways holds a concrete specification: accessors never trip that null check, equality is purely by name (QuantLib's non-empty branch), andDisplayalways prints the ISO code. The "not yet set" placeholder is anOption<Currency>at higher call sites, mirroring theDayCounterdecision above. Theroundingconvention,triangulationCurrencyandminorUnitCodesfields are dropped as unused by the index slice; rounding returns with the money layer. Only EUR is provided; theql/currencies/*catalogue is deferred.
Pricing engines (Milestone 1):
- Zero-volatility Black greeks dispatch on the stored option type, not on the
sign of
alpha_. This is a deliberate divergence where the oracle is wrong, and the only place in the port where a finite priced number intentionally disagrees with QuantLib. Upstream (treev1.42.1-266-g9863b578a, from commit17f1a1bed"Fixing zero vol for Black") detects the option type in its zero-vol branches withif (alpha_ >= 0) // Call, at nine sites (blackcalculator.cpp:215,222,229,257,264,271,439,446,453). For a plain-vanilla put,alpha_ = -1.0 + cum_d1_(blackcalculator.cpp:137), and at zero volatility an out-of-the-money put hascum_d1_ == 1.0exactly, soalpha_ == 0.0exactly and0.0 >= 0takes the Call branch: the OTM put is handed a delta of+1.0where the correct value is0.0. This port instead dispatches on the option type stored in the calculator, implementing the values the reference's own comments state, so the OTM-put delta is0.0. The full zero-vol ladder is pinned byzero_volatility_ladder_matches_stated_intentandzero_volatility_otm_put_gets_put_greeks_not_call_greeksinblackcalculator.rs, which assert the corrected numbers (OTM0.0, ATM-0.5, ITM-1.0for the put, and the call mirror), so a regression to thealpha_ >= 0form is a test failure.
Processes (L5):
HestonProcess::evolveports the ctor-default Andersen QE scheme; the other seven schemes andpdfare deferred. QuantLib'sHestonProcessoverridesevolve(hestonprocess.cpp:396) with a nine-wayDiscretizationswitch, defaulting toQuadraticExponentialMartingale(hestonprocess.hpp:65). This port implements the sharedQuadraticExponential/QuadraticExponentialMartingalebody (hestonprocess.cpp:461-516) and exposes the fullDiscretizationenum; the remaining seven variants (PartialTruncation, FullTruncation, Reflection, NonCentralChiSquareVariance, and the three BroadieKaya exact schemes) returnErrreferencing the deferral (#410) rather than silently mispricing via a wrong branch. The QEevolvedrifts the spot by the INTERVAL forwardforwardRate(t0, t0+dt, Continuous), not the instantaneous forward thatdriftuses. The ctor still installs the base Eulerexpectation/std_deviation/covariance(hestonprocess.cpp:46), which match QuantLib.pdf,varianceDistribution, and the modified-Bessel / complex machinery the BroadieKaya schemes need remain with #410.
Non-finite inputs (cross-cutting):
- Non-finite arguments are rejected at the API boundary. QuantLib validates
signs (
stdDev >= 0,forward > 0,t >= 0) and relies on NaN failing every such comparison, so a NaN is already an error wherever a sign is checked. An infinity is not: it passes>= 0.0and propagates to a NaN result several layers down, and where a curve extrapolates the range check is skipped entirely. Following D10, this port widens each of those guards from "not NaN" to "finite", and adds finiteness checks where C++ has none at all: solver arguments and functor values (solver1d.rs), sampled quadrature abscissae (discrete.rs), Black-Scholes process arguments (blackscholesprocess.rs), and the volatility and variance an implementation returns (termstructures/volatility/). Each site names the C++ guard it extends, or states that none exists. The only behavioural change is for infinities; every finite input QuantLib accepts is still accepted, so no priced number moves. - Statistics accumulators reject a NaN sample value, and accept infinities.
QuantLib's only sample guard is
QL_REQUIRE(weight >= 0.0)(generalstatistics.hpp:233,incrementalstatistics.cpp:127), which this port keeps verbatim, written!(weight >= 0.0)so a NaN weight fails it as it does in C++. A NaN value has no C++ guard: it is accumulated and poisons every subsequent mean, variance and percentile with no diagnostic. Infinite values remain accepted, as in C++, being meaningful tomin,maxand the risk measures. - Shape mismatches panic with a named cause.
SVD::solveFor(svd.cpp:528) and the defaultCostFunction::gradient/jacobianhave noQL_REQUIRE; a wrongly-sized output leaves stale entries the optimiser reads as real derivatives. These are caller errors, not market-data errors, so the port asserts rather than returningErr.
BSD-3-Clause — the same license as QuantLib, the ported source.