# FerroRisk / QuantLib Python API comparison — 9 September 2026

Fresh scalar measurements on one Apple M1 Pro (10 cores, MacBookPro18,1,
16 GiB), macOS 26.6.2. This compares **native Rust API calls with QuantLib's
Python API**. Python dispatch, loop and binding overhead are included. It is
not a native C++ kernel comparison, batch comparison or service benchmark.

## Versions and artifacts

- FerroRisk **0.38.4 source build**, revision
  `301f5d0a4dadfcd00defebc805366afa66fd36cd`, Rust/Cargo **1.97.1**, Cargo
  `bench` profile, engine features `default` + `simd`. Scalar calls execute
  on one thread; `RAYON_NUM_THREADS=4` configures the surrounding harness.
- QuantLib **1.41**, CPython **3.14.7**, released PyPI macOS ARM64 wheel
  `quantlib-1.41-cp38-abi3-macosx_11_0_arm64.whl`. Its installed native binary
  hash and WHEEL metadata are retained. The wheel's upstream compiler flags
  are not established by this experiment; no matching compiler-build claim
  is made. The separate local QuantLib checkout is 1.44-dev and is **not**
  the measured library.
- [summary.json](summary.json): displayed figures, phase medians, paired API
  ratios and artifact hashes.
- [raw-results.json](raw-results.json): Criterion samples and estimates,
  Python loop samples, exact inputs, before/after outputs, binary identities,
  planned order, host observations and cross-check results.
- [quantlib_compare.py](quantlib_compare.py): exact Python harness.
- [m1_exit_perf.rs](m1_exit_perf.rs): exact native Rust harness.
- [frozen-quotes.tsv](frozen-quotes.tsv): unchanged native IV quote inputs.

The native executable was already built for the source-bound 0.38.4 replay;
this experiment **measures it afresh**. Its source inventory was checked with
only the documented benchmark harness/manifest overlay permitted. The earlier
scalar cards retain their separate original measurements and source identity.

## Inputs, models and output scope

| Fixture | Option | Spot | Strike | Expiry (years) | Rate | Yield | Volatility |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| European | Call | 120 | 100 | 0.75 | 0.03 | 0.01 | 0.32 |
| American | Put | 100 | 105 | 0.5 | 0.03 | 0.01 | 0.25 |

Rates are continuously compounded decimal fractions; volatility is a decimal
fraction. No discrete dividend schedule. Prices use the input monetary unit.
QuantLib uses evaluation date **2026-09-09**, flat curves and constant
volatility with **Actual/360**, and maturities 270/180 days later. This makes
its year fractions exactly **0.75/0.5**, without rounding nominal year inputs
to approximate day counts. Exact IEEE-754 input bits are checked against the
native fixture records.

