# stochastic-rs-quant vol_surface / risk / strategies / portfolio audit (2026-05-10)

> Deep dive on the `vol_surface + risk + strategies + portfolio` slice of
> `stochastic-rs-quant`, driving the v2.0.0 stable cut. Companion to
> `docs/V2_RELEASE_AUDIT_2026-05-07.md` (deferred this scope as a dedicated
> fork) and to `QUANT_AUDIT_2026-05-10_rates.md` (rates/lattice slice).
> Codebase snapshot: `v2.0.0-rc.1`, ~6.7k LOC across the four subdirs.

---

## 1. Executive summary

- **2 P0 release blockers**, both in the vol-surface arbitrage check
  surface: `SsviSurface::is_calendar_spread_free` only verifies that the
  ATM term structure $\theta_t$ is non-decreasing — it does NOT verify
  the full Gatheral-Jacquier 2014 calendar condition over the smile,
  yet it is consumed by `VolSurfaceResult::is_arbitrage_free()`. Users
  asking "is my surface arb-free?" can get a wrong `true`. The second
  P0 is in `optimize_mean_cvar*` where the `cvar_alpha` parameter is
  silently the **tail proportion** while the rest of the risk module
  (`VaR`, `ES`) uses `confidence`. A user who passes `0.95` thinking
  "95% confidence" gets the average of the worst 95% of returns — i.e.
  almost the whole distribution.
- **8 P1 issues**, headlined by: SABR FX-smile calibrator hardcodes
  strike bounds `[1.0, 10.0]` (FX-spot-specific, breaks for any
  underlying outside that range); optimizers hardcode `252.0` daily
  annualization despite `risk/performance.rs` policy of "no 252
  constants hard-coded"; MC-CVaR optimizer uses fixed `penalty=10.0`
  on the return-target soft constraint with no API to override;
  `align_return_series` silently date-misaligns when input series end
  on different dates; `OptimizerMethod::from_str` falls back to
  `Markowitz` on any unknown string with `Err = Infallible`; SSVI
  butterfly check `η(1+|ρ|) ≤ 2` is sufficient but the projection in
  `SsviParams::project` does not enforce the calendar-spread side.
- **11 P2** style/naming/dead-code/test items, including: SVI
  `is_admissible` projection adds `|v_min|` to `a` but never checks
  the corrected slice still butterfly-arb-free; historical-ES finite
  sample correction (Rockafellar-Uryasev tail-share term) missing;
  `running_drawdown` uses `T::min_positive_val()` as initial peak —
  fragile if equity series can go negative.
- The Strategies subdir has only `delta_hedge.rs` (108 LOC, a static
  state container — no rebalancing engine, so no look-ahead bias to
  chase). The audit prompt's "strategy backtest look-ahead" question
  is **not applicable** here.
- VaR / ES / Sharpe / Sortino / Calmar / IR / drawdown signs and
  formulas all match Jorion / Acerbi-Tasche / Rockafellar-Uryasev.
  Gaussian VaR `μ_L + σ_L Φ⁻¹(α)` is correct. SVI Levenberg-Marquardt
  Jacobian (5 partials) is verified against pen-and-paper derivatives.
  SVI butterfly density formula matches Gatheral & Jacquier 2014 Eq.
  2.1.

**Verdict:** the 2 P0s must close before tagging `v2.0.0`. The
calendar-arb P0 is the bigger one — wrong "arb-free" answer is a real
quant bug. The CVaR-α convention can be fixed with a 2-line rename +
docstring. P1s can slip to `2.0.x` but the SABR strike-bound issue is
a footgun for non-FX users today.

---

## 2. P0 — release blockers

### 2.1 **`SsviSurface::is_calendar_spread_free` only checks $\theta_t$, missing the smile-wide calendar condition**

**File:** `stochastic-rs-quant/src/vol_surface/ssvi.rs:309-312`
**Caller:** `stochastic-rs-quant/src/vol_surface/pipeline.rs:138, 38`

Implementation:

```rust
pub fn is_calendar_spread_free(&self) -> bool {
    self.thetas.windows(2).all(|w| w[1] >= w[0])
}
```

This checks only that the ATM total variance term structure is
non-decreasing. The actual Gatheral & Jacquier 2014 (arXiv:1204.0646)
Theorem 4.2 calendar condition requires
$\partial_T w(k, T) \geq 0$ for **every** log-moneyness $k$, not just
$k=0$. The full SSVI total variance derivative
$\partial_\theta w(k, \theta)$ is already implemented at
`ssvi.rs:122-133` (`w_prime_theta`), and the chain rule
$\partial_T w = \theta'(T) \cdot \partial_\theta w$ is already used in
`local_var()` at line 329, but the calendar arb check ignores it.

