Skip to content

Add Black-76 pricing model for futures/forwards (#396)#398

Merged
joaquinbejar merged 4 commits into
mainfrom
pricing/black-76
Apr 26, 2026
Merged

Add Black-76 pricing model for futures/forwards (#396)#398
joaquinbejar merged 4 commits into
mainfrom
pricing/black-76

Conversation

@joaquinbejar
Copy link
Copy Markdown
Owner

@joaquinbejar joaquinbejar commented Apr 26, 2026

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
F with the cost-of-carry already baked in. Until now users had to
overload dividend_yield against the BSM kernel to approximate it; this
PR provides a dedicated, well-named entry point with closed-form
correctness verified against the Black–Scholes–Merton kernel to 1e-9.

Changes

  • New pricing kernel black_76(option) -> Result<Decimal, PricingError>:
    d1 = [ln(F/K) + σ²T/2] / (σ√T), d2 = d1 − σ√T, with the
    drift term collapsed to σ²/2 (Black-76 has no carry term in
    d1/d2); both legs share a single discount factor e^(-rT).
    Decimal end-to-end via d_mul / d_sub; tracing::instrument on
    the entry point; the existing big_n is reused.
  • Black-76 only prices OptionType::European. American, Bermuda, and all
    exotics (Asian, Barrier, Binary, Lookback, Compound, Chooser, Cliquet,
    Rainbow, Spread, Quanto, Exchange, Power) return
    PricingError::UnsupportedOptionType { method: "Black-76" }.
  • New Black76 trait mirroring BlackScholes: implementors provide
    get_option(&self) -> Result<&Options, PricingError> and get a default
    calculate_price_black_76.
  • New pub(crate) fn calculate_d_values_black_76 in src/greeks/utils.rs
    that calls existing d1/d2 with risk_free_rate = 0 (Black-76
    drift b = 0); dividend_yield is intentionally ignored because F
    is already carry-adjusted.
  • New PricingEngine::ClosedFormBlack76 variant in
    src/pricing/unified.rs and dispatch from price_option.
  • New workspace member examples/examples_pricing/ with binary
    black_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. Uses
    tracing::info! per project conventions.
  • prelude.rs already re-exports pricing::*, so Black76 and black_76
    are picked up automatically.
  • Doc updates: src/pricing/mod.rs Core Models / Model Selection
    Guidelines / Performance Considerations sections now include Black-76;
    src/lib.rs file inventory adds black_76.rs and the pricing-models
    mermaid gains a Forward-Priced subgraph routing
    black_76 -> {Future, Forward}.

Technical decisions

  • Reference: Black, F. (1976). The pricing of commodity contracts.
    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).
  • Reuse vs. duplication: rather than duplicate a numerator
    computation, the new helper feeds risk_free_rate = 0 into the
    existing d1 / d2. This collapses the BSM drift to σ²/2 and
    matches the Black-76 specification exactly. A standalone test
    (test_black_76_matches_bsm_with_discounted_spot_*) cross-checks the
    full closed form against BSM with S = F·e^(-rT), q = 0 to 1e-9.
  • Schema: no change to Options. underlying_price carries F
    directly; the caller is responsible for that semantic. Future /
    Forward variants on UnderlyingAssetType arrive via the
    financial_types 0.2.2 bump (filed as
    Add Future and Forward variants to UnderlyingAssetType financial_types#46).
  • Dispatch: Black-76 is a kernel; we don't gate on
    UnderlyingAssetType to keep it usable for any forward-priced
    contract.
  • Greeks: deferred to a follow-up issue (acceptance criterion in
    Add Black-76 pricing model (pricing/futures-forwards) #396 explicitly allows splitting them off).

Public API impact

  • Added: pricing::black_76, pricing::Black76 trait,
    pricing::PricingEngine::ClosedFormBlack76. All re-exported from the
    prelude via the existing pub use crate::pricing::*; line.
  • No removals or renames. No feature-flag changes. No new dependencies.
  • README.tpl is generated from the lib-level docs; make readme is
    part of make pre-push and was run.

Testing

  • Unit tests added or updated (20 new co-located tests in
    src/pricing/black_76.rs):
    • Hull reference call (≈ 1.1166, tolerance 1e-3)
    • ATM symmetry put-call equality
    • Modified put-call parity at ATM / ITM / OTM, tolerance 1e-6
    • BSM cross-check: black_76 vs black_scholes(S = F·e^(-rT), q = 0)
      for call/put at ATM/ITM/OTM, tolerance 1e-9
    • Zero-vol returns PricingError
    • Deep-ITM / deep-OTM monotonicity in F for both styles
    • Side::Short returns the negation of the long price
    • Quantity invariance (per-contract pricing)
    • American / Bermuda dispatch errors
    • Black76 trait default-method consistency
    • PricingEngine::ClosedFormBlack76 dispatch (long + short, the
      short path goes through Positive::new_decimal(price.abs()))
  • Integration tests added or updated — covered via the unified
    PricingEngine dispatch tests above; no separate tests/
    additions required for this kernel.
  • Reference regression test added — Hull worked example +
    structural BSM equivalence to 1e-9.
  • Criterion benchmark added/updated — not a perf PR.
  • cargo clippy --all-targets --all-features --workspace -- -D warnings
    clean.
  • cargo fmt --all --check clean.
  • cargo test --lib 3784 passed; 0 failed (includes the 20 new
    Black-76 tests).
  • cargo build --release clean.
  • cargo test --all-features --workspace clean: 416 passed; 6
    pre-existing failures in visualization::plotly_render_test::*
    and visualization::plotly_test::* (PNG / SVG export — needs the
    kaleido / Chrome binary for the static_export feature). Verified
    by checking out main and re-running the same filter — the same
    tests fail there. Not introduced by this branch.

Checklist

  • Code follows rules/global_rules.md and CLAUDE.md
  • All pub items have /// documentation; # Errors on fallible
    functions
  • No .unwrap() / .expect() / unchecked indexing in production
    code
  • Checked arithmetic only — no saturating_* / wrapping_* in
    financial math
  • rust_decimal::Decimal at public monetary boundaries — no f64
    on prices / premia / P&L
  • Module boundaries respected (model/ has no deps on
    strategies/, backtesting/, visualization/)
  • No new dependencies added without explicit approval
  • tracing only for logging — no println! / eprintln! / dbg!
    / log crate (the new example uses tracing::info!)
  • README.tpl updated (regenerated via make readme) since the
    public surface grew

Closes #396

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.
@joaquinbejar joaquinbejar added enhancement New feature or request pricing Related to options pricing labels Apr 26, 2026
@joaquinbejar joaquinbejar self-assigned this Apr 26, 2026
@joaquinbejar joaquinbejar requested a review from Copilot April 26, 2026 07:30
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.
@joaquinbejar joaquinbejar changed the title Add Black-76 pricing model (#396) Add Black-76 pricing model for futures/forwards (#396) Apr 26, 2026
…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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_76 module implementing Black-76 pricing plus unit tests.
  • Adds a calculate_d_values_black_76 helper in greeks utilities and wires Black-76 into PricingEngine.
  • Updates module exports/docs and adds a new examples_pricing workspace 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.

Comment thread src/pricing/mod.rs Outdated
Comment thread src/pricing/unified.rs
Comment thread src/greeks/utils.rs
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`.
@joaquinbejar
Copy link
Copy Markdown
Owner Author

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 PricingEngine is now #[non_exhaustive] and MonteCarlo's implicit discriminant shifted) and drops the now-unreachable _ arm in price_option that was failing the lint job.

@joaquinbejar joaquinbejar merged commit 18de062 into main Apr 26, 2026
13 checks passed
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 26, 2026

Codecov Report

❌ Patch coverage is 98.50746% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/pricing/black_76.rs 98.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/greeks/utils.rs 82.60% <100.00%> (+2.41%) ⬆️
src/lib.rs 100.00% <ø> (ø)
src/pricing/unified.rs 100.00% <100.00%> (ø)
src/pricing/black_76.rs 98.00% <98.00%> (ø)

... and 16 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

joaquinbejar added a commit that referenced this pull request Apr 27, 2026
… (#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request pricing Related to options pricing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Black-76 pricing model (pricing/futures-forwards)

2 participants