1use nautilus_core::UnixNanos;
19use nautilus_model::{
20 enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
21 identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
22 reports::{FillReport, OrderStatusReport},
23 types::{Currency, Money, Price, Quantity},
24};
25use rust_decimal::Decimal;
26
27use crate::{
28 common::{
29 enums::{BetfairOrderStatus, BetfairOrderType, resolve_order_status},
30 parse::{
31 make_instrument_id, parse_betfair_price, parse_betfair_quantity,
32 parse_betfair_timestamp,
33 },
34 },
35 http::models::CurrentOrderSummary,
36};
37
38pub fn parse_current_order_report(
44 order: &CurrentOrderSummary,
45 account_id: AccountId,
46 ts_init: UnixNanos,
47) -> anyhow::Result<OrderStatusReport> {
48 let instrument_id = make_instrument_id(&order.market_id, order.selection_id, order.handicap);
49
50 let order_side = OrderSide::from(order.side);
51 let order_type = OrderType::from(order.order_type);
52
53 let time_in_force = if uses_liability_based_quantity(order) {
56 TimeInForce::AtTheClose
57 } else {
58 TimeInForce::from(order.persistence_type)
59 };
60
61 let size_matched = order.size_matched.unwrap_or(Decimal::ZERO);
62 let size_remaining = order.size_remaining.unwrap_or(Decimal::ZERO);
63 let size_cancelled = order.size_cancelled.unwrap_or(Decimal::ZERO);
64 let size_lapsed = order.size_lapsed.unwrap_or(Decimal::ZERO);
65 let size_voided = order.size_voided.unwrap_or(Decimal::ZERO);
66
67 let size_closed = size_cancelled + size_lapsed + size_voided;
69 let order_status = if order.status == BetfairOrderStatus::ExecutionComplete
70 && size_voided > Decimal::ZERO
71 && size_cancelled.is_zero()
72 && size_lapsed.is_zero()
73 {
74 OrderStatus::Voided
75 } else if is_resting_sp_bet(order) {
76 OrderStatus::Accepted
77 } else {
78 resolve_order_status(order.status, size_matched, size_closed)
79 };
80
81 let total_size = order.price_size.size;
84 let lifecycle_qty = size_matched + size_remaining + size_cancelled + size_lapsed + size_voided;
85 let qty = if total_size > Decimal::ZERO {
86 total_size
87 } else if lifecycle_qty > Decimal::ZERO {
88 lifecycle_qty
89 } else if uses_liability_based_quantity(order) && order.bsp_liability > Decimal::ZERO {
90 order.bsp_liability
91 } else {
92 Decimal::ZERO
93 };
94 anyhow::ensure!(
95 qty > Decimal::ZERO,
96 "failed to resolve positive quantity for current order {} \
97 (order_type={:?}, persistence_type={:?}, price_size={}, bsp_liability={}, \
98 size_matched={}, size_remaining={}, size_cancelled={}, size_lapsed={}, size_voided={})",
99 order.bet_id,
100 order.order_type,
101 order.persistence_type,
102 order.price_size.size,
103 order.bsp_liability,
104 size_matched,
105 size_remaining,
106 size_cancelled,
107 size_lapsed,
108 size_voided,
109 );
110 let quantity = parse_betfair_quantity(qty)?;
111 let filled_qty = parse_betfair_quantity(size_matched)?;
112
113 let ts_accepted = parse_betfair_timestamp(&order.placed_date)?;
114 let ts_last = order
115 .matched_date
116 .as_deref()
117 .and_then(|d| parse_betfair_timestamp(d).ok())
118 .unwrap_or(ts_accepted);
119
120 let venue_order_id = VenueOrderId::from(order.bet_id.as_str());
121 let client_order_id = order
122 .customer_order_ref
123 .as_deref()
124 .filter(|s| !s.is_empty())
125 .map(ClientOrderId::from);
126
127 let price = parse_betfair_price(order.price_size.price)?;
128
129 let mut report = OrderStatusReport::new(
130 account_id,
131 instrument_id,
132 client_order_id,
133 venue_order_id,
134 order_side.into(),
135 order_type,
136 time_in_force,
137 order_status,
138 quantity,
139 filled_qty,
140 ts_accepted,
141 ts_last,
142 ts_init,
143 None,
144 )
145 .with_price(price);
146
147 report.avg_px = order.average_price_matched;
148
149 Ok(report)
150}
151
152fn uses_liability_based_quantity(order: &CurrentOrderSummary) -> bool {
153 matches!(
154 order.order_type,
155 BetfairOrderType::LimitOnClose
156 | BetfairOrderType::MarketOnClose
157 | BetfairOrderType::MarketAtTheClose
158 )
159}
160
161fn is_resting_sp_bet(order: &CurrentOrderSummary) -> bool {
164 order.status == BetfairOrderStatus::ExecutionComplete
165 && uses_liability_based_quantity(order)
166 && order.size_matched.unwrap_or(Decimal::ZERO) <= Decimal::ZERO
167 && order.size_cancelled.unwrap_or(Decimal::ZERO) <= Decimal::ZERO
168 && order.size_lapsed.unwrap_or(Decimal::ZERO) <= Decimal::ZERO
169 && order.size_voided.unwrap_or(Decimal::ZERO) <= Decimal::ZERO
170}
171
172pub fn parse_current_order_fill_report(
181 order: &CurrentOrderSummary,
182 account_id: AccountId,
183 currency: Currency,
184 trade_id: TradeId,
185 last_qty: Quantity,
186 last_px: Price,
187 ts_init: UnixNanos,
188) -> anyhow::Result<FillReport> {
189 let instrument_id = make_instrument_id(&order.market_id, order.selection_id, order.handicap);
190 let venue_order_id = VenueOrderId::from(order.bet_id.as_str());
191 let client_order_id = order
192 .customer_order_ref
193 .as_deref()
194 .filter(|s| !s.is_empty())
195 .map(ClientOrderId::from);
196 let order_side = OrderSide::from(order.side);
197
198 let ts_event = order
199 .matched_date
200 .as_deref()
201 .and_then(|d| parse_betfair_timestamp(d).ok())
202 .unwrap_or(parse_betfair_timestamp(&order.placed_date)?);
203
204 Ok(FillReport::new(
205 account_id,
206 instrument_id,
207 venue_order_id,
208 trade_id,
209 order_side,
210 last_qty,
211 last_px,
212 Money::zero(currency),
213 LiquiditySide::NoLiquiditySide,
214 client_order_id,
215 None,
216 ts_event,
217 ts_init,
218 None,
219 ))
220}
221
222#[cfg(test)]
223mod tests {
224 use nautilus_model::{
225 enums::{OrderSide, OrderStatus},
226 types::{Price, Quantity},
227 };
228 use rstest::rstest;
229
230 use super::*;
231 use crate::{
232 common::testing::{load_test_json, parse_jsonrpc},
233 http::models::CurrentOrderSummaryReport,
234 };
235
236 #[rstest]
237 fn test_parse_current_order_single() {
238 let data = load_test_json("rest/list_current_orders_single.json");
239 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
240 let order = &resp.current_orders[0];
241
242 let report =
243 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default())
244 .unwrap();
245
246 assert_eq!(
247 report.venue_order_id,
248 VenueOrderId::from(order.bet_id.as_str())
249 );
250 assert_eq!(report.order_side, Some(OrderSide::from(order.side)),);
251 assert!(report.price.is_some());
252 }
253
254 #[rstest]
255 fn test_parse_current_order_executable() {
256 let data = load_test_json("rest/list_current_orders_executable.json");
257 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
258
259 for order in &resp.current_orders {
260 let report = parse_current_order_report(
261 order,
262 AccountId::from("BETFAIR-001"),
263 UnixNanos::default(),
264 )
265 .unwrap();
266
267 assert!(
269 report.order_status == OrderStatus::Accepted
270 || report.order_status == OrderStatus::PartiallyFilled,
271 "unexpected status: {:?}",
272 report.order_status,
273 );
274 }
275 }
276
277 #[rstest]
278 fn test_parse_current_order_execution_complete() {
279 let data = load_test_json("rest/list_current_orders_execution_complete.json");
280 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
281
282 let mut has_filled = false;
284
285 for order in &resp.current_orders {
286 let report = parse_current_order_report(
287 order,
288 AccountId::from("BETFAIR-001"),
289 UnixNanos::default(),
290 )
291 .unwrap();
292
293 assert!(
294 matches!(
295 report.order_status,
296 OrderStatus::Filled
297 | OrderStatus::Canceled
298 | OrderStatus::Accepted
299 | OrderStatus::PartiallyFilled,
300 ),
301 "unexpected status: {:?}",
302 report.order_status,
303 );
304
305 if report.order_status == OrderStatus::Filled {
306 has_filled = true;
307 }
308 }
309
310 assert!(
311 has_filled,
312 "fixture should contain at least one filled order"
313 );
314 }
315
316 #[rstest]
317 fn test_parse_current_order_lapsed() {
318 let data = load_test_json("rest/list_current_orders_lapsed.json");
319 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
320
321 let order = &resp.current_orders[0];
323 let report =
324 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default())
325 .unwrap();
326
327 assert_eq!(report.order_side, Some(OrderSide::Sell));
328 assert_eq!(report.order_status, OrderStatus::Canceled);
329 assert_eq!(report.filled_qty, Quantity::from("0.00"));
330 assert_eq!(report.quantity, Quantity::from("20.00"));
331 assert_eq!(report.venue_order_id, VenueOrderId::from("229430281400"));
332 }
333
334 #[rstest]
335 fn test_parse_current_order_sp_resting() {
336 let data = load_test_json("rest/list_current_orders_sp_resting.json");
337 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
338 let order = &resp.current_orders[0];
339
340 let report =
341 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default())
342 .unwrap();
343
344 assert_eq!(report.order_status, OrderStatus::Accepted);
347 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
349 assert_eq!(report.quantity, Quantity::from("2.00"));
350 assert_eq!(report.filled_qty, Quantity::from("0.00"));
351 assert_eq!(report.venue_order_id, VenueOrderId::from("442849719274"));
352 }
353
354 #[rstest]
355 fn test_parse_current_order_sp_matched() {
356 let data = load_test_json("rest/list_current_orders_sp_matched.json");
357 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
358 let order = &resp.current_orders[0];
359
360 let report =
361 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default())
362 .unwrap();
363
364 assert_eq!(report.order_status, OrderStatus::Filled);
366 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
367 assert_eq!(report.quantity, Quantity::from("2.00"));
368 assert_eq!(report.filled_qty, Quantity::from("2.00"));
369 }
370
371 #[rstest]
372 fn test_parse_current_order_partially_filled_and_voided() {
373 let data = load_test_json("rest/list_current_orders_lapsed.json");
374 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
375
376 let order = &resp.current_orders[1];
378 let report =
379 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default())
380 .unwrap();
381
382 assert_eq!(report.order_side, Some(OrderSide::Buy));
383 assert_eq!(report.order_status, OrderStatus::Canceled);
384 assert_eq!(report.filled_qty, Quantity::from("30.00"));
385 assert_eq!(report.quantity, Quantity::from("50.00"));
386 assert_eq!(report.avg_px, Some(Decimal::new(24, 1)));
387 }
388
389 #[rstest]
390 fn test_parse_current_order_pure_void_is_terminal_voided() {
391 let data = load_test_json("rest/list_current_orders_lapsed.json");
392 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
393 let mut order = resp.current_orders[1].clone();
394 order.size_lapsed = Some(Decimal::ZERO);
395 order.size_cancelled = Some(Decimal::ZERO);
396
397 let report = parse_current_order_report(
398 &order,
399 AccountId::from("BETFAIR-001"),
400 UnixNanos::default(),
401 )
402 .unwrap();
403
404 assert_eq!(report.order_status, OrderStatus::Voided);
405 assert_eq!(report.filled_qty, Quantity::from("30.00"));
406 assert_eq!(report.quantity, Quantity::from("50.00"));
407 }
408
409 #[rstest]
410 fn test_parse_current_order_working_after_partial_void_is_not_terminal() {
411 let data = load_test_json("rest/list_current_orders_lapsed.json");
412 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
413 let mut order = resp.current_orders[1].clone();
414 order.status = BetfairOrderStatus::Executable;
415 order.size_matched = Some(Decimal::new(20, 0));
416 order.size_remaining = Some(Decimal::new(20, 0));
417 order.size_lapsed = Some(Decimal::ZERO);
418 order.size_cancelled = Some(Decimal::ZERO);
419 order.size_voided = Some(Decimal::new(10, 0));
420
421 let report = parse_current_order_report(
422 &order,
423 AccountId::from("BETFAIR-001"),
424 UnixNanos::default(),
425 )
426 .unwrap();
427
428 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
429 assert_eq!(report.filled_qty, Quantity::from("20.00"));
430 assert_eq!(report.quantity, Quantity::from("50.00"));
431 }
432
433 #[rstest]
434 fn test_parse_current_order_market_on_close_uses_bsp_liability() {
435 let data = r#"{
436 "jsonrpc": "2.0",
437 "id": 1,
438 "result": {
439 "currentOrders": [
440 {
441 "betId": "424009603606",
442 "marketId": "1.256134154",
443 "selectionId": 86018523,
444 "handicap": 0.0,
445 "priceSize": {
446 "price": 1.01,
447 "size": 0.0
448 },
449 "bspLiability": 2.0,
450 "side": "BACK",
451 "status": "EXECUTABLE",
452 "persistenceType": "MARKET_ON_CLOSE",
453 "orderType": "MARKET_ON_CLOSE",
454 "placedDate": "2026-04-03T00:51:29.000Z",
455 "averagePriceMatched": 0.0,
456 "sizeMatched": 0.0,
457 "sizeRemaining": 0.0,
458 "sizeLapsed": 0.0,
459 "sizeCancelled": 0.0,
460 "sizeVoided": 0.0
461 }
462 ],
463 "moreAvailable": false
464 }
465 }"#;
466 let resp: CurrentOrderSummaryReport = parse_jsonrpc(data);
467 let order = &resp.current_orders[0];
468
469 let report =
470 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default())
471 .unwrap();
472
473 assert_eq!(report.order_type, OrderType::Market);
474 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
475 assert_eq!(report.quantity, Quantity::from("2.00"));
476 }
477
478 #[rstest]
479 fn test_parse_current_order_zero_quantity_sources_fails() {
480 let data = r#"{
481 "jsonrpc": "2.0",
482 "id": 1,
483 "result": {
484 "currentOrders": [
485 {
486 "betId": "424009603607",
487 "marketId": "1.256134154",
488 "selectionId": 86018523,
489 "handicap": 0.0,
490 "priceSize": {
491 "price": 1.01,
492 "size": 0.0
493 },
494 "bspLiability": 0.0,
495 "side": "BACK",
496 "status": "EXECUTABLE",
497 "persistenceType": "LAPSE",
498 "orderType": "LIMIT",
499 "placedDate": "2026-04-03T00:51:29.000Z",
500 "averagePriceMatched": 0.0,
501 "sizeMatched": 0.0,
502 "sizeRemaining": 0.0,
503 "sizeLapsed": 0.0,
504 "sizeCancelled": 0.0,
505 "sizeVoided": 0.0
506 }
507 ],
508 "moreAvailable": false
509 }
510 }"#;
511 let resp: CurrentOrderSummaryReport = parse_jsonrpc(data);
512 let order = &resp.current_orders[0];
513
514 let result =
515 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default());
516
517 assert!(result.is_err());
518 assert!(
519 result
520 .unwrap_err()
521 .to_string()
522 .contains("failed to resolve positive quantity for current order 424009603607")
523 );
524 }
525
526 #[rstest]
527 fn test_parse_current_order_customer_order_ref() {
528 let data = load_test_json("rest/list_current_orders_lapsed.json");
529 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
530
531 let report1 = parse_current_order_report(
533 &resp.current_orders[0],
534 AccountId::from("BETFAIR-001"),
535 UnixNanos::default(),
536 )
537 .unwrap();
538 let report2 = parse_current_order_report(
539 &resp.current_orders[1],
540 AccountId::from("BETFAIR-001"),
541 UnixNanos::default(),
542 )
543 .unwrap();
544
545 assert_eq!(
546 report1.client_order_id,
547 Some(ClientOrderId::from("O-20210730-001"))
548 );
549 assert!(report2.client_order_id.is_none());
550 }
551
552 #[rstest]
553 fn test_parse_fill_report_matched_order() {
554 let data = load_test_json("rest/list_current_orders_execution_complete.json");
555 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
556
557 let order = &resp.current_orders[1];
559 let currency = Currency::from("GBP");
560 let report = parse_cumulative_fill_fixture(order, currency).unwrap();
561
562 assert_eq!(report.venue_order_id, VenueOrderId::from("228059821049"));
563 assert_eq!(report.order_side, OrderSide::Sell);
564 assert_eq!(report.last_qty, Quantity::from("10.00"));
565 assert_eq!(report.last_px, Price::from("1.90"));
566 assert_eq!(report.trade_id, TradeId::new("228059821049-10.00"));
567 assert_eq!(report.commission, Money::zero(currency));
568 assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
569 }
570
571 #[rstest]
572 fn test_parse_fill_report_unmatched_order_skips() {
573 let data = load_test_json("rest/list_current_orders_execution_complete.json");
574 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
575
576 let order = &resp.current_orders[0];
578 let report = parse_cumulative_fill_fixture(order, Currency::from("GBP")).unwrap();
579
580 assert_eq!(report.last_qty, Quantity::from("0.00"));
581 }
582
583 #[rstest]
584 fn test_parse_fill_report_lay_side() {
585 let data = load_test_json("rest/list_current_orders_execution_complete.json");
586 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
587
588 let order = &resp.current_orders[2];
590 let report = parse_cumulative_fill_fixture(order, Currency::from("GBP")).unwrap();
591
592 assert_eq!(report.order_side, OrderSide::Buy);
593 assert_eq!(report.last_qty, Quantity::from("10.00"));
594 assert_eq!(report.last_px, Price::from("1.92"));
595 }
596
597 #[rstest]
598 fn test_parse_fill_report_partially_matched() {
599 let data = load_test_json("rest/list_current_orders_lapsed.json");
600 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
601
602 let order = &resp.current_orders[1];
604 let report = parse_cumulative_fill_fixture(order, Currency::from("GBP")).unwrap();
605
606 assert_eq!(report.last_qty, Quantity::from("30.00"));
607 assert_eq!(report.last_px, Price::from("2.40"));
608 assert_eq!(report.trade_id, TradeId::new("229430281401-30.00"));
609 }
610
611 #[rstest]
612 fn test_parse_fill_report_customer_order_ref() {
613 let data = load_test_json("rest/list_current_orders_lapsed.json");
614 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
615
616 let order = &resp.current_orders[0];
618 let report = parse_cumulative_fill_fixture(order, Currency::from("GBP")).unwrap();
619
620 assert_eq!(
621 report.client_order_id,
622 Some(ClientOrderId::from("O-20210730-001"))
623 );
624
625 let order2 = &resp.current_orders[1];
627 let report2 = parse_cumulative_fill_fixture(order2, Currency::from("GBP")).unwrap();
628
629 assert!(report2.client_order_id.is_none());
630 }
631
632 #[rstest]
633 fn test_parse_current_order_blank_customer_order_ref_normalizes_to_none() {
634 let data = load_test_json("rest/list_current_orders_lapsed.json");
635 let mut resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
636
637 resp.current_orders[0].customer_order_ref = Some(String::new());
641
642 let report = parse_current_order_report(
643 &resp.current_orders[0],
644 AccountId::from("BETFAIR-001"),
645 UnixNanos::default(),
646 )
647 .unwrap();
648 assert!(report.client_order_id.is_none());
649 assert_eq!(report.venue_order_id, VenueOrderId::from("229430281400"));
650 assert_eq!(report.order_side, Some(OrderSide::Sell));
651 assert_eq!(report.quantity, Quantity::from("20.00"));
652 assert_eq!(report.price.unwrap(), Price::from("6.00"));
653
654 let fill =
655 parse_cumulative_fill_fixture(&resp.current_orders[0], Currency::from("GBP")).unwrap();
656 assert!(fill.client_order_id.is_none());
657 assert_eq!(fill.venue_order_id, VenueOrderId::from("229430281400"));
658 assert_eq!(fill.order_side, OrderSide::Sell);
659 assert_eq!(fill.last_qty, Quantity::from("0.00"));
660 assert_eq!(fill.last_px, Price::from("0.00"));
662 }
663
664 fn parse_cumulative_fill_fixture(
665 order: &CurrentOrderSummary,
666 currency: Currency,
667 ) -> anyhow::Result<FillReport> {
668 let size_matched = order.size_matched.unwrap_or(Decimal::ZERO);
669 let last_qty = parse_betfair_quantity(size_matched)?;
670 let last_px = parse_betfair_price(
671 order
672 .average_price_matched
673 .unwrap_or(order.price_size.price),
674 )?;
675 let trade_id = TradeId::new(format!("{}-{}", order.bet_id, last_qty.as_decimal(),));
676
677 parse_current_order_fill_report(
678 order,
679 AccountId::from("BETFAIR-001"),
680 currency,
681 trade_id,
682 last_qty,
683 last_px,
684 UnixNanos::default(),
685 )
686 }
687}