# stochastic-rs-quant — traits/factors/microstructure/credit/misc audit

Date: 2026-05-10
Scope: `stochastic-rs-quant/src/{traits,factors,microstructure,credit,order_book,loss,macros,types,python,yahoo,lib}` — everything **not** pricing/risk/rates.
Crate state: `v2.0.0-rc.1`.
LOC examined: ~7 kLOC (3.4 kLOC scope files + 3.6 kLOC top-level), spot-reads in `python.rs` (63 kB).

## §1 Executive summary

- Math correctness is **largely sound** for the in-scope modules. Almgren-Chriss closed-form trajectory, Ledoit-Wolf shrinkage (eq. 14), PCA, Fama-MacBeth two-pass ordering, Roll, Corwin-Schultz, propagator kernels, Merton structural d1/d2, ISDA-style CDS legs and JLT generator embedding all match their references.
- **One math P0**: the multi-period Kyle 1985 backward recursion `α_{n-1} = (1 - √α_n)/2` (line 115 of `microstructure/kyle.rs`) is **not the canonical Kyle recursion** and the multi-period tests do not validate against any analytic benchmark (only signs and monotonicity). Single-period Kyle is correct.
- **One API P0**: `traits/time.rs` `tau_or_from_dates` and `tau_with_dcc` panic on missing data (`panic!("either tau or both eval and expiration must be set")`). These are default trait methods consumed by every `PricerExt` implementor, so the panic propagates through user-facing surface.
- **`yahoo.rs` is unfit for v2.0 stable** as written: ~14 `.unwrap()` calls in a feature-gated public surface, 3 online tests that hit live Yahoo on every CI run, an `Absolute` return variant whose name does not match its formula, and a `tokio_test::block_on` shim used outside a test context.
- Trait surface is otherwise clean (re-exports correctly handle `openblas`-only items behind `#[cfg]`), Cargo features are well-segmented, and the `factors/` directory is correctly gated through `factors.rs` despite the submodule files using unconditional `ndarray_linalg::*` imports.

## §2 P0 findings

### §2.1 Multi-period Kyle recursion is non-canonical

`stochastic-rs-quant/src/microstructure/kyle.rs:110-118`

```rust
fn backward_alpha_sequence<T: FloatExt>(n_periods: usize) -> Vec<T> {
  let mut alphas = vec![T::zero(); n_periods];
  alphas[n_periods - 1] = T::from_f64_fast(0.5);
  for n in (0..n_periods - 1).rev() {
    let next = alphas[n + 1];
    alphas[n] = (T::one() - next.sqrt()) / T::from_f64_fast(2.0);
  }
  alphas
}
```

Kyle 1985 §3 (and the standard restatement, e.g. Foster-Viswanathan 1993, Vives 2010 §4.4) characterizes the equilibrium via a recursion of the form `α_{n-1}(1-α_n) = α_n²/2` with terminal `α_{N-1} = 1/2` — equivalently `α_{n-1} = α_n²/(2(1-α_n))`. The recursion in code yields different numbers: at `n_periods=2`, this gives `α_0 = (1 - √(1/2))/2 ≈ 0.146`, vs the canonical `0.25`.

Tests `multi_period_returns_one_per_round` and `multi_period_posterior_decreases` only check that lambda/beta/Σ are positive and that posterior variance decreases — both true under almost any sensible recursion, so they do not catch this.

Recommendation: re-derive the recursion against Kyle 1985 §3 directly (or against Foster-Viswanathan 1993 §3) and add an analytic-benchmark test (e.g., for `n_periods=2` with `Σ_0 = σ_u² = 1`, the canonical equilibrium has known closed-form values for `λ_n` that the test should match within 1e-9). If the current recursion is intentional (e.g., a non-Kyle simplification), the doc-comment formula at lines 67-71 should be changed to reflect what the code actually computes.

### §2.2 `TimeExt::tau_or_from_dates` / `tau_with_dcc` panic on missing data

`stochastic-rs-quant/src/traits/time.rs:14-37`

```rust
fn tau_or_from_dates(&self) -> f64 {
  if let Some(tau) = self.tau() { return tau; }
  match (self.eval(), self.expiration()) {
    (Some(e), Some(x)) => crate::calendar::DayCountConvention::Actual365Fixed.year_fraction(e, x),
    _ => panic!("either tau or both eval and expiration must be set"),
  }
}
```

