1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CliquetPricer {
43 Analytical,
44 MonteCarlo,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum CliquetStyle {
50 Standard,
53 Reverse { coupon: f64 },
57 Napoleon { coupon: f64 },
61}
62
63#[derive(Clone, Debug, Deserialize, Serialize)]
65pub struct CliquetOptionData {
66 pub symbol: String,
67 pub resets: usize,
69 pub maturity: String,
71 pub local_floor: f64,
73 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 pub volatility: f64,
82 pub heston: Option<HestonParams>,
85 pub pricer: Option<String>,
86 pub simulation: Option<u64>,
87 pub mc_seed: Option<u64>,
88 pub style: Option<String>,
90 pub coupon: Option<f64>,
92 pub valuation_date: Option<String>,
94}
95
96#[derive(Debug, Clone)]
98pub struct Cliquet {
99 pub resets: usize,
100 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
117const 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 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 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 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 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 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 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 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 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 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 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 let mut tight = base();
403 tight.local_cap = Some(0.01);
404 assert!(tight.analytic_npv().unwrap() < baseline);
405 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 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 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 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 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 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 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 assert!(napoleon(1, 0.2) > napoleon(4, 0.2));
514 assert!(napoleon(4, 0.2) > napoleon(12, 0.2));
515 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 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 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}"); }
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 assert!(pv > 0.0 && pv < 1_000_000.0 * 4.0 * 0.05, "{pv}");
589 }
590}