Task: Short-rate stochastic process (Hull-White/CIR/Vasicek)
This page documents a task in the IR Rates synthetic data generation story. It captures the goal, current status, acceptance, and any notes or results.
Goal
Add mean-reverting short-rate stochastic processes implementing
IYieldCurveProcess
(projects/ores.analytics.quant/include/ores.analytics.quant/domain/i_yield_curve_process.hpp,
a new interface extending IStochasticProcess with a
discount_factor() method), wired into
process_factory::make_yield_curve_process()
(projects/ores.analytics.quant/src/service/process_factory.cpp).
Same shape as ou_process: pure closed-form math, no QuantLib.
Revised from the original goal in two ways, both decided with the
user mid-task: (1) location moved from ores.synthetic to
ores.analytics.quant, since that component was created (2026-07-11,
after this task was originally written) specifically to hold
dependency-light quant math; (2) scope widened from "pick one of the
three" to implementing all three, once analysis showed Hull-White
generalises Vasicek (constant-theta special case) rather than the
reverse, so Vasicek could be composed on top of a general Hull-White
engine instead of being separately-derived duplicate code. CIR
remains a genuinely distinct third engine.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | IR Rates synthetic data generation |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-12 |
Acceptance
- New process classes implement
IYieldCurveProcess, selectable viaprocess_factory::make_yield_curve_process():vasicek,cir,hull_white. - No QuantLib dependency introduced anywhere in
projects/ores.analytics.quant/. - Parameters (kappa, theta_path, sigma, initial_rate) are validated
via the new
ores.analytics.quant::domain::validate_yield_curve_process_parameters(a second function alongside the existingvalidate_process_parameters, not an overload onto its incompatible means/stdevs/weights array shape — see Notes). - Each process exposes the model's closed-form affine zero-coupon
bond price via
discount_factor(ticks_ahead), evaluated from the current short-rate state — not just a path ofr_tsamples. This is the "latent curve": every instrument quote the Curve Template config task derives (deposit, FRA, swap, at whatever tenor) must come from evaluating this one formula at the current state, so the whole tick batch is guaranteed, by construction, to be a slice of one internally consistent curve rather than independently-noised, potentially inconsistent quotes. See the parent story's "Analysis" section.
Plan
(Implementation strategy. Written when work starts; key decisions
are distilled into the parent story's * Decisions at close, but the
plan itself stays — it is the historical record of what we did.)
- New
IYieldCurveProcessinterface (IStochasticProcess+discount_factor(ticks_ahead)), inores.analytics.quant::domain. hull_white_process: general 1-factor Gaussian short-rate engine,dr = kappa*(theta(t)-r)*dt + sigma*dW, written in the same "target level" form asou_process(not Hull & White's original drift-intercept notation) specifically so the degeneratekappa <0= case and the constant-theta (Vasicek) case reduce to exactlyou_process's formula, not an approximation of it.theta(t)is a caller-supplied, piecewise-constant-per-ticktheta_pathvector (held flat once it runs out) — fitting it to a real market curve is out of scope (needs bootstrapping) and left for future work; the engine itself doesn't need that fitting step to be usable.discount_factor()uses an exact backward recursion (Brigo & Mercurio's discrete-time affine-model derivation) over the same one-tick transition lawnext()simulates, rather than a separately-derived continuous-time integral — guarantees the two are mutually consistent by construction.vasicek_process: literal composition, not reimplementation — wraps ahull_white_processconstructed with a single-elementtheta_path. Tests assert bit-identical simulated paths and identicaldiscount_factor()output against the equivalenthull_white_processdirectly, pinning the composition relationship as a tested invariant, not just a design intention.cir_process: independent engine (dr = kappa*(theta-r)*dt + sigma*sqrt(r)*dW), since CIR's square-root diffusion is genuinely different from the Gaussian OU/Vasicek/Hull-White family, not a special case of it. Exact simulation via the standard Poisson-mixture-of-central-chi-squared construction (Glasserman- — no Euler-discretisation approximation — using
std::poisson_distribution+std::chi_squared_distributiononly (no new dependency).sigma =0= is a separate deterministic-ODE branch: the general stochastic formulas and the closed-form bond price both have asigma-in-the-denominator singularity atsigma =0=, so this isn't an optimisation, it's required for correctness at that boundary.- Comprehensive Catch2 suites per class (deterministic-seed, edge
cases, statistical mean-reversion checks, closed-form cross-checks
against independently-recomputed oracle formulas), matching
ou_process_tests.cpp's existing conventions.
Notes
- Component location: this task predates
ores.analytics.quant(created 2026-07-11) and originally targetedores.synthetic/service/src/processes/viaIStochasticProcessinores.marketdata.api. Both of those paths are now stale — a freshgit fetchmid-task showedIStochasticProcess,process_factory, and every existing process implementation had already moved wholesale intoores.analytics.quantonmainbefore this task started. Followed that existing move rather than re-litigating it. - Extended
process_parameter_validation.hpp/.cppwith a second function,validate_yield_curve_process_parameters, instead of overloading the existingvalidate_process_parameters(means,stdevs,weights,initial_price)signature — atheta_pathvector pluskappa=/=sigma=/ =initial_ratedoesn't fit that array shape without forcing an awkward, misleading repurposing (unlike how "ou" already repurposesweights.front()=/=stdevs.front(), which is a defensible stretch for 2 scalars but not for a whole path vector). process_factory::make_yield_curve_process()throws on an unrecognisedprocess_typerather than silently falling back to a default engine (unlikemake_process()'s geometric fallback) — a caller asking for a yield-curve process by an unrecognised name is almost certainly a bug, not a case with a reasonable default.- Real bug found and fixed during testing:
std::poisson_distributionassertsmean > 0.0in libstdc++, which a zero short rate (r_t =0=, a value CIR must be able to reach and simulate from) triggers vialambda =0=. Guarded by skipping the Poisson draw entirely whenlambda =0= (deterministicallyN = 0in that case) rather than constructing a zero-mean distribution. - Not PR-sized on its own: this is a pure math-library addition with
no consumer yet (the Curve Template config task, still BACKLOG,
is what will actually call
make_yield_curve_process()). Fileddocument-stochastic-processesas a new BACKLOG task on this story for the knowledge-doc side (hub + per-process pages, Zettelkasten style) this work also surfaced a need for. Also captured a GPU-batched-simulation performance concern in the backlog inbox (doc/agile/product_backlog/inbox/gpu_batched_stochastic_processes.org) once instance counts grow beyond what sequential CPU generation can keep up with.
PRs
| PR | Title |
|---|---|
| #1555 | [analytics.quant] Add Vasicek/CIR/Hull-White short-rate processes |
Review
PR #1555 review round 1 (Claude automated, issue-level comment, no line comments):
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | cir_process::discount_factor overflows to NaN for large ticks_ahead (std::exp(gamma*tau) overflows once gamma*tau exceeds ~709) |
cir_process.cpp | Fixed | Rewrote B(tau)/A(tau) in the numerically-stable form dividing by e^{gamma*tau} up front, so every exponent stays non-positive; added a regression test with ticks_ahead up to 10000 asserting a finite result in (0,1] |
| 2 | Misleading test name/comment in hull_white_process_tests.cpp (claims a Vasicek cross-check but only asserts positivity) |
hull_white_process_tests.cpp | Declined | Non-blocking per the reviewer's own note; the real cross-check exists in vasicek_process_tests.cpp, not a coverage gap |
| 3 | CI site job failing: literal [[id:...]] placeholder link in prose breaks org-roam link resolution |
task_document-stochastic-processes.org | Fixed | Escaped as verbatim [[id:...]] text instead of a real link (this was a pre-existing bug in the doc carried over from the stale branch, unrelated to the reviewer's own findings, caught by CI's site-build check) |
Result
Added IYieldCurveProcess (IStochasticProcess + discount_factor())
and three engines implementing it: hull_white_process (general
1-factor Gaussian short-rate, exact one-step transition + backward
recursion for the bond price), vasicek_process (composes
hull_white_process with a single-element theta_path, tested for
bit-identical output against the equivalent Hull-White instance —
pinning the composition as a tested invariant), and cir_process
(independent square-root-diffusion engine, exact
Poisson-mixture-of-chi-squared simulation per Glasserman, with a
dedicated sigma = 0= deterministic-ODE branch since the general
formulas have a sigma-denominator singularity there). All three
selectable via process_factory::make_yield_curve_process(), which
throws on an unrecognised process_type (unlike make_process()'s
geometric fallback) since a caller asking for a yield-curve process
by an unrecognised name is almost certainly a bug. Parameters validated
via a new validate_yield_curve_process_parameters (kept separate
from the existing validate_process_parameters rather than overloaded
onto its incompatible means/stdevs/weights shape).
Comprehensive Catch2 suites per class, matching ou_process_tests.cpp's
conventions: deterministic-seed reproducibility, edge cases (kappa <= 0
degenerate walk, sigma == 0 ODE limit), statistical mean-reversion
checks, and closed-form cross-checks against independently-recomputed
oracle formulas.
Filed two follow-ups this work surfaced: document-stochastic-processes
(BACKLOG task on this story — knowledge hub + per-process pages) and a
GPU-batched-simulation performance capture in the backlog inbox.
Not wired into any consumer yet — the Curve Template config task
(ir_curve_generation_config, still BACKLOG) is what will actually call
make_yield_curve_process(); this task is the pure math-library
addition underneath it.