`TimeExt` is a public trait; `PricerExt: TimeExt` so every pricer inherits this default. A user constructing a pricer with no `tau` and no `eval`/`expiration` (legitimate API surface — both fields are `Option<…>`) gets a panic on first valuation call. v2.0 stable should either return `Result<f64, …>` or, minimally, `f64::NAN` so downstream code can detect and short-circuit.

Recommendation: change the return to `Option<f64>` and have `PricerExt::calculate_*` propagate via `?`. Less invasive: return `f64::NAN` on missing data — consistent with `Greeks::default()` and `CalibrationResult::max_error` defaults already used in this crate.

## §3 P1 findings

### §3.1 `yahoo.rs` not production-ready

`stochastic-rs-quant/src/yahoo.rs`

- 14 `.unwrap()` on the public surface (lines 57, 88-92, 94, 96, 104, 114-116, 140, 157, 168, 172, 183, 188-189, 193-198, 210). Network/IO failures crash the process.
- Tests `test_yahoo_get_price_history`, `test_yahoo_get_options_chain`, `test_yahoo_get_returns` (lines 222-259) hit live AAPL on every `cargo test --features yahoo` run. CI flakes on rate limits, weekends, listing changes, network outages.
- `tokio_test::block_on` (lines 87, 111) is intended for unit-tests only; using it in production code is fragile (it spawns an ephemeral runtime per call and panics on any panicking future).
- `ReturnType::Absolute` (lines 169-183) computes `price[t] / price[t-1]` (gross return), which is neither absolute nor a return. Either rename to `GrossReturn` or fix to `price[t] - price[t-1]`.
- `Default` panics on `YahooConnector::new().unwrap()` (line 57); the connector constructor can fail on malformed HTTP client init.

Recommendation for v2.0: gate yahoo behind `feature = "yahoo"` (already done) **and** mark all public methods `Result<…, anyhow::Error>`-returning. Replace the live tests with a `#[cfg(feature = "yahoo-online")]` opt-in tier, or replace with offline fixture tests using a recorded `polars::DataFrame`. Consider deprecating the module entirely for 2.0 — Yahoo's free API is unstable and most users plug in their own data source.

### §3.2 Order book uses exact-equality fp comparison

`stochastic-rs-quant/src/order_book.rs:98, 137, 196, 255`

```rust
maker.size -= traded;
…
if maker.size == 0.0 { /* drop maker */ }
```

`size` and `maker.size` are both `f64`, both potentially carrying floating-point residue from earlier `traded = size.min(maker.size); maker.size -= traded`. In all common cases this works (one of the two is taken exactly), but a sequence of partial fills against the same maker can leave `maker.size = 1e-17` and the maker stays in the book forever. Use `maker.size <= EPSILON` or — better — track sizes as `u64` minor units (typical exchange convention).

### §3.3 Order book `expect("time went backwards")` on add_order

`stochastic-rs-quant/src/order_book.rs:165-168`

`SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards")`. Realistic in production environments with NTP step-back. Use `unwrap_or(Duration::ZERO)` or `match` and accept timestamp 0 (orders submitted during clock anomalies still get unique ids via `next_id`).

### §3.4 Factor module: `least_squares` and `svd` failures `expect()`-panic

- `factors/pca.rs:52-54` — `.svd(true, true).expect("SVD failed")` and `u_opt.expect`/`vt.expect` on a public function.
- `factors/fama_macbeth.rs:55,70` — `least_squares(&y).expect("first-pass OLS failed")` ×2.
- `factors/pairs.rs:91` — same `.expect("hedge ratio OLS failed")`.

These can fail on rank-deficient inputs (pure noise, collinear columns). The functions are public; users should get `Result`, not a panic.

### §3.5 Roll spread denominator off-by-one

`stochastic-rs-quant/src/microstructure/spread.rs:36-44`

`cov` is divided by `count` (= `diffs.len() - 1`), which is the textbook biased autocovariance estimator with a divisor of `n-1` rather than `n`. Both are defensible; the docstring says nothing about the choice. Minor: align with whatever convention the reference uses (Roll 1984 uses `n`, not `n-1`).

### §3.6 `Almgren-Chriss` documentation formula does not match implementation

