diff --git a/ql_notebooks/gjrgarchmodel.ipynb b/ql_notebooks/gjrgarchmodel.ipynb
new file mode 100644
index 0000000..99a4a3b
--- /dev/null
+++ b/ql_notebooks/gjrgarchmodel.ipynb
@@ -0,0 +1,312 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "view-in-github",
+ "colab_type": "text"
+ },
+ "source": [
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "source": [
+ "!pip install QuantLib-Python"
+ ],
+ "metadata": {
+ "id": "FF0CFP5vvEOB",
+ "outputId": "d7a26383-1ab5-4c70-ee82-e76a2e762d4c",
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ }
+ },
+ "execution_count": null,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "name": "stdout",
+ "text": [
+ "Collecting QuantLib-Python\n",
+ " Downloading QuantLib_Python-1.18-py2.py3-none-any.whl.metadata (1.0 kB)\n",
+ "Collecting QuantLib (from QuantLib-Python)\n",
+ " Downloading quantlib-1.38-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (1.1 kB)\n",
+ "Downloading QuantLib_Python-1.18-py2.py3-none-any.whl (1.4 kB)\n",
+ "Downloading quantlib-1.38-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (20.0 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m20.0/20.0 MB\u001b[0m \u001b[31m25.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hInstalling collected packages: QuantLib, QuantLib-Python\n",
+ "Successfully installed QuantLib-1.38 QuantLib-Python-1.18\n"
+ ]
+ }
+ ]
+ },
+ {
+ "cell_type": "code",
+ "source": [
+ "import QuantLib as ql\n",
+ "import unittest\n",
+ "import math\n",
+ "\n",
+ "# Helper to create a flat yield term structure\n",
+ "def flat_rate(today, forward_rate, day_counter):\n",
+ " return ql.FlatForward(today, ql.QuoteHandle(ql.SimpleQuote(forward_rate)), day_counter)\n",
+ "\n",
+ "class GJRGARCHModelTests(unittest.TestCase):\n",
+ "\n",
+ " @unittest.skipIf(False, \"Test marked as Slow in C++\") # To run, set to False\n",
+ " def test_engines(self):\n",
+ " print(\"Testing Monte Carlo GJR-GARCH engine against analytic GJR-GARCH engine...\")\n",
+ "\n",
+ " day_counter = ql.ActualActual(ql.ActualActual.ISDA)\n",
+ " today = ql.Date.todaysDate()\n",
+ " # Settings.instance().evaluationDate = today # Usually needed for options\n",
+ " # However, the pricing engines and process take term structures with explicit reference dates.\n",
+ "\n",
+ " risk_free_ts = ql.YieldTermStructureHandle(flat_rate(today, 0.05, day_counter))\n",
+ " dividend_ts = ql.YieldTermStructureHandle(flat_rate(today, 0.0, day_counter))\n",
+ "\n",
+ " s0_val = 50.0\n",
+ " omega = 2.0e-6\n",
+ " alpha = 0.024\n",
+ " beta = 0.93\n",
+ " gamma = 0.059\n",
+ " days_per_year = 365.0 # number of trading days per year\n",
+ "\n",
+ " maturities_days = [90, 180]\n",
+ " strikes = [35.0, 40.0, 45.0, 50.0, 55.0, 60.0]\n",
+ " lambdas_param = [0.0, 0.1, 0.2] # Renamed from Lambda due to Python keyword\n",
+ "\n",
+ " # Expected analytic values [lambda_idx][maturity_idx][strike_idx]\n",
+ " analytic_expected = {\n",
+ " 0: { # Lambda = 0.0\n",
+ " 0: [15.4315, 10.5552, 5.9625, 2.3282, 0.5408, 0.0835], # Mat 90\n",
+ " 1: [15.8969, 11.2173, 6.9112, 3.4788, 1.3769, 0.4357] # Mat 180\n",
+ " },\n",
+ " 1: { # Lambda = 0.1\n",
+ " 0: [15.4556, 10.6929, 6.2381, 2.6831, 0.7822, 0.1738],\n",
+ " 1: [16.0587, 11.5338, 7.3170, 3.9074, 1.7279, 0.6568]\n",
+ " },\n",
+ " 2: { # Lambda = 0.2\n",
+ " 0: [15.8000, 11.2734, 7.0376, 3.6767, 1.5871, 0.5934],\n",
+ " 1: [16.9286, 12.3170, 8.0405, 4.6348, 2.3429, 1.0590]\n",
+ " }\n",
+ " }\n",
+ " # Expected MC values\n",
+ " mc_expected = {\n",
+ " 0: {\n",
+ " 0: [15.4332, 10.5453, 5.9351, 2.3521, 0.5597, 0.0776],\n",
+ " 1: [15.8910, 11.1772, 6.8827, 3.5096, 1.4196, 0.4502]\n",
+ " },\n",
+ " 1: {\n",
+ " 0: [15.4580, 10.6433, 6.2019, 2.7513, 0.8374, 0.1706],\n",
+ " 1: [15.9884, 11.4139, 7.3103, 4.0497, 1.8862, 0.7322]\n",
+ " },\n",
+ " 2: {\n",
+ " 0: [15.6619, 11.1263, 7.0968, 3.9152, 1.8133, 0.7010],\n",
+ " 1: [16.5195, 12.3181, 8.6085, 5.5700, 3.3103, 1.8053]\n",
+ " }\n",
+ " }\n",
+ "\n",
+ " norm_dist = ql.CumulativeNormalDistribution()\n",
+ "\n",
+ " for k_idx, lambda_val in enumerate(lambdas_param):\n",
+ " m1 = (beta + (alpha + gamma * norm_dist(lambda_val)) * (1.0 + lambda_val * lambda_val) +\n",
+ " gamma * lambda_val * math.exp(-lambda_val * lambda_val / 2.0) / math.sqrt(2.0 * math.pi))\n",
+ " v0 = omega / (1.0 - m1)\n",
+ "\n",
+ " s0_quote = ql.QuoteHandle(ql.SimpleQuote(s0_val))\n",
+ "\n",
+ " process = ql.GJRGARCHProcess(risk_free_ts, dividend_ts, s0_quote, v0,\n",
+ " omega, alpha, beta, gamma, lambda_val, days_per_year)\n",
+ "\n",
+ " # Monte Carlo Engine\n",
+ " # C++: MakeMCEuropeanGJRGARCHEngine(process).withStepsPerYear(20)...\n",
+ " # Python: ql.McEuropeanGJRGARCHEngine(process, \"pseudorandom\", stepsPerYear=20, ...)\n",
+ " mc_engine = ql.McEuropeanGJRGARCHEngine(process, \"pseudorandom\",\n",
+ " stepsPerYear=20, # C++ stepsPerYear\n",
+ " requiredTolerance=0.02, # C++ absoluteTolerance\n",
+ " seed=1234)\n",
+ "\n",
+ " # Analytic Engine\n",
+ " # In C++, GJRGARCHModel is created from the process\n",
+ " gjr_model = ql.GJRGARCHModel(process)\n",
+ " analytic_engine = ql.AnalyticGJRGARCHEngine(gjr_model)\n",
+ "\n",
+ " for i_idx, mat_days in enumerate(maturities_days):\n",
+ " for j_idx, strike_val in enumerate(strikes):\n",
+ " payoff = ql.PlainVanillaPayoff(ql.Option.Call, strike_val)\n",
+ " exercise_date = today + ql.Period(mat_days, ql.Days)\n",
+ " exercise = ql.EuropeanExercise(exercise_date)\n",
+ "\n",
+ " option = ql.VanillaOption(payoff, exercise)\n",
+ "\n",
+ " # Test Monte Carlo Engine\n",
+ " option.setPricingEngine(mc_engine)\n",
+ " mc_calculated = option.NPV()\n",
+ "\n",
+ " # Test Analytic Engine\n",
+ " option.setPricingEngine(analytic_engine)\n",
+ " analytic_calculated = option.NPV()\n",
+ "\n",
+ " tolerance = 7.5e-2\n",
+ "\n",
+ " expected_analytic_val = analytic_expected[k_idx][i_idx][j_idx]\n",
+ " expected_mc_val = mc_expected[k_idx][i_idx][j_idx]\n",
+ "\n",
+ " self.assertAlmostEqual(analytic_calculated, expected_analytic_val, delta=2.0 * tolerance,\n",
+ " msg=(f\"Analytic Engine mismatch for Lambda={lambda_val}, Mat={mat_days}d, K={strike_val}\\n\"\n",
+ " f\" Correct value: {expected_analytic_val}\\n\"\n",
+ " f\" Analytic Approx.: {analytic_calculated}\"))\n",
+ "\n",
+ " self.assertAlmostEqual(mc_calculated, expected_mc_val, delta=2.0 * tolerance,\n",
+ " msg=(f\"MC Engine mismatch for Lambda={lambda_val}, Mat={mat_days}d, K={strike_val}\\n\"\n",
+ " f\" Correct value: {expected_mc_val}\\n\"\n",
+ " f\" Monte Carlo: {mc_calculated}\"))\n",
+ "\n",
+ " # @unittest.skipIf(True, \"Test marked as Fast in C++, but can be slow to run frequently\")\n",
+ " def test_dax_calibration(self):\n",
+ " print(\"Testing GJR-GARCH model calibration using DAX volatility data...\")\n",
+ "\n",
+ " settlement_date = ql.Date(5, ql.July, 2002)\n",
+ " ql.Settings.instance().evaluationDate = settlement_date\n",
+ "\n",
+ " day_counter = ql.Actual365Fixed()\n",
+ " calendar = ql.TARGET()\n",
+ "\n",
+ " # Term structure data from C++\n",
+ " t_days = [13, 41, 75, 165, 256, 345, 524, 703]\n",
+ " r_rates = [0.0357, 0.0349, 0.0341, 0.0355, 0.0359, 0.0368, 0.0386, 0.0401]\n",
+ "\n",
+ " dates = [settlement_date]\n",
+ " rates = [0.0357] # First rate matching C++\n",
+ " for i in range(len(t_days)):\n",
+ " dates.append(settlement_date + ql.Period(t_days[i], ql.Days))\n",
+ " rates.append(r_rates[i])\n",
+ "\n",
+ " risk_free_ts = ql.YieldTermStructureHandle(ql.ZeroCurve(dates, rates, day_counter))\n",
+ " dividend_ts = ql.YieldTermStructureHandle(flat_rate(settlement_date, 0.0, day_counter))\n",
+ "\n",
+ " # Volatility surface data\n",
+ " vols_surface_data = [\n",
+ " 0.6625,0.4875,0.4204,0.3667,0.3431,0.3267,0.3121,0.3121,\n",
+ " 0.6007,0.4543,0.3967,0.3511,0.3279,0.3154,0.2984,0.2921,\n",
+ " 0.5084,0.4221,0.3718,0.3327,0.3155,0.3027,0.2919,0.2889,\n",
+ " 0.4541,0.3869,0.3492,0.3149,0.2963,0.2926,0.2819,0.2800,\n",
+ " 0.4060,0.3607,0.3330,0.2999,0.2887,0.2811,0.2751,0.2775,\n",
+ " 0.3726,0.3396,0.3108,0.2781,0.2788,0.2722,0.2661,0.2686,\n",
+ " 0.3550,0.3277,0.3012,0.2781,0.2781,0.2661,0.2661,0.2681,\n",
+ " 0.3428,0.3209,0.2958,0.2740,0.2688,0.2627,0.2580,0.2620,\n",
+ " 0.3302,0.3062,0.2799,0.2631,0.2573,0.2533,0.2504,0.2544,\n",
+ " 0.3343,0.2959,0.2705,0.2540,0.2504,0.2464,0.2448,0.2462,\n",
+ " 0.3460,0.2845,0.2624,0.2463,0.2425,0.2385,0.2373,0.2422,\n",
+ " 0.3857,0.2860,0.2578,0.2399,0.2357,0.2327,0.2312,0.2351,\n",
+ " 0.3976,0.2860,0.2607,0.2356,0.2297,0.2268,0.2241,0.2320\n",
+ " ]\n",
+ " # C++ used a 2D array implicitly via v[s*8+m].\n",
+ " # In Python, we can reshape or access carefully.\n",
+ " # The loops are for s=3..9 (7 strike levels) and m=0..2 (3 maturity levels).\n",
+ " # Total 7*3 = 21 options.\n",
+ "\n",
+ " s0_quote_val = 4468.17\n",
+ " s0_handle = ql.QuoteHandle(ql.SimpleQuote(s0_quote_val))\n",
+ "\n",
+ " strikes_calib = [3400, 3600, 3800, 4000, 4200, 4400,\n",
+ " 4500, 4600, 4800, 5000, 5200, 5400, 5600]\n",
+ "\n",
+ " # Initial GJR-GARCH parameters from C++\n",
+ " omega_init = 2.0e-6\n",
+ " alpha_init = 0.024\n",
+ " beta_init = 0.93\n",
+ " gamma_init = 0.059\n",
+ " lambda_init = 0.1 # Python keyword, so _init\n",
+ " days_per_year_calib = 365.0\n",
+ "\n",
+ " norm_dist = ql.CumulativeNormalDistribution()\n",
+ " m1_init = (beta_init + (alpha_init + gamma_init * norm_dist(lambda_init)) *\n",
+ " (1.0 + lambda_init * lambda_init) +\n",
+ " gamma_init * lambda_init * math.exp(-lambda_init * lambda_init / 2.0) /\n",
+ " math.sqrt(2.0 * math.pi))\n",
+ " v0_init = omega_init / (1.0 - m1_init)\n",
+ "\n",
+ " process = ql.GJRGARCHProcess(risk_free_ts, dividend_ts, s0_handle, v0_init,\n",
+ " omega_init, alpha_init, beta_init, gamma_init,\n",
+ " lambda_init, days_per_year_calib)\n",
+ "\n",
+ " model = ql.GJRGARCHModel(process)\n",
+ " engine = ql.AnalyticGJRGARCHEngine(model)\n",
+ "\n",
+ " calibration_helpers = []\n",
+ " # C++ loop: for (Size s = 3; s < 10; ++s) { for (Size m = 0; m < 3; ++m) { ... } }\n",
+ " # s index for strikes_calib and for row of vol_surface_data\n",
+ " # m index for maturities (t_days) and for col of vol_surface_data\n",
+ "\n",
+ " maturities_calib_indices = [0, 1, 2] # Corresponds to m=0,1,2 (first 3 maturities in t_days)\n",
+ " strike_indices_calib = range(3, 10) # Corresponds to s=3..9\n",
+ "\n",
+ " for s_idx_loopval in strike_indices_calib: # This s_idx_loopval is 's' in C++\n",
+ " current_strike = strikes_calib[s_idx_loopval]\n",
+ " for m_idx_loopval in maturities_calib_indices: # This m_idx_loopval is 'm' in C++\n",
+ " # C++: v[s*8+m]\n",
+ " # Python: vols_surface_data is 1D. Index is s_idx_loopval * 8 + m_idx_loopval\n",
+ " vol_val = vols_surface_data[s_idx_loopval * 8 + m_idx_loopval]\n",
+ " vol_handle = ql.QuoteHandle(ql.SimpleQuote(vol_val))\n",
+ "\n",
+ " # Maturity from t_days, C++ rounds to weeks.\n",
+ " # (t[m]+3)/7. t_days[m_idx_loopval]\n",
+ " maturity_in_days = t_days[m_idx_loopval]\n",
+ " maturity_in_weeks = int((maturity_in_days + 3) / 7.0)\n",
+ " maturity_period = ql.Period(maturity_in_weeks, ql.Weeks)\n",
+ "\n",
+ " # C++ uses HestonModelHelper as a BlackCalibrationHelper\n",
+ " helper = ql.HestonModelHelper(maturity_period, calendar,\n",
+ " s0_quote_val, current_strike, vol_handle,\n",
+ " risk_free_ts, dividend_ts,\n",
+ " ql.BlackCalibrationHelper.ImpliedVolError)\n",
+ " helper.setPricingEngine(engine)\n",
+ " calibration_helpers.append(helper)\n",
+ "\n",
+ " opt_method = ql.Simplex(0.05)\n",
+ " end_criteria = ql.EndCriteria(400, 40, 1.0e-8, 1.0e-8, 1.0e-8)\n",
+ "\n",
+ " model.calibrate(calibration_helpers, opt_method, end_criteria)\n",
+ "\n",
+ " sse = 0.0\n",
+ " for helper_item in calibration_helpers:\n",
+ " diff = helper_item.calibrationError() * 100.0 # Error is in vol points\n",
+ " sse += diff * diff\n",
+ "\n",
+ " max_expected_sse = 15.0 # From C++ test\n",
+ "\n",
+ " print(f\"DAX Calibration SSE: {sse}\")\n",
+ " self.assertTrue(sse <= max_expected_sse,\n",
+ " (f\"Failed to reproduce calibration error\\n\"\n",
+ " f\" calculated SSE: {sse}\\n\"\n",
+ " f\" expected SSE: < {max_expected_sse}\"))\n",
+ "\n",
+ "\n",
+ "if __name__ == '__main__':\n",
+ " print(\"Running Python QuantLib GJRGARCHModel tests...\")\n",
+ " unittest.main(argv=['first-arg-is-ignored'], exit=False)"
+ ],
+ "metadata": {
+ "id": "eDZrNJgTprIO"
+ },
+ "execution_count": null,
+ "outputs": []
+ }
+ ],
+ "metadata": {
+ "colab": {
+ "toc_visible": true,
+ "provenance": [],
+ "include_colab_link": true
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
\ No newline at end of file