1use std::sync::Arc;
19
20use arrow::{
21 array::{BooleanBuilder, Float64Builder, StringBuilder, TimestampNanosecondBuilder},
22 datatypes::Schema,
23 error::ArrowError,
24 record_batch::RecordBatch,
25};
26use nautilus_model::reports::OrderStatusReport;
27use rust_decimal::prelude::ToPrimitive;
28
29use super::{
30 bool_field, float64_field, quantity_to_f64, timestamp_field, unix_nanos_to_i64, utf8_field,
31};
32use crate::arrow::timestamp_data_type;
33
34#[must_use]
36pub fn order_status_report_schema() -> Schema {
37 Schema::new(vec![
38 utf8_field("account_id", false),
39 utf8_field("instrument_id", false),
40 utf8_field("client_order_id", true),
41 utf8_field("venue_order_id", false),
42 utf8_field("order_side", false),
43 utf8_field("order_type", false),
44 utf8_field("time_in_force", false),
45 utf8_field("order_status", false),
46 float64_field("quantity", false),
47 float64_field("filled_qty", false),
48 utf8_field("report_id", false),
49 timestamp_field("ts_accepted", false),
50 timestamp_field("ts_last", false),
51 timestamp_field("ts_init", false),
52 utf8_field("order_list_id", true),
53 utf8_field("venue_position_id", true),
54 utf8_field("linked_order_ids", true),
55 utf8_field("parent_order_id", true),
56 utf8_field("contingency_type", false),
57 timestamp_field("expire_time", true),
58 float64_field("price", true),
59 float64_field("activation_price", true),
60 float64_field("trigger_price", true),
61 utf8_field("trigger_type", true),
62 float64_field("limit_offset", true),
63 float64_field("trailing_offset", true),
64 utf8_field("trailing_offset_type", false),
65 float64_field("avg_px", true),
66 float64_field("display_qty", true),
67 bool_field("post_only", false),
68 bool_field("reduce_only", false),
69 utf8_field("cancel_reason", true),
70 timestamp_field("ts_triggered", true),
71 ])
72}
73
74pub fn encode_order_status_reports(data: &[OrderStatusReport]) -> Result<RecordBatch, ArrowError> {
86 let mut account_id = StringBuilder::new();
87 let mut instrument_id = StringBuilder::new();
88 let mut client_order_id = StringBuilder::new();
89 let mut venue_order_id = StringBuilder::new();
90 let mut order_side = StringBuilder::new();
91 let mut order_type = StringBuilder::new();
92 let mut time_in_force = StringBuilder::new();
93 let mut order_status = StringBuilder::new();
94 let mut quantity = Float64Builder::with_capacity(data.len());
95 let mut filled_qty = Float64Builder::with_capacity(data.len());
96 let mut report_id = StringBuilder::new();
97 let mut ts_accepted =
98 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
99 let mut ts_last =
100 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
101 let mut ts_init =
102 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
103 let mut order_list_id = StringBuilder::new();
104 let mut venue_position_id = StringBuilder::new();
105 let mut linked_order_ids = StringBuilder::new();
106 let mut parent_order_id = StringBuilder::new();
107 let mut contingency_type = StringBuilder::new();
108 let mut expire_time =
109 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
110 let mut price = Float64Builder::with_capacity(data.len());
111 let mut activation_price = Float64Builder::with_capacity(data.len());
112 let mut trigger_price = Float64Builder::with_capacity(data.len());
113 let mut trigger_type = StringBuilder::new();
114 let mut limit_offset = Float64Builder::with_capacity(data.len());
115 let mut trailing_offset = Float64Builder::with_capacity(data.len());
116 let mut trailing_offset_type = StringBuilder::new();
117 let mut avg_px = Float64Builder::with_capacity(data.len());
118 let mut display_qty = Float64Builder::with_capacity(data.len());
119 let mut post_only = BooleanBuilder::with_capacity(data.len());
120 let mut reduce_only = BooleanBuilder::with_capacity(data.len());
121 let mut cancel_reason = StringBuilder::new();
122 let mut ts_triggered =
123 TimestampNanosecondBuilder::with_capacity(data.len()).with_data_type(timestamp_data_type());
124
125 for report in data {
126 account_id.append_value(report.account_id);
127 instrument_id.append_value(report.instrument_id.to_string());
128 client_order_id.append_option(report.client_order_id.map(|v| v.to_string()));
129 venue_order_id.append_value(report.venue_order_id);
130 order_side.append_value(
131 report
132 .order_side
133 .as_ref()
134 .map_or("NO_ORDER_SIDE", AsRef::as_ref),
135 );
136 order_type.append_value(format!("{}", report.order_type));
137 time_in_force.append_value(format!("{}", report.time_in_force));
138 order_status.append_value(format!("{}", report.order_status));
139 quantity.append_value(quantity_to_f64(&report.quantity));
140 filled_qty.append_value(quantity_to_f64(&report.filled_qty));
141 report_id.append_value(report.report_id.to_string());
142 ts_accepted.append_value(unix_nanos_to_i64(report.ts_accepted.as_u64()));
143 ts_last.append_value(unix_nanos_to_i64(report.ts_last.as_u64()));
144 ts_init.append_value(unix_nanos_to_i64(report.ts_init.as_u64()));
145 order_list_id.append_option(report.order_list_id.map(|v| v.to_string()));
146 venue_position_id.append_option(report.venue_position_id.map(|v| v.to_string()));
147 linked_order_ids.append_option(report.linked_order_ids.as_ref().map(|ids| {
148 let values: Vec<String> = ids.iter().map(ToString::to_string).collect();
149 serde_json::to_string(&values).unwrap_or_default()
150 }));
151 parent_order_id.append_option(report.parent_order_id.map(|v| v.to_string()));
152 contingency_type.append_value(
153 report
154 .contingency_type
155 .as_ref()
156 .map_or("NO_CONTINGENCY", AsRef::as_ref),
157 );
158 expire_time.append_option(report.expire_time.map(|v| unix_nanos_to_i64(v.as_u64())));
159 price.append_option(report.price.map(|v| v.as_f64()));
160 activation_price.append_option(report.activation_price.map(|v| v.as_f64()));
161 trigger_price.append_option(report.trigger_price.map(|v| v.as_f64()));
162 trigger_type.append_option(report.trigger_type.map(|v| format!("{v}")));
163 limit_offset.append_option(report.limit_offset.and_then(|v| v.to_f64()));
164 trailing_offset.append_option(report.trailing_offset.and_then(|v| v.to_f64()));
165 trailing_offset_type.append_value(
166 report
167 .trailing_offset_type
168 .as_ref()
169 .map_or("NO_TRAILING_OFFSET", AsRef::as_ref),
170 );
171 avg_px.append_option(report.avg_px.and_then(|v| v.to_f64()));
172 display_qty.append_option(report.display_qty.map(|v| quantity_to_f64(&v)));
173 post_only.append_value(report.post_only);
174 reduce_only.append_value(report.reduce_only);
175 cancel_reason.append_option(report.cancel_reason.clone());
176 ts_triggered.append_option(report.ts_triggered.map(|v| unix_nanos_to_i64(v.as_u64())));
177 }
178
179 RecordBatch::try_new(
180 Arc::new(order_status_report_schema()),
181 vec![
182 Arc::new(account_id.finish()),
183 Arc::new(instrument_id.finish()),
184 Arc::new(client_order_id.finish()),
185 Arc::new(venue_order_id.finish()),
186 Arc::new(order_side.finish()),
187 Arc::new(order_type.finish()),
188 Arc::new(time_in_force.finish()),
189 Arc::new(order_status.finish()),
190 Arc::new(quantity.finish()),
191 Arc::new(filled_qty.finish()),
192 Arc::new(report_id.finish()),
193 Arc::new(ts_accepted.finish()),
194 Arc::new(ts_last.finish()),
195 Arc::new(ts_init.finish()),
196 Arc::new(order_list_id.finish()),
197 Arc::new(venue_position_id.finish()),
198 Arc::new(linked_order_ids.finish()),
199 Arc::new(parent_order_id.finish()),
200 Arc::new(contingency_type.finish()),
201 Arc::new(expire_time.finish()),
202 Arc::new(price.finish()),
203 Arc::new(activation_price.finish()),
204 Arc::new(trigger_price.finish()),
205 Arc::new(trigger_type.finish()),
206 Arc::new(limit_offset.finish()),
207 Arc::new(trailing_offset.finish()),
208 Arc::new(trailing_offset_type.finish()),
209 Arc::new(avg_px.finish()),
210 Arc::new(display_qty.finish()),
211 Arc::new(post_only.finish()),
212 Arc::new(reduce_only.finish()),
213 Arc::new(cancel_reason.finish()),
214 Arc::new(ts_triggered.finish()),
215 ],
216 )
217}
218
219#[cfg(test)]
220mod tests {
221 use arrow::{
222 array::{Array, BooleanArray, Float64Array, StringArray, TimestampNanosecondArray},
223 datatypes::{DataType, TimeUnit},
224 };
225 use nautilus_core::UUID4;
226 use nautilus_model::{
227 enums::{OrderSide, OrderStatus, OrderType, TimeInForce},
228 identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
229 types::{Price, Quantity},
230 };
231 use rstest::rstest;
232
233 use super::*;
234
235 fn make_report(instrument_id: &str, ts: u64) -> OrderStatusReport {
236 OrderStatusReport {
237 account_id: AccountId::from("SIM-001"),
238 instrument_id: InstrumentId::from(instrument_id),
239 client_order_id: Some(ClientOrderId::from("O-001")),
240 venue_order_id: VenueOrderId::from("V-001"),
241 order_side: Some(OrderSide::Buy),
242 order_type: OrderType::Limit,
243 time_in_force: TimeInForce::Gtc,
244 order_status: OrderStatus::Accepted,
245 quantity: Quantity::from(100),
246 filled_qty: Quantity::from(50),
247 report_id: UUID4::default(),
248 ts_accepted: ts.into(),
249 ts_last: (ts + 1_000).into(),
250 ts_init: (ts + 1).into(),
251 order_list_id: None,
252 venue_position_id: None,
253 linked_order_ids: None,
254 parent_order_id: None,
255 contingency_type: None,
256 expire_time: None,
257 price: Some(Price::from("100.50")),
258 activation_price: None,
259 trigger_price: None,
260 trigger_type: None,
261 limit_offset: None,
262 trailing_offset: None,
263 trailing_offset_type: None,
264 avg_px: None,
265 display_qty: None,
266 post_only: true,
267 reduce_only: false,
268 cancel_reason: None,
269 ts_triggered: None,
270 }
271 }
272
273 #[rstest]
274 fn test_encode_order_status_reports_schema() {
275 let batch = encode_order_status_reports(&[]).unwrap();
276 let schema = batch.schema();
277 let fields = schema.fields();
278 assert_eq!(fields.len(), 33);
279 assert_eq!(fields[0].name(), "account_id");
280 assert_eq!(fields[0].data_type(), &DataType::Utf8);
281 assert_eq!(fields[8].name(), "quantity");
282 assert_eq!(fields[8].data_type(), &DataType::Float64);
283 assert_eq!(fields[11].name(), "ts_accepted");
284 assert_eq!(
285 fields[11].data_type(),
286 &DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
287 );
288 assert_eq!(fields[21].name(), "activation_price");
289 assert_eq!(fields[21].data_type(), &DataType::Float64);
290 assert_eq!(fields[29].name(), "post_only");
291 assert_eq!(fields[29].data_type(), &DataType::Boolean);
292 }
293
294 #[rstest]
295 fn test_encode_order_status_reports_values() {
296 let reports = vec![make_report("AAPL.XNAS", 1_000_000)];
297 let batch = encode_order_status_reports(&reports).unwrap();
298
299 assert_eq!(batch.num_rows(), 1);
300
301 let quantity_col = batch
302 .column(8)
303 .as_any()
304 .downcast_ref::<Float64Array>()
305 .unwrap();
306 let filled_qty_col = batch
307 .column(9)
308 .as_any()
309 .downcast_ref::<Float64Array>()
310 .unwrap();
311 let price_col = batch
312 .column(20)
313 .as_any()
314 .downcast_ref::<Float64Array>()
315 .unwrap();
316 let post_only_col = batch
317 .column(29)
318 .as_any()
319 .downcast_ref::<BooleanArray>()
320 .unwrap();
321 let ts_accepted_col = batch
322 .column(11)
323 .as_any()
324 .downcast_ref::<TimestampNanosecondArray>()
325 .unwrap();
326
327 assert!((quantity_col.value(0) - 100.0).abs() < 1e-9);
328 assert!((filled_qty_col.value(0) - 50.0).abs() < 1e-9);
329 assert!((price_col.value(0) - 100.50).abs() < 1e-9);
330 assert!(post_only_col.value(0));
331 assert_eq!(ts_accepted_col.value(0), 1_000_000);
332 }
333
334 #[rstest]
335 fn test_encode_order_status_reports_linked_order_ids_round_trip() {
336 let mut report = make_report("AAPL.XNAS", 1_000);
337 report.linked_order_ids = Some(vec![
338 ClientOrderId::from("O-Z"),
339 ClientOrderId::from("O-A"),
340 ClientOrderId::from("O-M"),
341 ]);
342 let batch = encode_order_status_reports(&[report]).unwrap();
343
344 let linked_col = batch
345 .column(16)
346 .as_any()
347 .downcast_ref::<StringArray>()
348 .unwrap();
349 assert!(!linked_col.is_null(0));
350
351 let parsed: Vec<String> = serde_json::from_str(linked_col.value(0)).unwrap();
352 assert_eq!(parsed, vec!["O-Z", "O-A", "O-M"]);
353 }
354
355 #[rstest]
356 fn test_encode_order_status_reports_linked_order_ids_null_when_absent() {
357 let batch = encode_order_status_reports(&[make_report("AAPL.XNAS", 1_000)]).unwrap();
358 let linked_col = batch
359 .column(16)
360 .as_any()
361 .downcast_ref::<StringArray>()
362 .unwrap();
363 assert!(linked_col.is_null(0));
364 }
365
366 #[rstest]
367 fn test_encode_order_status_reports_nullable_fields() {
368 let reports = vec![make_report("AAPL.XNAS", 1_000)];
369 let batch = encode_order_status_reports(&reports).unwrap();
370
371 let trigger_price_col = batch
372 .column(22)
373 .as_any()
374 .downcast_ref::<Float64Array>()
375 .unwrap();
376 let expire_time_col = batch
377 .column(19)
378 .as_any()
379 .downcast_ref::<TimestampNanosecondArray>()
380 .unwrap();
381
382 assert!(trigger_price_col.is_null(0));
383 assert!(expire_time_col.is_null(0));
384 }
385
386 #[rstest]
387 fn test_encode_order_status_reports_no_order_side() {
388 let mut report = make_report("AAPL.XNAS", 1_000);
389 report.order_side = None;
390
391 let batch = encode_order_status_reports(&[report]).unwrap();
392 let order_side_col = batch
393 .column(4)
394 .as_any()
395 .downcast_ref::<StringArray>()
396 .unwrap();
397
398 assert_eq!(order_side_col.value(0), "NO_ORDER_SIDE");
399 }
400
401 #[rstest]
402 fn test_encode_order_status_reports_empty() {
403 let batch = encode_order_status_reports(&[]).unwrap();
404 assert_eq!(batch.num_rows(), 0);
405 assert_eq!(batch.schema().fields().len(), 33);
406 }
407
408 #[rstest]
409 fn test_encode_order_status_reports_mixed_instruments() {
410 let reports = vec![make_report("AAPL.XNAS", 1), make_report("MSFT.XNAS", 2)];
411 let batch = encode_order_status_reports(&reports).unwrap();
412
413 let instrument_id_col = batch
414 .column(1)
415 .as_any()
416 .downcast_ref::<StringArray>()
417 .unwrap();
418 assert_eq!(instrument_id_col.value(0), "AAPL.XNAS");
419 assert_eq!(instrument_id_col.value(1), "MSFT.XNAS");
420 }
421}