For power-law SSVI specifically, GJ 2014 Theorem 4.2 + Lemma 4.4 give
the necessary-and-sufficient calendar bound
$$
\partial_\theta(\theta\,\varphi(\theta))(1-\rho^2) \geq \rho^2,
\qquad \forall\,\theta>0
$$
which after the power-law substitution
$\varphi(\theta) = \eta\,\theta^{-\gamma}(1+\theta)^{\gamma-1}$ becomes
a parameter constraint linking $\eta$, $\rho$, $\gamma$ that is
**stricter** than the butterfly bound $\eta(1+|\rho|)\leq 2$ already
checked at line 142.

**Impact:** `VolSurfaceResult::is_arbitrage_free()`
(`pipeline.rs:37-39`) ANDs this incorrect check with the per-slice
butterfly checks. A user asking the headline question "is the surface
arbitrage-free" can get `true` for a surface that has calendar
arbitrage off-ATM — the exact kind of bug a quant reaches for this
crate to detect.

**Fix:**

```rust
pub fn is_calendar_spread_free(&self, ks: &[T]) -> bool {
  if !self.thetas.windows(2).all(|w| w[1] >= w[0]) {
    return false;
  }
  // For each consecutive (theta_lo, theta_hi) and each k, check
  // w(k, theta_hi) >= w(k, theta_lo).
  for win in self.thetas.windows(2) {
    let (lo, hi) = (win[0], win[1]);
    for &k in ks {
      if self.params.total_variance(k, hi)
         < self.params.total_variance(k, lo) {
        return false;
      }
    }
  }
  true
}
```

The pipeline at `pipeline.rs:138` already iterates k via
`smile_slice(j).log_moneyness`; reuse that grid.

Or document this as `is_atm_calendar_spread_free` and add a separate
`is_calendar_spread_free_grid(ks)` method.

### 2.2 **`optimize_mean_cvar*` `cvar_alpha` parameter is the tail proportion, not the confidence — silent semantic mismatch with VaR/ES module**

**File:** `stochastic-rs-quant/src/portfolio/optimizers.rs:102-113`
**Callers:** `optimizers.rs:298, 398, 963`; `portfolio/engine.rs:32, 43, 82, 108, 176`; `portfolio/momentum.rs:282, 315, 366`.

```rust
fn empirical_cvar(returns: &mut [f64], alpha: f64) -> f64 {
  returns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(...));
  let cutoff = ((returns.len() as f64) * alpha.clamp(0.0, 1.0)).ceil() as usize;
  let cutoff = cutoff.max(1).min(returns.len());
  let tail_mean: f64 = returns[..cutoff].iter().sum::<f64>() / cutoff as f64;
  -tail_mean
}
```

`alpha` here is the proportion of the lower tail to average — `0.05`
means "average the worst 5% of returns". This is the **opposite**
convention from `risk/var.rs::value_at_risk(.., confidence, ..)` and
`risk/expected_shortfall.rs::expected_shortfall(.., confidence, ..)`,
where `confidence = 0.95` selects the worst 5%.

