GJR-GARCH model and European pricing¶
The shared Rust core provides a six-parameter calibrated model, a European Edgeworth approximation and a European Monte Carlo diffusion engine through Rust, C, Go and Python. See process and paths for daily/annual units and the three variance schemes. Neither engine is a historical estimator or an exact discrete daily GJR-GARCH recursion.
Model contract¶
Parameters, calibration masks and returned arrays use this order:
| Position | Rust / Go | Python | Unit and checked model domain |
|---|---|---|---|
| 0 | omega / Omega |
omega |
Daily intercept, strictly positive |
| 1 | alpha / Alpha |
alpha |
Daily coefficient in [0, 1] |
| 2 | beta / Beta |
beta |
Daily coefficient in [0, 1] |
| 3 | gamma / Gamma |
gamma |
Daily coefficient in [-1, 1] |
| 4 | lambda / Lambda |
lambda_ |
Finite innovation displacement |
| 5 | v0 / DailyVariance |
daily_variance |
Initial daily variance, strictly positive |
All inputs must be finite. Coupled checks require beta + gamma >= 0 and the
supported process domain, including alpha + gamma >= 0, representable annual
coefficients and nonnegative residual diffusion variance. These are checked
for construction, parameter updates and calibration trials. No stationarity
restriction is imposed. The process alone supports zero omega and variance;
the calibrated model does not.
- Construction and accepted updates rebuild the model process using
FullTruncation, even if the supplied process uses another scheme. - The analytic engine retains the live model. Parameter changes and live spot/curve notifications invalidate attached option prices.
model.process()returns the current process snapshot. Existing MC engines retain their supplied process and scheme: they do not retarget when model parameters change. Their retained market handles still remain live.- Rebuild an MC engine from the current model process to sample newly fitted
parameters. Model-derived processes use
FullTruncation.
Example: approximation, sampled price and live quote¶
These examples use the same market, one-year call, daily parameters and seed.
The analytic and MC numbers need not agree within the MC standard error.
After attaching MC, the last npv call reprices that MC engine.
from itofin import Settings
from itofin.instruments import OptionType, VanillaOption
from itofin.models import GJRGARCHModel
from itofin.pricingengines import AnalyticGJRGARCHEngine, MCEuropeanGJRGARCHEngine
from itofin.processes import GJRGARCHProcess
from itofin.quotes import SimpleQuote
from itofin.termstructures import FlatForward
from itofin.time import Date, DayCounter
today = Date(3, 10, 2026)
settings = Settings()
settings.set_evaluation_date(today)
dc = DayCounter.actual365_fixed()
spot = SimpleQuote(100.0)
risk = FlatForward(today, 0.05, dc)
dividend = FlatForward(today, 0.02, dc)
process = GJRGARCHProcess(
spot, risk, dividend, 0.00016, 0.000002, 0.04, 0.9, 0.06, 0.1,
days_per_year=252.0, scheme="FullTruncation",
)
model = GJRGARCHModel(process)
option = VanillaOption(OptionType.Call, 100.0, today + 365, settings)
analytic = AnalyticGJRGARCHEngine(model)
print("Analytic GJR NPV:", option.price_gjr(analytic))
mc = MCEuropeanGJRGARCHEngine(
process, steps_per_year=252, samples=4096, seed=42, antithetic=True,
)
print("Monte Carlo GJR NPV:", option.price_mc_gjr(mc))
print("Monte Carlo standard error:", option.error_estimate())
print("Daily omega,alpha,beta,gamma,lambda,v0:", model.params())
spot.set_value(105.0)
print("After live spot update:", option.npv())
package main
import (
"errors"
"fmt"
"log"
itofin "github.com/benbenbang/libitofin/sdk/go"
)
func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() (err error) {
s, err := itofin.NewSession()
if err != nil {
return err
}
defer func() { err = errors.Join(err, s.Close()) }()
today := must(itofin.NewDate(3, 10, 2026))
expiry := must(today.AddDays(365))
settings := must(s.NewSettings())
if err = settings.SetEvaluationDate(today); err != nil {
return err
}
dc := must(s.Actual365Fixed())
spot := must(s.NewSimpleQuote(100))
risk := must(s.NewFlatForward(today, .05, dc))
dividend := must(s.NewFlatForward(today, .02, dc))
params := itofin.GJRParameters{DailyVariance: .00016, Omega: .000002, Alpha: .04, Beta: .9, Gamma: .06, Lambda: .1, DaysPerYear: 252}
process := must(s.NewGJRProcess(spot, risk, dividend, params, itofin.GJRFullTruncation))
model := must(s.NewGJRModel(process))
analytic := must(s.NewAnalyticGJREngine(model))
option := must(s.NewVanillaOption(itofin.Call, 100, expiry, settings))
fmt.Println("Analytic GJR NPV:", must(option.PriceAnalyticGJR(analytic)))
mc := must(s.NewMCGJREngine(process, itofin.GJRMCConfig{StepsPerYear: 252, Samples: 4096, Seed: 42, Antithetic: true}))
fmt.Println("Monte Carlo GJR NPV:", must(option.PriceMCGJR(mc)))
fmt.Println("Monte Carlo standard error:", must(option.ErrorEstimate()))
fmt.Println("Daily omega,alpha,beta,gamma,lambda,v0:", must(model.Params()))
if err = spot.SetValue(105); err != nil {
return err
}
fmt.Println("After live spot update:", must(option.NPV()))
return nil
}
//! Price a European GJR-GARCH option and observe a live spot update.
use libitofin::errors::QlResult;
use libitofin::exercise::EuropeanExercise;
use libitofin::handle::Handle;
use libitofin::instrument::Instrument;
use libitofin::instruments::{PlainVanillaPayoff, VanillaOption};
use libitofin::interestrate::Compounding;
use libitofin::math::randomnumbers::rngtraits::PseudoRandom;
use libitofin::models::{CalibratedModelHolder, GjrGarchModel};
use libitofin::option::OptionType;
use libitofin::pricingengines::vanilla::{AnalyticGjrGarchEngine, MakeMcEuropeanGjrGarchEngine};
use libitofin::processes::{GjrGarchDiscretization, GjrGarchParameters, GjrGarchProcess};
use libitofin::quotes::{Quote, SimpleQuote};
use libitofin::settings::Settings;
use libitofin::shared::{Shared, SharedMut, shared, shared_mut};
use libitofin::termstructures::yields::FlatForward;
use libitofin::termstructures::yieldtermstructure::YieldTermStructure;
use libitofin::time::date::{Date, Month};
use libitofin::time::daycounters::actual365fixed::Actual365Fixed;
use libitofin::time::frequency::Frequency;
fn main() -> QlResult<()> {
let today = Date::new(3, Month::October, 2026);
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
let spot = shared(SimpleQuote::new(100.0));
let curve = |rate| {
Handle::new(shared(FlatForward::with_rate(
today,
rate,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>)
};
let process = shared(GjrGarchProcess::new(
curve(0.05),
curve(0.02),
Handle::new(Shared::clone(&spot) as Shared<dyn Quote>),
GjrGarchParameters {
v0: 0.00016,
omega: 0.000002,
alpha: 0.04,
beta: 0.9,
gamma: 0.06,
lambda: 0.1,
days_per_year: 252.0,
},
GjrGarchDiscretization::FullTruncation,
)?);
let model = GjrGarchModel::new(Shared::clone(&process))?;
let mut option = VanillaOption::new(
shared(PlainVanillaPayoff::new(OptionType::Call, 100.0)),
shared(EuropeanExercise::new(today + 365)),
settings,
);
option
.base_mut()
.set_pricing_engine(shared_mut(AnalyticGjrGarchEngine::new(SharedMut::clone(
&model,
))));
println!("Analytic GJR NPV: {}", option.npv()?);
let mc = MakeMcEuropeanGjrGarchEngine::<PseudoRandom>::new(process)
.with_steps_per_year(252)
.with_samples(4096)
.with_seed(42)
.with_antithetic_variate(true)
.build()?;
option.base_mut().set_pricing_engine(shared_mut(mc));
println!("Monte Carlo GJR NPV: {}", option.npv()?);
println!("Monte Carlo standard error: {}", option.error_estimate()?);
println!(
"Daily omega,alpha,beta,gamma,lambda,v0: {:?}",
model.borrow().calibrated_model().params()
);
spot.set_value(105.0);
println!("After live spot update: {}", option.npv()?);
Ok(())
}
Analytic approximation: source parity, not an exact oracle¶
The engine ports the pinned QuantLib Edgeworth moment expansion. It supports European plain-vanilla NPV only. Unsupported exercise/payoff types and requests for missing Greeks fail explicitly.
Preserved nonstandard dividend convention: with maturity discounts Dr
(risk-free) and Dq (dividend), the source uses
The call is not multiplied by Dq. This is not conventional discounted
put-call parity. Finite negative approximation values or arbitrage-bound
violations are not clamped or silently replaced by another pricer.
| Analytic guard | Behavior |
|---|---|
| Spot, strike, discounts | Finite and strictly positive |
| Maturity | Finite positive process time |
Daily horizon T |
round(days_per_year * time) in 1..=1000 |
| Moment poles | Reject coefficient differences within 64 * f64::EPSILON * max(1, abs(a), abs(b)) |
| Moments/output | Reject zero expected variance, nonpositive computed variance or nonfinite results |
| Work/storage | Direct cubic moment summation, linear temporary storage |
The moment cache includes all six parameters, rounded horizon and daily carry rate. Spot and discounts are read live. This fixes the pinned source's stale carry-rate cache behavior: rate/dividend bumps match fresh-engine calculations, not its stale cached result.
Monte Carlo configuration and uncertainty¶
MC discounts the terminal plain-vanilla payoff under the supplied diffusion
process. It preserves PartialTruncation, FullTruncation or Reflection.
European calls/puts are supported, including zero strike. Greeks are unavailable.
| Configuration | Contract |
|---|---|
| Grid | Exactly one of fixed steps or steps_per_year |
| Sampling | Exactly one of fixed samples or positive finite absolute tolerance |
| Fixed pseudo-random samples | At least two observations |
| Pseudo-random seed | Nonzero uint32 seed is reproducible; zero means randomized seed |
| Antithetic | One observation averages a path and its negated-draw partner |
| Standard error | Sampling error of observations/pair averages, not model or discretization error |
| Tolerance cap | Defaults to 50,000 observations; exhaustion returns an error |
| Limits | 100,000 steps, 1,000,000 observations, 100,000,000 factor-step evaluations |
Work is steps * 2 factors * observations, doubled for antithetic partners.
The requested maximum sample budget is included in the work check. Raising
max_samples cannot bypass the step, observation or work ceilings. Tolerance
mode requires a cap at least as large as its initial batch of 1,023 observations.
These errors do not return a successful partial price.
Rust also supports the low-discrepancy RNG policy with fixed samples only, without a standard-error estimate. Python/Go expose pseudo-random MC. Brownian bridge and control variates are not exposed by these GJR engines. Pseudo-random MC seed zero follows the existing randomized-seed convention; the separate stateless batch-path API rejects seed zero instead.
Keep approximation bias separate from sampling error¶
For the native matrix case lambda=0.2, 180-day maturity and strike 50:
| Quantity | Native value |
|---|---|
| Analytic approximation | 4.54912735976 |
| Diffusion MC estimate | 5.60851935058 |
| Difference / MC standard error | About 53.13 |
This is approximation bias, not evidence of agreement within sampling error.
The original 36-case cached-value tests retain their separate absolute 0.15
acceptance bands unchanged. Independent native price/error fixtures are checked
separately. The constant-variance diffusion limit is independently checked
against Black pricing within four MC standard errors; that limit does not
make the general analytic approximation exact.
Calibration and rollback¶
Calibration reuses HestonModelHelper Black-volatility quotes with the analytic
GJR engine, the existing optimizers, optional nonnegative weights and a
six-position fixed-parameter mask. Optional constraints are intersected with
the checked model constraint, not substituted for it. A true mask position
holds that parameter fixed.
- Failed optimization or final helper evaluation restores the exact previous six parameters and retained process snapshot, plus the previous end-criteria status, residual vector and function-evaluation count.
- Rollback does not reconstruct the saved process from current market inputs. It therefore works even if a quote/curve becomes invalid during fitting.
- Market values are not rolled back. Pricing still reports the invalid market until it is repaired, and observers are notified after model restoration.
The original 21-helper DAX calibration retains its nonflat risk-free curve and
percent-volatility SSE acceptance criterion <= 15. The checked Rust fit
reproduces native SSE about 7.8522 with 604 function evaluations. These are
fixture results, not a guarantee for another calibration dataset.
Python/Go HestonModelHelper constructors currently accept flat-rate inputs,
not arbitrary curve handles. The Python DAX acceptance test uses a maturity-specific
effective-rate adapter, rate = -log(discount_at_maturity) / time, to reproduce
the nonflat curve's discount for each helper. The GJR process/engine itself
retains the actual nonflat curve. Go checks independent flat-helper fits, not
the full nonflat DAX case. This adapter is not a general curve-helper API.