1use nautilus_model::reports::{
17 ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport,
18};
19
20use super::json::{JsonFieldSpec, impl_json_arrow};
21
22const ORDER_STATUS_REPORT_FIELDS: &[JsonFieldSpec] = &[
23 JsonFieldSpec::utf8("account_id", false),
24 JsonFieldSpec::utf8("instrument_id", false),
25 JsonFieldSpec::utf8("client_order_id", true),
26 JsonFieldSpec::utf8("venue_order_id", false),
27 JsonFieldSpec::utf8("order_side", false),
28 JsonFieldSpec::utf8("order_type", false),
29 JsonFieldSpec::utf8("time_in_force", false),
30 JsonFieldSpec::utf8("order_status", false),
31 JsonFieldSpec::utf8("quantity", false),
32 JsonFieldSpec::utf8("filled_qty", false),
33 JsonFieldSpec::utf8("report_id", false),
34 JsonFieldSpec::u64("ts_accepted", false),
35 JsonFieldSpec::u64("ts_last", false),
36 JsonFieldSpec::u64("ts_init", false),
37 JsonFieldSpec::utf8("order_list_id", true),
38 JsonFieldSpec::utf8("venue_position_id", true),
39 JsonFieldSpec::utf8_json("linked_order_ids", true),
40 JsonFieldSpec::utf8("parent_order_id", true),
41 JsonFieldSpec::utf8("contingency_type", false),
42 JsonFieldSpec::u64("expire_time", true),
43 JsonFieldSpec::utf8("price", true),
44 JsonFieldSpec::utf8("activation_price", true),
45 JsonFieldSpec::utf8("trigger_price", true),
46 JsonFieldSpec::utf8("trigger_type", true),
47 JsonFieldSpec::utf8("limit_offset", true),
48 JsonFieldSpec::utf8("trailing_offset", true),
49 JsonFieldSpec::utf8("trailing_offset_type", false),
50 JsonFieldSpec::utf8("avg_px", true),
51 JsonFieldSpec::utf8("display_qty", true),
52 JsonFieldSpec::boolean("post_only", false),
53 JsonFieldSpec::boolean("reduce_only", false),
54 JsonFieldSpec::utf8("cancel_reason", true),
55 JsonFieldSpec::u64("ts_triggered", true),
56];
57
58const FILL_REPORT_FIELDS: &[JsonFieldSpec] = &[
59 JsonFieldSpec::utf8("account_id", false),
60 JsonFieldSpec::utf8("instrument_id", false),
61 JsonFieldSpec::utf8("venue_order_id", false),
62 JsonFieldSpec::utf8("trade_id", false),
63 JsonFieldSpec::utf8("order_side", false),
64 JsonFieldSpec::utf8("last_qty", false),
65 JsonFieldSpec::utf8("last_px", false),
66 JsonFieldSpec::utf8("commission", false),
67 JsonFieldSpec::utf8("liquidity_side", false),
68 JsonFieldSpec::utf8("report_id", false),
69 JsonFieldSpec::u64("ts_event", false),
70 JsonFieldSpec::u64("ts_init", false),
71 JsonFieldSpec::utf8("client_order_id", true),
72 JsonFieldSpec::utf8("venue_position_id", true),
73 JsonFieldSpec::decimal_str("avg_px", true),
76];
77
78const POSITION_STATUS_REPORT_FIELDS: &[JsonFieldSpec] = &[
79 JsonFieldSpec::utf8("account_id", false),
80 JsonFieldSpec::utf8("instrument_id", false),
81 JsonFieldSpec::utf8("position_side", false),
82 JsonFieldSpec::utf8("quantity", false),
83 JsonFieldSpec::utf8("signed_decimal_qty", false),
84 JsonFieldSpec::utf8("report_id", false),
85 JsonFieldSpec::u64("ts_last", false),
86 JsonFieldSpec::u64("ts_init", false),
87 JsonFieldSpec::utf8("venue_position_id", true),
88 JsonFieldSpec::utf8("avg_px_open", true),
89];
90
91const EXECUTION_MASS_STATUS_FIELDS: &[JsonFieldSpec] = &[
92 JsonFieldSpec::utf8("client_id", false),
93 JsonFieldSpec::utf8("account_id", false),
94 JsonFieldSpec::utf8("venue", false),
95 JsonFieldSpec::utf8("report_id", false),
96 JsonFieldSpec::u64("ts_init", false),
97 JsonFieldSpec::utf8_json("order_reports", false),
98 JsonFieldSpec::utf8_json("fill_reports", false),
99 JsonFieldSpec::utf8_json("position_reports", false),
100 JsonFieldSpec::u64("lookback_start", true),
103 JsonFieldSpec::boolean_default_true("reports_complete"),
104];
105
106impl_json_arrow!(instrument OrderStatusReport, "OrderStatusReport", ORDER_STATUS_REPORT_FIELDS);
107impl_json_arrow!(instrument FillReport, "FillReport", FILL_REPORT_FIELDS, &["avg_px"]);
108impl_json_arrow!(instrument PositionStatusReport, "PositionStatusReport", POSITION_STATUS_REPORT_FIELDS);
109impl_json_arrow!(
110 typed ExecutionMassStatus,
111 "ExecutionMassStatus",
112 EXECUTION_MASS_STATUS_FIELDS,
113 &["lookback_start", "reports_complete"]
114);
115
116#[cfg(test)]
117mod tests {
118 use std::{str::FromStr, sync::Arc};
119
120 use arrow::{
121 array::{ArrayRef, BooleanArray},
122 datatypes::{DataType, Field, Schema},
123 record_batch::RecordBatch,
124 };
125 use nautilus_core::{UUID4, UnixNanos};
126 use nautilus_model::{
127 enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, PositionSide, TimeInForce},
128 identifiers::{
129 AccountId, ClientId, ClientOrderId, InstrumentId, PositionId, TradeId, Venue,
130 VenueOrderId,
131 },
132 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
133 types::{Currency, Money, Price, Quantity},
134 };
135 use rstest::rstest;
136 use rust_decimal::Decimal;
137
138 use crate::arrow::{
139 ArrowSchemaProvider, DecodeTypedFromRecordBatch, EncodeToRecordBatch, EncodingError,
140 };
141
142 #[rstest]
146 #[case::fill_report(
147 FillReport::get_schema(None),
148 &[
149 "account_id",
150 "instrument_id",
151 "venue_order_id",
152 "trade_id",
153 "order_side",
154 "last_qty",
155 "last_px",
156 "commission",
157 "liquidity_side",
158 "report_id",
159 "ts_event",
160 "ts_init",
161 "client_order_id",
162 "venue_position_id",
163 "avg_px",
164 ]
165 )]
166 #[case::execution_mass_status(
167 ExecutionMassStatus::get_schema(None),
168 &[
169 "client_id",
170 "account_id",
171 "venue",
172 "report_id",
173 "ts_init",
174 "order_reports",
175 "fill_reports",
176 "position_reports",
177 "lookback_start",
178 "reports_complete",
179 ]
180 )]
181 fn test_schema_column_order(#[case] schema: Schema, #[case] expected: &[&str]) {
182 let names: Vec<&str> = schema
183 .fields()
184 .iter()
185 .map(|field| field.name().as_str())
186 .collect();
187
188 assert_eq!(names, expected);
189 }
190
191 #[rstest]
192 fn test_order_status_report_round_trip() {
193 let report = OrderStatusReport::new(
194 AccountId::from("SIM-001"),
195 InstrumentId::from("AUDUSD.SIM"),
196 Some(ClientOrderId::from("O-19700101-000000-001-001-1")),
197 VenueOrderId::from("1"),
198 OrderSide::Buy.into(),
199 OrderType::Limit,
200 TimeInForce::Gtc,
201 OrderStatus::Accepted,
202 Quantity::from("100"),
203 Quantity::from("25"),
204 UnixNanos::from(1_000_000_000),
205 UnixNanos::from(2_000_000_000),
206 UnixNanos::from(3_000_000_000),
207 None,
208 )
209 .with_linked_order_ids([ClientOrderId::from("O-19700101-000000-001-001-2")]);
210 let report = OrderStatusReport {
211 activation_price: Some(Price::from("1.05000")),
212 limit_offset: Some(Decimal::from_str("0.123456789123456789").unwrap()),
213 trailing_offset: Some(Decimal::from_str("0.987654321987654321").unwrap()),
214 avg_px: Some(Decimal::from_str("1.23456789123456789").unwrap()),
215 ..report
216 };
217
218 let metadata = report.metadata();
219 let batch =
220 OrderStatusReport::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
221 let decoded =
222 OrderStatusReport::decode_typed_batch(batch.schema().metadata(), batch).unwrap();
223
224 assert_eq!(decoded, vec![report]);
225 }
226
227 #[rstest]
228 fn test_position_status_report_round_trip_preserves_decimal_precision() {
229 let report = PositionStatusReport {
230 account_id: AccountId::from("SIM-001"),
231 instrument_id: InstrumentId::from("AUDUSD.SIM"),
232 position_side: PositionSide::Long,
233 quantity: Quantity::from("100.25"),
234 signed_decimal_qty: Decimal::from_str("100.250000000123456789").unwrap(),
235 report_id: UUID4::default(),
236 ts_last: UnixNanos::from(1_000_000_000),
237 ts_init: UnixNanos::from(2_000_000_000),
238 venue_position_id: Some(PositionId::from("P-001")),
239 avg_px_open: Some(Decimal::from_str("1.23456789123456789").unwrap()),
240 };
241 let metadata = report.metadata();
242 let batch =
243 PositionStatusReport::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
244 let decoded =
245 PositionStatusReport::decode_typed_batch(batch.schema().metadata(), batch).unwrap();
246
247 assert_eq!(decoded, vec![report]);
248 }
249
250 #[rstest]
251 fn test_fill_report_round_trip_preserves_average_price() {
252 let report = sample_fill_report(Some(Decimal::from_str("1.23456789123456789").unwrap()));
253
254 let metadata = report.metadata();
255 let batch = FillReport::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
256 let decoded = FillReport::decode_typed_batch(batch.schema().metadata(), batch).unwrap();
257
258 assert_eq!(decoded, vec![report]);
259 }
260
261 #[rstest]
262 fn test_fill_report_decodes_merged_schema_order() {
263 let report = sample_fill_report(Some(Decimal::from_str("1.23456789123456789").unwrap()));
264 let metadata = report.metadata();
265 let batch = FillReport::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
266 let merged_batch = batch_with_columns_at_end(&batch, &["account_id", "avg_px"]);
267
268 let decoded =
269 FillReport::decode_typed_batch(merged_batch.schema().metadata(), merged_batch).unwrap();
270
271 assert_eq!(decoded, vec![report]);
272 }
273
274 #[rstest]
275 fn test_fill_report_decodes_legacy_batch_without_average_price() {
276 let report = sample_fill_report(None);
277 let metadata = report.metadata();
278 let batch = FillReport::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
279 let legacy_batch = batch_without_columns(&batch, &["avg_px"]);
280
281 let decoded =
282 FillReport::decode_typed_batch(legacy_batch.schema().metadata(), legacy_batch).unwrap();
283
284 assert_eq!(decoded, vec![report]);
285 }
286
287 #[rstest]
288 fn test_execution_mass_status_round_trip_preserves_report_window_and_reports() {
289 let order_report = OrderStatusReport::new(
290 AccountId::from("SIM-001"),
291 InstrumentId::from("AUDUSD.SIM"),
292 Some(ClientOrderId::from("O-19700101-000000-001-001-4")),
293 VenueOrderId::from("3"),
294 OrderSide::Buy.into(),
295 OrderType::Market,
296 TimeInForce::Ioc,
297 OrderStatus::Filled,
298 Quantity::from("20"),
299 Quantity::from("20"),
300 UnixNanos::from(6_000_000_000),
301 UnixNanos::from(7_000_000_000),
302 UnixNanos::from(8_000_000_000),
303 None,
304 );
305 let fill_report = sample_fill_report(Some(Decimal::from_str("1.25001").unwrap()));
306 let position_report = PositionStatusReport::new(
307 AccountId::from("SIM-001"),
308 InstrumentId::from("AUDUSD.SIM"),
309 PositionSide::Long,
310 Quantity::from("20"),
311 UnixNanos::from(7_000_000_000),
312 UnixNanos::from(8_000_000_000),
313 None,
314 Some(PositionId::from("P-003")),
315 Some(Decimal::from_str("1.25001").unwrap()),
316 );
317 let mut report = ExecutionMassStatus::new(
318 ClientId::from("SIM"),
319 AccountId::from("SIM-001"),
320 Venue::from("SIM"),
321 UnixNanos::from(9_000_000_000),
322 None,
323 );
324 report.set_report_window(Some(UnixNanos::from(5_000_000_000)), false);
325 report.add_order_reports(vec![order_report]);
326 report.add_fill_reports(vec![fill_report]);
327 report.add_position_reports(vec![position_report]);
328
329 let metadata = report.metadata();
330 let batch =
331 ExecutionMassStatus::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
332 let decoded =
333 ExecutionMassStatus::decode_typed_batch(batch.schema().metadata(), batch).unwrap();
334
335 assert_eq!(decoded, vec![report]);
336 }
337
338 #[rstest]
339 fn test_execution_mass_status_decodes_legacy_batch_with_report_window_defaults() {
340 let report = ExecutionMassStatus::new(
341 ClientId::from("SIM"),
342 AccountId::from("SIM-001"),
343 Venue::from("SIM"),
344 UnixNanos::from(10_000_000_000),
345 None,
346 );
347 let metadata = report.metadata();
348 let batch =
349 ExecutionMassStatus::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
350 let legacy_batch = batch_without_columns(&batch, &["lookback_start", "reports_complete"]);
351
352 let decoded =
353 ExecutionMassStatus::decode_typed_batch(legacy_batch.schema().metadata(), legacy_batch)
354 .unwrap();
355
356 assert_eq!(decoded, vec![report]);
357 }
358
359 #[rstest]
360 fn test_execution_mass_status_decodes_merged_legacy_rows_with_report_window_defaults() {
361 let report = ExecutionMassStatus::new(
362 ClientId::from("SIM"),
363 AccountId::from("SIM-001"),
364 Venue::from("SIM"),
365 UnixNanos::from(11_000_000_000),
366 None,
367 );
368 let metadata = report.metadata();
369 let batch =
370 ExecutionMassStatus::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
371 let batch = batch_with_null_boolean_column(&batch, "reports_complete");
372 let merged_batch = batch_with_columns_at_end(&batch, &["client_id", "lookback_start"]);
373
374 let decoded =
375 ExecutionMassStatus::decode_typed_batch(merged_batch.schema().metadata(), merged_batch)
376 .unwrap();
377
378 assert_eq!(decoded, vec![report]);
379 }
380
381 #[rstest]
382 #[case("lookback_start", 8)]
383 #[case("reports_complete", 9)]
384 fn test_execution_mass_status_rejects_partial_report_window_schema(
385 #[case] missing: &'static str,
386 #[case] expected_index: usize,
387 ) {
388 let report = ExecutionMassStatus::new(
389 ClientId::from("SIM"),
390 AccountId::from("SIM-001"),
391 Venue::from("SIM"),
392 UnixNanos::from(12_000_000_000),
393 None,
394 );
395 let metadata = report.metadata();
396 let batch =
397 ExecutionMassStatus::encode_batch(&metadata, std::slice::from_ref(&report)).unwrap();
398 let partial_batch = batch_without_columns(&batch, &[missing]);
399
400 let error = ExecutionMassStatus::decode_typed_batch(
401 partial_batch.schema().metadata(),
402 partial_batch,
403 )
404 .expect_err("partial report window schema must be rejected");
405
406 assert!(matches!(
407 error,
408 EncodingError::MissingColumn(field, index)
409 if field == missing && index == expected_index
410 ));
411 }
412
413 fn sample_fill_report(avg_px: Option<Decimal>) -> FillReport {
414 let report = FillReport::new(
415 AccountId::from("SIM-001"),
416 InstrumentId::from("AUDUSD.SIM"),
417 VenueOrderId::from("2"),
418 TradeId::from("T-002"),
419 OrderSide::Sell,
420 Quantity::from("17.25"),
421 Price::from("1.23456"),
422 Money::new(2.75, Currency::USD()),
423 LiquiditySide::Maker,
424 Some(ClientOrderId::from("O-19700101-000000-001-001-3")),
425 Some(PositionId::from("P-002")),
426 UnixNanos::from(4_000_000_000),
427 UnixNanos::from(5_000_000_000),
428 None,
429 );
430
431 FillReport { avg_px, ..report }
432 }
433
434 fn batch_without_columns(batch: &RecordBatch, names: &[&str]) -> RecordBatch {
435 batch_with_column_order(batch, &column_indices(batch, names, false))
436 }
437
438 fn batch_with_columns_at_end(batch: &RecordBatch, names: &[&str]) -> RecordBatch {
439 let mut indices = column_indices(batch, names, false);
440 indices.extend(column_indices(batch, names, true));
441
442 batch_with_column_order(batch, &indices)
443 }
444
445 fn column_indices(batch: &RecordBatch, names: &[&str], named: bool) -> Vec<usize> {
446 batch
447 .schema()
448 .fields()
449 .iter()
450 .enumerate()
451 .filter(|(_, field)| names.contains(&field.name().as_str()) == named)
452 .map(|(index, _)| index)
453 .collect()
454 }
455
456 fn batch_with_column_order(batch: &RecordBatch, indices: &[usize]) -> RecordBatch {
457 let schema = batch.schema();
458 let fields: Vec<_> = indices
459 .iter()
460 .map(|index| schema.field(*index).clone())
461 .collect();
462 let columns: Vec<ArrayRef> = indices
463 .iter()
464 .map(|index| Arc::clone(batch.column(*index)))
465 .collect();
466 let reordered = Schema::new_with_metadata(fields, schema.metadata().clone());
467
468 RecordBatch::try_new(Arc::new(reordered), columns).unwrap()
469 }
470
471 fn batch_with_null_boolean_column(batch: &RecordBatch, name: &str) -> RecordBatch {
472 let schema = batch.schema();
473 let column_index = schema.index_of(name).unwrap();
474 let mut fields = schema.fields().to_vec();
475 fields[column_index] = Arc::new(Field::new(name, DataType::Boolean, true));
476 let mut columns = batch.columns().to_vec();
477 columns[column_index] = Arc::new(BooleanArray::from(vec![None; batch.num_rows()]));
478 let schema = Schema::new_with_metadata(fields, schema.metadata().clone());
479
480 RecordBatch::try_new(Arc::new(schema), columns).unwrap()
481 }
482}