1use crate::core::traits::Instrument;
25use crate::equity::vanilla_option::EquityOption;
26
27pub struct Position {
29 pub option: EquityOption,
30 pub quantity: f64,
31}
32
33#[derive(Default)]
35pub struct EquityPortfolio {
36 pub positions: Vec<Position>,
37}
38
39#[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#[derive(Debug, Clone, Copy, Default)]
58pub struct MarketMove {
59 pub d_spot: f64,
61 pub d_vol: f64,
63 pub d_rate: f64,
65 pub d_time: f64,
67}
68
69#[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 pub explained: f64,
81 pub actual: f64,
83 pub unexplained: f64,
85}
86
87impl EquityPortfolio {
88 pub fn new() -> Self {
89 Self { positions: Vec::new() }
90 }
91
92 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 pub fn npv(&self) -> f64 {
118 self.positions.iter().map(|p| p.quantity * p.option.npv()).sum()
119 }
120
121 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 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 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 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 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 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 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 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 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}