# stochastic-rs-quant rates / instruments / lattice / FX audit (2026-05-10)

> Deep dive on the `lattice + bonds + instruments + fx + market + curves +
> cashflows + inflation + calendar` slice of `stochastic-rs-quant`,
> driving the v2.0.0 stable cut. Companion to
> `docs/V2_RELEASE_AUDIT_2026-05-07.md` which deferred this scope as a
> dedicated fork. Codebase snapshot: `v2.0.0-rc.1`, ~13.3k LOC across
> the listed subdirs.

---

## 1. Executive summary

- **3 P0 release blockers** found in priced rates products. Two are
  textbook math/sign errors that yield wrong prices today (CIR closed-form,
  Hull-White closed-form bond), one is non-determinism + an
  `unwrap()` on a hardcoded date pattern (Hull-White bond again).
  TRS, FX delta round-trips, day-count edge cases, calendars, and the
  Hull-White trinomial tree drift fitting are **clean**.
- **6 P1 issues** worth fixing before stable: G2++ joint-probability
  correction is only valid for symmetric branches (drift ≈ 0); curve
  bootstrap swap-leg formula is off by one term; cashflow `add_months` /
  `days_in_month` / `is_leap_year` are duplicated across 3 modules;
  monotone-convex interpolation has dead code; bond `panic!` on YTM
  bracket failure should be Result; inflation YoY uses ZC ratios without
  convexity adjustment.
- **9 P2** style / naming / dead-code items.
- The 6 spot checks in scope (HW trinomial, BK trinomial, G2++ tree,
  Vasicek/CIR closed forms, TRS forward, FX delta round-trip,
  day-count edges, calendar boundary cases) all hit textbook Brigo /
  Hull / Castagna conventions except where flagged in §2.
- The lattice machinery is well-structured (`OneFactorShortRateModel`,
  `TrinomialTree::backward_induct`, `correlated_joint_probabilities`)
  and reused cleanly by Bermudan swaption + ZCB pricing.

**Verdict:** the 3 P0 must close before tagging `v2.0.0`. They are
isolated edits in `src/bonds/cir.rs:47` and `src/bonds/hull_white.rs:40-58`.
None require redesign. P1s can slip to `2.0.x`.

---

## 2. P0 — release blockers

### 2.1. **CIR closed-form bond price has wrong sign + parameter mislabel**

**File:** `src/bonds/cir.rs:13-49`

The Brigo-Mercurio §3.2.3 affine ZCB formula for CIR is
$P(t,T)=A(t,T)\exp(-B(t,T)\,r(t))$ where

- $h=\sqrt{\kappa^2+2\sigma^2}$ (`κ` = mean-reversion speed)
- $B=2(e^{h\tau}-1)/[(\kappa+h)(e^{h\tau}-1)+2h]$
- $A=\bigl[2h\,e^{(\kappa+h)\tau/2} \big/ ((\kappa+h)(e^{h\tau}-1)+2h)\bigr]^{2\kappa\theta/\sigma^2}$

with `θ` = long-term mean. Two bugs in the code:

1. **Sign error (line 47).** Returns `A * (self.r_t * B).exp()` which is
   `A·exp(+B·r)`. Standard formula is `A·exp(−B·r)`. With this sign,
   ZCB **increases** with the short rate — the opposite of correct
   behaviour. The test `zcb_finite_at_short_tau` (line 84) only checks
   finite/positive but never compares against a `r_low < r_high → P_low > P_high`
   monotonicity (which Vasicek does test — `vasicek.rs:122-136`).
2. **Parameter swap.** The struct doc says `theta = long-term mean,
   mu = mean-reversion speed` (lines 17, 19). But the code uses
   `self.theta` in `h = sqrt(theta² + 2σ²)` (line 40) — that slot needs
   the **speed**, not the long-term mean. The exponent `(2·theta·mu)/σ²`
   on line 43 happens to coincide because it's `speed × mean`
   (commutative), but the `h` term is wrong. Either rename the fields
   to match Brigo (`kappa = speed, theta = long-term mean`) — matching the
   workspace convention enforced for OU / Vasicek in the
   2026-05-07 audit §4.3 — or swap the assignments at the call sites.

**Recommended fix:** rename the fields (`theta → kappa`, `mu → theta`),
swap the sign on line 47 to `(-self.r_t * B).exp()`, and add a
"ZCB decreases with short rate" regression test mirroring
`vasicek.rs:122`.

### 2.2. **`bonds/hull_white.rs` is broken / non-deterministic**

**File:** `src/bonds/hull_white.rs:40-58`

`HullWhite::calculate_price` is non-functional production code:

- Line 42: `Utc::now().year()` — pricing uses **wall-clock today**.
  Calling the same pricer on consecutive days returns different
  values for the same trade. Determinism violation. Test runs are
  non-reproducible.
