1use std::{
17 any::Any,
18 cell::RefCell,
19 collections::{HashMap, HashSet},
20 fmt::Display,
21 rc::Rc,
22 sync::Arc,
23};
24
25use ahash::AHashMap;
26use anyhow::Context;
27use datafusion::arrow::{
28 datatypes::Schema, error::ArrowError, ipc::writer::StreamWriter, record_batch::RecordBatch,
29};
30use futures::StreamExt;
31use jiff::{
32 SignedDuration,
33 civil::Time,
34 tz::{AmbiguousOffset, TimeZone},
35};
36use nautilus_common::{
37 cache::fifo::FifoCache,
38 clock::Clock,
39 msgbus::{
40 self,
41 mstr::MStr,
42 typed_handler::{ShareableMessageHandler, TypedHandler},
43 },
44};
45use nautilus_core::{UUID4, UnixNanos, datetime::NANOSECONDS_IN_SECOND};
46use nautilus_model::{
47 data::{
48 Bar, CatalogPathPrefix, CustomData, CustomDataTrait, Data, FundingRateUpdate,
49 IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta,
50 OrderBookDeltas, OrderBookDepth10, QuoteTick, TradeTick, close::InstrumentClose,
51 encode_custom_to_arrow, get_arrow_schema,
52 },
53 events::{
54 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
55 OrderEmulated, OrderEventAny, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
56 OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected, OrderReleased,
57 OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated, PositionAdjusted,
58 PositionChanged, PositionClosed, PositionEvent, PositionOpened, PositionSnapshot,
59 },
60 instruments::InstrumentAny,
61 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
62};
63use nautilus_serialization::arrow::{EncodeToRecordBatch, KEY_INSTRUMENT_ID};
64use object_store::{ObjectStore, ObjectStoreExt, path::Path};
65
66use super::catalog::urisafe_instrument_id;
67use crate::{
68 backend::{
69 catalog::safe_directory_identifier,
70 custom::{augment_batch_with_data_type_column, schema_with_data_type_column},
71 },
72 parquet::{ObjectStoreLocationKind, create_object_store_location_from_path},
73};
74
75#[derive(Debug, Default, PartialEq, PartialOrd, Hash, Eq, Clone)]
76pub struct FileWriterPath {
77 path: Path,
78 type_str: String,
79 instrument_id: Option<String>,
80}
81
82pub struct FeatherBuffer {
86 writer: StreamWriter<Vec<u8>>,
88 size: u64,
90 schema: Schema,
94 max_buffer_size: u64,
96 rotation_config: RotationConfig,
98}
99
100impl FeatherBuffer {
101 pub fn new(schema: &Schema, rotation_config: RotationConfig) -> Result<Self, ArrowError> {
107 let writer = StreamWriter::try_new(Vec::new(), schema)?;
108 let mut max_buffer_size = 1_000_000_000_000; if let RotationConfig::Size { max_size } = &rotation_config {
111 max_buffer_size = *max_size;
112 }
113
114 Ok(Self {
115 writer,
116 size: 0,
117 max_buffer_size,
119 schema: schema.clone(),
120 rotation_config,
121 })
122 }
123
124 pub fn write_record_batch(&mut self, batch: &RecordBatch) -> Result<bool, ArrowError> {
132 self.writer.write(batch)?;
133 self.size += batch.get_array_memory_size() as u64;
134 Ok(self.size >= self.max_buffer_size)
135 }
136
137 pub fn take_buffer(&mut self) -> Result<Vec<u8>, ArrowError> {
144 let mut writer = StreamWriter::try_new(Vec::new(), &self.schema)?;
145 std::mem::swap(&mut self.writer, &mut writer);
146 let buffer = writer.into_inner()?;
147 self.size = 0;
149 Ok(buffer)
150 }
151
152 #[must_use]
154 pub const fn should_rotate(&self) -> bool {
155 match &self.rotation_config {
156 RotationConfig::Size { max_size } => self.size >= *max_size,
157 _ => false,
158 }
159 }
160}
161
162#[derive(Debug, Clone)]
164pub enum RotationConfig {
165 Size {
167 max_size: u64,
169 },
170 Interval {
172 interval_ns: u64,
174 },
175 ScheduledDates {
177 interval_ns: u64,
179 rotation_time: UnixNanos,
181 rotation_timezone: TimeZone,
183 },
184 NoRotation,
186}
187
188pub struct FeatherWriter {
195 base_path: String,
197 store: Arc<dyn ObjectStore>,
199 clock: Rc<RefCell<dyn Clock>>,
201 rotation_config: RotationConfig,
203 included_types: Option<HashSet<String>>,
205 per_instrument_types: HashSet<String>,
207 writers: HashMap<FileWriterPath, FeatherBuffer>,
209 next_rotation_times: HashMap<FileWriterPath, UnixNanos>,
211 runtime: tokio::runtime::Handle,
213 flush_interval_ms: u64,
215 last_flush_ns: UnixNanos,
217 pending_write_error: Option<String>,
219 seen_event_ids: Box<FifoCache<UUID4, 10_000>>,
221}
222
223pub struct FeatherWriterSubscriptions {
225 any: ShareableMessageHandler,
226 instruments: TypedHandler<InstrumentAny>,
227 deltas: TypedHandler<OrderBookDeltas>,
228 depths: TypedHandler<OrderBookDepth10>,
229 quotes: TypedHandler<QuoteTick>,
230 trades: TypedHandler<TradeTick>,
231 bars: TypedHandler<Bar>,
232 mark_prices: TypedHandler<MarkPriceUpdate>,
233 index_prices: TypedHandler<IndexPriceUpdate>,
234 funding_rates: TypedHandler<FundingRateUpdate>,
235 option_greeks: TypedHandler<OptionGreeks>,
236 account_states: TypedHandler<AccountState>,
237 order_events: TypedHandler<OrderEventAny>,
238 position_events: TypedHandler<PositionEvent>,
239}
240
241impl FeatherWriter {
242 pub fn new(
244 base_path: String,
245 store: Arc<dyn ObjectStore>,
246 clock: Rc<RefCell<dyn Clock>>,
247 rotation_config: RotationConfig,
248 included_types: Option<HashSet<String>>,
249 per_instrument_types: Option<HashSet<String>>,
250 flush_interval_ms: Option<u64>,
251 ) -> Self {
252 let runtime = nautilus_common::live::get_runtime().handle().clone();
254 let flush_interval_ms = flush_interval_ms.unwrap_or(1000); let last_flush_ns = clock.borrow().timestamp_ns();
256
257 Self {
258 base_path,
259 store,
260 clock,
261 rotation_config,
262 included_types,
263 per_instrument_types: per_instrument_types.unwrap_or_default(),
264 writers: HashMap::new(),
265 next_rotation_times: HashMap::new(),
266 runtime,
267 flush_interval_ms,
268 last_flush_ns,
269 pending_write_error: None,
270 seen_event_ids: Box::new(FifoCache::new()),
271 }
272 }
273
274 pub fn from_uri(
281 uri: &str,
282 storage_options: Option<AHashMap<String, String>>,
283 clock: Rc<RefCell<dyn Clock>>,
284 rotation_config: RotationConfig,
285 included_types: Option<HashSet<String>>,
286 flush_interval_ms: Option<u64>,
287 replace_existing: bool,
288 ) -> anyhow::Result<Self> {
289 let normalized_uri = crate::parquet::normalize_path_to_uri(uri);
290 if normalized_uri.starts_with("file://") {
291 let path = crate::parquet::file_uri_to_native_path(&normalized_uri);
292 std::fs::create_dir_all(&path)
293 .with_context(|| format!("Failed to create streaming directory '{path}'"))?;
294 }
295 let location = create_object_store_location_from_path(&normalized_uri, storage_options)?;
296 let is_local = matches!(location.kind, ObjectStoreLocationKind::Local);
297 let store = location.object_store;
298 let base_path = location.base_path;
299
300 if replace_existing {
301 let prefix = if base_path.is_empty() {
302 anyhow::ensure!(
303 is_local,
304 "replace_existing for remote streaming paths requires a non-empty prefix",
305 );
306 None
307 } else {
308 Some(Path::from(base_path.clone()))
309 };
310 let runtime = nautilus_common::live::get_runtime();
311 runtime.block_on(async {
312 let mut objects = store.list(prefix.as_ref());
313 let mut paths = Vec::new();
314 while let Some(result) = objects.next().await {
315 paths.push(result?.location);
316 }
317
318 for path in paths {
319 store.delete(&path).await?;
320 }
321 anyhow::Ok(())
322 })?;
323 }
324
325 Ok(Self::new(
326 base_path,
327 store,
328 clock,
329 rotation_config,
330 included_types,
331 Some(default_per_instrument_types()),
332 flush_interval_ms,
333 ))
334 }
335
336 pub async fn write<T>(&mut self, data: T) -> Result<(), Box<dyn std::error::Error>>
346 where
347 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
348 {
349 if !self.should_write::<T>() {
350 return Ok(());
351 }
352
353 let path = self.get_writer_path(&data)?;
354
355 if !self.writers.contains_key(&path) {
357 self.create_writer::<T>(path.clone(), &data)?;
358 }
359
360 let batch = T::encode_batch(&T::metadata(&data), &[data])?;
362
363 if let Some(writer) = self.writers.get_mut(&path) {
365 let should_rotate = writer.write_record_batch(&batch)?;
366 if should_rotate || self.check_scheduled_rotation(&path) {
367 self.rotate_writer(&path).await?;
368 }
369 }
370
371 self.check_flush().await?;
373
374 Ok(())
375 }
376
377 pub async fn write_batch<T>(&mut self, data: Vec<T>) -> Result<(), Box<dyn std::error::Error>>
392 where
393 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
394 {
395 if data.is_empty() || !self.should_write::<T>() {
396 return Ok(());
397 }
398
399 let type_str = T::path_prefix();
403 let needs_instrument =
404 self.per_instrument_types.contains(type_str) || type_str.starts_with("custom_");
405
406 let mut groups: AHashMap<Option<String>, Vec<T>> = AHashMap::new();
407
408 for item in data {
409 let instrument_id = if needs_instrument {
410 T::metadata(&item).get(KEY_INSTRUMENT_ID).cloned()
411 } else {
412 None
413 };
414 groups.entry(instrument_id).or_default().push(item);
415 }
416
417 for group in groups.into_values() {
418 let path = self.get_writer_path(&group[0])?;
419 let metadata = T::chunk_metadata(&group);
420
421 if !self.writers.contains_key(&path) {
422 self.create_writer_with_metadata::<T>(path.clone(), metadata.clone())?;
423 }
424
425 let batch = T::encode_batch(&metadata, &group)?;
426
427 if let Some(writer) = self.writers.get_mut(&path) {
428 let should_rotate = writer.write_record_batch(&batch)?;
429 if should_rotate || self.check_scheduled_rotation(&path) {
430 self.rotate_writer(&path).await?;
431 }
432 }
433 }
434
435 self.check_flush().await?;
436
437 Ok(())
438 }
439
440 async fn check_flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
442 if self.flush_interval_ms == 0 {
443 return Ok(()); }
445
446 let now_ns = self.clock.borrow().timestamp_ns();
447 let elapsed_ms = (now_ns.as_u64() - self.last_flush_ns.as_u64()) / 1_000_000;
448
449 if elapsed_ms >= self.flush_interval_ms {
450 self.flush().await?;
451 self.last_flush_ns = now_ns;
452 }
453
454 Ok(())
455 }
456
457 fn check_scheduled_rotation(&mut self, path: &FileWriterPath) -> bool {
458 match &self.rotation_config {
459 RotationConfig::Interval { interval_ns } => {
460 let now = self.clock.borrow().timestamp_ns();
461 let next_rotation = self.next_rotation_times.get(path).copied();
462
463 match next_rotation {
464 None => {
465 self.next_rotation_times
466 .insert(path.clone(), now + *interval_ns);
467 false
468 }
469 Some(next) if now >= next => {
470 self.next_rotation_times
471 .insert(path.clone(), now + *interval_ns);
472 true
473 }
474 _ => false,
475 }
476 }
477 RotationConfig::ScheduledDates {
478 interval_ns,
479 rotation_time,
480 rotation_timezone,
481 } => {
482 let now = self.clock.borrow().timestamp_ns();
483 let next_rotation = self.next_rotation_times.get(path).copied();
484
485 match next_rotation {
486 None => {
487 let next = self.calculate_next_scheduled_rotation(
488 *rotation_time,
489 rotation_timezone,
490 *interval_ns,
491 );
492 self.next_rotation_times.insert(path.clone(), next);
493 false
494 }
495 Some(next) if now >= next => {
496 self.next_rotation_times
497 .insert(path.clone(), now + *interval_ns);
498 true
499 }
500 _ => false,
501 }
502 }
503 _ => false,
504 }
505 }
506
507 fn calculate_next_scheduled_rotation(
508 &self,
509 rotation_time: UnixNanos,
510 rotation_timezone: &TimeZone,
511 interval_ns: u64,
512 ) -> UnixNanos {
513 let now_utc = self.clock.borrow().utc_now();
514 let now_local = rotation_timezone.to_datetime(now_utc);
515
516 let rotation_time_secs = u32::try_from(*rotation_time / NANOSECONDS_IN_SECOND).unwrap_or(0);
517 let rotation_time_nanos =
518 i32::try_from(*rotation_time % NANOSECONDS_IN_SECOND).unwrap_or(0);
519 let rotation_time = if rotation_time_secs < 86_400 {
520 Time::new(
521 i8::try_from(rotation_time_secs / 3_600).unwrap_or(0),
522 i8::try_from(rotation_time_secs % 3_600 / 60).unwrap_or(0),
523 i8::try_from(rotation_time_secs % 60).unwrap_or(0),
524 rotation_time_nanos,
525 )
526 .unwrap_or(Time::MIN)
527 } else {
528 Time::MIN
529 };
530
531 let local_rotation = now_local.date().to_datetime(rotation_time);
532 let ambiguous = rotation_timezone.to_ambiguous_timestamp(local_rotation);
533 let mut next_rotation = match ambiguous.offset() {
534 AmbiguousOffset::Gap { .. } => now_utc,
535 _ => ambiguous.earlier().unwrap_or(now_utc),
536 };
537
538 if next_rotation <= now_utc {
539 while next_rotation <= now_utc {
542 next_rotation += SignedDuration::from_nanos_i128(i128::from(interval_ns));
543 }
544 }
545
546 UnixNanos::from(u64::try_from(next_rotation.as_nanosecond()).unwrap_or(0))
547 }
548
549 async fn rotate_writer(
552 &mut self,
553 path: &FileWriterPath,
554 ) -> Result<(), Box<dyn std::error::Error>> {
555 let mut writer = self.writers.remove(path).unwrap();
556 let bytes = writer.take_buffer()?;
557 self.store.put(&path.path, bytes.into()).await?;
558 let new_path = self.regen_writer_path(path);
559 self.writers.insert(new_path, writer);
560 Ok(())
561 }
562
563 fn create_writer<T>(&mut self, path: FileWriterPath, data: &T) -> Result<(), ArrowError>
565 where
566 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
567 {
568 self.create_writer_with_metadata::<T>(path, T::metadata(data))
569 }
570
571 fn create_writer_with_metadata<T>(
576 &mut self,
577 path: FileWriterPath,
578 metadata: HashMap<String, String>,
579 ) -> Result<(), ArrowError>
580 where
581 T: EncodeToRecordBatch + CatalogPathPrefix + 'static,
582 {
583 let schema = if self.per_instrument_types.contains(T::path_prefix()) {
584 T::get_schema(Some(metadata))
585 } else {
586 T::get_schema(None)
587 };
588
589 let writer = FeatherBuffer::new(&schema, self.rotation_config.clone())?;
590 self.writers.insert(path, writer);
591 Ok(())
592 }
593
594 fn create_custom_writer(
596 &mut self,
597 path: FileWriterPath,
598 type_name: &str,
599 ) -> Result<(), Box<dyn std::error::Error>> {
600 if self.writers.contains_key(&path) {
601 return Ok(());
602 }
603 let base_schema = get_arrow_schema(type_name).ok_or_else(|| {
604 format!("Custom data type \"{type_name}\" is not registered for Arrow encoding")
605 })?;
606 let schema = schema_with_data_type_column(base_schema.as_ref(), type_name);
607 let writer = FeatherBuffer::new(&schema, self.rotation_config.clone())
608 .map_err(|e| format!("Failed to create feather buffer for custom {type_name}: {e}"))?;
609 self.writers.insert(path, writer);
610 Ok(())
611 }
612
613 fn encode_custom_to_batch(
615 custom: &CustomData,
616 ) -> Result<RecordBatch, Box<dyn std::error::Error>> {
617 let type_name = custom.data.type_name();
618 let data_type_json = custom
619 .data_type
620 .to_persistence_json()
621 .map_err(|e| format!("Failed to serialize data_type for persistence: {e}"))?;
622 let dt_meta = custom.data_type.metadata_string_map();
623 let items: [Arc<dyn CustomDataTrait>; 1] = [Arc::clone(&custom.data)];
624 let batch = encode_custom_to_arrow(type_name, &items)
625 .map_err(|e| format!("Failed to encode custom data: {e}"))?
626 .ok_or_else(|| {
627 format!("Custom data type \"{type_name}\" is not registered for Arrow")
628 })?;
629 let batch = augment_batch_with_data_type_column(
630 &batch,
631 &data_type_json,
632 type_name,
633 dt_meta.as_ref(),
634 )
635 .map_err(|e| e.to_string())?;
636 Ok(batch)
637 }
638
639 pub async fn flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
652 if let Err(e) = self.flush_active_writers().await {
653 self.record_write_error("streaming output", &e);
654 return Err(e);
655 }
656
657 self.last_flush_ns = self.clock.borrow().timestamp_ns();
658
659 if let Some(error) = &self.pending_write_error {
660 return Err(error.clone().into());
661 }
662 Ok(())
663 }
664
665 async fn flush_active_writers(&mut self) -> Result<(), Box<dyn std::error::Error>> {
666 let paths_to_flush: Vec<FileWriterPath> = self.writers.keys().cloned().collect();
668
669 for path in paths_to_flush {
671 if let Some(mut writer) = self.writers.remove(&path) {
672 let bytes = writer.take_buffer()?;
673 if !bytes.is_empty() {
674 self.store.put(&path.path, bytes.into()).await?;
676 }
677
678 }
682 }
683 Ok(())
684 }
685
686 fn record_write_error(&mut self, type_name: &str, error: impl Display) {
687 let message = format!("Failed to write {type_name}: {error}");
688 log::warn!("{message}");
689 if self.pending_write_error.is_none() {
690 self.pending_write_error = Some(message);
691 }
692 }
693
694 pub async fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
702 self.flush().await?;
703 self.writers.clear();
704 Ok(())
705 }
706
707 #[must_use]
709 pub fn is_closed(&self) -> bool {
710 self.writers.is_empty()
711 }
712
713 #[must_use]
718 pub fn get_current_file_info(&self) -> HashMap<String, (u64, String)> {
719 let mut info = HashMap::new();
720
721 for (path, buffer) in &self.writers {
722 let key = match &path.instrument_id {
723 Some(id) => format!("{}:{}", path.type_str, id),
724 None => path.type_str.clone(),
725 };
726 info.insert(key, (buffer.size, path.path.to_string()));
727 }
728 info
729 }
730
731 #[must_use]
733 pub fn get_next_rotation_time(
734 &self,
735 type_str: &str,
736 instrument_id: Option<&str>,
737 ) -> Option<UnixNanos> {
738 self.next_rotation_times
739 .iter()
740 .find(|(k, _)| k.type_str == type_str && k.instrument_id.as_deref() == instrument_id)
741 .map(|(_, &v)| v)
742 }
743
744 fn should_write<T: CatalogPathPrefix>(&self) -> bool {
746 self.included_types.as_ref().is_none_or(|included| {
747 let path = T::path_prefix();
748 included.contains(path)
749 })
750 }
751
752 pub fn is_duplicate_event_id(&mut self, event_id: &UUID4) -> bool {
755 if self.seen_event_ids.contains(event_id) {
756 return true;
757 }
758
759 self.seen_event_ids.add(*event_id);
760
761 false
762 }
763
764 fn regen_writer_path(&self, path: &FileWriterPath) -> FileWriterPath {
765 let type_str = path.type_str.clone();
766 let instrument_id = path.instrument_id.clone();
767 let timestamp = self.clock.borrow().timestamp_ns();
768 let mut path = Path::from(self.base_path.clone());
770
771 if type_str.starts_with("data/custom/") {
772 let type_name = type_str.strip_prefix("data/custom/").unwrap_or(&type_str);
774 path = path.join("data").join("custom").join(type_name.to_string());
775
776 if let Some(ref id) = instrument_id {
777 let safe = safe_directory_identifier(id);
778 if !safe.is_empty() {
779 for segment in safe.split('/') {
780 path = path.join(segment.to_string());
781 }
782 }
783 }
784 let file_stem = instrument_id.as_deref().unwrap_or(type_name);
785 path = path.join(format!("{file_stem}_{timestamp}.feather"));
786 } else if let Some(ref instrument_id) = instrument_id {
787 path = self.per_instrument_path(&type_str, instrument_id, timestamp);
788 } else {
789 path = path.join(format!("{type_str}_{timestamp}.feather"));
790 }
791
792 FileWriterPath {
793 path,
794 type_str,
795 instrument_id,
796 }
797 }
798
799 fn get_writer_path_custom(&self, type_name: &str, identifier: Option<&str>) -> FileWriterPath {
801 let timestamp = self.clock.borrow().timestamp_ns();
802 let type_str = format!("data/custom/{type_name}");
803 let instrument_id = identifier.map(String::from);
804
805 let mut path = Path::from(self.base_path.clone());
806 path = path.join("data").join("custom").join(type_name.to_string());
807
808 if let Some(id) = &identifier {
809 let safe = safe_directory_identifier(id);
810 if !safe.is_empty() {
811 for segment in safe.split('/') {
812 path = path.join(segment.to_string());
813 }
814 }
815 }
816 let file_stem = identifier.unwrap_or(type_name);
817 path = path.join(format!("{file_stem}_{timestamp}.feather"));
818
819 FileWriterPath {
820 path,
821 type_str,
822 instrument_id,
823 }
824 }
825
826 fn get_writer_path<T>(&self, data: &T) -> Result<FileWriterPath, Box<dyn std::error::Error>>
830 where
831 T: EncodeToRecordBatch + CatalogPathPrefix,
832 {
833 let type_str = T::path_prefix();
834 let metadata = T::metadata(data);
835
836 let instrument_id = if self.per_instrument_types.contains(type_str)
837 || (type_str.starts_with("custom_") && metadata.contains_key(KEY_INSTRUMENT_ID))
838 {
839 Some(metadata.get(KEY_INSTRUMENT_ID).cloned().ok_or_else(|| {
840 format!("Data {type_str} expected instrument_id metadata for per instrument writer")
841 })?)
842 } else {
843 None
844 };
845
846 if let Some(existing) = self
848 .writers
849 .keys()
850 .find(|k| k.type_str == type_str && k.instrument_id == instrument_id)
851 {
852 return Ok(existing.clone());
853 }
854
855 let timestamp = self.clock.borrow().timestamp_ns();
856 let path = if let Some(ref instrument_id) = instrument_id {
857 self.per_instrument_path(type_str, instrument_id, timestamp)
858 } else {
859 Path::from(self.base_path.clone()).join(format!("{type_str}_{timestamp}.feather"))
860 };
861
862 Ok(FileWriterPath {
863 path,
864 type_str: type_str.to_string(),
865 instrument_id,
866 })
867 }
868
869 fn per_instrument_path(
870 &self,
871 type_str: &str,
872 instrument_id: &str,
873 timestamp: UnixNanos,
874 ) -> Path {
875 let safe_id = urisafe_instrument_id(instrument_id);
876 let filename = if type_str.contains('/') {
877 format!("{safe_id}_{timestamp}.feather")
878 } else {
879 format!("{type_str}_{timestamp}.feather")
880 };
881 Path::from(self.base_path.clone())
882 .join(type_str)
883 .join(safe_id)
884 .join(filename)
885 }
886
887 #[allow(
896 clippy::match_wildcard_for_single_variants,
897 reason = "Data::Defi appears through nautilus-model feature unification"
898 )]
899 pub async fn write_data(&mut self, data: Data) -> Result<(), Box<dyn std::error::Error>> {
900 match data {
901 Data::Quote(quote) => self.write(quote).await,
902 Data::Trade(trade) => self.write(trade).await,
903 Data::Bar(bar) => self.write(bar).await,
904 Data::Delta(delta) => self.write(delta).await,
905 Data::Depth10(depth) => self.write(*depth).await,
906 Data::IndexPrice(price) => self.write(price).await,
907 Data::MarkPrice(price) => self.write(price).await,
908 Data::FundingRate(funding) => self.write(funding).await,
909 Data::OptionGreeks(greeks) => self.write(greeks).await,
910 Data::InstrumentStatus(status) => self.write(status).await,
911 Data::InstrumentClose(close) => self.write(close).await,
912 Data::Custom(custom) => self.write_custom_data(&custom).await,
913 Data::Deltas(deltas) => {
914 self.write_batch(deltas.deltas.clone()).await
916 }
917 #[cfg(feature = "defi")]
918 Data::Defi(_) => Err("Unsupported Data::Defi variant for feather writes".into()),
919 #[allow(unreachable_patterns)]
920 _ => Err("Unsupported Data variant for feather writes".into()),
921 }
922 }
923
924 async fn write_custom_data(
926 &mut self,
927 custom: &CustomData,
928 ) -> Result<(), Box<dyn std::error::Error>> {
929 let type_name = custom.data.type_name();
930 let identifier = custom.data_type.identifier().map(String::from);
931
932 if !self.should_write_custom(type_name) {
933 return Ok(());
934 }
935
936 let path = self.get_writer_path_custom(type_name, identifier.as_deref());
937 if !self.writers.contains_key(&path) {
938 self.create_custom_writer(path.clone(), type_name)?;
939 }
940
941 let batch = Self::encode_custom_to_batch(custom)?;
942
943 if let Some(writer) = self.writers.get_mut(&path) {
944 let should_rotate = writer.write_record_batch(&batch)?;
945 if should_rotate || self.check_scheduled_rotation(&path) {
946 self.rotate_writer(&path).await?;
947 }
948 }
949
950 self.check_flush().await?;
951 Ok(())
952 }
953
954 fn should_write_custom(&self, type_name: &str) -> bool {
955 self.included_types.as_ref().is_none_or(|included| {
956 included.contains(type_name)
957 || included.contains("custom")
958 || included.contains(&format!("custom/{type_name}"))
959 })
960 }
961
962 pub async fn write_instrument(
971 &mut self,
972 instrument: InstrumentAny,
973 ) -> Result<(), Box<dyn std::error::Error>> {
974 self.write(instrument).await
975 }
976
977 pub fn subscribe_to_message_bus(
991 writer: Rc<RefCell<Self>>,
992 ) -> Result<FeatherWriterSubscriptions, Box<dyn std::error::Error>> {
993 Ok(Self::subscribe_to_message_bus_inner(writer, true))
994 }
995
996 pub fn subscribe_builtin_to_message_bus(
1002 writer: Rc<RefCell<Self>>,
1003 ) -> Result<FeatherWriterSubscriptions, Box<dyn std::error::Error>> {
1004 Ok(Self::subscribe_to_message_bus_inner(writer, false))
1005 }
1006
1007 #[expect(
1008 clippy::too_many_lines,
1009 reason = "subscription assembly keeps the symmetric handler set and ownership in one place"
1010 )]
1011 fn subscribe_to_message_bus_inner(
1012 writer: Rc<RefCell<Self>>,
1013 include_custom_data: bool,
1014 ) -> FeatherWriterSubscriptions {
1015 let runtime = writer.borrow().runtime.clone();
1016
1017 macro_rules! typed_writer_handler {
1018 ($type:ty, $name:literal) => {{
1019 let writer = Rc::clone(&writer);
1020 let runtime = runtime.clone();
1021 TypedHandler::from(move |value: &$type| {
1022 let mut writer = writer.borrow_mut();
1023 if let Err(e) = runtime.block_on(writer.write(value.clone())) {
1024 writer.record_write_error($name, e);
1025 }
1026 })
1027 }};
1028 }
1029
1030 let instruments = {
1031 let writer = Rc::clone(&writer);
1032 let runtime = runtime.clone();
1033 TypedHandler::from(move |instrument: &InstrumentAny| {
1034 let mut writer = writer.borrow_mut();
1035 if let Err(e) = runtime.block_on(writer.write_instrument(instrument.clone())) {
1036 writer.record_write_error("InstrumentAny", e);
1037 }
1038 })
1039 };
1040 let deltas = {
1041 let writer = Rc::clone(&writer);
1042 let runtime = runtime.clone();
1043 TypedHandler::from(move |deltas: &OrderBookDeltas| {
1044 let mut writer = writer.borrow_mut();
1045 if let Err(e) = runtime.block_on(writer.write_batch(deltas.deltas.clone())) {
1046 writer.record_write_error("OrderBookDeltas", e);
1047 }
1048 })
1049 };
1050 let depths = typed_writer_handler!(OrderBookDepth10, "OrderBookDepth10");
1051 let quotes = typed_writer_handler!(QuoteTick, "QuoteTick");
1052 let trades = typed_writer_handler!(TradeTick, "TradeTick");
1053 let bars = typed_writer_handler!(Bar, "Bar");
1054 let mark_prices = typed_writer_handler!(MarkPriceUpdate, "MarkPriceUpdate");
1055 let index_prices = typed_writer_handler!(IndexPriceUpdate, "IndexPriceUpdate");
1056 let funding_rates = typed_writer_handler!(FundingRateUpdate, "FundingRateUpdate");
1057 let option_greeks = typed_writer_handler!(OptionGreeks, "OptionGreeks");
1058 let account_states = typed_writer_handler!(AccountState, "AccountState");
1059 let order_events = {
1060 let writer = Rc::clone(&writer);
1061 let runtime = runtime.clone();
1062 TypedHandler::from(move |event: &OrderEventAny| {
1063 macro_rules! write_event {
1064 ($value:expr, $name:literal) => {{
1065 let mut writer = writer.borrow_mut();
1066 if let Err(e) = runtime.block_on(writer.write($value.clone())) {
1067 writer.record_write_error($name, e);
1068 }
1069 }};
1070 }
1071
1072 match event {
1073 OrderEventAny::Initialized(value) => write_event!(value, "OrderInitialized"),
1074 OrderEventAny::Denied(value) => write_event!(value, "OrderDenied"),
1075 OrderEventAny::Emulated(value) => write_event!(value, "OrderEmulated"),
1076 OrderEventAny::Released(value) => write_event!(value, "OrderReleased"),
1077 OrderEventAny::Submitted(value) => write_event!(value, "OrderSubmitted"),
1078 OrderEventAny::Accepted(value) => write_event!(value, "OrderAccepted"),
1079 OrderEventAny::Rejected(value) => write_event!(value, "OrderRejected"),
1080 OrderEventAny::Canceled(value) => write_event!(value, "OrderCanceled"),
1081 OrderEventAny::Expired(value) => write_event!(value, "OrderExpired"),
1082 OrderEventAny::Triggered(value) => write_event!(value, "OrderTriggered"),
1083 OrderEventAny::PendingUpdate(value) => {
1084 write_event!(value, "OrderPendingUpdate");
1085 }
1086 OrderEventAny::PendingCancel(value) => {
1087 write_event!(value, "OrderPendingCancel");
1088 }
1089 OrderEventAny::ModifyRejected(value) => {
1090 write_event!(value, "OrderModifyRejected");
1091 }
1092 OrderEventAny::CancelRejected(value) => {
1093 write_event!(value, "OrderCancelRejected");
1094 }
1095 OrderEventAny::Updated(value) => write_event!(value, "OrderUpdated"),
1096 OrderEventAny::Filled(value) => write_event!(value, "OrderFilled"),
1097 OrderEventAny::FillVoided(value) => write_event!(value, "OrderFillVoided"),
1098 }
1099 })
1100 };
1101 let position_events = {
1102 let writer = Rc::clone(&writer);
1103 let runtime = runtime.clone();
1104 TypedHandler::from(move |event: &PositionEvent| {
1105 macro_rules! write_event {
1106 ($value:expr, $name:literal) => {{
1107 let mut writer = writer.borrow_mut();
1108 if let Err(e) = runtime.block_on(writer.write($value.clone())) {
1109 writer.record_write_error($name, e);
1110 }
1111 }};
1112 }
1113
1114 match event {
1115 PositionEvent::PositionOpened(value) => write_event!(value, "PositionOpened"),
1116 PositionEvent::PositionChanged(value) => {
1117 write_event!(value, "PositionChanged");
1118 }
1119 PositionEvent::PositionClosed(value) => write_event!(value, "PositionClosed"),
1120 PositionEvent::PositionAdjusted(value) => {
1121 write_event!(value, "PositionAdjusted");
1122 }
1123 }
1124 })
1125 };
1126
1127 let any = {
1128 ShareableMessageHandler::from_any(move |message: &dyn Any| {
1129 let _guard = runtime.enter();
1130
1131 macro_rules! try_write {
1132 ($message:expr, $type:ty, $name:literal) => {
1133 if let Some(value) = $message.downcast_ref::<$type>() {
1134 let mut writer = writer.borrow_mut();
1135 if let Err(e) = runtime.block_on(writer.write(value.clone())) {
1136 writer.record_write_error($name, e);
1137 }
1138 return;
1139 }
1140 };
1141 }
1142
1143 try_write!(message, QuoteTick, "QuoteTick");
1144 try_write!(message, TradeTick, "TradeTick");
1145 try_write!(message, Bar, "Bar");
1146 try_write!(message, OrderBookDelta, "OrderBookDelta");
1147 try_write!(message, OrderBookDepth10, "OrderBookDepth10");
1148 try_write!(message, IndexPriceUpdate, "IndexPriceUpdate");
1149 try_write!(message, MarkPriceUpdate, "MarkPriceUpdate");
1150 try_write!(message, FundingRateUpdate, "FundingRateUpdate");
1151 try_write!(message, OptionGreeks, "OptionGreeks");
1152 try_write!(message, InstrumentStatus, "InstrumentStatus");
1153 try_write!(message, InstrumentClose, "InstrumentClose");
1154 try_write!(message, AccountState, "AccountState");
1155 try_write!(message, OrderInitialized, "OrderInitialized");
1156 try_write!(message, OrderDenied, "OrderDenied");
1157 try_write!(message, OrderEmulated, "OrderEmulated");
1158 try_write!(message, OrderSubmitted, "OrderSubmitted");
1159 try_write!(message, OrderAccepted, "OrderAccepted");
1160 try_write!(message, OrderRejected, "OrderRejected");
1161 try_write!(message, OrderPendingCancel, "OrderPendingCancel");
1162 try_write!(message, OrderCanceled, "OrderCanceled");
1163 try_write!(message, OrderCancelRejected, "OrderCancelRejected");
1164 try_write!(message, OrderExpired, "OrderExpired");
1165 try_write!(message, OrderTriggered, "OrderTriggered");
1166 try_write!(message, OrderPendingUpdate, "OrderPendingUpdate");
1167 try_write!(message, OrderReleased, "OrderReleased");
1168 try_write!(message, OrderModifyRejected, "OrderModifyRejected");
1169 try_write!(message, OrderUpdated, "OrderUpdated");
1170 try_write!(message, OrderFilled, "OrderFilled");
1171 try_write!(message, OrderFillVoided, "OrderFillVoided");
1172 try_write!(message, PositionOpened, "PositionOpened");
1173 try_write!(message, PositionChanged, "PositionChanged");
1174 try_write!(message, PositionClosed, "PositionClosed");
1175 try_write!(message, PositionAdjusted, "PositionAdjusted");
1176 try_write!(message, OrderSnapshot, "OrderSnapshot");
1177 try_write!(message, PositionSnapshot, "PositionSnapshot");
1178 try_write!(message, OrderStatusReport, "OrderStatusReport");
1179 try_write!(message, FillReport, "FillReport");
1180 try_write!(message, PositionStatusReport, "PositionStatusReport");
1181 try_write!(message, ExecutionMassStatus, "ExecutionMassStatus");
1182
1183 if let Some(deltas) = message.downcast_ref::<OrderBookDeltas>() {
1184 let mut writer = writer.borrow_mut();
1185 if let Err(e) = runtime.block_on(writer.write_batch(deltas.deltas.clone())) {
1186 writer.record_write_error("OrderBookDeltas", e);
1187 }
1188 } else if include_custom_data
1189 && let Some(custom) = message.downcast_ref::<CustomData>()
1190 {
1191 let mut writer = writer.borrow_mut();
1192 if let Err(e) =
1193 runtime.block_on(writer.write_data(Data::Custom(custom.clone())))
1194 {
1195 writer.record_write_error("CustomData", e);
1196 }
1197 } else if let Some(instrument) = message.downcast_ref::<InstrumentAny>() {
1198 let mut writer = writer.borrow_mut();
1199 if let Err(e) = runtime.block_on(writer.write_instrument(instrument.clone())) {
1200 writer.record_write_error("InstrumentAny", e);
1201 }
1202 }
1203 })
1204 };
1205
1206 let pattern = MStr::pattern("*");
1207 msgbus::subscribe_any(pattern, any.clone(), None);
1208 msgbus::subscribe_instruments(pattern, instruments.clone(), None);
1209 msgbus::subscribe_book_deltas(pattern, deltas.clone(), None);
1210 msgbus::subscribe_book_depth10(pattern, depths.clone(), None);
1211 msgbus::subscribe_quotes(pattern, quotes.clone(), None);
1212 msgbus::subscribe_trades(pattern, trades.clone(), None);
1213 msgbus::subscribe_bars(pattern, bars.clone(), None);
1214 msgbus::subscribe_mark_prices(pattern, mark_prices.clone(), None);
1215 msgbus::subscribe_index_prices(pattern, index_prices.clone(), None);
1216 msgbus::subscribe_funding_rates(pattern, funding_rates.clone(), None);
1217 msgbus::subscribe_option_greeks(pattern, option_greeks.clone(), None);
1218 msgbus::subscribe_account_state(pattern, account_states.clone(), None);
1219 msgbus::subscribe_order_events(pattern, order_events.clone(), None);
1220 msgbus::subscribe_position_events(pattern, position_events.clone(), None);
1221
1222 FeatherWriterSubscriptions {
1223 any,
1224 instruments,
1225 deltas,
1226 depths,
1227 quotes,
1228 trades,
1229 bars,
1230 mark_prices,
1231 index_prices,
1232 funding_rates,
1233 option_greeks,
1234 account_states,
1235 order_events,
1236 position_events,
1237 }
1238 }
1239
1240 pub fn unsubscribe_from_message_bus(subscriptions: &FeatherWriterSubscriptions) {
1242 let pattern = MStr::pattern("*");
1243 msgbus::unsubscribe_any(pattern, &subscriptions.any);
1244 msgbus::unsubscribe_instruments(pattern, &subscriptions.instruments);
1245 msgbus::unsubscribe_book_deltas(pattern, &subscriptions.deltas);
1246 msgbus::unsubscribe_book_depth10(pattern, &subscriptions.depths);
1247 msgbus::unsubscribe_quotes(pattern, &subscriptions.quotes);
1248 msgbus::unsubscribe_trades(pattern, &subscriptions.trades);
1249 msgbus::unsubscribe_bars(pattern, &subscriptions.bars);
1250 msgbus::unsubscribe_mark_prices(pattern, &subscriptions.mark_prices);
1251 msgbus::unsubscribe_index_prices(pattern, &subscriptions.index_prices);
1252 msgbus::unsubscribe_funding_rates(pattern, &subscriptions.funding_rates);
1253 msgbus::unsubscribe_option_greeks(pattern, &subscriptions.option_greeks);
1254 msgbus::unsubscribe_account_state(pattern, &subscriptions.account_states);
1255 msgbus::unsubscribe_order_events(pattern, &subscriptions.order_events);
1256 msgbus::unsubscribe_position_events(pattern, &subscriptions.position_events);
1257 }
1258}
1259
1260pub(crate) fn default_per_instrument_types() -> HashSet<String> {
1261 [
1262 "bars",
1263 "funding_rate_update",
1264 "index_prices",
1265 "mark_prices",
1266 "order_book_deltas",
1267 "order_book_depths",
1268 "option_greeks",
1269 "quotes",
1270 "trades",
1271 ]
1272 .into_iter()
1273 .map(str::to_string)
1274 .collect()
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279 use std::{io::Cursor, sync::Arc};
1280
1281 use datafusion::arrow::ipc::reader::StreamReader;
1282 use nautilus_common::clock::TestClock;
1283 use nautilus_model::{
1284 data::{Data, QuoteTick, TradeTick},
1285 enums::AggressorSide,
1286 identifiers::{InstrumentId, TradeId},
1287 types::{Price, Quantity},
1288 };
1289 use nautilus_serialization::arrow::{
1290 ArrowSchemaProvider, DecodeDataFromRecordBatch, EncodeToRecordBatch,
1291 };
1292 use object_store::{ObjectStore, local::LocalFileSystem};
1293 use rstest::rstest;
1294 use tempfile::TempDir;
1295
1296 use super::*;
1297
1298 #[tokio::test]
1299 async fn test_writer_manager_keys() {
1300 let temp_dir = TempDir::new().unwrap();
1302 let base_path = temp_dir.path().to_str().unwrap().to_string();
1303
1304 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1306 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1307
1308 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1310 let timestamp = clock.borrow().timestamp_ns();
1311
1312 let quote_type_str = QuoteTick::path_prefix();
1313
1314 let mut per_instrument = HashSet::new();
1315 per_instrument.insert(quote_type_str.to_string());
1316
1317 let mut manager = FeatherWriter::new(
1318 base_path.clone(),
1319 store,
1320 clock,
1321 RotationConfig::NoRotation,
1322 None,
1323 Some(per_instrument),
1324 None, );
1326
1327 let instrument_id = "AAPL.AAPL";
1328 let quote = QuoteTick::new(
1330 InstrumentId::from(instrument_id),
1331 Price::from("100.0"),
1332 Price::from("100.0"),
1333 Quantity::from("100.0"),
1334 Quantity::from("100.0"),
1335 UnixNanos::from(1_000_000_000_000_000_000),
1336 UnixNanos::from(1_000_000_000_000_000_000),
1337 );
1338
1339 let trade = TradeTick::new(
1340 InstrumentId::from(instrument_id),
1341 Price::from("100.0"),
1342 Quantity::from("100.0"),
1343 AggressorSide::Buy,
1344 TradeId::from("1"),
1345 UnixNanos::from(1_000_000_000_000_000_000),
1346 UnixNanos::from(1_000_000_000_000_000_000),
1347 );
1348
1349 manager.write(quote).await.unwrap();
1350 manager.write(trade).await.unwrap();
1351
1352 let path = manager.get_writer_path("e).unwrap();
1354 let safe_id = instrument_id.replace('/', "");
1355 let expected_path = Path::from(format!(
1356 "{base_path}/quotes/{safe_id}/quotes_{timestamp}.feather"
1357 ));
1358 assert_eq!(path.path, expected_path);
1359 assert!(manager.writers.contains_key(&path));
1360 let writer = manager.writers.get(&path).unwrap();
1361 assert!(writer.size > 0);
1362
1363 let path = manager.get_writer_path(&trade).unwrap();
1364 let expected_path = Path::from(format!("{base_path}/trades_{timestamp}.feather"));
1365 assert_eq!(path.path, expected_path);
1366 assert!(manager.writers.contains_key(&path));
1367 let writer = manager.writers.get(&path).unwrap();
1368 assert!(writer.size > 0);
1369 }
1370
1371 #[tokio::test]
1372 async fn test_per_instrument_paths_support_long_instrument_id() {
1373 let temp_dir = TempDir::new().unwrap();
1374 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1375 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1376 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1377 let timestamp = clock.borrow().timestamp_ns();
1378 let mut per_instrument = HashSet::new();
1379 per_instrument.insert(QuoteTick::path_prefix().to_string());
1380 let mut manager = FeatherWriter::new(
1381 String::new(),
1382 Arc::clone(&store),
1383 clock,
1384 RotationConfig::NoRotation,
1385 None,
1386 Some(per_instrument),
1387 None,
1388 );
1389 let instrument_id = format!("{}.VENUE", "A".repeat(240));
1390 let quote = QuoteTick::new(
1391 InstrumentId::from(instrument_id.as_str()),
1392 Price::from("100.0"),
1393 Price::from("100.0"),
1394 Quantity::from("100.0"),
1395 Quantity::from("100.0"),
1396 UnixNanos::from(1_000_000_000_000_000_000),
1397 UnixNanos::from(1_000_000_000_000_000_000),
1398 );
1399
1400 manager.write(quote).await.unwrap();
1401 let path = manager.get_writer_path("e).unwrap();
1402 let regenerated_path = manager.regen_writer_path(&path);
1403 manager.close().await.unwrap();
1404 let persisted = store.head(&path.path).await.unwrap();
1405
1406 let safe_id = urisafe_instrument_id(&instrument_id);
1407 let expected_path = Path::from(format!("quotes/{safe_id}/quotes_{timestamp}.feather"));
1408 assert_eq!(path.path, expected_path);
1409 assert_eq!(regenerated_path.path, expected_path);
1410 assert_eq!(persisted.location, expected_path);
1411 }
1412
1413 #[rstest]
1414 fn test_per_instrument_path_preserves_nested_type_prefix() {
1415 let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1416 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1417 let manager = FeatherWriter::new(
1418 String::new(),
1419 store,
1420 clock,
1421 RotationConfig::NoRotation,
1422 None,
1423 None,
1424 None,
1425 );
1426
1427 let path = manager.per_instrument_path(
1428 "custom/RustTestCustomData",
1429 "RUST.TEST",
1430 UnixNanos::default(),
1431 );
1432
1433 let expected_path = Path::from("")
1434 .join("custom/RustTestCustomData")
1435 .join("RUST.TEST")
1436 .join("RUST.TEST_0.feather");
1437 assert_eq!(path, expected_path);
1438 }
1439
1440 #[rstest]
1441 fn test_file_writer_round_trip() {
1442 let instrument_id = "AAPL.AAPL";
1443 let quote = QuoteTick::new(
1445 InstrumentId::from(instrument_id),
1446 Price::from("100.0"),
1447 Price::from("100.0"),
1448 Quantity::from("100.0"),
1449 Quantity::from("100.0"),
1450 UnixNanos::from(100),
1451 UnixNanos::from(100),
1452 );
1453 let metadata = QuoteTick::metadata("e);
1454 let schema = QuoteTick::get_schema(Some(metadata.clone()));
1455 let batch = QuoteTick::encode_batch(&QuoteTick::metadata("e), &[quote]).unwrap();
1456
1457 let mut writer = FeatherBuffer::new(&schema, RotationConfig::NoRotation).unwrap();
1458 writer.write_record_batch(&batch).unwrap();
1459
1460 let buffer = writer.take_buffer().unwrap();
1461 let mut reader = StreamReader::try_new(Cursor::new(buffer.as_slice()), None).unwrap();
1462
1463 let read_metadata = reader.schema().metadata().clone();
1464 assert_eq!(read_metadata, metadata);
1465
1466 let read_batch = reader.next().unwrap().unwrap();
1467 assert_eq!(read_batch.column(0), batch.column(0));
1468
1469 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
1470 assert_eq!(decoded[0], Data::from(quote));
1471 }
1472
1473 #[tokio::test]
1474 async fn test_round_trip() {
1475 let temp_dir = TempDir::new_in(".").unwrap();
1477 let base_path = temp_dir.path().to_str().unwrap().to_string();
1478
1479 let local_fs = LocalFileSystem::new_with_prefix(&base_path).unwrap();
1481 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1482
1483 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1485
1486 let quote_type_str = QuoteTick::path_prefix();
1487 let trade_type_str = TradeTick::path_prefix();
1488
1489 let mut per_instrument = HashSet::new();
1490 per_instrument.insert(quote_type_str.to_string());
1491 per_instrument.insert(trade_type_str.to_string());
1492
1493 let mut manager = FeatherWriter::new(
1494 base_path.clone(),
1495 store,
1496 clock,
1497 RotationConfig::NoRotation,
1498 None,
1499 Some(per_instrument),
1500 None, );
1502
1503 let instrument_id = "AAPL.AAPL";
1504 let quote = QuoteTick::new(
1506 InstrumentId::from(instrument_id),
1507 Price::from("100.0"),
1508 Price::from("100.0"),
1509 Quantity::from("100.0"),
1510 Quantity::from("100.0"),
1511 UnixNanos::from(100),
1512 UnixNanos::from(100),
1513 );
1514
1515 let trade = TradeTick::new(
1516 InstrumentId::from(instrument_id),
1517 Price::from("100.0"),
1518 Quantity::from("100.0"),
1519 AggressorSide::Buy,
1520 TradeId::from("1"),
1521 UnixNanos::from(100),
1522 UnixNanos::from(100),
1523 );
1524
1525 manager.write(quote).await.unwrap();
1526 manager.write(trade).await.unwrap();
1527
1528 let paths = manager.writers.keys().cloned().collect::<Vec<_>>();
1529 assert_eq!(paths.len(), 2);
1530
1531 manager.flush().await.unwrap();
1533
1534 let mut recovered_quotes = Vec::new();
1536 let mut recovered_trades = Vec::new();
1537 let local_fs = LocalFileSystem::new_with_prefix(&base_path).unwrap();
1538 for path in paths {
1539 let path_str = local_fs.path_to_filesystem(&path.path).unwrap();
1540 let buffer = std::fs::File::open(&path_str).unwrap();
1541 let reader = StreamReader::try_new(buffer, None).unwrap();
1542 let metadata = reader.schema().metadata().clone();
1543 for batch in reader {
1544 let batch = batch.unwrap();
1545 if path_str.to_str().unwrap().contains("quotes") {
1546 let decoded = QuoteTick::decode_data_batch(&metadata, batch).unwrap();
1547 recovered_quotes.extend(decoded);
1548 } else if path_str.to_str().unwrap().contains("trades") {
1549 let decoded = TradeTick::decode_data_batch(&metadata, batch).unwrap();
1550 recovered_trades.extend(decoded);
1551 }
1552 }
1553 }
1554
1555 assert_eq!(recovered_quotes.len(), 1, "Expected one QuoteTick record");
1557 assert_eq!(recovered_trades.len(), 1, "Expected one TradeTick record");
1558
1559 assert_eq!(recovered_quotes[0], Data::from(quote));
1561 assert_eq!(recovered_trades[0], Data::from(trade));
1562 }
1563
1564 #[tokio::test]
1565 async fn test_write_data_enum() {
1566 let temp_dir = TempDir::new().unwrap();
1567 let base_path = temp_dir.path().to_str().unwrap().to_string();
1568 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1569 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1570 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1571
1572 let mut writer = FeatherWriter::new(
1573 base_path,
1574 store,
1575 clock,
1576 RotationConfig::NoRotation,
1577 None,
1578 None,
1579 None,
1580 );
1581
1582 let quote = QuoteTick::new(
1583 InstrumentId::from("AUD/USD.SIM"),
1584 Price::from("1.0"),
1585 Price::from("1.0"),
1586 Quantity::from("1000"),
1587 Quantity::from("1000"),
1588 UnixNanos::from(1000),
1589 UnixNanos::from(1000),
1590 );
1591
1592 writer.write_data(Data::Quote(quote)).await.unwrap();
1594 writer.flush().await.unwrap();
1595
1596 assert!(!writer.writers.is_empty() || temp_dir.path().read_dir().unwrap().count() > 0);
1598 }
1599
1600 #[tokio::test]
1601 async fn test_write_data_all_types() {
1602 let temp_dir = TempDir::new().unwrap();
1603 let base_path = temp_dir.path().to_str().unwrap().to_string();
1604 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1605 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1606 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1607
1608 let mut writer = FeatherWriter::new(
1609 base_path,
1610 store,
1611 clock,
1612 RotationConfig::NoRotation,
1613 None,
1614 None,
1615 None,
1616 );
1617
1618 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1619
1620 let quote = QuoteTick::new(
1622 instrument_id,
1623 Price::from("1.0"),
1624 Price::from("1.0"),
1625 Quantity::from("1000"),
1626 Quantity::from("1000"),
1627 UnixNanos::from(1000),
1628 UnixNanos::from(1000),
1629 );
1630 writer.write_data(Data::Quote(quote)).await.unwrap();
1631
1632 let trade = TradeTick::new(
1633 instrument_id,
1634 Price::from("1.0"),
1635 Quantity::from("1000"),
1636 AggressorSide::Buy,
1637 TradeId::from("1"),
1638 UnixNanos::from(2000),
1639 UnixNanos::from(2000),
1640 );
1641 writer.write_data(Data::Trade(trade)).await.unwrap();
1642
1643 let delta = OrderBookDelta::clear(
1644 instrument_id,
1645 0,
1646 UnixNanos::from(3000),
1647 UnixNanos::from(3000),
1648 );
1649 writer.write_data(Data::Delta(delta)).await.unwrap();
1650
1651 writer.flush().await.unwrap();
1652 }
1653
1654 #[tokio::test]
1655 async fn test_auto_flush() {
1656 let temp_dir = TempDir::new().unwrap();
1657 let base_path = temp_dir.path().to_str().unwrap().to_string();
1658 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1659 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1660 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1661
1662 let mut writer = FeatherWriter::new(
1663 base_path,
1664 store,
1665 clock.clone(),
1666 RotationConfig::NoRotation,
1667 None,
1668 None,
1669 Some(100), );
1671
1672 let quote = QuoteTick::new(
1673 InstrumentId::from("AUD/USD.SIM"),
1674 Price::from("1.0"),
1675 Price::from("1.0"),
1676 Quantity::from("1000"),
1677 Quantity::from("1000"),
1678 UnixNanos::from(1000),
1679 UnixNanos::from(1000),
1680 );
1681
1682 writer.write(quote).await.unwrap();
1684
1685 let quote2 = QuoteTick::new(
1691 InstrumentId::from("AUD/USD.SIM"),
1692 Price::from("1.1"),
1693 Price::from("1.1"),
1694 Quantity::from("1000"),
1695 Quantity::from("1000"),
1696 UnixNanos::from(2000),
1697 UnixNanos::from(2000),
1698 );
1699 writer.write(quote2).await.unwrap();
1700
1701 }
1704
1705 #[tokio::test]
1706 async fn test_flush_reports_previous_write_failure() {
1707 let temp_dir = TempDir::new().unwrap();
1708 let base_path = temp_dir.path().to_str().unwrap().to_string();
1709 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1710 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1711 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1712 let mut writer = FeatherWriter::new(
1713 base_path.clone(),
1714 store,
1715 clock,
1716 RotationConfig::NoRotation,
1717 None,
1718 None,
1719 None,
1720 );
1721 let quote = QuoteTick::new(
1722 InstrumentId::from("AUD/USD.SIM"),
1723 Price::from("1.0"),
1724 Price::from("1.0"),
1725 Quantity::from("1000"),
1726 Quantity::from("1000"),
1727 UnixNanos::from(1_000),
1728 UnixNanos::from(1_000),
1729 );
1730 writer.write(quote).await.unwrap();
1731 std::fs::remove_dir_all(&base_path).unwrap();
1732 std::fs::write(&base_path, b"not a directory").unwrap();
1733
1734 let first_error = writer.flush().await.unwrap_err().to_string();
1735 let second_error = writer.flush().await.unwrap_err().to_string();
1736 std::fs::remove_file(&base_path).unwrap();
1737
1738 assert_eq!(
1739 second_error,
1740 format!("Failed to write streaming output: {first_error}"),
1741 );
1742 }
1743
1744 #[tokio::test]
1745 async fn test_close() {
1746 let temp_dir = TempDir::new().unwrap();
1747 let base_path = temp_dir.path().to_str().unwrap().to_string();
1748 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1749 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1750 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1751
1752 let mut writer = FeatherWriter::new(
1753 base_path,
1754 store,
1755 clock,
1756 RotationConfig::NoRotation,
1757 None,
1758 None,
1759 None,
1760 );
1761
1762 let quote = QuoteTick::new(
1763 InstrumentId::from("AUD/USD.SIM"),
1764 Price::from("1.0"),
1765 Price::from("1.0"),
1766 Quantity::from("1000"),
1767 Quantity::from("1000"),
1768 UnixNanos::from(1000),
1769 UnixNanos::from(1000),
1770 );
1771
1772 writer.write(quote).await.unwrap();
1773 assert!(!writer.writers.is_empty());
1774
1775 writer.close().await.unwrap();
1776 assert!(writer.writers.is_empty());
1777 }
1778
1779 #[tokio::test]
1785 async fn test_write_data_orderbook_deltas() {
1786 let temp_dir = TempDir::new().unwrap();
1787 let base_path = temp_dir.path().to_str().unwrap().to_string();
1788 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1789 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1790 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1791
1792 let mut writer = FeatherWriter::new(
1793 base_path,
1794 store,
1795 clock,
1796 RotationConfig::NoRotation,
1797 None,
1798 None,
1799 None,
1800 );
1801
1802 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1803 let delta1 = OrderBookDelta::clear(
1804 instrument_id,
1805 0,
1806 UnixNanos::from(1000),
1807 UnixNanos::from(1000),
1808 );
1809 let delta2 = OrderBookDelta::clear(
1810 instrument_id,
1811 0,
1812 UnixNanos::from(2000),
1813 UnixNanos::from(2000),
1814 );
1815
1816 let book_deltas = OrderBookDeltas::new(instrument_id, vec![delta1, delta2]);
1817
1818 writer
1820 .write_data(Data::Deltas(Box::new(book_deltas)))
1821 .await
1822 .unwrap();
1823 writer.flush().await.unwrap();
1824 }
1825
1826 #[tokio::test]
1827 #[cfg(feature = "python")]
1828 async fn test_write_custom_data_round_trip() {
1829 use std::sync::Arc;
1830
1831 use futures::StreamExt;
1832 use nautilus_model::{
1833 data::{CustomData, Data, DataType},
1834 identifiers::InstrumentId,
1835 };
1836 use nautilus_serialization::{
1837 arrow::custom::CustomDataDecoder, ensure_custom_data_registered,
1838 };
1839
1840 use crate::test_data::RustTestCustomData;
1841
1842 ensure_custom_data_registered::<RustTestCustomData>();
1843
1844 let temp_dir = TempDir::new().unwrap();
1845 let base_path = temp_dir.path().to_str().unwrap().to_string();
1846 let local_fs = LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap();
1847 let store: Arc<dyn ObjectStore> = Arc::new(local_fs);
1848 let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1849
1850 let mut writer = FeatherWriter::new(
1851 base_path.clone(),
1852 store.clone(),
1853 clock,
1854 RotationConfig::NoRotation,
1855 None,
1856 None,
1857 None,
1858 );
1859
1860 let instrument_id = InstrumentId::from("RUST.TEST");
1861 let data_type = DataType::new("RustTestCustomData", None, Some(instrument_id.to_string()));
1862 let original = RustTestCustomData {
1863 instrument_id,
1864 value: 1.23,
1865 flag: true,
1866 ts_event: UnixNanos::from(1000),
1867 ts_init: UnixNanos::from(1000),
1868 };
1869 let custom = CustomData::new(Arc::new(original.clone()), data_type);
1870
1871 writer
1872 .write_data(Data::Custom(custom))
1873 .await
1874 .expect("write_data CustomData");
1875 writer.flush().await.expect("flush");
1876
1877 let prefix = Path::from(format!("{base_path}/data/custom/RustTestCustomData"));
1878 let mut list_stream = store.list(Some(&prefix));
1879 let first = list_stream.next().await.expect("at least one object");
1880 let meta = first.expect("list item");
1881 let bytes = store
1882 .get(&meta.location)
1883 .await
1884 .expect("get")
1885 .bytes()
1886 .await
1887 .expect("bytes");
1888 let mut reader =
1889 StreamReader::try_new(Cursor::new(bytes.as_ref()), None).expect("StreamReader");
1890 let schema = reader.schema();
1891 let metadata: std::collections::HashMap<String, String> = schema
1892 .metadata()
1893 .iter()
1894 .map(|(k, v)| (k.clone(), v.clone()))
1895 .collect();
1896 let batch = reader.next().expect("batch").expect("batch ok");
1897 let decoded =
1898 CustomDataDecoder::decode_data_batch(&metadata, batch).expect("decode_data_batch");
1899 assert_eq!(decoded.len(), 1);
1900 if let Data::Custom(decoded_custom) = &decoded[0] {
1901 assert_eq!(decoded_custom.data_type.type_name(), "RustTestCustomData");
1902 let rust: &RustTestCustomData = decoded_custom
1903 .data
1904 .as_any()
1905 .downcast_ref::<RustTestCustomData>()
1906 .expect("RustTestCustomData");
1907 assert_eq!(rust, &original);
1908 } else {
1909 panic!("Expected Data::Custom");
1910 }
1911 }
1912}