The defaults at `engine.rs:43, 176` (`cvar_alpha: 0.05`) and the
public engine field doc at `engine.rs:31` ("Tail probability for CVaR
optimizers") confirm the *intent* matches the implementation, but a
user accustomed to the rest of the risk module who passes `0.95`
expecting "95% CVaR" will silently get the average of the worst 95% —
nearly the entire return distribution, dominated by neutral / mild
returns. The optimizer will minimize the wrong quantity. No assert
catches `alpha > 0.5`.

**Fix:** rename to `tail_proportion: f64` or flip the semantics to
match VaR (`confidence = 1 - tail_proportion`) and document the
convention explicitly. If kept as `alpha`, add an assertion
`alpha < 0.5` in `empirical_cvar` to make the convention crash-loud.
Trivial source diff; the danger is the silent failure mode.

---

## 3. P1 — fix before stable, hard to change later

### 3.1 **SABR FX-smile calibrator hardcodes strike bounds `[1.0, 10.0]`**

**File:** `stochastic-rs-quant/src/vol_surface/sabr_smile.rs:309-310`

```rust
let lo = [1.0, 1.0, 1.0, 1.0, 0.01, -0.99];
let hi = [10.0, 10.0, 10.0, 10.0, 10.0, 0.99];
```

The first four entries are bounds on $K_{rr,c}, K_{rr,p}, K_{bf,c},
K_{bf,p}$. They were chosen for BRL/USD-style FX rates ($S\approx 3.7$
in the test at line 571). They will fail completely for any
underlying outside `[1.0, 10.0]`: equities (AAPL ~$200), indices
(SPX ~$5000), penny FX (JPY/USD ~$0.0067), or commodities (gold
~$2400).

The initial guess at `:320` `x0 = [s + 0.1, s - 0.1, s + 0.1, s - 0.1, 0.6, 0.5]`
is correctly proportional to spot but is ridiculously narrow — `±0.1`
for any spot, so a $200 stock starts every basin-hopping step at
$199.9 and $200.1 strikes. Not a bug per se but very inefficient.

**Fix:** scale bounds by spot, e.g.
`let lo_k = s * 0.5; let hi_k = s * 2.0;` and seed `x0[0..4]` at
`s ± s * 0.05` so the search starts a wing-distance away. Or expose
the bounds as struct fields. Same module already exposes
`with_basin_hopping_iters` — same pattern works.

Also `success: f_best.is_finite()` at line 360 is too lax; the test
uses `assert!(res.objective < 1e-3)` — apply that as the success
threshold internally.

### 3.2 **Optimizers hardcode `252.0` daily annualization in CVaR objective and portfolio-vol helper**

**File:** `stochastic-rs-quant/src/portfolio/optimizers.rs:132, 339, 439`

```rust
// optimizers.rs:132
pvar.sqrt() * 252.0_f64.sqrt()
// optimizers.rs:339, 439
let ann_cvar = cvar * 252.0_f64.sqrt();
```

`risk/performance.rs` lines 11-13 explicitly state:
> All ratios accept an explicit annualisation factor (periods per year)
> so no `252`, `365`, or `12` constants are hard-coded.

This rule is broken in `optimizers.rs`. Users with weekly (52),
monthly (12), or hourly returns get silently wrong annualized
volatility/CVaR. **Reachable via `PortfolioEngine::optimize` ->
`optimize_with_method` -> `optimize_mean_cvar` ->** `portfolio_vol_from_returns`.

**Fix:** thread `periods_per_year` through `optimize_with_method`
into `portfolio_vol_from_returns`/`empirical_cvar`. Default 252 is
fine; just make it overridable.

### 3.3 **CVaR/Markowitz optimizers use fixed `penalty = 10.0` for return-target soft constraint**

**File:** `optimizers.rs:173, 257, 353, 453`

The mean-variance / mean-CVaR objectives are
`port_var + penalty * (port_ret - target_return)^2`. With
`penalty = 10.0` hardcoded, the relative weight of return-tracking vs
risk-minimization is an arbitrary constant. For weekly returns
($\sigma^2 \sim 10^{-4}$) this swamps risk; for daily portfolios
($\sigma^2 \sim 10^{-3}$) it might be balanced. Should be exposed.

**Fix:** add `lambda: f64` to the optimizer signature, default `10.0`.

### 3.4 **`align_return_series` silently date-misaligns**

**File:** `stochastic-rs-quant/src/portfolio/data.rs:64-75`

```rust
let min_len = all_returns.iter().map(|r| r.len()).min().unwrap_or(0);
for (i, r) in all_returns.iter().enumerate() {
  let start = r.len().saturating_sub(min_len);
  for (j, &v) in r[start..].iter().enumerate() {
    out[(i, j)] = v;
  }
}
```

This takes the **last** `min_len` observations of each series
("right-aligned"). It silently assumes all input series end on the
same date. If asset A has data through 2026-05-10 and asset B has
data through 2026-04-30, the function still right-aligns — meaning
A's last 10 days get matched against B's last 10 days
(2026-04-21..04-30), and the correlation/cov is computed on
**date-misaligned** observations. Users won't notice unless they
inspect the output values.

**Fix:** either (a) require dates as part of the API and align by
date, or (b) document the right-aligned assumption in the docstring
loudly, or (c) accept `start_indices: &[usize]` so callers can opt in.

### 3.5 **`OptimizerMethod::from_str` silently falls back to `Markowitz` on any unknown string with `Err = Infallible`**

**File:** `stochastic-rs-quant/src/portfolio/types.rs:26-39`

```rust
impl std::str::FromStr for OptimizerMethod {
  type Err = std::convert::Infallible;
  fn from_str(s: &str) -> Result<Self, Self::Err> {
    Ok(match s.to_lowercase().as_str() {
      "cvar" | "mean-cvar" | "meancvar" => Self::MeanCVaR,
      ...
      _ => Self::Markowitz,
    })
  }
}
```

`from_str("garbage")` returns `Ok(Markowitz)`. Same anti-pattern at
`momentum.rs:109-118` for `WeightScheme`. Production code that
parses a config string and expects "unknown method" to error gets
silent Markowitz instead. With `Err = Infallible` users can't even
match on error.

**Fix:** define a real error type and reject unknown strings.

### 3.6 **SSVI `project` enforces butterfly bound but not the calendar-spread side condition**

**File:** `ssvi.rs:146-159`

```rust
let max_eta = two / (one + self.rho.abs());
self.eta = self.eta.min(max_eta);
```

Only the butterfly bound `η(1+|ρ|) ≤ 2`. After fix 2.1, the calendar
condition gives a tighter bound on $\eta$, $\rho$, $\gamma$ — `project`
should enforce both, otherwise calibration can drift into a
calendar-arbitrage region while still passing
`satisfies_no_butterfly_condition`.

### 3.7 **SSVI calibration Jacobian uses 1-sided forward differences with `h=1e-7`**

**File:** `ssvi.rs:433-461`

The SSVI LM problem implements `jacobian` via finite differences
with `h = 1e-7`. The analytic derivatives `phi`, `phi_prime`,
`w_prime_k`, `w_double_prime_k`, `w_prime_theta` are all already
implemented in this same file. The forward-difference Jacobian
introduces O(h) error vs the analytic O(machine-epsilon), and is
strictly slower (3 extra `total_variance` evaluations per data
point per parameter). On large surface fits this is the dominant
cost.

**Fix:** replace with closed-form Jacobian. The 3 partials
$\partial_\rho w$, $\partial_\eta w$, $\partial_\gamma w$ are
straightforward from the SSVI total-variance form.

### 3.8 **`SmileSlice::atm_total_variance` returns endpoint when smile is one-sided**

**File:** `vol_surface/implied.rs:353-379`

If a smile slice has only ITM/OTM strikes on one side of $k=0$, the
function returns `total_variance[0]` or `total_variance[n-1]` —
i.e., a non-ATM total variance — and the caller (e.g.
`fit_ssvi`/`pipeline.rs:121`) treats it as the ATM input $\theta$.
SSVI calibration then minimizes residuals against an off-ATM
"theta", which biases the global $(\rho,\eta,\gamma)$ fit.

**Fix:** linear extrapolation to $k=0$ from the two innermost data
points, with a documented warning logged when a slice doesn't
straddle zero.

---

## 4. P2 — polish

### 4.1 SVI `project` adds `|v_min|` to `a` to force `min_variance ≥ 0` (`svi.rs:108-119`) but never re-checks butterfly density `g(k)` — the corrected slice can still violate butterfly arb-freeness. Caller is responsible for `check_butterfly_svi` afterward.

### 4.2 `historical_es` (`risk/expected_shortfall.rs:73-93`) lacks the Rockafellar-Uryasev finite-sample tail-share correction term. Result is approximate ES rather than the coherent estimator. Fine on continuous distributions; biased with ties.

### 4.3 `running_drawdown` (`risk/drawdown.rs:22`) initializes `peak = T::min_positive_val()` (≈ `2.2e-308` for f64). Works for typical positive-equity series, but gives nonsense drawdown if the input series can go negative or starts below `min_positive_val`. Use `T::neg_infinity()` instead.

### 4.4 `max_drawdown_duration` (`drawdown.rs:45`) measures the longest "consecutive bars below previous peak" — one valid definition; the alternative "longest peak-to-recovery distance" is not provided. Document or expose both.

### 4.5 `bucket_dv01` docstring (`risk/greeks.rs:70-72`) describes "(V(up) - V(down)) / 2" but the return is dollar PnL per `bump_size` move, with sign opposite to the standard "DV01 is positive for long bonds" convention. Doc this explicitly.

### 4.6 Hardcoded Carr-Madan parameters `(N=2^12, α=0.75)` in `model_surface.rs:83 (fourier_model_surface_fft)`. Reasonable defaults but should be settable.

### 4.7 `sabr_smile.rs:269` `MoreThuenteLineSearch::new().with_c(1e-4, 0.9).unwrap()` — production unwrap inside basin-hopping loop. Wolfe coefficients are constants so this can't fail in practice, but `.expect("Wolfe params (1e-4, 0.9) are valid")` would document why it's safe.

### 4.8 `vol_surface/implied.rs:227` `panic!("missing forward for tau={t}")` on input data path. Should be `Result`.

### 4.9 `Sortino` denominator (`risk/performance.rs:73`) uses `n` (full sample) not `n_downside`. Bawa-Lindenberg vs Sortino-Price convention. Doc the choice — both are seen.

### 4.10 `assign_weights(WeightScheme::ScoreWeighted)` in `portfolio/momentum.rs:455-471` weights by `|score|`, then normalizes to sum to 1. For long basket this works; for the **short** basket the user may expect short-side weights to also sum to 1 (which they do, but only because the same function is called separately on each leg) — at least the docstring should say so. As coded the long and short legs each sum to 1 in absolute value.

### 4.11 `portfolio/optimizers.rs::mat_inverse` (line 826-884) is a hand-rolled Gauss-Jordan inverse with no SPD/Cholesky path. For Black-Litterman it's used on a 100×100 covariance — no observable performance issue today but `nalgebra::DMatrix::try_inverse` would be both faster (LU with partial pivoting) and more numerically stable.

### 4.12 `Strategies` module is one struct (`DeltaHedge`, 108 LOC). The directory exists but has no actual strategy backtest engine, no `Strategy` trait, no time-series interface. `delta_hedge.rs::moneyness` has odd asymmetric thresholds (only DOTM tested below, only DITM above) — reasonable but worth documenting.

---

## 5. What I did NOT cover and why

- **`pricing/sabr.rs` Hagan implementation arbitrage at extreme strikes.**
  The SABR pricer module is outside this audit's scope (covered by
  the rates/instruments fork). Note that `vol_surface/sabr_smile.rs`
  *consumes* `pricing::sabr::hagan_implied_vol` — the calibrator
  cosmetic vol cap at `sabr_smile.rs:445` (`vol_cap = q.sigma_atm * 3.0`)
  hides Hagan blow-ups for plotting only, not for the calibration
  itself.

