Skip to main content

nautilus_common/python/
greeks.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::collections::HashMap;
17
18use nautilus_core::{UnixNanos, python::to_pyvalue_err};
19use nautilus_model::{
20    data::greeks::{GreeksData, PortfolioGreeks},
21    enums::PositionSide,
22    identifiers::{InstrumentId, StrategyId, Venue},
23    position::Position,
24    types::Price,
25};
26use pyo3::{IntoPyObjectExt, prelude::*};
27
28use crate::{
29    greeks::{GreeksCalculator, GreeksFilter},
30    python::{cache::PyCache, clock::PyClock},
31};
32
33#[allow(non_camel_case_types)]
34#[pyo3::pyclass(
35    module = "nautilus_trader.common",
36    name = "GreeksCalculator",
37    unsendable
38)]
39#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.common")]
40#[derive(Debug)]
41pub struct PyGreeksCalculator(GreeksCalculator);
42
43#[pymethods]
44#[pyo3_stub_gen::derive::gen_stub_pymethods]
45impl PyGreeksCalculator {
46    #[new]
47    #[expect(clippy::needless_pass_by_value)]
48    fn py_new(cache: PyCache, clock: PyClock) -> Self {
49        Self(GreeksCalculator::new(cache.cache_rc(), clock.clock_rc()))
50    }
51
52    #[expect(clippy::too_many_arguments, clippy::needless_pass_by_value)]
53    #[pyo3(
54        name = "instrument_greeks",
55        signature = (
56            instrument_id,
57            flat_interest_rate=0.0425,
58            flat_dividend_yield=None,
59            spot_shock=0.0,
60            vol_shock=0.0,
61            time_to_expiry_shock=0.0,
62            use_cached_greeks=false,
63            update_vol=false,
64            cache_greeks=false,
65            ts_event=0,
66            position=None,
67            percent_greeks=false,
68            index_instrument_id=None,
69            beta_weights=None,
70            vega_time_weight_base=None,
71            vol_index_instrument_id=None,
72            vol_beta_weights=None
73        )
74    )]
75    fn py_instrument_greeks(
76        &self,
77        instrument_id: InstrumentId,
78        flat_interest_rate: f64,
79        flat_dividend_yield: Option<f64>,
80        spot_shock: f64,
81        vol_shock: f64,
82        time_to_expiry_shock: f64,
83        use_cached_greeks: bool,
84        update_vol: bool,
85        cache_greeks: bool,
86        ts_event: u64,
87        position: Option<Position>,
88        percent_greeks: bool,
89        index_instrument_id: Option<InstrumentId>,
90        beta_weights: Option<HashMap<InstrumentId, f64>>,
91        vega_time_weight_base: Option<i32>,
92        vol_index_instrument_id: Option<InstrumentId>,
93        vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
94    ) -> PyResult<Option<GreeksData>> {
95        match self.0.instrument_greeks(
96            instrument_id,
97            Some(flat_interest_rate),
98            flat_dividend_yield,
99            Some(spot_shock),
100            Some(vol_shock),
101            Some(time_to_expiry_shock),
102            Some(use_cached_greeks),
103            Some(update_vol),
104            Some(cache_greeks),
105            Some(false),
106            (ts_event != 0).then(|| UnixNanos::from(ts_event)),
107            position,
108            Some(percent_greeks),
109            index_instrument_id,
110            beta_weights.as_ref(),
111            vega_time_weight_base,
112            vol_index_instrument_id,
113            vol_beta_weights.as_ref(),
114        ) {
115            Ok(greeks) => Ok(Some(greeks)),
116            Err(e) if is_missing_market_data_error(&e) => Ok(None),
117            Err(e) => Err(to_pyvalue_err(e)),
118        }
119    }
120
121    #[expect(clippy::too_many_arguments, clippy::needless_pass_by_value)]
122    #[pyo3(
123        name = "modify_greeks",
124        signature = (
125            delta_input,
126            gamma_input,
127            underlying_instrument_id,
128            underlying_price,
129            unshocked_underlying_price,
130            percent_greeks,
131            index_instrument_id=None,
132            beta_weights=None,
133            vega_input=0.0,
134            vol=0.0,
135            expiry_in_days=0,
136            vega_time_weight_base=None,
137            unshocked_vol=0.0,
138            vol_index_instrument_id=None,
139            vol_beta_weights=None,
140            index_price=None,
141            vol_index_price=None
142        )
143    )]
144    fn py_modify_greeks(
145        &self,
146        delta_input: f64,
147        gamma_input: f64,
148        underlying_instrument_id: InstrumentId,
149        underlying_price: f64,
150        unshocked_underlying_price: f64,
151        percent_greeks: bool,
152        index_instrument_id: Option<InstrumentId>,
153        beta_weights: Option<HashMap<InstrumentId, f64>>,
154        vega_input: f64,
155        vol: f64,
156        expiry_in_days: i32,
157        vega_time_weight_base: Option<i32>,
158        unshocked_vol: f64,
159        vol_index_instrument_id: Option<InstrumentId>,
160        vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
161        index_price: Option<f64>,
162        vol_index_price: Option<f64>,
163    ) -> PyResult<(f64, f64, f64)> {
164        self.0
165            .modify_greeks(
166                delta_input,
167                gamma_input,
168                underlying_instrument_id,
169                underlying_price,
170                unshocked_underlying_price,
171                percent_greeks,
172                index_instrument_id,
173                beta_weights.as_ref(),
174                vega_input,
175                vol,
176                expiry_in_days,
177                vega_time_weight_base,
178                unshocked_vol,
179                vol_index_instrument_id,
180                vol_beta_weights.as_ref(),
181                index_price,
182                vol_index_price,
183            )
184            .map_err(to_pyvalue_err)
185    }
186
187    #[expect(clippy::too_many_arguments, clippy::needless_pass_by_value)]
188    #[pyo3(
189        name = "portfolio_greeks",
190        signature = (
191            underlyings=None,
192            venue=None,
193            instrument_id=None,
194            strategy_id=None,
195            side=None,
196            flat_interest_rate=0.0425,
197            flat_dividend_yield=None,
198            spot_shock=0.0,
199            vol_shock=0.0,
200            time_to_expiry_shock=0.0,
201            use_cached_greeks=false,
202            update_vol=false,
203            cache_greeks=false,
204            percent_greeks=false,
205            index_instrument_id=None,
206            beta_weights=None,
207            greeks_filter=None,
208            vega_time_weight_base=None,
209            vol_index_instrument_id=None,
210            vol_beta_weights=None
211        )
212    )]
213    fn py_portfolio_greeks(
214        &self,
215        underlyings: Option<Vec<String>>,
216        venue: Option<Venue>,
217        instrument_id: Option<InstrumentId>,
218        strategy_id: Option<StrategyId>,
219        side: Option<PositionSide>,
220        flat_interest_rate: f64,
221        flat_dividend_yield: Option<f64>,
222        spot_shock: f64,
223        vol_shock: f64,
224        time_to_expiry_shock: f64,
225        use_cached_greeks: bool,
226        update_vol: bool,
227        cache_greeks: bool,
228        percent_greeks: bool,
229        index_instrument_id: Option<InstrumentId>,
230        beta_weights: Option<HashMap<InstrumentId, f64>>,
231        greeks_filter: Option<Py<PyAny>>,
232        vega_time_weight_base: Option<i32>,
233        vol_index_instrument_id: Option<InstrumentId>,
234        vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
235    ) -> PyResult<PortfolioGreeks> {
236        let greeks_filter: Option<GreeksFilter> = greeks_filter.map(|callback| {
237            Box::new(move |data: &GreeksData| {
238                Python::attach(|py| {
239                    let result = data
240                        .clone()
241                        .into_py_any(py)
242                        .and_then(|data| callback.bind(py).call1((data,)))
243                        .and_then(|result| result.extract::<bool>());
244                    result.unwrap_or_else(|e| {
245                        log::error!("Error calling Python greeks filter: {e}");
246                        false
247                    })
248                })
249            }) as GreeksFilter
250        });
251
252        self.0
253            .portfolio_greeks(
254                underlyings.as_deref(),
255                venue,
256                instrument_id,
257                strategy_id,
258                side,
259                Some(flat_interest_rate),
260                flat_dividend_yield,
261                Some(spot_shock),
262                Some(vol_shock),
263                Some(time_to_expiry_shock),
264                Some(use_cached_greeks),
265                Some(update_vol),
266                Some(cache_greeks),
267                Some(false),
268                Some(percent_greeks),
269                index_instrument_id,
270                beta_weights.as_ref(),
271                greeks_filter.as_ref(),
272                vega_time_weight_base,
273                vol_index_instrument_id,
274                vol_beta_weights.as_ref(),
275            )
276            .map_err(to_pyvalue_err)
277    }
278
279    #[pyo3(name = "cache_futures_spread")]
280    fn py_cache_futures_spread(
281        &self,
282        call_instrument_id: InstrumentId,
283        put_instrument_id: InstrumentId,
284        futures_instrument_id: InstrumentId,
285    ) -> PyResult<Price> {
286        self.0
287            .cache_futures_spread(call_instrument_id, put_instrument_id, futures_instrument_id)
288            .map_err(to_pyvalue_err)
289    }
290
291    #[pyo3(name = "get_cached_futures_spread_price")]
292    fn py_get_cached_futures_spread_price(
293        &self,
294        underlying_instrument_id: InstrumentId,
295    ) -> Option<Price> {
296        self.0
297            .get_cached_futures_spread_price(underlying_instrument_id)
298    }
299}
300
301fn is_missing_market_data_error(error: &anyhow::Error) -> bool {
302    error.to_string().starts_with("No price available for ")
303}
304
305#[cfg(test)]
306mod tests {
307    use std::{cell::RefCell, rc::Rc};
308
309    use nautilus_model::{
310        data::QuoteTick,
311        identifiers::InstrumentId,
312        instruments::{
313            Instrument, InstrumentAny,
314            stubs::{equity_aapl, option_contract_appl},
315        },
316        types::{Price, Quantity},
317    };
318    use rstest::rstest;
319
320    use super::*;
321    use crate::{
322        cache::{Cache, INSTRUMENT_NOT_FOUND},
323        clock::TestClock,
324    };
325
326    #[derive(Clone, Copy)]
327    enum MissingPriceCase {
328        Option,
329        Underlying,
330        VolIndex,
331        NonOption,
332    }
333
334    #[rstest]
335    #[case::option_price(MissingPriceCase::Option)]
336    #[case::underlying_price(MissingPriceCase::Underlying)]
337    #[case::vol_index_price(MissingPriceCase::VolIndex)]
338    #[case::non_option_price(MissingPriceCase::NonOption)]
339    fn test_py_instrument_greeks_returns_none_when_market_price_missing(
340        #[case] case: MissingPriceCase,
341    ) {
342        let (calculator, instrument_id, vol_index_instrument_id) =
343            calculator_for_missing_price_case(case);
344
345        let result =
346            py_instrument_greeks(&calculator, instrument_id, vol_index_instrument_id).unwrap();
347
348        assert!(result.is_none());
349    }
350
351    #[rstest]
352    fn test_py_instrument_greeks_raises_when_instrument_missing() {
353        Python::initialize();
354        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
355        let calculator = make_calculator(cache);
356
357        let error = py_instrument_greeks(
358            &calculator,
359            InstrumentId::from("AAPL211217C00150000.OPRA"),
360            None,
361        )
362        .unwrap_err();
363
364        assert!(
365            error
366                .to_string()
367                .contains(&format!("{INSTRUMENT_NOT_FOUND}: AAPL211217C00150000.OPRA"))
368        );
369    }
370
371    fn py_instrument_greeks(
372        calculator: &PyGreeksCalculator,
373        instrument_id: InstrumentId,
374        vol_index_instrument_id: Option<InstrumentId>,
375    ) -> PyResult<Option<GreeksData>> {
376        calculator.py_instrument_greeks(
377            instrument_id,
378            0.0425,
379            None,
380            0.0,
381            0.0,
382            0.0,
383            false,
384            false,
385            false,
386            0,
387            None,
388            false,
389            None,
390            None,
391            None,
392            vol_index_instrument_id,
393            None,
394        )
395    }
396
397    fn calculator_for_missing_price_case(
398        case: MissingPriceCase,
399    ) -> (PyGreeksCalculator, InstrumentId, Option<InstrumentId>) {
400        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
401        let option = option_contract_appl();
402        let option_id = option.id();
403        let underlying_id = InstrumentId::from("AAPL.OPRA");
404        let vol_index_id = InstrumentId::from("VIX.XCBF");
405
406        match case {
407            MissingPriceCase::Option => {
408                cache
409                    .borrow_mut()
410                    .add_instrument(InstrumentAny::OptionContract(option))
411                    .unwrap();
412
413                (make_calculator(cache), option_id, None)
414            }
415            MissingPriceCase::Underlying => {
416                cache
417                    .borrow_mut()
418                    .add_instrument(InstrumentAny::OptionContract(option))
419                    .unwrap();
420                add_quote(&cache, option_id, "10.50");
421
422                (make_calculator(cache), option_id, None)
423            }
424            MissingPriceCase::VolIndex => {
425                cache
426                    .borrow_mut()
427                    .add_instrument(InstrumentAny::OptionContract(option))
428                    .unwrap();
429                add_quote(&cache, option_id, "10.50");
430                add_quote(&cache, underlying_id, "150.00");
431
432                (make_calculator(cache), option_id, Some(vol_index_id))
433            }
434            MissingPriceCase::NonOption => {
435                let equity = equity_aapl();
436                let instrument_id = equity.id();
437                cache
438                    .borrow_mut()
439                    .add_instrument(InstrumentAny::Equity(equity))
440                    .unwrap();
441
442                (make_calculator(cache), instrument_id, None)
443            }
444        }
445    }
446
447    fn add_quote(cache: &Rc<RefCell<Cache>>, instrument_id: InstrumentId, price: &str) {
448        let ts = UnixNanos::from(1u64);
449        cache
450            .borrow_mut()
451            .add_quote(QuoteTick::new(
452                instrument_id,
453                Price::from(price),
454                Price::from(price),
455                Quantity::from(100),
456                Quantity::from(100),
457                ts,
458                ts,
459            ))
460            .unwrap();
461    }
462
463    fn make_calculator(cache: Rc<RefCell<Cache>>) -> PyGreeksCalculator {
464        let clock = Rc::new(RefCell::new(TestClock::new()));
465        PyGreeksCalculator(GreeksCalculator::new(cache, clock))
466    }
467}