Skip to main content

rustyqlib/equity/
autocallable.rs

1//! Autocallable notes (single underlying) with an autocall coupon (rebate)
2//! and knock-in capital protection.
3//!
4//! Mechanics (classic "Athena" structure) on equally spaced observation
5//! dates `t_1 .. t_n` (with `t_n = T`):
6//! - if `S(t_m) >= autocall_barrier`, the note redeems early at `t_m`
7//!   paying `notional + m * coupon` (the accrued coupon is the rebate);
8//! - if never called: at `T`, if the path never breached
9//!   `protection_barrier` (discretely monitored on the simulation grid),
10//!   the holder receives the notional back; otherwise the protection is
11//!   knocked in and the holder receives `notional * S_T / S_initial`
12//!   (1:1 downside participation from the *contractual* initial fixing).
13//!
14//! Cash flows occur at different dates, so pricing is a dedicated Monte
15//! Carlo route that discounts each call date on the option's curve. The
16//! route runs under GBM, **Dupire local volatility** (the market-standard
17//! model for these notes — the skew drives the knock-in value) and Heston.
18
19use crate::core::trade::PutOrCall;
20use crate::core::utils::ContractStyle;
21use crate::equity::utils::{Payoff, PayoffType};
22
23#[derive(Debug)]
24pub struct AutocallablePayoff {
25    pub exercise_style: ContractStyle,
26    /// Early-redemption trigger level (absolute).
27    pub autocall_barrier: f64,
28    /// Knock-in barrier for the capital protection (absolute).
29    pub protection_barrier: f64,
30    /// Coupon (rebate) accrued per observation period, paid at call.
31    pub coupon: f64,
32    /// Number of equally spaced observations over the life (last = expiry).
33    pub observations: usize,
34    pub notional: f64,
35    /// Contractual initial fixing for the downside participation ratio.
36    pub initial_fixing: f64,
37}
38
39impl AutocallablePayoff {
40    /// Value of one simulated path: redemption cash flow times the discount
41    /// factor of its payment date. `obs_idx` maps observation m to its path
42    /// step; `dfs[m]` is the discount factor to that date.
43    pub fn path_value(&self, path: &[f64], obs_idx: &[usize], dfs: &[f64]) -> f64 {
44        for (m, &idx) in obs_idx.iter().enumerate() {
45            if path[idx] >= self.autocall_barrier {
46                return (self.notional + self.coupon * (m + 1) as f64) * dfs[m];
47            }
48        }
49        // never called: capital protection at maturity
50        let df_final = *dfs.last().unwrap();
51        let knocked_in = path.iter().any(|&s| s <= self.protection_barrier);
52        if knocked_in {
53            let terminal = *path.last().unwrap();
54            self.notional * (terminal / self.initial_fixing) * df_final
55        } else {
56            self.notional * df_final
57        }
58    }
59}
60
61impl Payoff for AutocallablePayoff {
62    /// Degenerate single-point value: zero (all value is path- and
63    /// schedule-dependent).
64    fn payoff(&self, _spot: f64, _strike: f64) -> f64 {
65        0.0
66    }
67    fn path_payoff(&self, _path: &[f64], _strike: f64) -> f64 {
68        panic!(
69            "Autocallables pay at multiple dates and cannot be valued through \
70             path_payoff; the Monte Carlo engine prices them via path_value"
71        );
72    }
73    fn is_path_dependent(&self) -> bool {
74        true
75    }
76    fn payoff_kind(&self) -> PayoffType {
77        PayoffType::Autocallable
78    }
79    fn put_or_call(&self) -> &PutOrCall {
80        // the embedded optionality is put-like; the field is not used by
81        // the pricing routes
82        &PutOrCall::Call
83    }
84    fn exercise_style(&self) -> &ContractStyle {
85        &self.exercise_style
86    }
87    fn as_any(&self) -> &dyn std::any::Any {
88        self
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn note() -> AutocallablePayoff {
97        AutocallablePayoff {
98            exercise_style: ContractStyle::European,
99            autocall_barrier: 100.0,
100            protection_barrier: 70.0,
101            coupon: 5.0,
102            observations: 4,
103            notional: 100.0,
104            initial_fixing: 100.0,
105        }
106    }
107
108    #[test]
109    fn calls_at_first_breach_with_accrued_coupon() {
110        let payoff = note();
111        let obs_idx = [1, 3, 5, 7];
112        let dfs = [0.99, 0.98, 0.97, 0.96];
113        // second observation (index 3) is the first at/above the barrier
114        let path = [90.0, 95.0, 99.0, 101.0, 50.0, 50.0, 50.0, 50.0];
115        let value = payoff.path_value(&path, &obs_idx, &dfs);
116        assert!((value - (100.0 + 2.0 * 5.0) * 0.98).abs() < 1e-12);
117    }
118
119    #[test]
120    fn protected_redemption_when_never_called_nor_knocked() {
121        let payoff = note();
122        let path = [90.0, 92.0, 91.0, 95.0, 93.0, 92.0, 94.0, 96.0];
123        let value = payoff.path_value(&path, &[1, 3, 5, 7], &[0.99, 0.98, 0.97, 0.96]);
124        assert!((value - 100.0 * 0.96).abs() < 1e-12);
125    }
126
127    #[test]
128    fn downside_participation_after_knock_in() {
129        let payoff = note();
130        // dips through the 70 protection barrier, finishes at 80
131        let path = [90.0, 65.0, 75.0, 80.0, 78.0, 82.0, 79.0, 80.0];
132        let value = payoff.path_value(&path, &[1, 3, 5, 7], &[0.99, 0.98, 0.97, 0.96]);
133        assert!((value - 100.0 * (80.0 / 100.0) * 0.96).abs() < 1e-12);
134    }
135}