nautilus_persistence/python/
feather.rs1use std::{
19 cell::RefCell,
20 collections::{HashMap, HashSet},
21 rc::Rc,
22};
23
24use nautilus_common::{
25 live::get_runtime,
26 python::{cache::PyCache, clock::PyClock},
27};
28use nautilus_core::{UnixNanos, datetime::get_timezone};
29use nautilus_model::{
30 data::{
31 Bar, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate,
32 OptionGreeks, OrderBookDelta, OrderBookDepth10, QuoteTick, TradeTick,
33 close::InstrumentClose,
34 },
35 events::{
36 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
37 OrderEmulated, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
38 OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased,
39 OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated, PositionAdjusted,
40 PositionChanged, PositionClosed, PositionOpened, PositionSnapshot,
41 },
42 python::instruments::pyobject_to_instrument_any,
43 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
44};
45use object_store::ObjectStoreExt;
46use pyo3::{exceptions::PyIOError, prelude::*};
47
48use crate::{
49 backend::feather::{
50 FeatherWriter, FeatherWriterSubscriptions, RotationConfig, default_per_instrument_types,
51 },
52 parquet::{ObjectStoreLocationKind, create_object_store_location_from_path},
53};
54
55#[pyclass(
60 name = "StreamingFeatherWriter",
61 module = "nautilus_trader.persistence",
62 unsendable
63)]
64#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")]
65pub struct PyStreamingFeatherWriter {
66 writer: Rc<RefCell<FeatherWriter>>,
67 subscriptions: Option<FeatherWriterSubscriptions>,
68}
69
70#[pymethods]
71#[pyo3_stub_gen::derive::gen_stub_pymethods]
72impl PyStreamingFeatherWriter {
73 #[new]
90 #[pyo3(signature = (
91 path,
92 cache,
93 clock,
94 fs_protocol=None,
95 fs_storage_options=None,
96 include_types=None,
97 rotation_mode=3,
98 max_file_size=1_073_741_824,
99 rotation_interval_ns=None,
100 rotation_time_ns=None,
101 rotation_timezone="UTC",
102 flush_interval_ms=None,
103 replace=false
104 ))]
105 #[expect(clippy::too_many_arguments, clippy::needless_pass_by_value)]
106 pub fn py_new(
107 path: String,
108 cache: PyCache,
109 clock: PyClock,
110 fs_protocol: Option<&str>,
111 fs_storage_options: Option<HashMap<String, String>>,
112 include_types: Option<Vec<String>>,
113 rotation_mode: u8,
114 max_file_size: u64,
115 rotation_interval_ns: Option<u64>,
116 rotation_time_ns: Option<u64>,
117 rotation_timezone: &str,
118 flush_interval_ms: Option<u64>,
119 replace: bool,
120 ) -> PyResult<Self> {
121 let full_path = match fs_protocol {
124 Some(protocol) if protocol != "file" && !path.contains("://") => {
125 format!("{protocol}://{path}")
126 }
127 _ => path,
128 };
129
130 let storage_options = fs_storage_options
131 .map(|map| map.into_iter().collect::<ahash::AHashMap<String, String>>());
132
133 let location = create_object_store_location_from_path(&full_path, storage_options)
134 .map_err(|e| PyIOError::new_err(format!("Failed to create object store: {e}")))?;
135 let is_local_store = matches!(&location.kind, ObjectStoreLocationKind::Local);
136 let object_store = location.object_store;
137 let base_path = location.base_path;
138
139 if replace {
141 let runtime = get_runtime();
142 let store_ref = object_store.clone();
143 let prefix = if base_path.is_empty() {
144 if !is_local_store {
145 return Err(PyIOError::new_err(
146 "replace=True for remote streaming paths requires a non-empty prefix",
147 ));
148 }
149 None
150 } else {
151 Some(object_store::path::Path::from(base_path.clone()))
152 };
153 runtime
154 .block_on(async {
155 let mut stream = store_ref.list(prefix.as_ref());
156 let mut to_delete = Vec::new();
157
158 while let Some(result) = futures::StreamExt::next(&mut stream).await {
159 if let Ok(meta) = result {
160 to_delete.push(meta.location);
161 }
162 }
163
164 for path in to_delete {
165 let _ = store_ref.delete(&path).await;
166 }
167 Ok::<(), anyhow::Error>(())
168 })
169 .map_err(|e| {
170 PyIOError::new_err(format!("Failed to replace existing files: {e}"))
171 })?;
172 }
173
174 let rotation_config = match rotation_mode {
177 0 => RotationConfig::Size {
178 max_size: max_file_size,
179 },
180 1 => {
181 let interval = rotation_interval_ns.unwrap_or(86_400_000_000_000); RotationConfig::Interval {
183 interval_ns: interval,
184 }
185 }
186 2 => {
187 let interval = rotation_interval_ns.unwrap_or(86_400_000_000_000); let tz = get_timezone(rotation_timezone).map_err(|e| {
189 PyIOError::new_err(format!("Failed to parse rotation_timezone: {e}"))
190 })?;
191 let time_ns = rotation_time_ns.unwrap_or(0);
192 RotationConfig::ScheduledDates {
193 interval_ns: interval,
194 rotation_time: UnixNanos::from(time_ns),
195 rotation_timezone: tz,
196 }
197 }
198 _ => RotationConfig::NoRotation, };
200
201 let type_filter = include_types.map(|types| types.into_iter().collect::<HashSet<String>>());
203
204 let clock_rc = clock.clock_rc();
207 let _cache = cache;
210
211 let writer = FeatherWriter::new(
213 base_path,
214 object_store,
215 clock_rc,
216 rotation_config,
217 type_filter,
218 Some(default_per_instrument_types()),
219 flush_interval_ms, );
221
222 Ok(Self {
223 writer: Rc::new(RefCell::new(writer)),
224 subscriptions: None,
225 })
226 }
227
228 pub fn subscribe(&mut self) -> PyResult<()> {
233 if self.subscriptions.is_some() {
234 return Ok(());
236 }
237
238 let handler = FeatherWriter::subscribe_to_message_bus(self.writer.clone())
239 .map_err(|e| PyIOError::new_err(format!("Failed to subscribe to message bus: {e}")))?;
240
241 self.subscriptions = Some(handler);
242 Ok(())
243 }
244
245 pub fn unsubscribe(&mut self) -> PyResult<()> {
247 if let Some(handler) = self.subscriptions.take() {
248 FeatherWriter::unsubscribe_from_message_bus(&handler);
249 }
250 Ok(())
251 }
252
253 #[expect(
259 clippy::needless_pass_by_value,
260 clippy::too_many_lines,
261 reason = "PyO3 writer binding must downcast supported data variants inline"
262 )]
263 pub fn write(&self, py: Python, data: Py<PyAny>) -> PyResult<()> {
264 macro_rules! try_write {
265 ($type:ty, $name:literal) => {
266 if let Ok(value) = data.extract::<$type>(py) {
267 let mut writer = self.writer.borrow_mut();
268 let runtime = get_runtime();
269 return runtime
270 .block_on(async { writer.write(value).await })
271 .map_err(|e| {
272 PyIOError::new_err(format!("Failed to write {}: {e}", $name))
273 });
274 }
275 };
276 }
277
278 if let Ok(quote) = data.extract::<QuoteTick>(py) {
280 let mut writer = self.writer.borrow_mut();
281 let runtime = get_runtime();
282 return runtime
283 .block_on(async { writer.write_data(Data::Quote(quote)).await })
284 .map_err(|e| PyIOError::new_err(format!("Failed to write QuoteTick: {e}")));
285 }
286
287 if let Ok(trade) = data.extract::<TradeTick>(py) {
288 let mut writer = self.writer.borrow_mut();
289 let runtime = get_runtime();
290 return runtime
291 .block_on(async { writer.write_data(Data::Trade(trade)).await })
292 .map_err(|e| PyIOError::new_err(format!("Failed to write TradeTick: {e}")));
293 }
294
295 if let Ok(bar) = data.extract::<Bar>(py) {
296 let mut writer = self.writer.borrow_mut();
297 let runtime = get_runtime();
298 return runtime
299 .block_on(async { writer.write_data(Data::Bar(bar)).await })
300 .map_err(|e| PyIOError::new_err(format!("Failed to write Bar: {e}")));
301 }
302
303 if let Ok(delta) = data.extract::<OrderBookDelta>(py) {
304 let mut writer = self.writer.borrow_mut();
305 let runtime = get_runtime();
306 return runtime
307 .block_on(async { writer.write_data(Data::Delta(delta)).await })
308 .map_err(|e| PyIOError::new_err(format!("Failed to write OrderBookDelta: {e}")));
309 }
310
311 if let Ok(depth) = data.extract::<OrderBookDepth10>(py) {
312 let mut writer = self.writer.borrow_mut();
313 let runtime = get_runtime();
314 return runtime
315 .block_on(async { writer.write_data(Data::Depth10(Box::new(depth))).await })
316 .map_err(|e| PyIOError::new_err(format!("Failed to write OrderBookDepth10: {e}")));
317 }
318
319 if let Ok(price) = data.extract::<IndexPriceUpdate>(py) {
320 let mut writer = self.writer.borrow_mut();
321 let runtime = get_runtime();
322 return runtime
323 .block_on(async { writer.write_data(Data::IndexPrice(price)).await })
324 .map_err(|e| PyIOError::new_err(format!("Failed to write IndexPriceUpdate: {e}")));
325 }
326
327 if let Ok(price) = data.extract::<MarkPriceUpdate>(py) {
328 let mut writer = self.writer.borrow_mut();
329 let runtime = get_runtime();
330 return runtime
331 .block_on(async { writer.write_data(Data::MarkPrice(price)).await })
332 .map_err(|e| PyIOError::new_err(format!("Failed to write MarkPriceUpdate: {e}")));
333 }
334
335 if let Ok(greeks) = data.extract::<OptionGreeks>(py) {
336 let mut writer = self.writer.borrow_mut();
337 let runtime = get_runtime();
338 return runtime
339 .block_on(async { writer.write_data(Data::OptionGreeks(greeks)).await })
340 .map_err(|e| PyIOError::new_err(format!("Failed to write OptionGreeks: {e}")));
341 }
342
343 if let Ok(close) = data.extract::<InstrumentClose>(py) {
344 let mut writer = self.writer.borrow_mut();
345 let runtime = get_runtime();
346 return runtime
347 .block_on(async { writer.write_data(Data::InstrumentClose(close)).await })
348 .map_err(|e| PyIOError::new_err(format!("Failed to write InstrumentClose: {e}")));
349 }
350
351 try_write!(FundingRateUpdate, "FundingRateUpdate");
352 try_write!(InstrumentStatus, "InstrumentStatus");
353 try_write!(AccountState, "AccountState");
354 try_write!(OrderInitialized, "OrderInitialized");
355 try_write!(OrderDenied, "OrderDenied");
356 try_write!(OrderEmulated, "OrderEmulated");
357 try_write!(OrderSubmitted, "OrderSubmitted");
358 try_write!(OrderAccepted, "OrderAccepted");
359 try_write!(OrderRejected, "OrderRejected");
360 try_write!(OrderPendingCancel, "OrderPendingCancel");
361 try_write!(OrderCanceled, "OrderCanceled");
362 try_write!(OrderCancelRejected, "OrderCancelRejected");
363 try_write!(OrderExpired, "OrderExpired");
364 try_write!(OrderTriggered, "OrderTriggered");
365 try_write!(OrderPendingUpdate, "OrderPendingUpdate");
366 try_write!(OrderReleased, "OrderReleased");
367 try_write!(OrderModifyRejected, "OrderModifyRejected");
368 try_write!(OrderUpdated, "OrderUpdated");
369 try_write!(OrderFilled, "OrderFilled");
370 try_write!(OrderFillVoided, "OrderFillVoided");
371 try_write!(PositionOpened, "PositionOpened");
372 try_write!(PositionChanged, "PositionChanged");
373 try_write!(PositionClosed, "PositionClosed");
374 try_write!(PositionAdjusted, "PositionAdjusted");
375 try_write!(OrderSnapshot, "OrderSnapshot");
376 try_write!(PositionSnapshot, "PositionSnapshot");
377 try_write!(OrderStatusReport, "OrderStatusReport");
378 try_write!(FillReport, "FillReport");
379 try_write!(PositionStatusReport, "PositionStatusReport");
380 try_write!(ExecutionMassStatus, "ExecutionMassStatus");
381
382 if let Ok(instrument) = pyobject_to_instrument_any(py, data.clone_ref(py)) {
384 let mut writer = self.writer.borrow_mut();
385 let runtime = get_runtime();
386 return runtime
387 .block_on(async { writer.write_instrument(instrument).await })
388 .map_err(|e| PyIOError::new_err(format!("Failed to write instrument: {e}")));
389 }
390
391 Err(PyIOError::new_err(
392 "Unsupported data type for feather writer",
393 ))
394 }
395
396 pub fn flush(&self) -> PyResult<()> {
401 let mut writer = self.writer.borrow_mut();
402 let runtime = get_runtime();
403
404 runtime
405 .block_on(async { writer.flush().await })
406 .map_err(|e| PyIOError::new_err(format!("Failed to flush: {e}")))
407 }
408
409 pub fn close(&self) -> PyResult<()> {
413 let mut writer = self.writer.borrow_mut();
414 let runtime = get_runtime();
415
416 runtime
417 .block_on(async { writer.close().await })
418 .map_err(|e| PyIOError::new_err(format!("Failed to close: {e}")))
419 }
420
421 #[getter]
423 #[must_use]
424 pub fn is_closed(&self) -> bool {
425 self.writer.borrow().is_closed()
426 }
427
428 #[must_use]
432 pub fn get_current_file_info(&self) -> HashMap<String, (u64, String)> {
433 self.writer.borrow().get_current_file_info()
434 }
435
436 #[pyo3(signature = (type_str, instrument_id=None))]
438 #[must_use]
439 pub fn get_next_rotation_time(
440 &self,
441 type_str: &str,
442 instrument_id: Option<&str>,
443 ) -> Option<u64> {
444 self.writer
445 .borrow()
446 .get_next_rotation_time(type_str, instrument_id)
447 .map(|ns| ns.as_u64())
448 }
449}