- Lines 43-44: `self.eval.unwrap()` and `self.expiration.unwrap()`
  panic if dates aren't supplied. The `tau` field is then unused in
  the body — only used to compute `B = (1 - exp(-α·τ))/α`. So a user
  who set `tau` without `eval`/`expiration` panics.
- Lines 45-46: `p0t = exp(-r_t·T)` and `p0s = exp(-r_t·S)` hard-code
  a flat zero rate `r_t` for the *initial discount curve*. Hull-White
  must consume an actual market discount curve $P^M(0,t)$ — that's
  the entire point of the model (curve fitting). The `theta(t)` field
  on line 21 is declared but never read by `calculate_price`.
- Line 53: `(2.0 * self.alpha * S) - 1.0` — the standard Hull-White
  formula has `(1 − exp(−2α·S))/(2α)` as the conditional variance.
  This expression is neither that nor anything I can match against
  Brigo §3.3.2.

**Impact.** This is the only `HullWhite` ZCB pricer reachable through
`bonds::HullWhite`. The `lattice::CurveFittedHullWhiteTree` is the
correct path (it does fit `α(t)` to a `DiscountCurve`, see
`lattice/short_rate.rs:498-548`). The closed-form `bonds::HullWhite`
should either be **deleted** (it's dominated by the tree-fitted
version) or **rewritten** to consume a `DiscountCurve` and use the
correct Brigo formula (3.39):

$$
P(t,T) = \frac{P^M(0,T)}{P^M(0,t)} \exp\!\left(B(t,T) f^M(0,t) - \frac{\sigma^2}{4a}(1 - e^{-2at})B(t,T)^2 - B(t,T)\,r(t)\right)
$$

The 2026-05-07 audit's verification table (line 117) claims
`calculate_tau_in_days` was removed but line 41 still calls
`calculate_tau_in_years`, which is fine — but the wider rewrite to
date-aware pricing missed this file entirely.

**Recommended fix:** drop `bonds::HullWhite` from the public re-exports
(`src/bonds.rs:12`) and rebuild against `DiscountCurve` if a closed-form
path is needed at all. Alternative: tag it `#[deprecated(since = "2.0.0", note = "use lattice::CurveFittedHullWhiteTree")]`.

### 2.3. **`bonds/hull_white.rs` has unsafe `.unwrap()` on hardcoded test pattern**

**File:** `src/bonds/hull_white.rs:43-44, 89-90`

The struct is keyed on `eval: Option<NaiveDate>` and `expiration:
Option<NaiveDate>`, but `calculate_price` unconditionally calls
`.unwrap()` on both. The single test (line 81) passes dates and so
masks the bug. Combined with §2.2 the right answer is to delete this
struct, but if it stays, the unwrap path needs to fall through to the
already-existing `tau` field rather than panic.

---

## 3. P1 — strongly recommended before stable

### 3.1. G2++ joint-probability correction valid only for symmetric branches

**File:** `src/lattice/short_rate.rs:654-710` (`correlated_joint_probabilities`)

The implementation uses `λ = ρ/12` as the corner correction. This is
exactly the Hull-White (2000) two-factor coupling for the **symmetric**
trinomial branch where `p_u = p_d = 1/6, p_m = 2/3` — under that
branching, the corner cells contribute `±λ` to the off-diagonal
covariance and `λ = ρ·p_d²·6 = ρ/12` reproduces the requested factor
correlation exactly. **For a drift-shifted branch** (where `p_u ≠ p_d`),
the same correction does not hit the requested covariance. The
docstring at line 281 says "moment-matched" but the matching only
holds at zero-drift nodes.

**Impact.** G2++ tree prices for ZCB / Bermudan swaption with
significant `mean_reversion_x` or `mean_reversion_y` will carry a
small bias proportional to drift. For typical `a ≤ 0.05` and `dt ≤ 0.25`
the bias is ~10⁻³ on the joint probabilities; on multi-year products
this can push prices a few bps. Acceptable for a "Tier-0" engine
(which the docstring concedes), but the docstring should be tightened
to "exact only at zero-drift nodes; a calibration-grade engine should
use the per-cell covariance solver".

**Recommended fix:** replace the `λ = ρ/12` shortcut with the per-cell
covariance solve from Hull-White (2000) eq. 16-18, or document the
limitation prominently in the struct docstring.

### 3.2. Bootstrap swap formula has annuity-bound off by one term

**File:** `src/curves/bootstrap.rs:75-98`

For a par swap of maturity `T` with `n_payments` and frequency `m` the
standard inversion gives the unknown final discount factor as

$$
D_n = \frac{1 - S\,\sum_{i=1}^{n-1} \delta\,D_i}{1 + S\,\delta}
$$