- **`risk/credit.rs` and survival-curve internals.** The credit module
  defers to `crate::credit::survival_curve` — out of scope for this
  fork.

- **`risk/execution.rs` Almgren-Chriss internals.** The micro-structure
  module is out of scope; only verified the bridge function
  `liquidity_adjusted_var` adds VaR + expected-cost (correct sign).

- **AI/neural surrogate IV grids.** `ImpliedVolSurface::from_flat_iv_grid`
  / `from_iv_grid` are correct on the structural side; the upstream
  `stochastic_rs_ai::volatility::*` networks are not in scope here.

- **Python bindings.** `stochastic-rs-py` does not currently export
  vol_surface / risk / strategies / portfolio (per CLAUDE.md "quant
  bindings are 2.x follow-up"); no Python-side audit performed.

- **`portfolio/optimizers.rs` HRP correctness.** Verified the
  Lopez-de-Prado seriation + recursive bisection structure looks
  right but did not run synthetic correctness tests; in-tree test
  only checks weight sum on Markowitz.

- **`vol_surface/pipeline.rs` integration tests.** Spot-checked the 6
  in-file tests — they all run a full pipeline and assert
  `result.calendar_spread_free`. Given the P0 in §2.1, those asserts
  are **trivially true** today even when the surface might have
  off-ATM calendar arb. After fixing 2.1, expect at least one test
  to fail and need its k-grid input updated.

---

## 6. Recommended next steps

In `v2.0.0-rc.2`:

1. Fix §2.1 (`SsviSurface::is_calendar_spread_free`). Add a smile-grid
   parameter and propagate from `pipeline.rs:138`. Update tests at
   `arbitrage.rs:180-195` and `ssvi.rs:539-551` to pass an explicit
   `ks` slice. Add a test specifically constructing a power-law SSVI
   parameter set that violates calendar at $k=±2$ but passes at
   $k=0$, and assert `is_calendar_spread_free(ks)` returns `false`.
2. Fix §2.2 (`empirical_cvar` α convention). Either rename
   `cvar_alpha → cvar_tail_proportion` everywhere (`engine.rs`,
   `momentum.rs`, `optimizers.rs::optimize_with_method` signature) or
   add `assert!(alpha < 0.5, "cvar_alpha is tail proportion not
   confidence; use 1 - confidence")`. Update default doc strings.
3. Fix §3.1 (SABR strike bounds). Make bounds a method on
   `SabrSmileCalibrator` so non-FX users can override.

In `v2.0.x` patch series:

4. §3.2-3.5 — annualization plumbing, penalty parameter,
   `align_return_series` API clarification, `from_str` real errors.
5. §3.6 — SSVI projection with calendar bound.
6. §3.7 — closed-form SSVI Jacobian.
7. §3.8 — extrapolated ATM total variance.

For `v2.1`:

8. Strategies module: add a real `Strategy` trait + backtest engine
   if intended. Currently the directory is unfinished.
9. §4.1-4.11 — polish.
10. Add a `examples/vol_surface_arb_demo.rs` showing how to detect
    each kind of arbitrage on a constructed parameter set.

---

**End of audit. Total LOC reviewed: 6,722 across 23 files.**
