1use 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}