1use std::{collections::HashMap, str::FromStr, sync::Arc};
17
18use arrow::{
19 array::{Decimal128Array, UInt64Array},
20 datatypes::{DataType, Field, Schema},
21 error::ArrowError,
22 record_batch::RecordBatch,
23};
24use nautilus_model::{
25 data::close::InstrumentClose, enums::InstrumentCloseType, identifiers::InstrumentId,
26};
27
28use super::{
29 DecodeDataFromRecordBatch, EncodingError, KEY_IDENTIFIER, KEY_INSTRUMENT_ID,
30 KEY_PRICE_PRECISION, decode_decimal_price, decode_required_timestamp, enum_dictionary_array,
31 enum_dictionary_data_type, extract_column, extract_column_string, fixed_decimal_data_type,
32 identifier_array_from_display, metadata_with_type_name, price_decimal_array,
33 validate_required_column,
34};
35use crate::arrow::{ArrowSchemaProvider, Data, DecodeFromRecordBatch, EncodeToRecordBatch};
36
37impl ArrowSchemaProvider for InstrumentClose {
38 fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema {
39 let fields = vec![
40 Field::new("close_price", fixed_decimal_data_type(), true),
41 Field::new("close_type", enum_dictionary_data_type(), false),
42 Field::new("ts_event", crate::arrow::timestamp_data_type(), false),
43 Field::new("ts_init", crate::arrow::timestamp_data_type(), false),
44 Field::new(KEY_IDENTIFIER, DataType::Utf8, true),
45 ];
46
47 Schema::new_with_metadata(fields, metadata_with_type_name("InstrumentClose", metadata))
48 }
49}
50
51fn parse_metadata(metadata: &HashMap<String, String>) -> Result<(InstrumentId, u8), EncodingError> {
52 let instrument_id_str = metadata
53 .get(KEY_INSTRUMENT_ID)
54 .ok_or_else(|| EncodingError::MissingMetadata(KEY_INSTRUMENT_ID))?;
55 let instrument_id = InstrumentId::from_str(instrument_id_str)
56 .map_err(|e| EncodingError::ParseError(KEY_INSTRUMENT_ID, e.to_string()))?;
57
58 let price_precision = metadata
59 .get(KEY_PRICE_PRECISION)
60 .ok_or_else(|| EncodingError::MissingMetadata(KEY_PRICE_PRECISION))?
61 .parse::<u8>()
62 .map_err(|e| EncodingError::ParseError(KEY_PRICE_PRECISION, e.to_string()))?;
63
64 Ok((instrument_id, price_precision))
65}
66
67impl EncodeToRecordBatch for InstrumentClose {
68 fn encode_batch<T>(
69 metadata: &HashMap<String, String>,
70 data: &[T],
71 ) -> Result<RecordBatch, ArrowError>
72 where
73 T: std::borrow::Borrow<Self>,
74 {
75 let mut ts_event_builder = UInt64Array::builder(data.len());
76 let mut ts_init_builder = UInt64Array::builder(data.len());
77
78 for item in data.iter().map(std::borrow::Borrow::borrow) {
79 ts_event_builder.append_value(item.ts_event.as_u64());
80 ts_init_builder.append_value(item.ts_init.as_u64());
81 }
82
83 crate::arrow::record_batch_with_timestamps(
84 Self::get_schema(Some(metadata.clone())).into(),
85 vec![
86 Arc::new(price_decimal_array(
87 data.iter()
88 .map(std::borrow::Borrow::borrow)
89 .map(|item| item.close_price.raw()),
90 "close_price",
91 )?),
92 Arc::new(enum_dictionary_array(
93 data.iter()
94 .map(std::borrow::Borrow::borrow)
95 .map(|item| item.close_type),
96 )?),
97 Arc::new(ts_event_builder.finish()),
98 Arc::new(ts_init_builder.finish()),
99 Arc::new(identifier_array_from_display(
100 data.iter()
101 .map(std::borrow::Borrow::borrow)
102 .map(|item| item.instrument_id),
103 )),
104 ],
105 )
106 }
107
108 fn metadata(&self) -> HashMap<String, String> {
109 Self::get_metadata(&self.instrument_id, self.close_price.precision)
110 }
111}
112
113impl DecodeFromRecordBatch for InstrumentClose {
114 fn decode_batch(
115 metadata: &HashMap<String, String>,
116 record_batch: RecordBatch,
117 ) -> Result<Vec<Self>, EncodingError> {
118 let (instrument_id, price_precision) = parse_metadata(metadata)?;
119 let record_batch = crate::arrow::record_batch_with_u64_timestamps(&record_batch)?;
120 let record_batch = &record_batch;
121 let cols = record_batch.columns();
122
123 let close_price_values =
124 extract_column::<Decimal128Array>(cols, "close_price", 0, fixed_decimal_data_type())?;
125 let close_type_values = extract_column_string(cols, "close_type", 1)?;
126 validate_required_column(cols[1].as_ref(), "close_type")?;
127 let ts_event_values = extract_column::<UInt64Array>(cols, "ts_event", 2, DataType::UInt64)?;
128 let ts_init_values = extract_column::<UInt64Array>(cols, "ts_init", 3, DataType::UInt64)?;
129
130 let result: Result<Vec<Self>, EncodingError> = (0..record_batch.num_rows())
131 .map(|row| {
132 let close_price =
133 decode_decimal_price(close_price_values, price_precision, "close_price", row)?;
134 let close_type_value = close_type_values.value(row);
135 let close_type = InstrumentCloseType::from_str(close_type_value).map_err(|e| {
136 EncodingError::ParseError(stringify!(InstrumentCloseType), e.to_string())
137 })?;
138 Ok(Self {
139 instrument_id,
140 close_price,
141 close_type,
142 ts_event: decode_required_timestamp(ts_event_values, "ts_event", row)?,
143 ts_init: decode_required_timestamp(ts_init_values, "ts_init", row)?,
144 })
145 })
146 .collect();
147
148 result
149 }
150}
151
152impl DecodeDataFromRecordBatch for InstrumentClose {
153 fn decode_data_batch(
154 metadata: &HashMap<String, String>,
155 record_batch: RecordBatch,
156 ) -> Result<Vec<Data>, EncodingError> {
157 let items: Vec<Self> = Self::decode_batch(metadata, record_batch)?;
158 Ok(items.into_iter().map(Data::from).collect())
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use std::sync::Arc;
165
166 use arrow::array::{Array, TimestampNanosecondArray};
167 use nautilus_model::types::{Price, fixed::FIXED_SCALAR, price::PriceRaw};
168 use rstest::rstest;
169
170 use super::*;
171 use crate::arrow::{KEY_TYPE_NAME, get_raw_price};
172
173 #[rstest]
174 fn test_get_schema() {
175 let instrument_id = InstrumentId::from("AAPL.XNAS");
176 let metadata = HashMap::from([
177 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
178 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
179 ]);
180 let schema = InstrumentClose::get_schema(Some(metadata.clone()));
181
182 let expected_fields = vec![
183 Field::new("close_price", fixed_decimal_data_type(), true),
184 Field::new("close_type", enum_dictionary_data_type(), false),
185 Field::new("ts_event", crate::arrow::timestamp_data_type(), false),
186 Field::new("ts_init", crate::arrow::timestamp_data_type(), false),
187 Field::new(KEY_IDENTIFIER, DataType::Utf8, true),
188 ];
189
190 let mut expected_metadata = metadata;
191 expected_metadata.insert(KEY_TYPE_NAME.to_string(), "InstrumentClose".to_string());
192 let expected_schema = Schema::new_with_metadata(expected_fields, expected_metadata);
193 assert_eq!(schema, expected_schema);
194 }
195
196 #[rstest]
197 fn test_get_schema_map() {
198 let schema_map = InstrumentClose::get_schema_map();
199 let mut expected_map = HashMap::new();
200
201 let fixed_size_binary = "Decimal128(38, 16)".to_string();
202 expected_map.insert("close_price".to_string(), fixed_size_binary);
203 expected_map.insert(
204 "close_type".to_string(),
205 "Dictionary(Int8, Utf8)".to_string(),
206 );
207 expected_map.insert(
208 "ts_event".to_string(),
209 "Timestamp(Nanosecond, Some(\"UTC\"))".to_string(),
210 );
211 expected_map.insert(
212 "ts_init".to_string(),
213 "Timestamp(Nanosecond, Some(\"UTC\"))".to_string(),
214 );
215 expected_map.insert(KEY_IDENTIFIER.to_string(), "Utf8".to_string());
216 assert_eq!(schema_map, expected_map);
217 }
218
219 #[rstest]
220 fn test_encode_batch() {
221 let instrument_id = InstrumentId::from("AAPL.XNAS");
222 let metadata = HashMap::from([
223 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
224 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
225 ]);
226
227 let close1 = InstrumentClose {
228 instrument_id,
229 close_price: Price::from("150.50"),
230 close_type: InstrumentCloseType::EndOfSession,
231 ts_event: 1.into(),
232 ts_init: 3.into(),
233 };
234
235 let close2 = InstrumentClose {
236 instrument_id,
237 close_price: Price::from("151.25"),
238 close_type: InstrumentCloseType::ContractExpired,
239 ts_event: 2.into(),
240 ts_init: 4.into(),
241 };
242
243 let data = vec![close1, close2];
244 let record_batch = InstrumentClose::encode_batch(&metadata, &data).unwrap();
245
246 let columns = record_batch.columns();
247 let close_price_values = columns[0]
248 .as_any()
249 .downcast_ref::<Decimal128Array>()
250 .unwrap();
251 let close_type_values = extract_column_string(columns, "close_type", 1).unwrap();
252 let ts_event_values = columns[2]
253 .as_any()
254 .downcast_ref::<TimestampNanosecondArray>()
255 .unwrap();
256 let ts_init_values = columns[3]
257 .as_any()
258 .downcast_ref::<TimestampNanosecondArray>()
259 .unwrap();
260
261 assert_eq!(columns.len(), 5);
262 assert_eq!(close_price_values.len(), 2);
263 assert_eq!(
264 get_raw_price(close_price_values.value(0)),
265 (150.50 * FIXED_SCALAR) as PriceRaw
266 );
267 assert_eq!(
268 get_raw_price(close_price_values.value(1)),
269 (151.25 * FIXED_SCALAR) as PriceRaw
270 );
271 assert_eq!(close_type_values.len(), 2);
272 assert_eq!(close_type_values.value(0), "END_OF_SESSION");
273 assert_eq!(close_type_values.value(1), "CONTRACT_EXPIRED");
274 assert_eq!(ts_event_values.len(), 2);
275 assert_eq!(ts_event_values.value(0), 1);
276 assert_eq!(ts_event_values.value(1), 2);
277 assert_eq!(ts_init_values.len(), 2);
278 assert_eq!(ts_init_values.value(0), 3);
279 assert_eq!(ts_init_values.value(1), 4);
280 }
281
282 #[rstest]
283 fn test_decode_batch() {
284 let instrument_id = InstrumentId::from("AAPL.XNAS");
285 let metadata = HashMap::from([
286 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
287 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
288 ]);
289
290 let raw_price1 = (150.50 * FIXED_SCALAR) as PriceRaw;
291 let raw_price2 = (151.25 * FIXED_SCALAR) as PriceRaw;
292 let close_price = crate::arrow::test_support::decimal_array_from_bytes(vec![
293 &raw_price1.to_le_bytes(),
294 &raw_price2.to_le_bytes(),
295 ]);
296 let close_type = enum_dictionary_array([
297 InstrumentCloseType::EndOfSession,
298 InstrumentCloseType::ContractExpired,
299 ])
300 .unwrap();
301 let ts_event = UInt64Array::from(vec![1, 2]);
302 let ts_init = UInt64Array::from(vec![3, 4]);
303
304 let record_batch = crate::arrow::record_batch_with_timestamps(
305 crate::arrow::schema_without_identifier_column(&InstrumentClose::get_schema(Some(
306 metadata.clone(),
307 )))
308 .into(),
309 vec![
310 Arc::new(close_price),
311 Arc::new(close_type),
312 Arc::new(ts_event),
313 Arc::new(ts_init),
314 ],
315 )
316 .unwrap();
317
318 let decoded_data = InstrumentClose::decode_batch(&metadata, record_batch).unwrap();
319
320 assert_eq!(decoded_data.len(), 2);
321 assert_eq!(decoded_data[0].instrument_id, instrument_id);
322 assert_eq!(decoded_data[0].close_price, Price::from_raw(raw_price1, 2));
323 assert_eq!(
324 decoded_data[0].close_type,
325 InstrumentCloseType::EndOfSession
326 );
327 assert_eq!(decoded_data[0].ts_event.as_u64(), 1);
328 assert_eq!(decoded_data[0].ts_init.as_u64(), 3);
329
330 assert_eq!(decoded_data[1].instrument_id, instrument_id);
331 assert_eq!(decoded_data[1].close_price, Price::from_raw(raw_price2, 2));
332 assert_eq!(
333 decoded_data[1].close_type,
334 InstrumentCloseType::ContractExpired
335 );
336 assert_eq!(decoded_data[1].ts_event.as_u64(), 2);
337 assert_eq!(decoded_data[1].ts_init.as_u64(), 4);
338 }
339
340 #[rstest]
341 fn test_decode_batch_rejects_null_close_type_with_field_and_row() {
342 use arrow::{array::StringDictionaryBuilder, datatypes::Int8Type};
343
344 let instrument_id = InstrumentId::from("AAPL.XNAS");
345 let metadata = HashMap::from([
346 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
347 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
348 ]);
349
350 let close = InstrumentClose {
351 instrument_id,
352 close_price: Price::from("150.50"),
353 close_type: InstrumentCloseType::EndOfSession,
354 ts_event: 1.into(),
355 ts_init: 2.into(),
356 };
357
358 let encoded = InstrumentClose::encode_batch(&metadata, &[close]).unwrap();
359 let mut close_type = StringDictionaryBuilder::<Int8Type>::new();
360 close_type.append_null();
361 let mut columns = encoded.columns().to_vec();
362 columns[1] = Arc::new(close_type.finish());
363
364 let fields = encoded
365 .schema()
366 .fields()
367 .iter()
368 .map(|field| {
369 if field.name() == "close_type" {
370 Arc::new(field.as_ref().clone().with_nullable(true))
371 } else {
372 Arc::clone(field)
373 }
374 })
375 .collect::<Vec<_>>();
376
377 let schema = Arc::new(Schema::new_with_metadata(fields, metadata.clone()));
378 let record_batch = RecordBatch::try_new(schema, columns).unwrap();
379
380 let result = InstrumentClose::decode_batch(&metadata, record_batch);
381
382 assert!(matches!(
383 result,
384 Err(EncodingError::ParseError("close_type", ref message))
385 if message == "row 0: required value is null"
386 ));
387 }
388
389 #[rstest]
390 fn test_decode_batch_rejects_null_timestamp_with_field_and_row() {
391 let instrument_id = InstrumentId::from("AAPL.XNAS");
392 let metadata = HashMap::from([
393 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
394 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
395 ]);
396 let close = InstrumentClose {
397 instrument_id,
398 close_price: Price::from("150.50"),
399 close_type: InstrumentCloseType::EndOfSession,
400 ts_event: 1.into(),
401 ts_init: 2.into(),
402 };
403 let encoded = InstrumentClose::encode_batch(&metadata, &[close]).unwrap();
404 let mut columns = encoded.columns().to_vec();
405 columns[3] = Arc::new(TimestampNanosecondArray::from(vec![None]).with_timezone("UTC"));
406 let fields = encoded
407 .schema()
408 .fields()
409 .iter()
410 .map(|field| {
411 if field.name() == "ts_init" {
412 Arc::new(field.as_ref().clone().with_nullable(true))
413 } else {
414 Arc::clone(field)
415 }
416 })
417 .collect::<Vec<_>>();
418 let schema = Arc::new(Schema::new_with_metadata(fields, metadata.clone()));
419 let batch = RecordBatch::try_new(schema, columns).unwrap();
420
421 let error = InstrumentClose::decode_batch(&metadata, batch).unwrap_err();
422
423 assert!(error.to_string().contains("ts_init"));
424 assert!(error.to_string().contains("row 0"));
425 }
426
427 #[rstest]
428 fn test_decode_batch_invalid_close_price_returns_error() {
429 let instrument_id = InstrumentId::from("AAPL.XNAS");
430 let metadata = HashMap::from([
431 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
432 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
433 ]);
434
435 let invalid_price: PriceRaw = PriceRaw::MAX - 1000;
436 let close_price = crate::arrow::test_support::decimal_array_from_bytes(vec![
437 &invalid_price.to_le_bytes(),
438 ]);
439 let close_type = enum_dictionary_array([InstrumentCloseType::EndOfSession]).unwrap();
440 let ts_event = UInt64Array::from(vec![1]);
441 let ts_init = UInt64Array::from(vec![2]);
442
443 let record_batch = crate::arrow::record_batch_with_timestamps(
444 crate::arrow::schema_without_identifier_column(&InstrumentClose::get_schema(Some(
445 metadata.clone(),
446 )))
447 .into(),
448 vec![
449 Arc::new(close_price),
450 Arc::new(close_type),
451 Arc::new(ts_event),
452 Arc::new(ts_init),
453 ],
454 )
455 .unwrap();
456
457 let result = InstrumentClose::decode_batch(&metadata, record_batch);
458 assert!(result.is_err());
459 let err = result.unwrap_err();
460 assert!(
461 err.to_string().contains("close_price") && err.to_string().contains("row 0"),
462 "Expected close_price error at row 0, was: {err}"
463 );
464 }
465
466 #[rstest]
467 fn test_decode_batch_invalid_close_type_returns_error() {
468 let instrument_id = InstrumentId::from("AAPL.XNAS");
469 let metadata = HashMap::from([
470 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
471 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
472 ]);
473
474 let raw_price = (150.50 * FIXED_SCALAR) as PriceRaw;
475 let close_price =
476 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
477 let close_type = enum_dictionary_array(["INVALID"]).unwrap();
478 let ts_event = UInt64Array::from(vec![1]);
479 let ts_init = UInt64Array::from(vec![2]);
480
481 let record_batch = crate::arrow::record_batch_with_timestamps(
482 crate::arrow::schema_without_identifier_column(&InstrumentClose::get_schema(Some(
483 metadata.clone(),
484 )))
485 .into(),
486 vec![
487 Arc::new(close_price),
488 Arc::new(close_type),
489 Arc::new(ts_event),
490 Arc::new(ts_init),
491 ],
492 )
493 .unwrap();
494
495 let result = InstrumentClose::decode_batch(&metadata, record_batch);
496 assert!(result.is_err());
497 let err = result.unwrap_err();
498 assert!(
499 err.to_string().contains("InstrumentCloseType"),
500 "Expected InstrumentCloseType error, was: {err}"
501 );
502 }
503
504 #[rstest]
505 fn test_decode_batch_missing_instrument_id_returns_error() {
506 let instrument_id = InstrumentId::from("AAPL.XNAS");
507 let mut metadata = HashMap::from([
508 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
509 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
510 ]);
511
512 let raw_price = (150.50 * FIXED_SCALAR) as PriceRaw;
513 let close_price =
514 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
515 let close_type = enum_dictionary_array([InstrumentCloseType::EndOfSession]).unwrap();
516 let ts_event = UInt64Array::from(vec![1]);
517 let ts_init = UInt64Array::from(vec![2]);
518
519 let record_batch = crate::arrow::record_batch_with_timestamps(
520 crate::arrow::schema_without_identifier_column(&InstrumentClose::get_schema(Some(
521 metadata.clone(),
522 )))
523 .into(),
524 vec![
525 Arc::new(close_price),
526 Arc::new(close_type),
527 Arc::new(ts_event),
528 Arc::new(ts_init),
529 ],
530 )
531 .unwrap();
532
533 metadata.remove(KEY_INSTRUMENT_ID);
534
535 let result = InstrumentClose::decode_batch(&metadata, record_batch);
536 assert!(result.is_err());
537 let err = result.unwrap_err();
538 assert!(
539 err.to_string().contains("instrument_id"),
540 "Expected missing instrument_id error, was: {err}"
541 );
542 }
543
544 #[rstest]
545 fn test_encode_decode_round_trip() {
546 let instrument_id = InstrumentId::from("AAPL.XNAS");
547 let metadata = HashMap::from([
548 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
549 (KEY_PRICE_PRECISION.to_string(), "2".to_string()),
550 ]);
551
552 let close1 = InstrumentClose {
553 instrument_id,
554 close_price: Price::from("150.50"),
555 close_type: InstrumentCloseType::EndOfSession,
556 ts_event: 1_000_000_000.into(),
557 ts_init: 1_000_000_001.into(),
558 };
559
560 let close2 = InstrumentClose {
561 instrument_id,
562 close_price: Price::from("151.25"),
563 close_type: InstrumentCloseType::ContractExpired,
564 ts_event: 2_000_000_000.into(),
565 ts_init: 2_000_000_001.into(),
566 };
567
568 let original = vec![close1, close2];
569 let record_batch = InstrumentClose::encode_batch(&metadata, &original).unwrap();
570 let decoded = InstrumentClose::decode_batch(&metadata, record_batch).unwrap();
571
572 assert_eq!(decoded.len(), original.len());
573 for (orig, dec) in original.iter().zip(decoded.iter()) {
574 assert_eq!(dec.instrument_id, orig.instrument_id);
575 assert_eq!(dec.close_price, orig.close_price);
576 assert_eq!(dec.close_type, orig.close_type);
577 assert_eq!(dec.ts_event, orig.ts_event);
578 assert_eq!(dec.ts_init, orig.ts_init);
579 }
580 }
581}