Skip to main content

rustyqlib/equity/
portfolio.rs

1//! Portfolio of options on a single underlying: aggregated Greeks and
2//! risk-based PnL attribution.
3//!
4//! Positions are quantity-weighted (negative quantity = short). All Greeks
5//! are additive, so the book's risk is the weighted sum of per-position
6//! Greeks — each computed by that position's own pricing engine, so a book
7//! can mix analytic vanillas, FD Americans and MC barriers.
8//!
9//! PnL attribution explains the book's change in value over a market move
10//! `(d_spot, d_vol, d_rate, d_time)` with a second-order Taylor expansion:
11//!
12//! ```text
13//! dV =  delta dS  +  1/2 gamma dS^2          (spot)
14//!    +  vega  dv  +  1/2 volga dv^2          (implied vol)
15//!    +  vanna dS dv                          (cross)
16//!    +  theta dt  +  rho dr                  (time, rate)
17//!    +  unexplained
18//! ```
19//!
20//! The `actual` PnL is a full reprice of every position under the shifted
21//! market ([`EquityOption::price_with`]), so `unexplained` is a true
22//! residual — third-order terms and any cross terms not in the expansion.
23
24use crate::core::traits::Instrument;
25use crate::equity::vanilla_option::EquityOption;
26
27/// A signed position in one option: `quantity` contracts (negative = short).
28pub struct Position {
29    pub option: EquityOption,
30    pub quantity: f64,
31}
32
33/// A book of option positions on the same underlying.
34#[derive(Default)]
35pub struct EquityPortfolio {
36    pub positions: Vec<Position>,
37}
38
39/// Quantity-weighted sums of the per-position Greeks.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct PortfolioGreeks {
42    pub npv: f64,
43    pub delta: f64,
44    pub gamma: f64,
45    pub vega: f64,
46    pub theta: f64,
47    pub rho: f64,
48    pub vanna: f64,
49    pub charm: f64,
50    pub zomma: f64,
51    pub volga: f64,
52}
53
54/// A market move to attribute PnL over. All fields default to zero, so a
55/// scenario can set only what moves, e.g.
56/// `MarketMove { d_spot: 2.0, d_time: 1.0 / 365.0, ..Default::default() }`.
57#[derive(Debug, Clone, Copy, Default)]
58pub struct MarketMove {
59    /// Absolute change in the underlying price.
60    pub d_spot: f64,
61    /// Parallel shift of the implied volatility (absolute, e.g. 0.01 = 1 pt).
62    pub d_vol: f64,
63    /// Parallel shift of the risk-free rate.
64    pub d_rate: f64,
65    /// Elapsed calendar time in years (1.0 / 365.0 = one day).
66    pub d_time: f64,
67}
68
69/// Risk-based PnL explain for one market move.
70#[derive(Debug, Clone, Copy)]
71pub struct PnlAttribution {
72    pub delta_pnl: f64,
73    pub gamma_pnl: f64,
74    pub vega_pnl: f64,
75    pub volga_pnl: f64,
76    pub vanna_pnl: f64,
77    pub theta_pnl: f64,
78    pub rho_pnl: f64,
79    /// Sum of the Taylor terms above.
80    pub explained: f64,
81    /// Full-reprice PnL of the book under the shifted market.
82    pub actual: f64,
83    /// `actual - explained`: third-order and unmodeled cross terms.
84    pub unexplained: f64,
85}
86
87impl EquityPortfolio {
88    pub fn new() -> Self {
89        Self { positions: Vec::new() }
90    }
91
92    /// Add `quantity` contracts of `option` (negative = short). All
93    /// positions must share one underlying; the first position pins the
94    /// symbol and a mismatch panics — this book aggregates risk against a
95    /// single spot.
96    pub fn add(&mut self, option: EquityOption, quantity: f64) -> &mut Self {
97        if let Some(first) = self.positions.first() {
98            assert_eq!(
99                first.option.base.symbol, option.base.symbol,
100                "EquityPortfolio aggregates one underlying: book is '{}', position is '{}'",
101                first.option.base.symbol, option.base.symbol
102            );
103        }
104        self.positions.push(Position { option, quantity });
105        self
106    }
107
108    pub fn len(&self) -> usize {
109        self.positions.len()
110    }
111
112    pub fn is_empty(&self) -> bool {
113        self.positions.is_empty()
114    }
115
116    /// Book value: quantity-weighted sum of position NPVs.
117    pub fn npv(&self) -> f64 {
118        self.positions.iter().map(|p| p.quantity * p.option.npv()).sum()
119    }
120
121    /// Aggregated Greeks, each position computed by its own engine.
122    pub fn greeks(&self) -> PortfolioGreeks {
123        let mut g = PortfolioGreeks::default();
124        for p in &self.positions {
125            let q = p.quantity;
126            g.npv += q * p.option.npv();
127            g.delta += q * p.option.delta();
128            g.gamma += q * p.option.gamma();
129            g.vega += q * p.option.vega();
130            g.theta += q * p.option.theta();
131            g.rho += q * p.option.rho();
132            g.vanna += q * p.option.vanna();
133            g.charm += q * p.option.charm();
134            g.zomma += q * p.option.zomma();
135            g.volga += q * p.option.volga();
136        }
137        g
138    }
139
140    /// Explain the book's PnL over `m` with second-order Greeks; `actual` is
141    /// a full reprice of every position under the shifted market.
142    pub fn pnl_attribution(&self, m: &MarketMove) -> PnlAttribution {
143        let g = self.greeks();
144
145        let delta_pnl = g.delta * m.d_spot;
146        let gamma_pnl = 0.5 * g.gamma * m.d_spot * m.d_spot;
147        let vega_pnl = g.vega * m.d_vol;
148        let volga_pnl = 0.5 * g.volga * m.d_vol * m.d_vol;
149        let vanna_pnl = g.vanna * m.d_spot * m.d_vol;
150        let theta_pnl = g.theta * m.d_time;
151        let rho_pnl = g.rho * m.d_rate;
152        let explained =
153            delta_pnl + gamma_pnl + vega_pnl + volga_pnl + vanna_pnl + theta_pnl + rho_pnl;
154
155        // base from price_with(0,0,0,0), not npv(): under Monte Carlo both
156        // legs then share the same draws and the difference is noise-free
157        let actual: f64 = self
158            .positions
159            .iter()
160            .map(|p| {
161                p.quantity
162                    * (p.option.price_with(m.d_spot, m.d_vol, m.d_rate, m.d_time)
163                        - p.option.price_with(0.0, 0.0, 0.0, 0.0))
164            })
165            .sum();
166
167        PnlAttribution {
168            delta_pnl,
169            gamma_pnl,
170            vega_pnl,
171            volga_pnl,
172            vanna_pnl,
173            theta_pnl,
174            rho_pnl,
175            explained,
176            actual,
177            unexplained: actual - explained,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::core::trade::PutOrCall;
186    use crate::equity::builder::EquityOptionBuilder;
187    use crate::equity::utils::Engine;
188    use chrono::NaiveDate;
189
190    fn option(put_or_call: PutOrCall, strike: f64) -> EquityOption {
191        EquityOptionBuilder::new()
192            .symbol("ACME")
193            .spot(100.0)
194            .strike(strike)
195            .flat_vol(0.30)
196            .flat_rate(0.05)
197            .dividend_yield(0.02)
198            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
199            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
200            .vanilla(put_or_call)
201            .engine(Engine::BlackScholes)
202            .build().expect("option must build")
203    }
204
205    #[test]
206    fn aggregation_is_quantity_weighted() {
207        // 1 + 1 of the same option equals 2 of it
208        let mut two_singles = EquityPortfolio::new();
209        two_singles.add(option(PutOrCall::Call, 100.0), 1.0);
210        two_singles.add(option(PutOrCall::Call, 100.0), 1.0);
211        let mut one_double = EquityPortfolio::new();
212        one_double.add(option(PutOrCall::Call, 100.0), 2.0);
213        let (a, b) = (two_singles.greeks(), one_double.greeks());
214        assert!((a.npv - b.npv).abs() < 1e-12);
215        assert!((a.delta - b.delta).abs() < 1e-12);
216        assert!((a.volga - b.volga).abs() < 1e-12);
217
218        // long + short cancels exactly
219        let mut flat = EquityPortfolio::new();
220        flat.add(option(PutOrCall::Call, 100.0), 5.0);
221        flat.add(option(PutOrCall::Call, 100.0), -5.0);
222        let g = flat.greeks();
223        for v in [g.npv, g.delta, g.gamma, g.vega, g.theta, g.rho, g.vanna, g.volga] {
224            assert!(v.abs() < 1e-12);
225        }
226    }
227
228    #[test]
229    fn straddle_greeks_have_the_expected_shape() {
230        let mut straddle = EquityPortfolio::new();
231        straddle.add(option(PutOrCall::Call, 100.0), 1.0);
232        straddle.add(option(PutOrCall::Put, 100.0), 1.0);
233        let g = straddle.greeks();
234        // near-ATM straddle: small residual delta, long gamma and vega
235        assert!(g.delta.abs() < 0.25);
236        assert!(g.gamma > 0.0);
237        assert!(g.vega > 0.0);
238        assert!(g.theta < 0.0);
239    }
240
241    #[test]
242    #[should_panic(expected = "one underlying")]
243    fn mixed_underlyings_are_rejected() {
244        let other = EquityOptionBuilder::new()
245            .symbol("OTHER")
246            .spot(50.0)
247            .strike(50.0)
248            .flat_vol(0.2)
249            .flat_rate(0.05)
250            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
251            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
252            .vanilla(PutOrCall::Call)
253            .engine(Engine::BlackScholes)
254            .build().expect("option must build");
255        let mut book = EquityPortfolio::new();
256        book.add(option(PutOrCall::Call, 100.0), 1.0);
257        book.add(other, 1.0);
258    }
259
260    #[test]
261    fn attribution_explains_small_moves() {
262        let mut book = EquityPortfolio::new();
263        book.add(option(PutOrCall::Call, 100.0), 10.0);
264        book.add(option(PutOrCall::Call, 110.0), -15.0);
265        book.add(option(PutOrCall::Put, 95.0), 5.0);
266
267        let m = MarketMove { d_spot: 1.0, d_vol: 0.01, d_rate: 1e-4, d_time: 1.0 / 365.0 };
268        let a = book.pnl_attribution(&m);
269
270        // the Taylor terms must reproduce the reprice up to third order
271        assert!((a.explained - (a.delta_pnl + a.gamma_pnl + a.vega_pnl + a.volga_pnl
272            + a.vanna_pnl + a.theta_pnl + a.rho_pnl)).abs() < 1e-12);
273        assert!(
274            a.unexplained.abs() < 0.01 * a.actual.abs().max(1.0),
275            "unexplained {} vs actual {}",
276            a.unexplained,
277            a.actual
278        );
279        assert!((a.actual - a.explained - a.unexplained).abs() < 1e-12);
280    }
281
282    #[test]
283    fn pure_time_move_is_theta() {
284        let mut book = EquityPortfolio::new();
285        book.add(option(PutOrCall::Call, 100.0), 10.0);
286        let m = MarketMove { d_time: 1.0 / 365.0, ..Default::default() };
287        let a = book.pnl_attribution(&m);
288        assert_eq!(a.delta_pnl, 0.0);
289        assert_eq!(a.vega_pnl, 0.0);
290        // theta term explains an overnight move to within second-order time decay
291        assert!((a.actual - a.theta_pnl).abs() < 5e-4 * a.theta_pnl.abs().max(1.0));
292    }
293
294    #[test]
295    fn attribution_holds_across_engines() {
296        // same book priced analytically and on the FD grid: attribution
297        // buckets must broadly agree (grid discretization is the tolerance)
298        let m = MarketMove { d_spot: 2.0, d_vol: 0.02, d_rate: 0.0, d_time: 1.0 / 365.0 };
299
300        let mut analytic = EquityPortfolio::new();
301        analytic.add(option(PutOrCall::Call, 100.0), 10.0);
302        let a = analytic.pnl_attribution(&m);
303
304        let mut fd_book = EquityPortfolio::new();
305        let mut fd = option(PutOrCall::Call, 100.0);
306        fd.engine = crate::equity::utils::PricingEngine::from_kind(Engine::FiniteDifference);
307        fd_book.add(fd, 10.0);
308        let f = fd_book.pnl_attribution(&m);
309
310        assert!((a.actual - f.actual).abs() < 0.05 * a.actual.abs().max(1.0),
311            "analytic actual {} vs fd actual {}", a.actual, f.actual);
312        assert!((a.delta_pnl - f.delta_pnl).abs() < 0.05 * a.delta_pnl.abs().max(1.0));
313    }
314}