where the sum runs over **all prior payments** at times $\delta, 2\delta,
\dots, (n-1)\delta$. The code at line 87 uses
`for i in 1..n_payments` which is correct (1 ≤ i ≤ n−1). However the
code at line 81 computes `n_payments = round(maturity * frequency)`,
which for a 1Y semi-annual swap (m=2, T=1.0) yields `n_payments = 2`,
so the loop runs `i ∈ {1}` and `D_1` is read by interpolation —
correct. **Edge case:** for a swap whose maturity is not an integer
multiple of `1/frequency` (e.g. 1.4Y semi-annual swap), `n_payments`
gets rounded and the implicit fixed-leg structure mismatches the swap
quote convention. The bootstrap will silently give wrong DFs.

**Recommended fix:** require `maturity * frequency` to be integer (assert),
or expose a date-aware `SwapRateHelper` (already implemented in
`market::rate_helper.rs`) and route the bootstrap exclusively through
it for swaps. The current `Instrument::Swap { maturity, rate, frequency }`
free-form path should be hidden or feature-gated as "approximate".

### 3.3. `cashflows/types.rs` re-implements calendar helpers

**File:** `src/cashflows/types.rs:393-439`

Functions `add_months`, `days_in_month`, `is_leap_year` are duplicated
from `src/calendar/schedule.rs:227` and `src/calendar/day_count.rs:126,130`.
Three copies in the workspace, no `#[cfg(test)]` to keep them
behind tests — these are production paths used by `CmsIndex::forward_rate`.

**Recommended fix:** lift one canonical impl to `crate::calendar::date_math`
(or just `pub(crate) use` from `calendar::day_count`) and delete the
duplicates.

### 3.4. Inflation YoY index uses ZC ratios without convexity adjustment

**File:** `src/inflation/swap.rs:97-113`

`YearOnYearInflationSwap::inflation_leg_pv` builds
$\mathbb E[I(T_i)/I(T_{i-1})]$ from `forward_index_ratio` directly:

```rust
let r_curr = curve.forward_index_ratio(t_i);
let yoy = r_curr / r_prev - T::one();
```

That equates $\mathbb E^{Q^{T_i}}[I(T_i)/I(T_{i-1})]$ with the
deterministic ZC forward ratio. Under nominal $T_i$-forward measure
the ratio carries a convexity term that depends on the joint nominal /
real volatility (Mercurio 2005 §6 / Brigo-Mercurio §16.5). The current
implementation is the "no convexity adjustment" approximation — fine
for sketch pricing but should be documented explicitly. The fact that
the test `yyiis_telescopes_to_zc_under_constant_breakeven` passes is
**because** the model is deterministic; the convexity bias only shows
up under non-trivial volatility.

**Recommended fix:** add a docstring to `inflation_leg_pv` explaining
the deterministic-curve assumption and link to the Mercurio (2005)
§6 convexity formula for callers who need it.

### 3.5. Bond YTM solver `panic!` on bracket failure

**File:** `src/instruments/bond/shared.rs:160, 377, 388`

`yield_to_maturity_from_dirty_price_for_leg` (and the OAS / Z-spread
solver) brackets the yield, expands twice, and `panic!`s if it can't
bracket. For pathological inputs (deeply distressed bonds, dirty price
above all coupons), this kills the pricing process. The dirty-price
solver also calls `find_root_brent(...).expect(...)` (line 171).

**Recommended fix:** return `Result<T, BondPricingError>` from the
`*_from_*` methods that can fail to bracket, or saturate to the
extrapolated boundary with a logged warning. At minimum, replace
`expect` with `expect_or_default` semantics that fall through to the
clamp value.

### 3.6. Monotone-convex interpolation has unused locals

**File:** `src/curves/interpolation.rs:234-245`

The `g` and `integral` locals are computed and immediately discarded:
`let _ = g; let _ = integral;`. The actually-returned `forward_adj`
(line 247) does not match the standard Hagan-West (2006) formula
exactly — it re-derives a polynomial integral but skips the corner
cases (Hagan-West eqs 25-27). Looks rushed.

**Recommended fix:** either delete the unused locals and add a
note that this is a Tier-1 implementation good for smooth curves but
not the full Hagan-West convexity preservation, or finish the
implementation. Currently the function is misleadingly named.

---

## 4. P2 — terser

- **`bonds/cir.rs:13-29`** field naming `theta`/`mu` swap matches
  the §2.1 P0 root cause; if §2.1 is fixed by renaming, this resolves
  in lockstep.
- **`fx/quoting.rs:88-105`** `cross_rate` returns `(pair, rate)`
  where the constructed pair's `(base, quote)` ordering depends on
  matched legs without enforcing the market priority order. A user
  who calls `cross_rate(usdjpy, ..., eurusd, ...)` gets a JPY-base
  pair instead of EUR-base. Doc warning suffices.
