Add Black-76 pricing model for futures/forwards (#396)#398
Conversation
Implement Black-76 closed-form pricing for European options on futures, forwards, swaptions, and caps/floors. Core formulas: - d1 = [ln(F/K) + σ²T/2] / (σ√T) - d2 = d1 - σ√T - Call: e^(-rT) * [F*N(d1) - K*N(d2)] - Put: e^(-rT) * [K*N(-d2) - F*N(-d1)] Key differences vs Black-Scholes: - Input is forward price F (not spot S) - No carry term (F already incorporates all carry) - Unified discount factor e^(-rT) on both legs Changes: - src/pricing/black_76.rs: kernel + trait + 20 unit tests - src/greeks/utils.rs: calculate_d_values_black_76 helper (b=0 drift) - src/pricing/mod.rs: module documentation and exports - src/pricing/unified.rs: PricingEngine::ClosedFormBlack76 variant + dispatch - examples/examples_pricing/: new workspace crate with runnable demo - Cargo.toml: workspace members (alphabetical sort) Tests validate: - Hull canonical reference (F=K=20, ATM) - Put-call parity across ATM/ITM/OTM scenarios - Equivalence to BS-Merton with S=F*e^(-rT), q=0 - Monotonicity in forward price - Short side sign negation - Quantity invariance - Unsupported types (American, Bermuda, exotics) All tests pass; clippy/fmt/build clean.
Adds Black-76 to the file inventory in `src/lib.rs::Pricing Models`,
extends the pricing-models mermaid with a `Forward-Priced` subgraph
mapping `black_76 -> {Future, Forward}`, and lists Black-76 in the
`pricing/mod.rs` Model Selection Guidelines and Performance
Considerations sections.
…compatibility The new ClosedFormBlack76 variant was flagged as a semver-breaking change because PricingEngine is a public exhaustive enum. Mark it #[non_exhaustive] to signal that future variants are possible, and add a catch-all _ pattern to price_option dispatch.
There was a problem hiding this comment.
Pull request overview
Adds a Black-76 closed-form pricing path to the library’s pricing subsystem, integrating it into the unified pricing API and exposing it through the pricing module along with a runnable example crate.
Changes:
- Introduces a new
pricing::black_76module implementing Black-76 pricing plus unit tests. - Adds a
calculate_d_values_black_76helper in greeks utilities and wires Black-76 intoPricingEngine. - Updates module exports/docs and adds a new
examples_pricingworkspace crate showcasing Black-76 usage.
Reviewed changes
Copilot reviewed 32 out of 85 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/pricing/unified.rs |
Adds PricingEngine::ClosedFormBlack76 and dispatches pricing via black_76(). |
src/pricing/mod.rs |
Documents and exports the new Black-76 module and symbols. |
src/pricing/black_76.rs |
Implements Black-76 pricing kernel + trait + comprehensive unit tests. |
src/greeks/utils.rs |
Adds calculate_d_values_black_76 (Black-76 uses b = 0). |
src/greeks/mod.rs |
Re-exports the new Black-76 d-value helper for crate-internal use. |
examples/examples_pricing/src/bin/black_76.rs |
Adds a runnable demo for Black-76 pricing and unified dispatch. |
examples/examples_pricing/Cargo.toml |
Defines the new examples workspace crate and dependencies. |
Cargo.toml |
Adds the new examples crate to workspace members (reordered). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Bumps the crate version to 0.17.0 because the 0.16.x → main diff introduces two semver-breaking changes: `PricingEngine` is now `#[non_exhaustive]` and `PricingEngine::ClosedFormBlack76` shifts the implicit discriminant of `PricingEngine::MonteCarlo` from 1 to 2 (flagged by `cargo-semver-checks`). Also addresses the three Copilot review comments on PR #398: - pricing/mod.rs: reword the Black-76 module description so it lists "options on" futures / forwards / commodity futures rather than the underlyings themselves. - pricing/unified.rs: extend the `price_option` `# Errors` docstring to cover Black-76 (`MethodError` for zero-vol / non-finite, `UnsupportedOptionType` for non-European inputs). - greeks/utils.rs: compute `expiration_date.get_years()?` once in `calculate_d_values_black_76` and reuse the value for both `d1` and `d2`. Also drops the now-unreachable `_` arm in `price_option` (intra-crate matches see all variants regardless of `#[non_exhaustive]`), which was failing the `lint` job, and updates the version references in `src/lib.rs` and `CHANGELOG.md`.
|
Thanks for the review. All three suggestions are valid and addressed in 1a27d84. Same commit also bumps the crate to 0.17.0 (the Black-76 addition is semver-major because |
Codecov Report❌ Patch coverage is
... and 16 files with indirect coverage changes 🚀 New features to boost your workflow:
|
… (#402) Closed-form Greeks for the Black-76 (Black 1976) model, complementing the pricing kernel landed in #398. Black-76 prices options on futures, forwards, swaptions, caps/floors, and commodity futures — anywhere the underlying is a forward price `F` with the cost of carry already baked in. The Greeks share the same `e^(-rT)` discount factor across both legs and ignore `dividend_yield` (since `F` is carry-adjusted). Public surface (`src/greeks/black_76.rs`): - `delta_b76`, `gamma_b76`, `vega_b76`, `theta_b76`, `rho_b76` - `Black76Greeks` trait mirroring the `Black76` pricing trait — implementors provide `get_option(&self)` and inherit default delegations to the free functions above. Units mirror the BSM module: vega per 1 % vol, theta per calendar day (annual ÷ 365), rho per 1 % rate (annual ÷ 100). Quantity scales linearly; `Side::Short` flips the delta sign only (consistent with `greeks::delta`). Promoted `calculate_d_values_black_76` from `pub(crate)` to `pub` so external crates and the new module can share the helper. Formulas use the complete Hull (10th ed., Ch. 18) expressions. Note that the issue's formula for theta and rho omitted the `r·F·e^(-rT)·N(d1)` and F-leg discount terms; this implementation follows Hull rigorously and the relationship `ρ = -T · price` is verified analytically and by test. Tests (21, all passing): - Delta range: call ∈ (0,1), put ∈ (-1,0) - Identity `Δ_call − Δ_put = e^(-rT)` to 1e-9 - Gamma > 0, Vega > 0 across multiple strikes - Gamma and Vega are call/put symmetric - Theta < 0 for long ATM call/put (decay) - `ρ = -T · price / 100` analytic identity - BSM cross-check via `S = F·e^(-rT)`, `q = 0`: - `Δ_b76 = e^(-rT) · Δ_bsm` to 1e-9 - `ν_b76 = ν_bsm` to 1e-9 - `Γ_b76 = e^(-2rT) · Γ_bsm` to 1e-9 - Hull ATM-call reference (F=K=20, r=0.09, T≈1/3, σ=0.25) → Δ ≈ 0.5132 - Zero volatility → error - American / Bermuda / exotic → `GreeksError::Pricing(UnsupportedOptionType)` - Trait impl, side negation, quantity scaling Example: `examples/examples_pricing/src/bin/black_76_greeks.rs` walks ATM / ITM / OTM calls and puts on a 6-month CL futures contract, prints all Greeks, demonstrates the call-minus-put identity and trait usage. Closes #400.
Summary
Adds the Black-76 (Black 1976) closed-form pricing model for European
options on futures and forwards. Black-76 is the standard model for
options on commodity futures, equity-index / interest-rate futures,
swaptions, and caps/floors — anywhere the underlying is a forward price
Fwith the cost-of-carry already baked in. Until now users had tooverload
dividend_yieldagainst the BSM kernel to approximate it; thisPR provides a dedicated, well-named entry point with closed-form
correctness verified against the Black–Scholes–Merton kernel to
1e-9.Changes
black_76(option) -> Result<Decimal, PricingError>:d1 = [ln(F/K) + σ²T/2] / (σ√T),d2 = d1 − σ√T, with thedrift term collapsed to
σ²/2(Black-76 has no carry term ind1/d2); both legs share a single discount factore^(-rT).Decimalend-to-end viad_mul/d_sub;tracing::instrumentonthe entry point; the existing
big_nis reused.OptionType::European. American, Bermuda, and allexotics (Asian, Barrier, Binary, Lookback, Compound, Chooser, Cliquet,
Rainbow, Spread, Quanto, Exchange, Power) return
PricingError::UnsupportedOptionType { method: "Black-76" }.Black76trait mirroringBlackScholes: implementors provideget_option(&self) -> Result<&Options, PricingError>and get a defaultcalculate_price_black_76.pub(crate) fn calculate_d_values_black_76insrc/greeks/utils.rsthat calls existing
d1/d2withrisk_free_rate = 0(Black-76drift
b = 0);dividend_yieldis intentionally ignored becauseFis already carry-adjusted.
PricingEngine::ClosedFormBlack76variant insrc/pricing/unified.rsand dispatch fromprice_option.examples/examples_pricing/with binaryblack_76. Demos cover the Hull canonical example (ATM call/put +modified parity), an ITM commodity-futures call, dispatch through the
unified
PricingEngine, and the short-side sign convention. Usestracing::info!per project conventions.prelude.rsalready re-exportspricing::*, soBlack76andblack_76are picked up automatically.
src/pricing/mod.rsCore Models / Model SelectionGuidelines / Performance Considerations sections now include Black-76;
src/lib.rsfile inventory addsblack_76.rsand the pricing-modelsmermaid gains a
Forward-Pricedsubgraph routingblack_76 -> {Future, Forward}.Technical decisions
Hull Options, Futures and Other Derivatives — canonical worked
example regressed against (
F = K = 20,r = 0.09,T = 4/12,σ = 0.25, call ≈1.1166).computation, the new helper feeds
risk_free_rate = 0into theexisting
d1/d2. This collapses the BSM drift toσ²/2andmatches the Black-76 specification exactly. A standalone test
(
test_black_76_matches_bsm_with_discounted_spot_*) cross-checks thefull closed form against BSM with
S = F·e^(-rT), q = 0to1e-9.Options.underlying_pricecarriesFdirectly; the caller is responsible for that semantic.
Future/Forwardvariants onUnderlyingAssetTypearrive via thefinancial_types 0.2.2bump (filed asAdd Future and Forward variants to UnderlyingAssetType financial_types#46).
UnderlyingAssetTypeto keep it usable for any forward-pricedcontract.
Add Black-76 pricing model (pricing/futures-forwards) #396 explicitly allows splitting them off).
Public API impact
pricing::black_76,pricing::Black76trait,pricing::PricingEngine::ClosedFormBlack76. All re-exported from thepreludevia the existingpub use crate::pricing::*;line.README.tplis generated from the lib-level docs;make readmeispart of
make pre-pushand was run.Testing
src/pricing/black_76.rs):≈ 1.1166, tolerance1e-3)1e-6black_76vsblack_scholes(S = F·e^(-rT), q = 0)for call/put at ATM/ITM/OTM, tolerance
1e-9PricingErrorFfor both stylesSide::Shortreturns the negation of the long priceBlack76trait default-method consistencyPricingEngine::ClosedFormBlack76dispatch (long + short, theshort path goes through
Positive::new_decimal(price.abs()))PricingEnginedispatch tests above; no separatetests/additions required for this kernel.
structural BSM equivalence to
1e-9.cargo clippy --all-targets --all-features --workspace -- -D warningsclean.
cargo fmt --all --checkclean.cargo test --lib3784 passed; 0 failed (includes the 20 newBlack-76 tests).
cargo build --releaseclean.cargo test --all-features --workspaceclean: 416 passed; 6pre-existing failures in
visualization::plotly_render_test::*and
visualization::plotly_test::*(PNG / SVG export — needs thekaleido / Chrome binary for the
static_exportfeature). Verifiedby checking out
mainand re-running the same filter — the sametests fail there. Not introduced by this branch.
Checklist
rules/global_rules.mdandCLAUDE.mdpubitems have///documentation;# Errorson falliblefunctions
.unwrap()/.expect()/ unchecked indexing in productioncode
saturating_*/wrapping_*infinancial math
rust_decimal::Decimalat public monetary boundaries — nof64on prices / premia / P&L
model/has no deps onstrategies/,backtesting/,visualization/)tracingonly for logging — noprintln!/eprintln!/dbg!/
logcrate (the new example usestracing::info!)README.tplupdated (regenerated viamake readme) since thepublic surface grew
Closes #396