Skip to main content

nautilus_persistence/python/backend/
feather.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Python bindings for the Rust `FeatherWriter` as `StreamingFeatherWriter`.
17
18use 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
72/// Source clock plus the shared atomic the writer reads time from.
73type ClockBridge = (Rc<RefCell<dyn Clock>>, Arc<AtomicU64>);
74
75/// Python binding for the Rust `FeatherWriter`.
76///
77/// This provides a streaming writer of Nautilus objects into feather files with rotation
78/// capabilities, matching the interface of Python's `StreamingFeatherWriter`.
79#[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    /// Present when constructed with a non-live clock: the source clock plus the
91    /// shared atomic the core writer reads, refreshed before each forwarded call.
92    /// Note: writes arriving via the message bus subscription do not refresh the
93    /// bridge; live usage pairs the subscription with a `LiveClock`.
94    clock_bridge: Option<ClockBridge>,
95}
96
97#[pymethods]
98#[pyo3_stub_gen::derive::gen_stub_pymethods]
99impl PyStreamingFeatherWriter {
100    /// Creates a new `StreamingFeatherWriter` instance.
101    ///
102    /// # Parameters
103    ///
104    /// - `path`: The local directory to append the stream files to.
105    /// - `cache`: The cache for query info (`PyCache`).
106    /// - `clock`: The clock to use for time-related operations (`PyClock`).
107    /// - `include_types`: Optional data or record types to include, as `NautilusDataType` or
108    ///   `NautilusRecordType` values.
109    /// - `rotation_config`: File rotation policy (default: no rotation).
110    /// - `flush_interval_ms`: Interval in milliseconds for flushing open files to disk (default:
111    ///   1000). Set to 0 to disable auto-flush.
112    /// - `replace`: If existing files at the given path should be replaced (default: False).
113    #[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        // Extract Clock from Python wrapper and translate it into the core
193        // writer's Send time source (live clocks read the wall clock directly;
194        // test clocks are bridged through a shared atomic)
195        let clock_rc = clock.clock_rc();
196        let (writer_clock, shared_time) = WriterClock::from_shared_clock(&clock_rc);
197        // Note: Cache parameter is kept for API compatibility with Python StreamingFeatherWriter
198        // but is not directly used by FeatherWriter
199        let _cache = cache;
200
201        // Create FeatherWriter
202        let writer = FeatherWriter::new(
203            directory,
204            writer_clock,
205            rotation_config,
206            type_filter,
207            flush_interval_ms, // Auto-flush interval in milliseconds
208        )
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    /// Subscribes to all messages on the message bus (pattern "*").
221    ///
222    /// This matches the behavior of Python's `StreamingFeatherWriter` when subscribed
223    /// via `trader.subscribe("*", writer.write)`.
224    pub fn subscribe(&mut self) -> PyResult<()> {
225        if self.handler.is_some() {
226            // Already subscribed
227            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    /// Unsubscribes from the message bus.
238    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    /// Writes a data object to the stream.
247    ///
248    /// # Parameters
249    ///
250    /// - `data`: The data object to write (must be a Nautilus data type from pyo3).
251    #[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        // Try to convert from common pyo3 data types
282        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        // Try instrument types (uses type_str attribute for dispatch)
356        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    /// Flushes buffered bytes of every open file to disk.
371    ///
372    /// This is called automatically based on `flush_interval_ms` if configured, but can also
373    /// be called manually by the client. Files stay open, so flushing never starts a new file.
374    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    /// Seals all open files so each complete stream is visible as a `.feather` file.
383    ///
384    /// Writes after closing open new files.
385    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    /// Returns whether the writer has been closed (no active writers).
400    #[getter]
401    #[must_use]
402    pub fn is_closed(&self) -> bool {
403        self.writer.borrow().is_closed()
404    }
405
406    /// Returns the current file size and path of each open file, keyed by the type it stages.
407    #[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    /// Returns the next rotation time of a type's file, or None if not set.
418    ///
419    /// Pass a `NautilusInstrumentType` for the file of one instrument class.
420    #[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    // Pushes the source clock's current time into the core writer's shared
431    // atomic so test clocks drive flush/rotation cadence correctly.
432    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}