1use std::{
19 fmt::{Debug, Display},
20 fs, io,
21 sync::Arc,
22};
23
24use indexmap::IndexMap;
25use nautilus_core::Params;
26
27use super::{
28 feather::{RotationConfig, WriterClock},
29 filter::WriterRecordFilter,
30 traits::StreamingDataSink,
31};
32use crate::{
33 catalog::factory::CatalogConnectConfig,
34 common::{backend_name::backend_type, paths::local_writer_directory},
35};
36
37pub const FEATHER_WRITER_FACTORY_NAME: &str = "Feather";
39
40pub const PARQUET_WRITER_FACTORY_NAME: &str = "Parquet";
42
43backend_type!(
44 WriterBackendType {
46 Feather => FEATHER_WRITER_FACTORY_NAME,
47 Parquet => PARQUET_WRITER_FACTORY_NAME,
48 }
49);
50
51#[derive(Clone, Debug)]
53pub struct WriterConnectConfig {
54 pub uri: String,
56 pub catalog: Option<CatalogConnectConfig>,
58 pub rotation_config: RotationConfig,
60 pub flush_interval_ms: Option<u64>,
62 pub record_filter: Option<WriterRecordFilter>,
64 pub promotion_interval_ms: Option<u64>,
66 pub promote_on_close: bool,
68 pub delete_feather_after_promotion: bool,
70 pub use_ts_event_for_ts_init: bool,
72 pub params: Option<Params>,
74}
75
76impl WriterConnectConfig {
77 #[must_use]
79 pub fn new(uri: impl Into<String>, catalog: Option<CatalogConnectConfig>) -> Self {
80 Self {
81 uri: uri.into(),
82 catalog,
83 rotation_config: RotationConfig::NoRotation,
84 flush_interval_ms: None,
85 record_filter: None,
86 promotion_interval_ms: None,
87 promote_on_close: true,
88 delete_feather_after_promotion: false,
89 use_ts_event_for_ts_init: false,
90 params: None,
91 }
92 }
93
94 pub fn required_catalog(&self, backend: &str) -> anyhow::Result<&CatalogConnectConfig> {
100 self.catalog
101 .as_ref()
102 .ok_or_else(|| anyhow::anyhow!("{backend} writer requires a promotion catalog"))
103 }
104}
105
106pub type WriterFactory =
108 Arc<dyn Fn(&WriterConnectConfig, WriterClock) -> anyhow::Result<StreamingDataSink>>;
109
110pub type WriterFactoryRegistry = IndexMap<String, WriterFactory>;
112
113pub fn create_writer(
121 backend: &WriterBackendType,
122 config: &WriterConnectConfig,
123 clock: WriterClock,
124 factories: &WriterFactoryRegistry,
125) -> anyhow::Result<StreamingDataSink> {
126 let name = backend.to_string();
127 factories
128 .get(&name)
129 .ok_or_else(|| anyhow::anyhow!("No writer factory registered for '{name}'"))?(
130 config, clock
131 )
132}
133
134pub fn replace_existing_writer_data(config: &WriterConnectConfig) -> anyhow::Result<()> {
140 let directory = local_writer_directory(&config.uri)?;
141
142 let entries = match fs::read_dir(&directory) {
144 Ok(entries) => entries,
145 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
146 Err(e) => return Err(e.into()),
147 };
148
149 for entry in entries {
150 let entry = entry?;
151 if entry.file_type()?.is_dir() {
152 fs::remove_dir_all(entry.path())?;
153 } else {
154 fs::remove_file(entry.path())?;
155 }
156 }
157
158 Ok(())
159}
160#[cfg(test)]
161mod tests {
162 use std::sync::atomic::AtomicU64;
163
164 use nautilus_common::enums::Environment;
165 use nautilus_core::UnixNanos;
166 use nautilus_model::{
167 data::{Data, InstrumentClose, InstrumentStatus, NautilusDataType, QuoteTick},
168 enums::{InstrumentCloseType, MarketStatusAction},
169 identifiers::InstrumentId,
170 instruments::{
171 InstrumentAny, NautilusInstrumentType,
172 stubs::{audusd_sim, futures_contract_es},
173 },
174 types::{Price, Quantity},
175 };
176 use rstest::rstest;
177 use tempfile::TempDir;
178
179 use super::*;
180 use crate::{
181 backend::{default_writer_factories, parquet::catalog::ParquetDataCatalog},
182 catalog::types::CatalogDataType,
183 writer::feather::FEATHER_PARTIAL_EXTENSION,
184 };
185
186 fn quote(ts_init: u64) -> QuoteTick {
187 QuoteTick::new(
188 InstrumentId::from("AUD/USD.SIM"),
189 Price::from("0.66"),
190 Price::from("0.67"),
191 Quantity::from("1000"),
192 Quantity::from("1000"),
193 UnixNanos::from(ts_init),
194 UnixNanos::from(ts_init),
195 )
196 }
197
198 #[rstest]
199 fn replace_existing_writer_data_removes_local_files() {
200 let directory = TempDir::new().unwrap();
201 let stale_file = directory.path().join("stale.feather");
202 let stale_type_directory = directory.path().join("quotes");
203 std::fs::write(&stale_file, b"stale").unwrap();
204 std::fs::create_dir(&stale_type_directory).unwrap();
205 std::fs::write(stale_type_directory.join("quotes_0.feather"), b"stale").unwrap();
206 let config =
207 WriterConnectConfig::new(format!("file://{}", directory.path().display()), None);
208
209 replace_existing_writer_data(&config).unwrap();
210
211 assert!(directory.path().is_dir());
212 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
213 }
214
215 #[rstest]
216 fn writer_record_filter_allows_empty_and_record_family_filters() {
217 let quotes = CatalogDataType::from(NautilusDataType::QuoteTick);
218 let trades = CatalogDataType::from(NautilusDataType::TradeTick);
219 let empty = WriterRecordFilter::new();
220 assert!(empty.contains("es));
221 assert!(empty.allows("es, None));
222 assert!(empty.allows(&trades, Some("AUD/USD.SIM")));
223
224 let mut filter = WriterRecordFilter::new();
225 filter.insert(NautilusDataType::QuoteTick, None);
226 assert!(filter.contains("es));
227 assert!(!filter.contains(&trades));
228 assert!(filter.allows("es, None));
229 assert!(filter.allows("es, Some("AUD/USD.SIM")));
230 assert!(!filter.allows(&trades, Some("AUD/USD.SIM")));
231 }
232
233 #[rstest]
234 fn writer_record_filter_restricts_by_instrument_type() {
235 let mut filter = WriterRecordFilter::new();
236 filter.insert_instrument_type(NautilusInstrumentType::FuturesContract);
237
238 assert!(filter.contains(&NautilusDataType::Instrument.into()));
239 assert!(filter.allows(
240 &CatalogDataType::Instrument(NautilusInstrumentType::FuturesContract),
241 Some("ESM4.GLBX"),
242 ));
243 assert!(!filter.allows(
244 &CatalogDataType::Instrument(NautilusInstrumentType::Equity),
245 Some("AAPL.XNAS"),
246 ));
247 assert!(!filter.allows(&NautilusDataType::QuoteTick.into(), Some("ESM4.GLBX")));
248 }
249
250 #[rstest]
251 fn writer_record_filter_allows_data_and_instrument_type_union() {
252 let mut filter = WriterRecordFilter::new();
253 filter.insert(NautilusDataType::Bar, None);
254 filter.insert_instrument_type(NautilusInstrumentType::FuturesContract);
255
256 assert!(filter.allows(&NautilusDataType::Bar.into(), Some("ESM4.GLBX")));
257 assert!(filter.allows(
258 &CatalogDataType::Instrument(NautilusInstrumentType::FuturesContract),
259 Some("ESM4.GLBX"),
260 ));
261 assert!(!filter.allows(
262 &CatalogDataType::Instrument(NautilusInstrumentType::Equity),
263 Some("AAPL.XNAS"),
264 ));
265 }
266
267 #[rstest]
268 fn writer_record_filter_restricts_by_identifier() {
269 let quotes = CatalogDataType::from(NautilusDataType::QuoteTick);
270 let mut filter = WriterRecordFilter::new();
271 filter.insert(
272 NautilusDataType::QuoteTick,
273 Some(vec!["AUD/USD.SIM".to_string()]),
274 );
275
276 assert!(filter.contains("es));
277 assert!(filter.allows("es, Some("AUD/USD.SIM")));
278 assert!(!filter.allows("es, Some("GBP/USD.SIM")));
279 assert!(!filter.allows("es, None));
280 assert!(!filter.allows(&NautilusDataType::TradeTick.into(), Some("AUD/USD.SIM")));
281 }
282
283 #[rstest]
284 fn writer_backend_type_parses_built_ins_case_insensitively() {
285 assert_eq!(
286 "feather".parse::<WriterBackendType>().unwrap(),
287 WriterBackendType::Feather,
288 );
289 assert_eq!(
290 "parquet".parse::<WriterBackendType>().unwrap(),
291 WriterBackendType::Parquet,
292 );
293 assert_eq!(
294 "parquet".parse::<WriterBackendType>().unwrap(),
295 WriterBackendType::Parquet,
296 );
297 assert_eq!(WriterBackendType::Feather.to_string(), "Feather");
298 assert_eq!(WriterBackendType::Parquet.to_string(), "Parquet");
299 assert_eq!(WriterBackendType::Parquet.to_string(), "Parquet");
300 }
301
302 #[rstest]
303 fn writer_backend_type_preserves_custom_factory_case() {
304 assert_eq!(
305 "CustomSink".parse::<WriterBackendType>().unwrap(),
306 WriterBackendType::External("CustomSink".to_string()),
307 );
308 assert!("".parse::<WriterBackendType>().is_err());
309 }
310
311 #[rstest]
312 fn default_registry_contains_built_in_writer_factories() {
313 let registry = default_writer_factories();
314 assert!(registry.contains_key(FEATHER_WRITER_FACTORY_NAME));
315 }
316
317 #[rstest]
318 fn create_writer_builds_feather_writer_through_registry() {
319 let temp_dir = TempDir::new().unwrap();
320 let config = WriterConnectConfig::new(temp_dir.path().to_str().unwrap(), None);
321 let registry = default_writer_factories();
322
323 let mut writer = create_writer(
324 &WriterBackendType::Feather,
325 &config,
326 WriterClock::Live,
327 ®istry,
328 )
329 .unwrap();
330 writer.write_data(Data::Quote(quote(100))).unwrap();
331 writer.flush().unwrap();
332 writer.close().unwrap();
333
334 assert_eq!(count_feather_files(temp_dir.path()), 1);
335 }
336
337 #[rstest]
338 #[case("backtest")]
339 #[case("live")]
340 fn create_writer_builds_feather_writer_for_run_session_kind(#[case] run_kind: &str) {
341 let temp_dir = TempDir::new().unwrap();
342 let session = temp_dir.path().join(run_kind).join("run-001");
343 let config = WriterConnectConfig::new(session.to_str().unwrap(), None);
344 let registry = default_writer_factories();
345
346 let mut writer = create_writer(
347 &WriterBackendType::Feather,
348 &config,
349 WriterClock::Live,
350 ®istry,
351 )
352 .unwrap();
353 writer.write_data(Data::Quote(quote(100))).unwrap();
354 writer.close().unwrap();
355
356 assert_eq!(count_feather_files(&session), 1);
357 }
358
359 #[rstest]
360 fn create_writer_feather_recovers_partial_files_at_startup() {
361 let temp_dir = TempDir::new().unwrap();
362 let config = WriterConnectConfig::new(temp_dir.path().to_str().unwrap(), None);
363 let registry = default_writer_factories();
364 let start = || {
365 create_writer(
366 &WriterBackendType::Feather,
367 &config,
368 WriterClock::Test(Arc::new(AtomicU64::new(0))),
369 ®istry,
370 )
371 .unwrap()
372 };
373
374 let mut crashed = start();
375 crashed.write_data(Data::Quote(quote(100))).unwrap();
376 crashed.close().unwrap();
377
378 let sealed = temp_dir.path().join("quotes").join("quotes_0.feather");
380 let partial = sealed.with_extension(FEATHER_PARTIAL_EXTENSION);
381 fs::rename(&sealed, &partial).unwrap();
382
383 let mut restarted = start();
384 let sealed_at_start = sealed.exists();
385 let partial_at_start = partial.exists();
386 restarted.close().unwrap();
387
388 assert!(sealed_at_start);
389 assert!(!partial_at_start);
390 assert_eq!(count_feather_files(temp_dir.path()), 1);
391 }
392
393 #[rstest]
394 fn create_writer_feather_round_trips_quotes_through_catalog() {
395 let temp_dir = TempDir::new().unwrap();
396 let session = temp_dir.path().join("backtest").join("run-001");
397 let expected = quote(100);
398 let config = WriterConnectConfig::new(session.to_str().unwrap(), None);
399 let registry = default_writer_factories();
400
401 let mut writer = create_writer(
402 &WriterBackendType::Feather,
403 &config,
404 WriterClock::Live,
405 ®istry,
406 )
407 .unwrap();
408 writer.write_data(Data::Quote(expected)).unwrap();
409 writer.close().unwrap();
410
411 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
412 catalog
413 .convert_stream_to_data(
414 "run-001",
415 &CatalogDataType::from(NautilusDataType::QuoteTick),
416 Environment::Backtest,
417 None,
418 false,
419 )
420 .unwrap();
421
422 let loaded = catalog
423 .query::<QuoteTick>(None, None, None, None, None, true)
424 .unwrap();
425 assert_eq!(loaded, vec![expected]);
426 }
427
428 #[rstest]
429 fn create_writer_feather_round_trips_status_and_closes_for_each_instrument() {
430 let temp_dir = TempDir::new().unwrap();
431 let session = temp_dir.path().join("backtest").join("run-001");
432 let first = InstrumentId::from("AUD/USD.SIM");
433 let second = InstrumentId::from("GBP/USD.SIM");
434 let statuses = vec![
435 InstrumentStatus::new(
436 first,
437 MarketStatusAction::Trading,
438 UnixNanos::from(1),
439 UnixNanos::from(1),
440 None,
441 None,
442 Some(true),
443 None,
444 None,
445 ),
446 InstrumentStatus::new(
447 second,
448 MarketStatusAction::Halt,
449 UnixNanos::from(2),
450 UnixNanos::from(2),
451 None,
452 None,
453 Some(false),
454 None,
455 None,
456 ),
457 ];
458 let closes = vec![
459 InstrumentClose::new(
460 first,
461 Price::from("0.66"),
462 InstrumentCloseType::EndOfSession,
463 UnixNanos::from(3),
464 UnixNanos::from(3),
465 ),
466 InstrumentClose::new(
467 second,
468 Price::from("1.250"),
469 InstrumentCloseType::ContractExpired,
470 UnixNanos::from(4),
471 UnixNanos::from(4),
472 ),
473 ];
474 let config = WriterConnectConfig::new(session.to_str().unwrap(), None);
475 let registry = default_writer_factories();
476
477 let mut writer = create_writer(
478 &WriterBackendType::Feather,
479 &config,
480 WriterClock::Live,
481 ®istry,
482 )
483 .unwrap();
484
485 for status in &statuses {
486 writer.write_data(Data::InstrumentStatus(*status)).unwrap();
487 }
488
489 for close in &closes {
490 writer.write_data(Data::InstrumentClose(*close)).unwrap();
491 }
492
493 writer.close().unwrap();
494
495 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
496
497 for data_type in [
498 NautilusDataType::InstrumentStatus,
499 NautilusDataType::InstrumentClose,
500 ] {
501 catalog
502 .convert_stream_to_data(
503 "run-001",
504 &CatalogDataType::from(data_type),
505 Environment::Backtest,
506 None,
507 false,
508 )
509 .unwrap();
510 }
511
512 let loaded_statuses = catalog
513 .query::<InstrumentStatus>(None, None, None, None, None, true)
514 .unwrap();
515 let loaded_closes = catalog
516 .query::<InstrumentClose>(None, None, None, None, None, true)
517 .unwrap();
518 assert_eq!(loaded_statuses, statuses);
519 assert_eq!(loaded_closes, closes);
520 }
521
522 #[rstest]
523 fn create_writer_feather_filters_and_converts_instruments_by_type() {
524 let temp_dir = TempDir::new().unwrap();
525 let session = temp_dir.path().join("backtest").join("run-001");
526 let futures = InstrumentAny::FuturesContract(futures_contract_es(None, None));
527 let mut record_filter = WriterRecordFilter::new();
528 record_filter.insert_instrument_type(NautilusInstrumentType::FuturesContract);
529 let mut config = WriterConnectConfig::new(session.to_str().unwrap(), None);
530 config.record_filter = Some(record_filter);
531 let registry = default_writer_factories();
532
533 let mut writer = create_writer(
534 &WriterBackendType::Feather,
535 &config,
536 WriterClock::Live,
537 ®istry,
538 )
539 .unwrap();
540 writer
541 .write_data(Data::Instrument(Box::new(InstrumentAny::CurrencyPair(
542 audusd_sim(),
543 ))))
544 .unwrap();
545 writer
546 .write_data(Data::Instrument(Box::new(futures.clone())))
547 .unwrap();
548 writer.close().unwrap();
549
550 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
551 catalog
552 .convert_stream_to_data(
553 "run-001",
554 &CatalogDataType::from(NautilusDataType::Instrument),
555 Environment::Backtest,
556 None,
557 false,
558 )
559 .unwrap();
560
561 let loaded = catalog.query_instruments(None).unwrap();
562 assert_eq!(
563 serde_json::to_value(&loaded).unwrap(),
564 serde_json::to_value(vec![futures]).unwrap()
565 );
566 }
567
568 #[rstest]
569 fn create_writer_errors_for_missing_external() {
570 let temp_dir = TempDir::new().unwrap();
571 let session = temp_dir.path().join("backtest").join("run-001");
572 let config = WriterConnectConfig::new(session.to_str().unwrap(), None);
573 let registry = default_writer_factories();
574
575 let result = create_writer(
576 &WriterBackendType::External("Missing".to_string()),
577 &config,
578 WriterClock::Live,
579 ®istry,
580 );
581 assert!(
582 result
583 .unwrap_err()
584 .to_string()
585 .contains("No writer factory registered for 'Missing'"),
586 );
587 }
588
589 #[rstest]
590 #[case::feather(WriterBackendType::Feather)]
591 #[cfg_attr(feature = "cloud", case::parquet(WriterBackendType::Parquet))]
593 fn create_writer_rejects_remote_staging_uri(#[case] backend: WriterBackendType) {
594 let catalog = TempDir::new().unwrap();
595
596 let config = WriterConnectConfig::new(
597 "s3://bucket/backtest/run-001",
598 Some(CatalogConnectConfig::new(
599 catalog.path().to_string_lossy(),
600 None,
601 )),
602 );
603
604 let error = create_writer(
605 &backend,
606 &config,
607 WriterClock::Live,
608 &default_writer_factories(),
609 )
610 .unwrap_err();
611
612 assert_eq!(
613 error.to_string(),
614 "Streaming writers append to local files, writer path must be local, was \
615 s3://bucket/backtest/run-001",
616 );
617 }
618
619 #[rstest]
620 fn replace_existing_writer_data_rejects_remote_uri() {
621 let config = WriterConnectConfig::new("s3://bucket/backtest/run-001", None);
622
623 let error = replace_existing_writer_data(&config).unwrap_err();
624
625 assert_eq!(
626 error.to_string(),
627 "Streaming writers append to local files, writer path must be local, was \
628 s3://bucket/backtest/run-001",
629 );
630 }
631
632 fn count_feather_files(dir: &std::path::Path) -> usize {
633 let mut count = 0;
634 let mut stack = vec![dir.to_path_buf()];
635 while let Some(path) = stack.pop() {
636 let Ok(entries) = std::fs::read_dir(&path) else {
637 continue;
638 };
639
640 for entry in entries.flatten() {
641 let path = entry.path();
642 if path.is_dir() {
643 stack.push(path);
644 } else if path
645 .extension()
646 .is_some_and(|extension| extension == "feather")
647 {
648 count += 1;
649 }
650 }
651 }
652
653 count
654 }
655}