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, Clone)]
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 observations over the life (last = expiry). Equally
33    /// spaced unless `observation_times` is set.
34    pub observations: usize,
35    /// Explicit observation times as year fractions from valuation,
36    /// strictly increasing, last at expiry — e.g. from a
37    /// [`Schedule`](crate::core::calendar::Schedule) of business-day
38    /// adjusted call dates. `None` keeps equal spacing.
39    pub observation_times: Option<Vec<f64>>,
40    pub notional: f64,
41    /// Contractual initial fixing for the downside participation ratio.
42    pub initial_fixing: f64,
43    /// Phoenix feature: when set, the coupon is paid **at each
44    /// observation** with `S >= coupon_barrier` (independently of the
45    /// autocall), instead of accruing as a rebate paid only at call.
46    pub coupon_barrier: Option<f64>,
47    /// Phoenix memory feature: missed coupons are recovered at the next
48    /// observation above the coupon barrier.
49    pub memory: bool,
50}
51
52impl AutocallablePayoff {
53    /// Value of one simulated path: redemption cash flow times the discount
54    /// factor of its payment date. `obs_idx` maps observation m to its path
55    /// step; `dfs[m]` is the discount factor to that date.
56    pub fn path_value(&self, path: &[f64], obs_idx: &[usize], dfs: &[f64]) -> f64 {
57        match self.coupon_barrier {
58            None => self.athena_path_value(path, obs_idx, dfs),
59            Some(cb) => self.phoenix_path_value(path, obs_idx, dfs, cb),
60        }
61    }
62
63    /// Classic Athena: the coupon accrues and is paid only at call.
64    fn athena_path_value(&self, path: &[f64], obs_idx: &[usize], dfs: &[f64]) -> f64 {
65        for (m, &idx) in obs_idx.iter().enumerate() {
66            if path[idx] >= self.autocall_barrier {
67                return (self.notional + self.coupon * (m + 1) as f64) * dfs[m];
68            }
69        }
70        self.redemption_at_maturity(path) * dfs.last().unwrap()
71    }
72
73    /// Phoenix: conditional coupons at every observation above the coupon
74    /// barrier (with optional memory), redemption logic unchanged.
75    fn phoenix_path_value(&self, path: &[f64], obs_idx: &[usize], dfs: &[f64], cb: f64) -> f64 {
76        let mut value = 0.0;
77        let mut missed = 0usize;
78        for (m, &idx) in obs_idx.iter().enumerate() {
79            let s = path[idx];
80            if s >= cb {
81                let units = if self.memory { 1 + missed } else { 1 };
82                value += self.coupon * units as f64 * dfs[m];
83                missed = 0;
84            } else {
85                missed += 1;
86            }
87            if s >= self.autocall_barrier {
88                return value + self.notional * dfs[m];
89            }
90        }
91        value + self.redemption_at_maturity(path) * dfs.last().unwrap()
92    }
93
94    /// Never called: knock-in protection at maturity.
95    fn redemption_at_maturity(&self, path: &[f64]) -> f64 {
96        let knocked_in = path.iter().any(|&s| s <= self.protection_barrier);
97        if knocked_in {
98            self.notional * (path.last().unwrap() / self.initial_fixing)
99        } else {
100            self.notional
101        }
102    }
103}
104
105impl Payoff for AutocallablePayoff {
106    /// Degenerate single-point value: zero (all value is path- and
107    /// schedule-dependent).
108    fn payoff(&self, _spot: f64, _strike: f64) -> f64 {
109        0.0
110    }
111    fn path_payoff(&self, _path: &[f64], _strike: f64) -> f64 {
112        panic!(
113            "Autocallables pay at multiple dates and cannot be valued through \
114             path_payoff; the Monte Carlo engine prices them via path_value"
115        );
116    }
117    fn is_path_dependent(&self) -> bool {
118        true
119    }
120    fn payoff_kind(&self) -> PayoffType {
121        PayoffType::Autocallable
122    }
123    fn put_or_call(&self) -> &PutOrCall {
124        // the embedded optionality is put-like; the field is not used by
125        // the pricing routes
126        &PutOrCall::Call
127    }
128    fn exercise_style(&self) -> &ContractStyle {
129        &self.exercise_style
130    }
131    fn as_any(&self) -> &dyn std::any::Any {
132        self
133    }
134    fn clone_box(&self) -> Box<dyn Payoff> {
135        Box::new(self.clone())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn note() -> AutocallablePayoff {
144        AutocallablePayoff {
145            exercise_style: ContractStyle::European,
146            autocall_barrier: 100.0,
147            protection_barrier: 70.0,
148            coupon: 5.0,
149            observations: 4,
150            observation_times: None,
151            notional: 100.0,
152            initial_fixing: 100.0,
153            coupon_barrier: None,
154            memory: false,
155        }
156    }
157
158    #[test]
159    fn calls_at_first_breach_with_accrued_coupon() {
160        let payoff = note();
161        let obs_idx = [1, 3, 5, 7];
162        let dfs = [0.99, 0.98, 0.97, 0.96];
163        // second observation (index 3) is the first at/above the barrier
164        let path = [90.0, 95.0, 99.0, 101.0, 50.0, 50.0, 50.0, 50.0];
165        let value = payoff.path_value(&path, &obs_idx, &dfs);
166        assert!((value - (100.0 + 2.0 * 5.0) * 0.98).abs() < 1e-12);
167    }
168
169    #[test]
170    fn protected_redemption_when_never_called_nor_knocked() {
171        let payoff = note();
172        let path = [90.0, 92.0, 91.0, 95.0, 93.0, 92.0, 94.0, 96.0];
173        let value = payoff.path_value(&path, &[1, 3, 5, 7], &[0.99, 0.98, 0.97, 0.96]);
174        assert!((value - 100.0 * 0.96).abs() < 1e-12);
175    }
176
177    fn phoenix(memory: bool) -> AutocallablePayoff {
178        let mut p = note();
179        p.coupon_barrier = Some(80.0);
180        p.memory = memory;
181        p
182    }
183
184    #[test]
185    fn phoenix_pays_conditional_coupons_and_redeems_at_call() {
186        let payoff = phoenix(false);
187        let obs_idx = [1, 3, 5, 7];
188        let dfs = [0.99, 0.98, 0.97, 0.96];
189        // obs1: 85 >= 80 -> coupon; obs2: 101 -> coupon + autocall
190        let path = [90.0, 85.0, 99.0, 101.0, 50.0, 50.0, 50.0, 50.0];
191        let value = payoff.path_value(&path, &obs_idx, &dfs);
192        let expect = 5.0 * 0.99 + 5.0 * 0.98 + 100.0 * 0.98;
193        assert!((value - expect).abs() < 1e-12, "{value} vs {expect}");
194    }
195
196    #[test]
197    fn phoenix_memory_recovers_missed_coupons() {
198        let no_memory = phoenix(false);
199        let with_memory = phoenix(true);
200        let obs_idx = [1, 3, 5, 7];
201        let dfs = [0.99, 0.98, 0.97, 0.96];
202        // obs1 below coupon barrier (75 < 80), obs2 above (85): memory
203        // pays 2 coupons there; never autocalled, never knocked in (>70)
204        let path = [90.0, 75.0, 78.0, 85.0, 90.0, 88.0, 90.0, 95.0];
205        let v_plain = no_memory.path_value(&path, &obs_idx, &dfs);
206        let v_memory = with_memory.path_value(&path, &obs_idx, &dfs);
207        // plain: coupons at obs2, obs3, obs4 + notional at maturity
208        let plain = 5.0 * (0.98 + 0.97 + 0.96) + 100.0 * 0.96;
209        // memory: obs2 pays the missed obs1 coupon too
210        assert!((v_plain - plain).abs() < 1e-12, "{v_plain} vs {plain}");
211        assert!((v_memory - (plain + 5.0 * 0.98)).abs() < 1e-12, "{v_memory}");
212    }
213
214    #[test]
215    fn phoenix_with_zero_coupon_equals_athena_with_zero_coupon() {
216        // no coupons anywhere: both structures are pure autocall + protection
217        let mut athena = note();
218        athena.coupon = 0.0;
219        let mut phx = phoenix(true);
220        phx.coupon = 0.0;
221        let obs_idx = [1, 3, 5, 7];
222        let dfs = [0.99, 0.98, 0.97, 0.96];
223        for path in [
224            [90.0, 85.0, 99.0, 101.0, 50.0, 50.0, 50.0, 50.0],
225            [90.0, 65.0, 75.0, 80.0, 78.0, 82.0, 79.0, 80.0],
226            [90.0, 92.0, 91.0, 95.0, 93.0, 92.0, 94.0, 96.0],
227        ] {
228            let a = athena.path_value(&path, &obs_idx, &dfs);
229            let p = phx.path_value(&path, &obs_idx, &dfs);
230            assert!((a - p).abs() < 1e-12);
231        }
232    }
233
234    #[test]
235    fn downside_participation_after_knock_in() {
236        let payoff = note();
237        // dips through the 70 protection barrier, finishes at 80
238        let path = [90.0, 65.0, 75.0, 80.0, 78.0, 82.0, 79.0, 80.0];
239        let value = payoff.path_value(&path, &[1, 3, 5, 7], &[0.99, 0.98, 0.97, 0.96]);
240        assert!((value - 100.0 * (80.0 / 100.0) * 0.96).abs() < 1e-12);
241    }
242}