`stochastic-rs-quant/src/microstructure.rs:8-9` parent module doc:

```text
\kappa = \sqrt{\lambda\sigma^2/\tilde\eta},  \tilde\eta = \eta - \gamma\tau/2.
```

`almgren_chriss.rs:13-15` correct formula:

```text
\kappa = \frac{1}{\tau} \mathrm{arccosh}(1 + \lambda\sigma^2\tau^2/(2\tilde\eta)).
```

The parent-module formula is the **continuous-time limit** of the discrete-time κ — they agree only as `τ → 0`. The implementation correctly uses the discrete `arccosh` form. Update the parent module summary to match, or label it explicitly as the continuous limit.

### §3.7 `Buy` direction in Almgren-Chriss only flips `rates`, not `inventory` / `trades`

`stochastic-rs-quant/src/microstructure/almgren_chriss.rs:192-196`

```rust
if matches!(params.direction, ExecutionDirection::Buy) {
  for k in 0..n {
    rates[k] = -rates[k];
  }
}
```

`inventory` and `trades` remain expressed as positive sells. Caller code that uses `plan.inventory` or `plan.trades` for a buy will see "sell-shaped" numbers. Either flip both, or document the sign convention prominently and rename `trades`/`inventory` to make the sell-frame explicit.

### §3.8 Migration matrix `power(0)` is undefined

`stochastic-rs-quant/src/credit/migration.rs:69-76`

`assert!(k >= 1, …)` panics on `k=0`, but logically `P^0 = I`. Either accept `k=0` (return identity) or document the convention.

### §3.9 `lib.rs` exposes a `pub mod` with no module-doc on first read

`stochastic-rs-quant/src/lib.rs:26-59` lists 23 public modules without sub-categorisation. Tools like `cargo doc` produce a flat module list. The audit-level commentary at lines 32-54 (PCA / fourier-malliavin / microstructure / order_book) is helpful — extending that pattern to other "standalone" modules (`portfolio`, `strategies`, `inflation`, `lattice`, `fx`) would make 2.0 docs more navigable.

### §3.10 `python.rs` is 63 kB single file

`stochastic-rs-quant/src/python.rs` (2617 lines). PyO3 bindings work but are unwieldy; refactor into `python/{pricing,calibration,vol_surface}.rs` as a 2.0.x follow-up. Not a blocker.

## §4 P2 findings

- `factors/shrinkage.rs:146` — `let _ = Axis(1);` is a stray no-op.
- `credit/bootstrap.rs:151` — `let _ = k;` is a stray no-op (the loop `enumerate()` could just be `for quote in &sorted`).
- `migration.rs:216` — `let _ = total_off;` stray no-op (the local is computed but unused).
- `loss.rs` — `mae`, `mre`, `mape`, `mspe`, `mpe` silently return `0.0` if `market_i ≈ 0` (line 38, 53, 75, 91). For zero-strike instruments this masks bugs. Consider returning NaN or filtering inputs upstream.
- `traits/instrument.rs:9` — doc-example uses `r.greeks().unwrap().delta` (fine in `///` rustdoc, but reads as encouraging `unwrap()` to consumers).
- `types.rs:118-120` — `CalibrationLossScore::get` returns `0.0` for missing metrics. Inconsistent with `f64::NAN` defaults elsewhere; switch to `Option<f64>` or `f64::NAN`.
- `microstructure/spread.rs:147-162` test `roll_recovers_known_spread_under_pure_bounce` uses `SimdNormal` with `mean=0.0, std=1.0` then takes `sign(z)`. Equivalent to a Bernoulli ±1 test — fine, but the variance of the estimator is large for n=10000 (tolerance is 0.03 on a 0.10 spread). On a different RNG seed the test could be flaky; pin the seed deterministically (already done at line 154 with seed=11).
- `factors/pca.rs:46` — `to_f64().unwrap()` on `T: FloatExt` values. `FloatExt` already has a `Default + ToPrimitive` bound; `unwrap()` is unreachable but noisy.
- `Greeks::default = Self::nan()` (pricing.rs:35-39): perfect, matches conventions. Worth promoting into project memory if not already.
- No `#[allow(dead_code)]` markers in scope. Clean.

## §5 What I did NOT cover and why

