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