Skip to content

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

call - put = spot - strike * Dr / Dq

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.

API reference