nautilus_data/python/
option_chain_manager.rs1use std::collections::HashMap;
24
25use nautilus_core::{
26 UnixNanos,
27 python::{correctness_error_to_pyvalue_err, to_pyvalue_err},
28};
29use nautilus_model::{
30 data::{
31 QuoteTick,
32 option_chain::{OptionChainSlice, OptionGreeks},
33 },
34 enums::OptionKind,
35 identifiers::{InstrumentId, OptionSeriesId},
36 python::data::option_chain::PyStrikeRange,
37 types::Price,
38};
39use pyo3::prelude::*;
40
41use crate::option_chains::{AtmTracker, OptionChainAggregator};
42
43fn parse_option_kind(value: u8) -> PyResult<OptionKind> {
44 match value {
45 0 => Ok(OptionKind::Call),
46 1 => Ok(OptionKind::Put),
47 _ => Err(to_pyvalue_err(format!(
48 "invalid `OptionKind` value, expected 0 (Call) or 1 (Put), received {value}"
49 ))),
50 }
51}
52
53#[pyclass(
64 name = "OptionChainManager",
65 module = "nautilus_trader.core.nautilus_pyo3.data"
66)]
67#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.data")]
68#[derive(Debug)]
69pub struct PyOptionChainManager {
70 aggregator: OptionChainAggregator,
71 series_id: OptionSeriesId,
72 raw_mode: bool,
73 bootstrapped: bool,
74}
75
76#[pymethods]
77#[pyo3_stub_gen::derive::gen_stub_pymethods]
78impl PyOptionChainManager {
79 #[new]
81 #[pyo3(signature = (series_id, strike_range, instruments, snapshot_interval_ms=None, initial_atm_price=None))]
82 fn py_new(
83 series_id: OptionSeriesId,
84 strike_range: PyStrikeRange,
85 instruments: HashMap<InstrumentId, (Price, u8)>,
86 snapshot_interval_ms: Option<u64>,
87 initial_atm_price: Option<Price>,
88 ) -> PyResult<Self> {
89 let rust_instruments: HashMap<InstrumentId, (Price, OptionKind)> = instruments
90 .into_iter()
91 .map(|(id, (strike, kind_u8))| {
92 parse_option_kind(kind_u8).map(|kind| (id, (strike, kind)))
93 })
94 .collect::<PyResult<_>>()?;
95
96 let mut tracker = AtmTracker::new();
97
98 if let Some((strike, _)) = rust_instruments.values().next() {
100 tracker.set_forward_precision(strike.precision);
101 }
102
103 if let Some(price) = initial_atm_price {
104 tracker.set_initial_price(price);
105 }
106
107 let aggregator =
108 OptionChainAggregator::new(series_id, strike_range.inner, tracker, rust_instruments);
109
110 let active_ids = aggregator.instrument_ids();
111 let all_ids = aggregator.all_instrument_ids();
112 let bootstrapped = !active_ids.is_empty() || all_ids.is_empty();
113 let raw_mode = snapshot_interval_ms.is_none();
114
115 Ok(Self {
116 aggregator,
117 series_id,
118 raw_mode,
119 bootstrapped,
120 })
121 }
122
123 #[pyo3(name = "handle_quote")]
128 fn py_handle_quote(&mut self, quote: &Bound<'_, PyAny>) -> PyResult<bool> {
129 let tick = quote
130 .extract::<QuoteTick>()
131 .or_else(|_| QuoteTick::from_pyobject(quote))?;
132 self.aggregator.update_quote(&tick);
133
134 if !self.bootstrapped && self.aggregator.atm_tracker().atm_price().is_some() {
135 self.aggregator.recompute_active_set();
136 self.bootstrapped = true;
137 return Ok(true);
138 }
139 Ok(false)
140 }
141
142 #[pyo3(name = "handle_greeks")]
147 fn py_handle_greeks(&mut self, greeks_obj: &Bound<'_, PyAny>) -> PyResult<bool> {
148 let greeks = greeks_obj
149 .extract::<OptionGreeks>()
150 .or_else(|_| OptionGreeks::from_pyobject(greeks_obj))?;
151
152 self.aggregator
153 .atm_tracker_mut()
154 .try_update_from_option_greeks(&greeks)
155 .map_err(correctness_error_to_pyvalue_err)?;
156
157 self.aggregator.update_greeks(&greeks);
158
159 if !self.bootstrapped && self.aggregator.atm_tracker().atm_price().is_some() {
160 self.aggregator.recompute_active_set();
161 self.bootstrapped = true;
162 return Ok(true);
163 }
164 Ok(false)
165 }
166
167 #[pyo3(name = "snapshot")]
171 fn py_snapshot(&self, ts_ns: u64) -> Option<OptionChainSlice> {
172 if self.aggregator.is_buffer_empty() {
173 return None;
174 }
175 Some(self.aggregator.snapshot(UnixNanos::from(ts_ns)))
176 }
177
178 #[pyo3(name = "check_rebalance")]
184 fn py_check_rebalance(&mut self, ts_ns: u64) -> Option<(Vec<InstrumentId>, Vec<InstrumentId>)> {
185 let now = UnixNanos::from(ts_ns);
186 let action = self.aggregator.check_rebalance(now)?;
187 let add = action.add.clone();
188 let remove = action.remove.clone();
189 self.aggregator.apply_rebalance(&action, now);
190 Some((add, remove))
191 }
192
193 #[pyo3(name = "active_instrument_ids")]
195 fn py_active_instrument_ids(&self) -> Vec<InstrumentId> {
196 self.aggregator.instrument_ids()
197 }
198
199 #[pyo3(name = "all_instrument_ids")]
201 fn py_all_instrument_ids(&self) -> Vec<InstrumentId> {
202 self.aggregator.all_instrument_ids()
203 }
204
205 #[pyo3(name = "add_instrument")]
209 fn py_add_instrument(
210 &mut self,
211 instrument_id: InstrumentId,
212 strike: Price,
213 kind: u8,
214 ) -> PyResult<bool> {
215 let option_kind = parse_option_kind(kind)?;
216 Ok(self
217 .aggregator
218 .add_instrument(instrument_id, strike, option_kind))
219 }
220
221 #[pyo3(name = "remove_instrument")]
225 fn py_remove_instrument(&mut self, instrument_id: InstrumentId) -> bool {
226 let _ = self.aggregator.remove_instrument(&instrument_id);
227 self.aggregator.is_catalog_empty()
228 }
229
230 #[getter]
231 #[pyo3(name = "series_id")]
232 fn py_series_id(&self) -> OptionSeriesId {
233 self.series_id
234 }
235
236 #[getter]
237 #[pyo3(name = "bootstrapped")]
238 fn py_bootstrapped(&self) -> bool {
239 self.bootstrapped
240 }
241
242 #[getter]
243 #[pyo3(name = "raw_mode")]
244 fn py_raw_mode(&self) -> bool {
245 self.raw_mode
246 }
247
248 #[getter]
249 #[pyo3(name = "atm_price")]
250 fn py_atm_price(&self) -> Option<Price> {
251 self.aggregator.atm_tracker().atm_price()
252 }
253
254 fn __repr__(&self) -> String {
255 format!(
256 "OptionChainManager(series_id={}, bootstrapped={}, raw_mode={}, \
257 active={}/{})",
258 self.series_id,
259 self.bootstrapped,
260 self.raw_mode,
261 self.aggregator.instrument_ids().len(),
262 self.aggregator.all_instrument_ids().len(),
263 )
264 }
265}