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