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