- **`pricing/`, `risk/`, `calibration/`, `vol_surface/`, `bonds/`, `lattice/`, `lattice/`, `instruments/`, `curves/`, `inflation/`, `cashflows/`, `calendar/`, `fx/`, `market/`, `strategies/`, `portfolio/`, `fourier_malliavin/`** — all out of scope per audit instructions (these are the pricing/risk/rates/strategies surface flagged for separate audits).
- **`python.rs` correctness of every PyO3 wrapper** (2617 lines, 100+ classes) — only verified feature gating and structural soundness (`#![cfg(feature = "python")]` at line 7 is correct, and `#[cfg(feature = "openblas")]` is consistently applied to openblas-dependent classes at 2320, 2471, 2477, 2514, 2520, 2553, 2559).
- **End-to-end build** — no `cargo check`/`cargo test` was run because the harness denied repeated `Bash` calls midway through; static review of `Cargo.toml` confirms `factors/` submodules are correctly gated through `factors.rs` (`#[cfg(feature = "openblas")]` on each `pub mod {fama_macbeth,pairs,pca}` line) so the unconditional `use ndarray_linalg::*` in those files is fine.
- **Macros** (`macros.rs`, 21 lines) — empty `py_*` shims that swallow input. Real codegen is in `stochastic-rs-py`. Trivially correct.
- **Order book latency / fairness invariants under concurrent access** — `OrderBook` is not `Send`/`Sync`; no concurrency model claimed. Out of scope.
- **CDS bootstrap convergence behaviour under non-monotone term structure** — relies on `roots::find_root_brent`, which falls back to the initial guess on bracket failure (line 145). Fine for monotone par-spread curves; non-monotone curves should be flagged at the API level. Not investigated further.
- **Migration matrix `expm` numerical stability** — Padé-13 + scaling-and-squaring is implemented from scratch (no LAPACK). Not benchmarked against `scipy.linalg.expm` here; recommend a regression test against a known matrix exponential.
- **Python file content correctness for non-pricing classes** (calibrators, vol-surface, factor/credit/microstructure exposure if any) — same as above, out of LOC budget.

## §6 Recommended next steps

1. **P0**: Re-derive the multi-period Kyle recursion in `microstructure/kyle.rs:110-118` against Kyle 1985 §3, or against the standard Foster-Viswanathan 1993 / Vives 2010 §4.4 restatement, and add a 2-period analytic-benchmark test. Keep the single-period implementation untouched.
2. **P0**: Replace the two `panic!` arms in `traits/time.rs:14-37` with `f64::NAN` (consistent with the rest of the crate's "missing data" convention) and update `PricerExt` callsites to short-circuit on NaN.
3. **P1**: Decide the fate of `yahoo.rs` for v2.0 stable. Options:
   - Mark `feature = "yahoo"` as experimental in the README, replace `unwrap()` with `Result`, gate live tests behind `feature = "yahoo-online"`, fix the `Absolute` variant naming/formula.
   - Remove the module from v2.0 stable and re-introduce it as a `stochastic-rs-yahoo` micro-crate in a v2.x point release.
4. **P1**: In `factors/{pca,fama_macbeth,pairs}.rs`, replace `.expect()` on `least_squares` / `svd` with `Result<…, FactorsError>` returns. Public surface; users with rank-deficient data deserve graceful failures.
5. **P1**: Fix the order-book exact-equality FP comparison (`maker.size == 0.0` → `<= EPSILON`) and the `expect("time went backwards")` time-step.
6. **P2**: Sweep the three stray `let _ = …` no-ops, and align `loss.rs` zero-handling with `f64::NAN` (or document the `0.0` choice).
7. **Doc-only**: Update the parent-module formula in `microstructure.rs:8-9` to match the discrete-time κ used in the implementation, or label it as the continuous limit.
8. **Doc-only**: Document the sign convention used by `AlmgrenChrissPlan::{inventory, trades}` for `ExecutionDirection::Buy`, or flip them along with `rates`.
9. **Test gap**: Add a regression test for `migration.rs::expm` against a known scalar-matrix exponential (`expm(diag(λ_i)) = diag(exp(λ_i))`) and a 2×2 nilpotent case.
10. **Refactor (post-2.0)**: Split `python.rs` into per-domain submodules; the file is unwieldy at 63 kB.

End of audit.