European pricing uses BSM: FerroRisk `bsm_price`, QuantLib
`AnalyticEuropeanEngine`. American pricing uses **FerroRisk BS2002** versus
**QuantLib BS1993**, via `BjerksundStenslandApproximationEngine`. The models
have different exercise approximations. QuantLib identifies BS1993 in its
[versioned engine header](https://github.com/lballabio/QuantLib/blob/v1.41/ql/pricingengines/vanilla/bjerksundstenslandengine.hpp);
its [implementation](https://github.com/lballabio/QuantLib/blob/v1.41/ql/pricingengines/vanilla/bjerksundstenslandengine.cpp)
and the observed outputs establish that American Greeks are exposed.

FerroRisk returns price plus **ten Greeks**: delta, gamma, theta, vega, rho,
vanna, volga, charm, veta and color. QuantLib's timed Greek row retrieves
**five**: delta, gamma, vega, theta and rho. This is the measured set, not a
claim that QuantLib supports only five sensitivities. Theta is normalized to
per-calendar-day units by dividing QuantLib's annual theta by 365. Vega/rho
are per unit volatility/rate, not per volatility point/basis point.
FerroRisk's higher time sensitivities also use calendar-day units.

European IV consumes the same frozen model-generated price on both sides:
IEEE-754 bits `40397df98ba57cc1` (25.492089012052705). It is a timing fixture,
not an independent accuracy oracle. FerroRisk uses the public `implied_vol`
BSM/Jäckel path with its source-pinned default stopping/fallback policy;
the retained result reports two iterations and no fallback. QuantLib uses
`VanillaOption.impliedVolatility(price, process, 1e-10, 100, 1e-7, 4.0)`:
accuracy 1e-10, maximum 100 evaluations, volatility bracket [1e-7, 4]. These
are different solver policies, not a matched-tolerance algorithm experiment.

## Timing boundary and statistics

Options, payoff/exercise objects, curves, processes, engine attachment and
FerroRisk resolved Greek requests are constructed outside timing. No transport
or formatting occurs inside the loops.

Each QuantLib price/Greek invocation calls **`option.recalculate()`** before
retrieving outputs, forcing the engine to recompute at the fixed inputs.
Thus it is not timing a cached NPV. Forced recalculation and result getters,
Python function/loop/binding overhead, tuple creation and theta-unit conversion
are included; no overhead is subtracted. The engine computes its own supported
Greeks while calculating price, so even the price row includes that work.
IV invokes the solver directly each time; its solver/process construction
inside that API is included. These are deliberate API boundaries, not identical
instruction counts across libraries.

Native pricing calls the public pricing function and returns its `Result`.
Native Greeks call `ResolvedGreekRequest::greeks`; request construction is
excluded. IV calls `implied_vol` with the frozen quote. Black-box consumption
prevents compiler removal. Allocation instrumentation is disabled during timing;
this experiment makes no new allocation comparison with QuantLib.

Run order is **FerroRisk F1 → QuantLib Q1 → QuantLib Q2 → FerroRisk F2**.
Each workload has a one-second warmup and 60 samples per phase. Criterion
uses at least three seconds per native workload. Python calibrates loop counts
outside retained samples to target approximately 50 ms per sample (approximately
three seconds total); actual durations and denominators are retained.

Each phase median is computed from elapsed nanoseconds / invocation count.
Displayed times round the arithmetic midpoint of the two phase medians.
The summary retains both medians; their span is a phase range, not a confidence
interval. European price/IV API ratios divide the midpoint QuantLib latency
by midpoint native latency; both paired ratios are also retained. Greek and
American rows deliberately have no speedup ratio because sets/models differ.
There is no accuracy ranking or scalar-to-batch extrapolation.

AC power was required. Timing proceeded under normal host activity pursuant to
the user's immediate-run instruction, without CPU/worker admission or a quiet
window. Background CPU and warning observations are retained. No compiler,
browser QA, builds or other task-owned numerical work ran alongside timing.
No die temperature was measured. Independent native arithmetic-control drift
must be at most 5%; the recorded result is in the summary. This does not certify
an isolated host or establish a calibrated release-performance budget.

Before/after native outputs agree across both phases, as do Python outputs.
European price and five common Greeks agree within absolute 1e-10; IV agrees
within 1e-9 and recovers the 0.32 fixture volatility. American results are finite
and retained without asserting equality between different approximations.
These fixture checks are not a general correctness proof.

## Reproduction

Install the pinned Python package in a dedicated environment:

```sh
python3.14 -m venv .ql-1.41
.ql-1.41/bin/python -m pip install QuantLib==1.41
.ql-1.41/bin/python quantlib_compare.py --preflight
.ql-1.41/bin/python quantlib_compare.py > Q1.json
```

Use Python 3.14.7 and retain the installed binary hash/WHEEL metadata for exact
comparison. Run the Python harness again for Q2. `--help`, `--version` and `--`
use standard argparse behavior. Preflight does no timing.

For the native build, source access to the FerroRisk repository is required.
Check out the exact source revision above. Copy `m1_exit_perf.rs` to
`crates/ferro-risk/benches/` and append to that package's Cargo.toml:

```toml
[[bench]]
name = "m1_exit_perf"
harness = false
```

Build once before timing, with the committed lockfile, pinned toolchain and a
fresh `CARGO_TARGET_DIR`:

```sh
cargo rustc --locked -p ferro-risk --bench m1_exit_perf --profile bench \
  --message-format=json-render-diagnostics -- \
  --check-cfg 'cfg(candidate_report)' --cfg candidate_report > build.jsonl
```

Use the executable in its compiler-artifact record. Set `FERRO_PERF_QUOTES`
to the absolute path of `frozen-quotes.tsv`, and `RAYON_NUM_THREADS=4`. Retain
`--preflight` output before and after each phase. For F1/F2, set
`CRITERION_HOME` to a fresh exclusive directory and invoke:

```sh
FERRO_M1_PERF_RUN=1 /absolute/path/to/m1_exit_perf \
  --bench --noplot --sample-size 60 --measurement-time 3 --warm-up-time 1 \
  '^(controls/bsm_price|controls/bsm_all|controls/european_iv_baseline|ordinary/put_price|ordinary/put_all_resolved|controls/harness_arithmetic)$'
```

Run F1/Q1/Q2/F2 serially; retain host/power observations and original samples.
The website's `npm run check:benchmarks` independently checks all five rows,
raw medians/ratios, output agreement, exact inputs, artifact identities and the
arithmetic control before the production build.
