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