Skip to main content

rustyqlib/equity/
cliquet.rs

1//! Cliquet (ratchet) options: a strip of forward-start performance
2//! periods with local and global caps/floors.
3//!
4//! The payoff observes period returns `R_i = S_{t_i}/S_{t_{i-1}} - 1`
5//! over an equally-spaced reset schedule and pays at maturity
6//!
7//! ```text
8//! N * clamp( sum_i clamp(R_i, local_floor, local_cap),
9//!            global_floor, global_cap )
10//! ```
11//!
12//! A **ratchet** is the `local_floor = 0` special case: each period
13//! locks in its gain and losses are forgiven. Because the payoff is
14//! built from returns it is spot-homogeneous — the classic product
15//! whose value is all **forward smile**: under Black-Scholes each
16//! period is an independent lognormal and the price collapses to a
17//! closed form (a strip of forward-start call spreads); under Heston
18//! the forward smile is model-generated and the price genuinely
19//! differs, which is the reason desks price cliquets on stochastic-vol
20//! models.
21//!
22//! Engines: `Analytical` (Black-Scholes closed form; requires no
23//! global cap/floor, which break the per-period independence) and
24//! `MonteCarlo` (GBM per-period sampling, or full Heston paths when
25//! parameters are supplied). Under homogeneous dynamics the pure
26//! cliquet has zero spot delta; the output reports Monte Carlo
27//! standard errors instead of spot Greeks.
28
29use chrono::NaiveDate;
30use serde::{Deserialize, Serialize};
31
32use crate::core::montecarlo::{mean_std_err, path_rng};
33use crate::core::traits::Instrument;
34use crate::core::utils::norm_cdf;
35use crate::equity::heston::HestonParams;
36use rand::Rng;
37use rand_distr::StandardNormal;
38use crate::core::errors::RustyQLibError;
39
40/// Pricing engine choice for a cliquet.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CliquetPricer {
43    Analytical,
44    MonteCarlo,
45}
46
47/// Payoff family on the reset schedule.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum CliquetStyle {
50    /// Sum of locally clamped returns (the classic cliquet; local
51    /// floor 0 = ratchet).
52    Standard,
53    /// Reverse cliquet: a headline `coupon` eroded by the negative
54    /// period returns, `coupon + sum_i min(R_i, 0)` — conventionally
55    /// sold with `global_floor = 0`. Ignores the local clamp fields.
56    Reverse { coupon: f64 },
57    /// Napoleon: a `coupon` plus the **worst** period return,
58    /// `coupon + min_i R_i` — conventionally `global_floor = 0`.
59    /// Ignores the local clamp fields.
60    Napoleon { coupon: f64 },
61}
62
63/// JSON contract data (`"product_type": "cliquet_option"`).
64#[derive(Clone, Debug, Deserialize, Serialize)]
65pub struct CliquetOptionData {
66    pub symbol: String,
67    /// Number of equally-spaced reset periods.
68    pub resets: usize,
69    /// Maturity date, `YYYY-MM-DD`.
70    pub maturity: String,
71    /// Per-period floor on the return (e.g. `0.0` for a ratchet).
72    pub local_floor: f64,
73    /// Per-period cap on the return.
74    pub local_cap: Option<f64>,
75    pub global_floor: Option<f64>,
76    pub global_cap: Option<f64>,
77    pub notional: Option<f64>,
78    pub risk_free_rate: f64,
79    pub dividend: Option<f64>,
80    /// Flat Black-Scholes volatility (GBM engine and the closed form).
81    pub volatility: f64,
82    /// Optional Heston parameters: when present the Monte Carlo engine
83    /// simulates Heston dynamics instead of GBM.
84    pub heston: Option<HestonParams>,
85    pub pricer: Option<String>,
86    pub simulation: Option<u64>,
87    pub mc_seed: Option<u64>,
88    /// "standard" (default) | "reverse" | "napoleon".
89    pub style: Option<String>,
90    /// Headline coupon for reverse / napoleon styles.
91    pub coupon: Option<f64>,
92    /// Pricing as-of date (`YYYY-MM-DD`); defaults to today.
93    pub valuation_date: Option<String>,
94}
95
96/// A cliquet/ratchet option on equally-spaced resets.
97#[derive(Debug, Clone)]
98pub struct Cliquet {
99    pub resets: usize,
100    /// Year fraction to maturity.
101    pub t: f64,
102    pub r: f64,
103    pub q: f64,
104    pub sigma: f64,
105    pub local_floor: f64,
106    pub local_cap: Option<f64>,
107    pub global_floor: Option<f64>,
108    pub global_cap: Option<f64>,
109    pub notional: f64,
110    pub heston: Option<HestonParams>,
111    pub style: CliquetStyle,
112    pub pricer: CliquetPricer,
113    pub paths: usize,
114    pub seed: u64,
115}
116
117/// Euler substeps per reset period for the Heston path engine.
118const HESTON_SUBSTEPS: usize = 32;
119
120impl Cliquet {
121    fn clamp_local(&self, ret: f64) -> f64 {
122        let mut x = ret.max(self.local_floor);
123        if let Some(cap) = self.local_cap {
124            x = x.min(cap);
125        }
126        x
127    }
128
129    fn accumulate(&self, ret: f64, total: &mut f64, worst: &mut f64) {
130        match self.style {
131            CliquetStyle::Standard => *total += self.clamp_local(ret),
132            CliquetStyle::Reverse { .. } => *total += ret.min(0.0),
133            CliquetStyle::Napoleon { .. } => *worst = worst.min(ret),
134        }
135    }
136
137    fn clamp_global(&self, sum: f64) -> f64 {
138        let mut x = sum;
139        if let Some(floor) = self.global_floor {
140            x = x.max(floor);
141        }
142        if let Some(cap) = self.global_cap {
143            x = x.min(cap);
144        }
145        x
146    }
147
148    /// Black-Scholes closed form: each period's clamped return is a
149    /// forward-start call spread on the lognormal period ratio, and the
150    /// periods are independent, so the sum prices term by term. Errs
151    /// when a global cap/floor is present (it couples the periods) or
152    /// Heston dynamics are requested.
153    pub fn analytic_npv(&self) -> Result<f64, RustyQLibError> {
154        if self.global_floor.is_some() || self.global_cap.is_some() {
155            return Err(RustyQLibError::UnsupportedEngine("global cap/floor couples the periods: use Monte Carlo".to_string()));
156        }
157        if self.heston.is_some() {
158            return Err(RustyQLibError::UnsupportedEngine("Heston cliquets price by Monte Carlo".to_string()));
159        }
160        let dt = self.t / self.resets as f64;
161        let fwd = ((self.r - self.q) * dt).exp();
162        let sd = self.sigma * dt.sqrt();
163        // undiscounted E[(X - k)+] for the lognormal period ratio X
164        let ratio_call = |k: f64| -> f64 {
165            let d1 = ((fwd / k).ln() + 0.5 * sd * sd) / sd;
166            fwd * norm_cdf(d1) - k * norm_cdf(d1 - sd)
167        };
168        let df_n = self.notional * (-self.r * self.t).exp();
169        match self.style {
170            CliquetStyle::Standard => {
171                // E[clamp(R, lf, lc)] = lf + call(1 + lf) - call(1 + lc)
172                let mut period = self.local_floor + ratio_call(1.0 + self.local_floor);
173                if let Some(cap) = self.local_cap {
174                    period -= ratio_call(1.0 + cap);
175                }
176                Ok(df_n * self.resets as f64 * period)
177            }
178            CliquetStyle::Reverse { coupon } => {
179                // E[min(R, 0)] = -E[(1 - X)+], a put on the period ratio
180                // struck at 1, via parity: put(1) = call(1) - (F - 1)
181                let ratio_put_at_one = ratio_call(1.0) - (fwd - 1.0);
182                Ok(df_n * (coupon - self.resets as f64 * ratio_put_at_one))
183            }
184            CliquetStyle::Napoleon { .. } => {
185                Err(RustyQLibError::UnsupportedEngine("the Napoleon's worst-of statistic prices by Monte Carlo".to_string()))
186            }
187        }
188    }
189
190    /// Monte Carlo price with standard error: per-period lognormal
191    /// sampling under GBM, full Euler paths under Heston. Deterministic
192    /// per seed.
193    pub fn mc_npv(&self) -> (f64, f64) {
194        let dt = self.t / self.resets as f64;
195        let mut sum = 0.0;
196        let mut sum_sq = 0.0;
197        for i in 0..self.paths {
198            let mut rng = path_rng(self.seed, i as u64);
199            let mut total = 0.0;
200            let mut worst = f64::INFINITY;
201            match &self.heston {
202                None => {
203                    let drift = (self.r - self.q - 0.5 * self.sigma * self.sigma) * dt;
204                    let sd = self.sigma * dt.sqrt();
205                    for _ in 0..self.resets {
206                        let z: f64 = rng.sample(StandardNormal);
207                        let ret = (drift + sd * z).exp() - 1.0;
208                        self.accumulate(ret, &mut total, &mut worst);
209                    }
210                }
211                Some(hp) => {
212                    let sub = dt / HESTON_SUBSTEPS as f64;
213                    let rho_bar = (1.0 - hp.rho * hp.rho).sqrt();
214                    let mut v: f64 = hp.v0;
215                    for _ in 0..self.resets {
216                        let mut log_ret = 0.0;
217                        for _ in 0..HESTON_SUBSTEPS {
218                            let z1: f64 = rng.sample(StandardNormal);
219                            let z2: f64 = rng.sample(StandardNormal);
220                            let zv = hp.rho * z1 + rho_bar * z2;
221                            let vp = v.max(0.0);
222                            log_ret += (self.r - self.q - 0.5 * vp) * sub
223                                + (vp * sub).sqrt() * z1;
224                            v += hp.kappa * (hp.theta - vp) * sub
225                                + hp.vol_of_vol * (vp * sub).sqrt() * zv;
226                        }
227                        self.accumulate(log_ret.exp() - 1.0, &mut total, &mut worst);
228                    }
229                }
230            }
231            let units = match self.style {
232                CliquetStyle::Standard => self.clamp_global(total),
233                CliquetStyle::Reverse { coupon } => self.clamp_global(coupon + total),
234                CliquetStyle::Napoleon { coupon } => self.clamp_global(coupon + worst),
235            };
236            let payoff = self.notional * (-self.r * self.t).exp() * units;
237            sum += payoff;
238            sum_sq += payoff * payoff;
239        }
240        mean_std_err(sum, sum_sq, self.paths)
241    }
242
243    /// Price with the configured engine (analytic falls back to Monte
244    /// Carlo when global constraints or Heston dynamics require it).
245    /// Build from contract data, panicking on any invalid field. Fallible
246    /// callers should use [`Cliquet::try_from_json`].
247    pub fn from_json(data: &CliquetOptionData) -> Box<Cliquet> {
248        Self::try_from_json(data).unwrap_or_else(|e| panic!("{e}"))
249    }
250
251    pub fn try_from_json(data: &CliquetOptionData) -> Result<Box<Cliquet>, RustyQLibError> {
252        let today =
253            crate::core::data_models::parse_valuation_date(data.valuation_date.as_deref())?;
254        let maturity = NaiveDate::parse_from_str(&data.maturity, "%Y-%m-%d")
255            .map_err(|_| RustyQLibError::invalid_input(
256                "maturity",
257                format!("invalid date '{}' (expected YYYY-MM-DD)", data.maturity),
258            ))?;
259        let t = (maturity - today).num_days() as f64 / 365.0;
260        if t <= 0.0 {
261            return Err(RustyQLibError::invalid_input("maturity", "cliquet is expired"));
262        }
263        if data.resets < 1 {
264            return Err(RustyQLibError::invalid_input("resets", "need at least one reset period"));
265        }
266        if let Some(hp) = &data.heston {
267            hp.validate()?;
268        }
269        let pricer = match data.pricer.as_deref().map(str::trim) {
270            None | Some("Analytical") | Some("analytical") | Some("bs") => {
271                CliquetPricer::Analytical
272            }
273            Some("MonteCarlo") | Some("montecarlo") | Some("MC") | Some("mc") => {
274                CliquetPricer::MonteCarlo
275            }
276            Some(other) => return Err(RustyQLibError::invalid_input(
277                "pricer",
278                format!("invalid cliquet pricer '{other}' (use Analytical or MonteCarlo)"),
279            )),
280        };
281        let need_coupon = |style: &str| {
282            data.coupon.ok_or_else(|| RustyQLibError::invalid_input(
283                "coupon",
284                format!("{style} cliquet needs a coupon"),
285            ))
286        };
287        let style = match data.style.as_deref().map(str::trim) {
288            None | Some("standard") | Some("Standard") => CliquetStyle::Standard,
289            Some("reverse") | Some("Reverse") => CliquetStyle::Reverse {
290                coupon: need_coupon("reverse")?,
291            },
292            Some("napoleon") | Some("Napoleon") => CliquetStyle::Napoleon {
293                coupon: need_coupon("napoleon")?,
294            },
295            Some(other) => return Err(RustyQLibError::invalid_input(
296                "style",
297                format!("invalid cliquet style '{other}' (use standard, reverse or napoleon)"),
298            )),
299        };
300        Ok(Box::new(Cliquet {
301            resets: data.resets,
302            t,
303            r: data.risk_free_rate,
304            q: data.dividend.unwrap_or(0.0),
305            sigma: data.volatility,
306            local_floor: data.local_floor,
307            local_cap: data.local_cap,
308            global_floor: data.global_floor,
309            global_cap: data.global_cap,
310            notional: data.notional.unwrap_or(1.0),
311            heston: data.heston,
312            style,
313            pricer,
314            paths: data.simulation.unwrap_or(100_000) as usize,
315            seed: data.mc_seed.unwrap_or(42),
316        }))
317    }
318}
319
320impl Instrument for Cliquet {
321    fn try_npv(&self) -> Result<f64, RustyQLibError> {
322        Ok(self.price()?.pv)
323    }
324
325    /// Analytic where the payoff permits (falling back to Monte Carlo
326    /// otherwise, mirroring the documented pricing policy); the standard
327    /// error is reported whenever a simulation produced the value.
328    fn price(&self) -> Result<crate::core::results::PricingResult, RustyQLibError> {
329        let (pv, std_err) = match self.pricer {
330            CliquetPricer::Analytical => match self.analytic_npv() {
331                Ok(v) => (v, None),
332                Err(_) => {
333                    let (pv, se) = self.mc_npv();
334                    (pv, Some(se))
335                }
336            },
337            CliquetPricer::MonteCarlo => {
338                let (pv, se) = self.mc_npv();
339                (pv, Some(se))
340            }
341        };
342        // the return-based payoff is spot-homogeneous: no spot Greeks
343        Ok(crate::core::results::PricingResult { pv, greeks: Default::default(), std_err })
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::equity::blackscholes::bs_price;
351    use crate::core::trade::PutOrCall;
352
353    fn base() -> Cliquet {
354        Cliquet {
355            resets: 12,
356            t: 1.0,
357            r: 0.03,
358            q: 0.01,
359            sigma: 0.2,
360            local_floor: 0.0,
361            local_cap: Some(0.03),
362            global_floor: None,
363            global_cap: None,
364            notional: 1.0,
365            heston: None,
366            style: CliquetStyle::Standard,
367            pricer: CliquetPricer::Analytical,
368            paths: 60_000,
369            seed: 42,
370        }
371    }
372
373    #[test]
374    fn single_period_ratchet_is_a_scaled_atm_call() {
375        // one reset, floor 0, no cap: pays (S_T/S_0 - 1)+ = ATM call / S0
376        let mut c = base();
377        c.resets = 1;
378        c.local_cap = None;
379        let analytic = c.analytic_npv().unwrap();
380        let atm = bs_price(100.0, 100.0, c.r, c.q, c.sigma, c.t, PutOrCall::Call) / 100.0;
381        assert!((analytic - atm).abs() < 1e-12, "{analytic} vs {atm}");
382    }
383
384    #[test]
385    fn monte_carlo_agrees_with_the_closed_form() {
386        let c = base();
387        let analytic = c.analytic_npv().unwrap();
388        let (mc, se) = c.mc_npv();
389        assert!(
390            (mc - analytic).abs() < 3.0 * se + 1e-4,
391            "mc {mc} +/- {se} vs analytic {analytic}"
392        );
393        // deterministic per seed
394        assert_eq!(c.mc_npv().0, mc);
395    }
396
397    #[test]
398    fn caps_and_floors_move_the_price_the_right_way() {
399        let c = base();
400        let baseline = c.analytic_npv().unwrap();
401        // a tighter local cap must cheapen the strip
402        let mut tight = base();
403        tight.local_cap = Some(0.01);
404        assert!(tight.analytic_npv().unwrap() < baseline);
405        // a global floor adds value, a global cap removes it (MC only)
406        let mut floored = base();
407        floored.global_floor = Some(0.06);
408        assert!(floored.analytic_npv().is_err());
409        assert!(floored.mc_npv().0 > c.mc_npv().0 - 1e-12);
410        let mut capped = base();
411        capped.global_cap = Some(0.10);
412        assert!(capped.mc_npv().0 < c.mc_npv().0);
413        // local floor above the cap is degenerate but bounded
414        let mut sunk = base();
415        sunk.local_floor = -0.02;
416        assert!(sunk.analytic_npv().unwrap() < baseline);
417    }
418
419    #[test]
420    fn heston_with_tiny_vol_of_vol_matches_the_gbm_closed_form() {
421        let mut c = base();
422        c.heston = Some(HestonParams {
423            v0: c.sigma * c.sigma,
424            kappa: 1.0,
425            theta: c.sigma * c.sigma,
426            vol_of_vol: 1e-4,
427            rho: 0.0,
428        });
429        c.paths = 40_000;
430        let (mc, se) = c.mc_npv();
431        let analytic = base().analytic_npv().unwrap();
432        assert!(
433            (mc - analytic).abs() < 4.0 * se + 5e-4,
434            "heston-degenerate {mc} +/- {se} vs analytic {analytic}"
435        );
436    }
437
438    #[test]
439    fn heston_forward_smile_moves_the_cliquet_off_black_scholes() {
440        // same total variance, real vol-of-vol and negative rho: the
441        // capped/floored strip prices differently from flat-vol GBM —
442        // the whole reason cliquets are priced on stochastic vol
443        let mut c = base();
444        c.heston = Some(HestonParams {
445            v0: 0.04,
446            kappa: 1.5,
447            theta: 0.04,
448            vol_of_vol: 0.7,
449            rho: -0.7,
450        });
451        c.paths = 60_000;
452        let (mc, se) = c.mc_npv();
453        let flat = base().analytic_npv().unwrap();
454        assert!(
455            (mc - flat).abs() > 3.0 * se,
456            "expected a forward-smile effect: heston {mc} +/- {se} vs bs {flat}"
457        );
458    }
459
460    #[test]
461    fn reverse_cliquet_closed_form_matches_monte_carlo() {
462        // unfloored reverse: coupon minus a strip of forward-start puts
463        let mut c = base();
464        c.style = CliquetStyle::Reverse { coupon: 0.20 };
465        c.local_cap = None;
466        let analytic = c.analytic_npv().unwrap();
467        let (mc, se) = c.mc_npv();
468        assert!(
469            (mc - analytic).abs() < 3.0 * se + 1e-4,
470            "mc {mc} +/- {se} vs analytic {analytic}"
471        );
472        // the conventional 0% floor only adds value
473        let mut floored = c.clone();
474        floored.global_floor = Some(0.0);
475        assert!(floored.analytic_npv().is_err());
476        assert!(floored.mc_npv().0 >= mc - 1e-12);
477    }
478
479    #[test]
480    fn single_period_napoleon_is_a_forward_start_call() {
481        // one reset, floor 0: max(0, C + R) = (X - (1 - C))+ on the ratio
482        let coupon = 0.10;
483        let mut c = base();
484        c.resets = 1;
485        c.local_cap = None;
486        c.style = CliquetStyle::Napoleon { coupon };
487        c.global_floor = Some(0.0);
488        c.paths = 200_000;
489        let (mc, se) = c.mc_npv();
490        // a call on the unit-spot period ratio struck at 1 - C, already
491        // discounted by bs_price
492        let analytic = bs_price(1.0, 1.0 - coupon, c.r, c.q, c.sigma, c.t, PutOrCall::Call);
493        assert!(
494            (mc - analytic).abs() < 3.0 * se + 1e-4,
495            "mc {mc} +/- {se} vs analytic {analytic}"
496        );
497    }
498
499    #[test]
500    fn napoleon_worsens_with_more_resets_and_higher_vol() {
501        let napoleon = |resets: usize, sigma: f64| -> f64 {
502            let mut c = base();
503            c.resets = resets;
504            c.t = 1.0;
505            c.sigma = sigma;
506            c.local_cap = None;
507            c.style = CliquetStyle::Napoleon { coupon: 0.10 };
508            c.global_floor = Some(0.0);
509            c.paths = 30_000;
510            c.mc_npv().0
511        };
512        // the minimum of more period returns is worse
513        assert!(napoleon(1, 0.2) > napoleon(4, 0.2));
514        assert!(napoleon(4, 0.2) > napoleon(12, 0.2));
515        // and the structure is short volatility
516        assert!(napoleon(12, 0.15) > napoleon(12, 0.30));
517    }
518
519    #[test]
520    fn napoleon_vol_of_vol_exposure_is_large_and_directionally_convex() {
521        // Same total variance, real vol-of-vol. Note the direction: the
522        // FLOORED Napoleon is convex in the worst-month distribution (the
523        // 0% floor truncates the fat left tail, while calm-vol regimes
524        // improve the worst month), so vol-of-vol RAISES the buyer's
525        // value here — the famous Napoleon blowups were the sellers'
526        // short position in exactly this convexity. The unfloored
527        // structure, by contrast, is hurt by vol clustering.
528        let mut gbm = base();
529        gbm.style = CliquetStyle::Napoleon { coupon: 0.10 };
530        gbm.local_cap = None;
531        gbm.global_floor = Some(0.0);
532        gbm.paths = 60_000;
533        let (flat, flat_se) = gbm.mc_npv();
534        let hp = HestonParams { v0: 0.04, kappa: 1.5, theta: 0.04, vol_of_vol: 0.7, rho: -0.7 };
535        let mut heston = gbm.clone();
536        heston.heston = Some(hp);
537        let (stoch, stoch_se) = heston.mc_npv();
538        let noise = (flat_se * flat_se + stoch_se * stoch_se).sqrt();
539        assert!(stoch > flat + 5.0 * noise, "floored: heston {stoch} vs gbm {flat}");
540
541        // without the floor two effects compete (worst-month concavity
542        // vs the right-skewed CIR variance making the typical month
543        // calmer), so no sign is asserted — only that the model choice
544        // moves the price by far more than the Monte Carlo noise
545        let mut gbm_unfloored = gbm.clone();
546        gbm_unfloored.global_floor = None;
547        let (flat_u, se_u) = gbm_unfloored.mc_npv();
548        let mut heston_unfloored = gbm_unfloored.clone();
549        heston_unfloored.heston = Some(hp);
550        let (stoch_u, se_u2) = heston_unfloored.mc_npv();
551        let noise_u = (se_u * se_u + se_u2 * se_u2).sqrt();
552        assert!(
553            (stoch_u - flat_u).abs() > 5.0 * noise_u,
554            "unfloored: heston {stoch_u} vs gbm {flat_u} (noise {noise_u})"
555        );
556    }
557
558    #[test]
559    fn styled_json_contracts_parse() {
560        let json = r#"{
561            "symbol": "NAP", "resets": 12, "maturity": "2030-01-01",
562            "local_floor": 0.0, "global_floor": 0.0,
563            "risk_free_rate": 0.03, "volatility": 0.2,
564            "style": "napoleon", "coupon": 0.08,
565            "pricer": "MC", "simulation": 5000
566        }"#;
567        let data: CliquetOptionData = serde_json::from_str(json).unwrap();
568        let napoleon = Cliquet::from_json(&data);
569        assert_eq!(napoleon.style, CliquetStyle::Napoleon { coupon: 0.08 });
570        let pv = napoleon.npv();
571        assert!(pv > 0.0 && pv < 0.08, "{pv}"); // bounded by the coupon
572    }
573
574    #[test]
575    fn json_contract_round_trip() {
576        let json = r#"{
577            "symbol": "CLIQ", "resets": 4, "maturity": "2030-01-01",
578            "local_floor": 0.0, "local_cap": 0.05, "global_floor": 0.02,
579            "notional": 1000000.0, "risk_free_rate": 0.03, "dividend": 0.01,
580            "volatility": 0.25, "pricer": "MC", "simulation": 20000, "mc_seed": 7
581        }"#;
582        let data: CliquetOptionData = serde_json::from_str(json).unwrap();
583        let cliquet = Cliquet::from_json(&data);
584        assert_eq!(cliquet.resets, 4);
585        assert_eq!(cliquet.pricer, CliquetPricer::MonteCarlo);
586        let pv = cliquet.npv();
587        // bounded by the discounted global-capped maximum
588        assert!(pv > 0.0 && pv < 1_000_000.0 * 4.0 * 0.05, "{pv}");
589    }
590}