- **`market/observable.rs:65, 73, 81`** `expect("observable poisoned")`
  on `Mutex::lock` — fine for in-process use, but if this ever runs in
  a long-lived service the panic should be logged + the observer
  evicted instead of crashing the process.
- **`curves/types.rs:95`** struct named `Instrument` collides with
  `crate::instruments::*` namespace. Rename to `BootstrapInstrument`
  or move under `curves::bootstrap::Instrument`.
- **`calendar/day_count.rs:16`** missing variants: `Bus/252` (Brazil),
  `ACT/364`, `ACT/ACT AFB`, `NL/365` (no-leap), and the 30E/360 ISDA
  variant (year-based vs day-based truncation). Each is a few-line
  addition. Affects fixed-income pricing in Brazil / Latin America.
- **`market/rate_helper.rs:281, 316, 322`** all test-only
  `NaiveDate::from_ymd_opt(...).unwrap()` are fine but inconsistent
  with the prod paths' `?`-propagation.
- **`fx/forward.rs:33-40`** `Display` impl uses `.to_f64().unwrap()`
  — for a non-`f64` `T` this would panic on display. Replace with
  `T::to_f64().unwrap_or(f64::NAN)`.
- **`lattice/short_rate.rs:584,594`** `(((mean - x0) / dx).round().to_f64().unwrap()) as isize`
  panics on NaN (e.g. zero diffusion + zero drift). Adversarial inputs
  to the tree builder; defensive default to `0` is safer.
- **`calendar/schedule.rs:155-181`** `ScheduleBuilder::build` does not
  expose stub conventions (long-first / short-first / long-last /
  short-last). Backward generation defaults to "short first" implicitly;
  forward generation defaults to "short last" implicitly. ISDA 2006 §4.15
  defines all four — the API is one enum away from being complete.

---

## 5. What I did NOT cover and why

- **`src/lattice/equity.rs` (CRR equity tree).** Out of "rates"
  scope; covered by the equity audit slice.
- **`src/instruments/equity.rs` (digital, european).** Same.
- **`src/instruments/option/cms.rs`** beyond the high-level use of
  `caplet_price` — the CMS replication adjustment internals were
  not validated against Hagan / Hunt-Kennedy.
- **`src/calibration/hw_swaption.rs`** — calibration loop, not a
  pricer. Out of "priced products" scope.
- **Two-factor G2++ Jamshidian extension.** Not in the codebase
  (only one-factor Jamshidian implemented).
- **`src/instruments/option/bermudan.rs:281-321`
  `backward_expectation_with_injection`** — the single-injection
  pattern was reviewed but the corner case where a level holds
  *both* an exercise opportunity and a coupon injection was not
  unit-tested. (The `BermudanSwaption::price_on_one_factor_tree`
  loop applies injection inside the discount step but never on the
  same step as the exercise, see lines 215-238 — that's by design.)
- **Settlement-cycle handling (T+1 / T+2 spot dates).** The FX
  forward and FRA modules assume the user supplies the correct
  spot-adjusted date; the library does not auto-roll T+2 across
  joint calendars. Out of audit scope but worth mentioning.
- **MTM accuracy under negative rates.** Only spot-checked SABR via
  `ShiftedSabrVolatility::implied_volatility`; no negative-rate
  Bachelier vs Black agreement test was added.

---

## 6. Recommended next steps

1. **Close §2.1 (CIR sign + naming).** Single-file edit
   in `src/bonds/cir.rs`. Add a `zcb_decreases_with_rate` regression
   test mirroring `vasicek.rs:122-136`. Estimated effort: 30 min.
2. **Close §2.2-2.3 (HullWhite bond).** Delete
   `bonds/hull_white.rs` and remove `pub use hull_white::HullWhite`
   from `src/bonds.rs:12`. Migrate any downstream callers to
   `lattice::CurveFittedHullWhiteTree`. Estimated effort: 1 h
   (codebase has 0 callers outside the file's own tests).
3. **Close §3.3 (calendar helper duplication).** Move
   `add_months/days_in_month/is_leap_year` to a single module,
   `pub(crate) use` everywhere. Estimated effort: 30 min.
4. **Tighten G2++ docstring (§3.1)** to call out the symmetric-branch
   limitation, link to Hull-White (2000) §16. Estimated effort: 15 min.
5. **Bond solver Result-ification (§3.5).** Optional for `2.0.0`;
   non-blocking but high-value for callers. Estimated effort: 2-3 h.
6. **Day-count enum expansion (§4 Bus/252, ACT/364)** can ship in
   `2.0.x`. Estimated effort: 1 h per convention.

Once §2.1-2.3 are closed and a quick test pass goes green, this
slice is ready for the `v2.0.0` tag.
