1use std::{
19 cell::RefCell,
20 collections::{HashMap, HashSet},
21 rc::Rc,
22 sync::{
23 Arc,
24 atomic::{AtomicU64, Ordering},
25 },
26};
27
28use nautilus_common::{
29 clock::Clock,
30 enums::Environment,
31 live::block_on_nautilus_with,
32 python::{cache::PyCache, clock::PyClock},
33};
34use nautilus_core::python::to_pyvalue_err;
35use nautilus_model::{
36 data::{
37 Bar, CustomData, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus,
38 MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick,
39 close::InstrumentClose,
40 greeks::{GreeksData, PortfolioGreeks},
41 },
42 events::{
43 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
44 OrderEmulated, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
45 OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased,
46 OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated, PositionAdjusted,
47 PositionChanged, PositionClosed, PositionOpened, PositionSnapshot,
48 },
49 python::instruments::pyobject_to_instrument_any,
50 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
51};
52use pyo3::{exceptions::PyIOError, prelude::*};
53
54use crate::{
55 common::{
56 paths::{environment_from_directory, local_writer_directory, normalize_path_separators},
57 storage::{StorageBackend, create_storage_backend_from_path},
58 },
59 config::RotationConfig,
60 python::{
61 backend::{PyCatalogDataType, catalog_filter_family_from_py, writer_record_filter_from_py},
62 config::PyRotationConfig,
63 },
64 writer::{
65 factory::{WriterConnectConfig, replace_existing_writer_data},
66 feather::{FeatherWriter, WriterClock, recover_partial_feather_files},
67 run::RunStatus,
68 subscription::StreamingSinkSubscription,
69 },
70};
71
72type ClockBridge = (Rc<RefCell<dyn Clock>>, Arc<AtomicU64>);
74
75#[pyclass(
80 name = "StreamingFeatherWriter",
81 module = "nautilus_trader.persistence",
82 unsendable
83)]
84#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.persistence")]
85pub struct PyStreamingFeatherWriter {
86 writer: Rc<RefCell<FeatherWriter>>,
87 handler: Option<StreamingSinkSubscription>,
88 run_manifest: Option<(StorageBackend, Environment, String)>,
89 run_manifest_has_data: RefCell<bool>,
90 clock_bridge: Option<ClockBridge>,
95}
96
97#[pymethods]
98#[pyo3_stub_gen::derive::gen_stub_pymethods]
99impl PyStreamingFeatherWriter {
100 #[new]
114 #[pyo3(signature = (
115 path,
116 cache,
117 clock,
118 include_types=None,
119 record_types=None,
120 record_filters=None,
121 rotation_config=None,
122 flush_interval_ms=None,
123 replace=false
124 ))]
125 #[expect(
126 clippy::too_many_arguments,
127 clippy::needless_pass_by_value,
128 reason = "PyO3 constructor mirrors the writer configuration fields"
129 )]
130 pub fn py_new(
131 path: String,
132 cache: PyCache,
133 clock: PyClock,
134 include_types: Option<Vec<Bound<'_, PyAny>>>,
135 record_types: Option<&Bound<'_, PyAny>>,
136 record_filters: Option<&Bound<'_, PyAny>>,
137 rotation_config: Option<PyRotationConfig>,
138 flush_interval_ms: Option<u64>,
139 replace: bool,
140 ) -> PyResult<Self> {
141 let directory =
142 local_writer_directory(&path).map_err(|e| PyIOError::new_err(e.to_string()))?;
143 let rotation_config = rotation_config
144 .map_or(RotationConfig::NoRotation, Into::into)
145 .to_writer_rotation_config()
146 .map_err(to_pyvalue_err)?;
147
148 if replace {
149 replace_existing_writer_data(&WriterConnectConfig::new(path.clone(), None)).map_err(
150 |e| PyIOError::new_err(format!("Failed to replace existing files: {e}")),
151 )?;
152 }
153
154 recover_partial_feather_files(&directory);
155
156 let storage = create_storage_backend_from_path(&path, None)
157 .map_err(|e| PyIOError::new_err(format!("Failed to create storage backend: {e}")))?;
158
159 let run_manifest = if let Some((environment, instance_id)) =
160 run_environment_and_instance_id_from_path(&path)
161 {
162 let manifest_storage = storage.clone();
163 let manifest_instance_id = instance_id.clone();
164 block_on_nautilus_with(move || async move {
165 manifest_storage
166 .write_current_run_manifest(
167 environment,
168 &manifest_instance_id,
169 RunStatus::InProgress,
170 true,
171 )
172 .await
173 })
174 .map_err(|e| PyIOError::new_err(format!("Failed to write run manifest: {e}")))?;
175
176 Some((storage, environment, instance_id))
177 } else {
178 None
179 };
180
181 let type_filter = include_types
182 .map(|types| {
183 types
184 .iter()
185 .map(catalog_filter_family_from_py)
186 .collect::<PyResult<HashSet<_>>>()
187 })
188 .transpose()?;
189
190 let record_filter = writer_record_filter_from_py(record_types, record_filters)?;
191
192 let clock_rc = clock.clock_rc();
196 let (writer_clock, shared_time) = WriterClock::from_shared_clock(&clock_rc);
197 let _cache = cache;
200
201 let writer = FeatherWriter::new(
203 directory,
204 writer_clock,
205 rotation_config,
206 type_filter,
207 flush_interval_ms, )
209 .with_record_filter(record_filter);
210
211 Ok(Self {
212 writer: Rc::new(RefCell::new(writer)),
213 handler: None,
214 run_manifest,
215 run_manifest_has_data: RefCell::new(false),
216 clock_bridge: shared_time.map(|shared| (clock_rc, shared)),
217 })
218 }
219
220 pub fn subscribe(&mut self) -> PyResult<()> {
225 if self.handler.is_some() {
226 return Ok(());
228 }
229
230 let handler = FeatherWriter::subscribe_to_message_bus(self.writer.clone())
231 .map_err(|e| PyIOError::new_err(format!("Failed to subscribe to message bus: {e}")))?;
232
233 self.handler = Some(handler);
234 Ok(())
235 }
236
237 pub fn unsubscribe(&mut self) -> PyResult<()> {
239 if let Some(handler) = self.handler.take() {
240 FeatherWriter::unsubscribe_from_message_bus(&handler);
241 }
242
243 Ok(())
244 }
245
246 #[expect(
252 clippy::needless_pass_by_value,
253 reason = "PyO3 writer binding must downcast supported data variants inline"
254 )]
255 pub fn write(&self, py: Python, data: Py<PyAny>) -> PyResult<()> {
256 self.refresh_writer_clock();
257
258 macro_rules! try_write {
259 ($type:ty, $name:literal) => {
260 if let Ok(value) = data.extract::<$type>(py) {
261 let result =
262 self.writer.borrow_mut().write(value).map_err(|e| {
263 PyIOError::new_err(format!("Failed to write {}: {e}", $name))
264 });
265 return self.finish_write_result(result);
266 }
267 };
268 }
269
270 macro_rules! try_write_data {
271 ($data:expr, $name:literal) => {{
272 let result = self
273 .writer
274 .borrow_mut()
275 .write_data($data)
276 .map_err(|e| PyIOError::new_err(format!("Failed to write {}: {e}", $name)));
277 return self.finish_write_result(result);
278 }};
279 }
280
281 if let Ok(quote) = data.extract::<QuoteTick>(py) {
283 try_write_data!(Data::Quote(quote), "QuoteTick");
284 }
285
286 if let Ok(trade) = data.extract::<TradeTick>(py) {
287 try_write_data!(Data::Trade(trade), "TradeTick");
288 }
289
290 if let Ok(bar) = data.extract::<Bar>(py) {
291 try_write_data!(Data::Bar(bar), "Bar");
292 }
293
294 if let Ok(delta) = data.extract::<OrderBookDelta>(py) {
295 try_write_data!(Data::BookDelta(delta), "OrderBookDelta");
296 }
297
298 if let Ok(depth) = data.extract::<OrderBookDepth>(py) {
299 try_write_data!(Data::BookDepth(Box::new(depth)), "OrderBookDepth");
300 }
301
302 if let Ok(price) = data.extract::<IndexPriceUpdate>(py) {
303 try_write_data!(Data::IndexPrice(price), "IndexPriceUpdate");
304 }
305
306 if let Ok(price) = data.extract::<MarkPriceUpdate>(py) {
307 try_write_data!(Data::MarkPrice(price), "MarkPriceUpdate");
308 }
309
310 if let Ok(greeks) = data.extract::<OptionGreeks>(py) {
311 try_write_data!(Data::OptionGreeks(greeks), "OptionGreeks");
312 }
313
314 if let Ok(close) = data.extract::<InstrumentClose>(py) {
315 try_write_data!(Data::InstrumentClose(close), "InstrumentClose");
316 }
317
318 if let Ok(custom) = data.extract::<CustomData>(py) {
319 try_write_data!(Data::Custom(custom), "CustomData");
320 }
321
322 try_write!(FundingRateUpdate, "FundingRateUpdate");
323 try_write!(InstrumentStatus, "InstrumentStatus");
324 try_write!(AccountState, "AccountState");
325 try_write!(OrderInitialized, "OrderInitialized");
326 try_write!(OrderDenied, "OrderDenied");
327 try_write!(OrderEmulated, "OrderEmulated");
328 try_write!(OrderSubmitted, "OrderSubmitted");
329 try_write!(OrderAccepted, "OrderAccepted");
330 try_write!(OrderRejected, "OrderRejected");
331 try_write!(OrderPendingCancel, "OrderPendingCancel");
332 try_write!(OrderCanceled, "OrderCanceled");
333 try_write!(OrderCancelRejected, "OrderCancelRejected");
334 try_write!(OrderExpired, "OrderExpired");
335 try_write!(OrderTriggered, "OrderTriggered");
336 try_write!(OrderPendingUpdate, "OrderPendingUpdate");
337 try_write!(OrderReleased, "OrderReleased");
338 try_write!(OrderModifyRejected, "OrderModifyRejected");
339 try_write!(OrderUpdated, "OrderUpdated");
340 try_write!(OrderFilled, "OrderFilled");
341 try_write!(OrderFillVoided, "OrderFillVoided");
342 try_write!(PositionOpened, "PositionOpened");
343 try_write!(PositionChanged, "PositionChanged");
344 try_write!(PositionClosed, "PositionClosed");
345 try_write!(PositionAdjusted, "PositionAdjusted");
346 try_write!(OrderSnapshot, "OrderSnapshot");
347 try_write!(PositionSnapshot, "PositionSnapshot");
348 try_write!(OrderStatusReport, "OrderStatusReport");
349 try_write!(FillReport, "FillReport");
350 try_write!(PositionStatusReport, "PositionStatusReport");
351 try_write!(ExecutionMassStatus, "ExecutionMassStatus");
352 try_write!(GreeksData, "GreeksData");
353 try_write!(PortfolioGreeks, "PortfolioGreeks");
354
355 if let Ok(instrument) = pyobject_to_instrument_any(py, data.clone_ref(py)) {
357 let result = self
358 .writer
359 .borrow_mut()
360 .write_instrument(instrument)
361 .map_err(|e| PyIOError::new_err(format!("Failed to write instrument: {e}")));
362 return self.finish_write_result(result);
363 }
364
365 Err(PyIOError::new_err(
366 "Unsupported data type for feather writer",
367 ))
368 }
369
370 pub fn flush(&self) -> PyResult<()> {
375 self.refresh_writer_clock();
376 self.writer
377 .borrow_mut()
378 .flush()
379 .map_err(|e| PyIOError::new_err(format!("Failed to flush: {e}")))
380 }
381
382 pub fn close(&self) -> PyResult<()> {
386 self.refresh_writer_clock();
387 self.writer
388 .borrow_mut()
389 .close()
390 .map_err(|e| PyIOError::new_err(format!("Failed to close: {e}")))?;
391
392 self.write_run_manifest(
393 RunStatus::Completed,
394 !*self.run_manifest_has_data.borrow(),
395 "complete",
396 )
397 }
398
399 #[getter]
401 #[must_use]
402 pub fn is_closed(&self) -> bool {
403 self.writer.borrow().is_closed()
404 }
405
406 #[must_use]
408 pub fn get_current_file_info(&self) -> HashMap<PyCatalogDataType, (u64, String)> {
409 self.writer
410 .borrow()
411 .get_current_file_info()
412 .into_iter()
413 .map(|(data_type, info)| (PyCatalogDataType::new(data_type), info))
414 .collect()
415 }
416
417 #[must_use]
421 pub fn get_next_rotation_time(&self, data_type: PyCatalogDataType) -> Option<u64> {
422 self.writer
423 .borrow()
424 .get_next_rotation_time(&data_type.into_inner())
425 .map(|ns| ns.as_u64())
426 }
427}
428
429impl PyStreamingFeatherWriter {
430 fn refresh_writer_clock(&self) {
433 if let Some((clock, shared)) = &self.clock_bridge {
434 shared.store(clock.borrow().timestamp_ns().as_u64(), Ordering::Relaxed);
435 }
436 }
437
438 fn finish_write_result(&self, result: PyResult<()>) -> PyResult<()> {
439 result?;
440 self.mark_run_non_empty()
441 }
442
443 fn mark_run_non_empty(&self) -> PyResult<()> {
444 if *self.run_manifest_has_data.borrow() {
445 return Ok(());
446 }
447
448 self.write_run_manifest(RunStatus::InProgress, false, "update")?;
449 *self.run_manifest_has_data.borrow_mut() = true;
450 Ok(())
451 }
452
453 fn write_run_manifest(&self, status: RunStatus, empty: bool, operation: &str) -> PyResult<()> {
454 let Some((storage, environment, instance_id)) = &self.run_manifest else {
455 return Ok(());
456 };
457
458 let storage = storage.clone();
459 let environment = *environment;
460 let instance_id = instance_id.clone();
461 block_on_nautilus_with(move || async move {
462 storage
463 .write_current_run_manifest(environment, &instance_id, status, empty)
464 .await
465 })
466 .map_err(|e| PyIOError::new_err(format!("Failed to {operation} run manifest: {e}")))
467 }
468}
469
470fn run_environment_and_instance_id_from_path(path: &str) -> Option<(Environment, String)> {
471 let normalized = normalize_path_separators(path);
472 let parsed_url = url::Url::parse(&normalized).ok();
473
474 let path = parsed_url.as_ref().map_or(normalized.as_str(), |url| {
475 url.path().trim_start_matches('/')
476 });
477
478 let components: Vec<&str> = path
479 .trim_matches('/')
480 .split('/')
481 .filter(|component| !component.is_empty())
482 .collect();
483 let instance_id = components.last()?;
484 let environment =
485 environment_from_directory(components.get(components.len().checked_sub(2)?)?)?;
486
487 Some((environment, (*instance_id).to_string()))
488}
489
490#[cfg(test)]
491mod tests {
492 use nautilus_common::enums::Environment;
493 use rstest::rstest;
494
495 use super::run_environment_and_instance_id_from_path;
496
497 #[rstest]
498 #[case(
499 r"C:\Users\Administrator\AppData\Local\Temp\pytest-0\backtest\run-greeks",
500 Environment::Backtest,
501 "run-greeks"
502 )]
503 #[case("C:/catalog/backtest/run-1", Environment::Backtest, "run-1")]
504 #[case(r"\\server\share\live\run-2", Environment::Live, "run-2")]
505 #[case("/tmp/catalog/sandbox/run-3", Environment::Sandbox, "run-3")]
506 #[case("file:///C:/catalog/backtest/run-1", Environment::Backtest, "run-1")]
507 fn run_environment_and_instance_id_handles_platform_paths(
508 #[case] path: &str,
509 #[case] environment: Environment,
510 #[case] instance_id: &str,
511 ) {
512 assert_eq!(
513 run_environment_and_instance_id_from_path(path),
514 Some((environment, instance_id.to_string())),
515 );
516 }
517
518 #[rstest]
519 fn run_environment_and_instance_id_rejects_non_run_paths() {
520 assert_eq!(
521 run_environment_and_instance_id_from_path(r"C:\catalog\data\quotes"),
522 None
523 );
524 assert_eq!(
525 run_environment_and_instance_id_from_path("/tmp/catalog"),
526 None
527 );
528 }
529}