ワンクリックで
add-diffusion-process
// How to add a new diffusion / SDE process to stochastic-rs-stochastic. Invoke when implementing GBM-like, OU-like, Vasicek-like, CIR-like, Heston-like models that satisfy `dX_t = drift dt + diffusion dW_t`.
// How to add a new diffusion / SDE process to stochastic-rs-stochastic. Invoke when implementing GBM-like, OU-like, Vasicek-like, CIR-like, Heston-like models that satisfy `dX_t = drift dt + diffusion dW_t`.
| name | add-diffusion-process |
| description | How to add a new diffusion / SDE process to stochastic-rs-stochastic. Invoke when implementing GBM-like, OU-like, Vasicek-like, CIR-like, Heston-like models that satisfy `dX_t = drift dt + diffusion dW_t`. |
This SKILL covers the recipe for adding a new diffusion process
(stochastic-rs-stochastic/src/diffusion/<name>.rs). The recipe
applies equally to volatility / interest-rate / credit-style
diffusions; for fractional processes (driven by fBm or a Volterra
kernel) see add-fractional-process. For jump processes see
add-jump-process.
A new process must implement ProcessExt<T> for the standard sample
shape:
// stochastic-rs-stochastic/src/traits.rs
pub trait ProcessExt<T: FloatExt> {
type Output;
fn sample(&self) -> Self::Output;
// Optional overrides (defaults are: call `sample` and unwrap)
fn sample_par(&self, _m: usize) -> Vec<Self::Output> { ... }
fn sample_pair(&self) -> [Self::Output; 2] { ... }
fn sample_pair_par(&self, _m: usize) -> Vec<[Self::Output; 2]> { ... }
}
For the typical 1-D path output Array1<T>, defaults are fine. For
2-D / multi-asset output (e.g. Heston returning [Array1<T>; 2] for
price + vol), you set type Output = [Array1<T>; 2] and provide the
joint sampler in sample().
Every diffusion struct ships both an unseeded and a seeded
constructor with the same parameter list, plus an explicit t total
horizon:
// stochastic-rs-stochastic/src/diffusion/foo.rs
use ndarray::Array1;
use stochastic_rs_core::simd_rng::Deterministic;
use stochastic_rs_core::simd_rng::SeedExt;
use stochastic_rs_core::simd_rng::Unseeded;
use crate::traits::FloatExt;
use crate::traits::ProcessExt;
pub struct Foo<T: FloatExt, S: SeedExt = Unseeded> {
/// Mean-reversion speed.
pub theta: T,
/// Long-run mean.
pub mu: T,
/// Diffusion / noise scale.
pub sigma: T,
/// Number of discretisation points (≥ 2).
pub n: usize,
/// Initial value (defaults to zero / 1.0 — match the SDE convention).
pub x0: Option<T>,
/// Total horizon; defaults to 1.0.
pub t: Option<T>,
/// Seed strategy (compile-time: Unseeded or Deterministic).
pub seed: S,
}
impl<T: FloatExt> Foo<T> {
#[must_use]
pub fn new(theta: T, mu: T, sigma: T, n: usize, x0: Option<T>, t: Option<T>) -> Self {
assert!(n >= 2, "n must be at least 2");
Self {
theta, mu, sigma, n, x0, t, seed: Unseeded,
}
}
}
impl<T: FloatExt> Foo<T, Deterministic> {
#[must_use]
pub fn seeded(
theta: T, mu: T, sigma: T, n: usize, x0: Option<T>, t: Option<T>, seed: u64,
) -> Self {
assert!(n >= 2, "n must be at least 2");
Self {
theta, mu, sigma, n, x0, t, seed: Deterministic::new(seed),
}
}
}
The phantom seed: S field is the compile-time switch between the two.
Both constructors validate n >= 2 upfront — the rc.2 Fukasawa-Hurst
fix taught us to thread seeded constructors through every test that
samples a process.
theta vs muMandatory: the workspace uses
theta: mean-reversion speed (κ in many texts, e.g. Brigo).mu: long-run mean level (θ in many texts).The rc.0 CIR bug shipped because Vasicek and CIR had theta/mu
swapped between source and tests. Every new diffusion that has a
mean-reversion-speed × long-run-mean structure (dX = θ(μ-X)dt + ...)
must keep this convention. If the canonical paper uses different
symbols, document the translation in the struct's doc comment but use
our names in the field.
For Euler-Maruyama discretisation:
impl<T: FloatExt, S: SeedExt> ProcessExt<T> for Foo<T, S> {
type Output = Array1<T>;
fn sample(&self) -> Self::Output {
let t = self.t.unwrap_or(T::one());
let dt = t / T::from_usize_(self.n - 1);
let mut rng = self.seed.derive(); // <-- rng from the seed strategy
let mut path = Array1::<T>::zeros(self.n);
path[0] = self.x0.unwrap_or(T::zero());
for i in 1..self.n {
let z = rng.sample::<f64, StandardNormal>();
let z = T::from_f64_fast(z);
let drift = self.theta * (self.mu - path[i - 1]);
let diffusion = self.sigma;
path[i] = path[i - 1] + drift * dt + diffusion * dt.sqrt() * z;
}
path
}
}
Use T::from_f64_fast (not T::from) for compile-time-known constant
conversions — it's the workspace convention for the FloatExt
boundary.
For higher-order schemes (Milstein, SRK2, SRK4), see crate::sde::*
helpers — there's a generic milstein_step, srk2_step etc. that take
drift/diffusion closures.
After the inherent + ProcessExt impls, append the Python wrapper macro at the bottom of the source file:
py_process_1d!(PyFoo, Foo,
sig: (theta, mu, sigma, n, x0 = 0.0, t = 1.0, m = None, seed = None),
params: (theta: f64, mu: f64, sigma: f64, n: usize, x0: f64, t: f64, m: Option<usize>),
);
The macro generates:
#[pyclass(unsendable)] PyFoo with __new__ accepting the listed
signaturesample(n) returning a numpy array (via the IntoF64 shim from
stochastic-rs-core::python)sample_par(m, n) returning a 2-D numpy array (parallel paths)For 2-D output (e.g. Heston), use py_process_2x1d! instead. For
multi-asset correlated (returning Array2<T>), use py_process_2d!.
After the macro, remember to register the class in
stochastic-rs-py/src/lib.rs:
use stochastic_rs_stochastic::diffusion::foo::PyFoo;
// ...
m.add_class::<PyFoo>()?;
If you rename an existing process (e.g. for the mean-reversion-speed
convention fix), add an alias in
stochastic-rs-stochastic/src/diffusion/aliases.rs:
#[deprecated(since = "X.Y.Z", note = "renamed to Foo; use Foo::new instead")]
pub use super::foo::Foo as OldFoo;
The alias keeps the old name compiling for one release cycle. Drop it in the next major.
A new diffusion ships with at least four tests:
#[cfg(test)]
mod tests {
use super::*;
/// 1. Seeded determinism.
#[test]
fn seeded_is_deterministic() {
let p1 = Foo::seeded(0.5, 0.0, 0.1, 100, None, None, 42).sample();
let p2 = Foo::seeded(0.5, 0.0, 0.1, 100, None, None, 42).sample();
for i in 0..100 {
assert_eq!(p1[i], p2[i]);
}
}
/// 2. Pure drift (sigma = 0) collapses to deterministic ODE.
#[test]
fn zero_diffusion_matches_deterministic() {
// dX/dt = theta * (mu - X) → X(t) = mu + (X0 - mu) * exp(-theta * t)
// ...
}
/// 3. Theoretical moment recovery on long path.
#[test]
fn long_path_mean_matches_theory() {
// ...
}
/// 4. Constructor validates n >= 2.
#[test]
#[should_panic(expected = "n must be at least 2")]
fn rejects_n_below_two() {
let _ = Foo::<f64>::new(0.5, 0.0, 0.1, 1, None, None);
}
}
The first test (seeded determinism) is non-negotiable; without it calibrators that consume the process get nondeterministic regression tests downstream.
Per CLAUDE.md, the prelude does NOT include individual process
types — users go through stochastic_rs::stochastic::diffusion::foo::Foo.
But the umbrella crate's "Workspace layout" section may mention notable
new processes (e.g. "120+ processes, incl. interest::lmm::Lmm"). Update
that line if your new process is material.
thread_rng() directly in sample(). Always go
through self.seed.derive() so Foo::seeded(...) produces
reproducible paths.kappa / theta (Brigo convention). The
workspace uses theta / mu. Sticking to local conventions when the
surrounding code uses ours produces silent numeric bugs (rc.0 CIR).seeded constructor. The user
will need it for testing eventually; adding it later breaks the API.debug_assert!. assert!(n >= 2)
is a permanent invariant; debug_assert hides it from release builds
and lets users hit cryptic out-of-bounds panics in path[0].Bm (process/bm.rs) — single-line Brownian motion, no parameters
besides n.Gbm (diffusion/gbm.rs) — geometric BM, two parameters.Vasicek (diffusion/vasicek.rs) — mean-reverting OU; the theta
/ mu reference.Cir (diffusion/cir.rs) — CIR with reflection at 0; rc.0 fixed
the field-naming convention.Fou (diffusion/fou.rs) — fractional OU; goes through
add-fractional-process once you wrap Fgn.Heston (volatility/heston.rs) — 2-D output ([price, vol]); uses
py_process_2x1d!.add-fractional-process — for Hurst-parameterised processes wrapping
Fgn or extending MarkovLift.add-jump-process — for compound-Poisson / Lévy-driven additions.python-bindings — invoked by py_process_*! and the registration
step.feature-flag-management — if your process needs an optional GPU
backend or LAPACK helper.Development rules for stochastic-rs — enforces project conventions when writing new modules, adding dependencies, or implementing algorithms
Conventions for writing and maintaining stochastic-rs documentation pages under website/content/docs/. Eight page templates (process / distribution / copula / pricer / calibrator / estimator / AI surrogate / concept), frontmatter schema, KaTeX gotchas, meta.json sidebar wiring, doctest-backed examples, and the audit-script contract. Invoke whenever a new public type ships and needs a docs page, or when fixing rot in existing pages.
How to add a Hurst-parameterised (rough / fractional) process to stochastic-rs-stochastic. Invoke when wrapping fBm / fGn / Volterra-kernel processes — fOU, rBergomi, rough Heston, fBates, fractional CIR, etc.
How to add a GPU-accelerated sampler (CUDA / Metal) for a process or distribution. Invoke when porting a CPU sampler to GPU for the long-horizon / many-paths regime where CPU SIMD saturates.
How to add a jump-diffusion / Lévy / compound-Poisson process to stochastic-rs-stochastic. Invoke for Merton-jump, Kou-jump, Bates-style models, or for layering jumps onto an existing diffusion (GBM → MJD, Heston → Bates).
How to add a Monte Carlo variance-reduction technique to stochastic-rs-quant. Covers antithetic, control-variate, stratified, importance, quasi-MC (Halton/Sobol), and MLMC. Returns McEstimate<T> with 95% CI.