1use nautilus_core::UnixNanos;
19use nautilus_model::{
20 enums::{LiquiditySide, OrderSide, OrderType, TimeInForce},
21 identifiers::{AccountId, ClientOrderId, TradeId, VenueOrderId},
22 reports::{FillReport, OrderStatusReport},
23 types::{Currency, Money},
24};
25use rust_decimal::Decimal;
26
27use crate::{
28 common::{
29 enums::{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 = resolve_order_status(order.status, size_matched, size_closed);
63
64 let total_size = order.price_size.size;
67 let lifecycle_qty = size_matched + size_remaining + size_cancelled + size_lapsed + size_voided;
68 let qty = if total_size > Decimal::ZERO {
69 total_size
70 } else if lifecycle_qty > Decimal::ZERO {
71 lifecycle_qty
72 } else if uses_liability_based_quantity(order) && order.bsp_liability > Decimal::ZERO {
73 order.bsp_liability
74 } else {
75 Decimal::ZERO
76 };
77 anyhow::ensure!(
78 qty > Decimal::ZERO,
79 "failed to resolve positive quantity for current order {} \
80 (order_type={:?}, persistence_type={:?}, price_size={}, bsp_liability={}, \
81 size_matched={}, size_remaining={}, size_cancelled={}, size_lapsed={}, size_voided={})",
82 order.bet_id,
83 order.order_type,
84 order.persistence_type,
85 order.price_size.size,
86 order.bsp_liability,
87 size_matched,
88 size_remaining,
89 size_cancelled,
90 size_lapsed,
91 size_voided,
92 );
93 let quantity = parse_betfair_quantity(qty)?;
94 let filled_qty = parse_betfair_quantity(size_matched)?;
95
96 let ts_accepted = parse_betfair_timestamp(&order.placed_date)?;
97 let ts_last = order
98 .matched_date
99 .as_deref()
100 .and_then(|d| parse_betfair_timestamp(d).ok())
101 .unwrap_or(ts_accepted);
102
103 let venue_order_id = VenueOrderId::from(order.bet_id.as_str());
104 let client_order_id = order
105 .customer_order_ref
106 .as_deref()
107 .filter(|s| !s.is_empty())
108 .map(ClientOrderId::from);
109
110 let price = parse_betfair_price(order.price_size.price)?;
111
112 let mut report = OrderStatusReport::new(
113 account_id,
114 instrument_id,
115 client_order_id,
116 venue_order_id,
117 order_side,
118 order_type,
119 time_in_force,
120 order_status,
121 quantity,
122 filled_qty,
123 ts_accepted,
124 ts_last,
125 ts_init,
126 None,
127 )
128 .with_price(price);
129
130 report.avg_px = order.average_price_matched;
131
132 Ok(report)
133}
134
135fn uses_liability_based_quantity(order: &CurrentOrderSummary) -> bool {
136 matches!(
137 order.order_type,
138 BetfairOrderType::LimitOnClose
139 | BetfairOrderType::MarketOnClose
140 | BetfairOrderType::MarketAtTheClose
141 )
142}
143
144pub fn parse_current_order_fill_report(
154 order: &CurrentOrderSummary,
155 account_id: AccountId,
156 currency: Currency,
157 ts_init: UnixNanos,
158) -> anyhow::Result<FillReport> {
159 let instrument_id = make_instrument_id(&order.market_id, order.selection_id, order.handicap);
160 let venue_order_id = VenueOrderId::from(order.bet_id.as_str());
161 let client_order_id = order
162 .customer_order_ref
163 .as_deref()
164 .filter(|s| !s.is_empty())
165 .map(ClientOrderId::from);
166 let order_side = OrderSide::from(order.side);
167
168 let size_matched = order.size_matched.unwrap_or(Decimal::ZERO);
169 let avg_px = order
170 .average_price_matched
171 .unwrap_or(order.price_size.price);
172
173 let last_qty = parse_betfair_quantity(size_matched)?;
174 let last_px = parse_betfair_price(avg_px)?;
175
176 let trade_id = TradeId::new(format!("{}-{size_matched}", order.bet_id));
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::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, 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, 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, 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_market_on_close_uses_bsp_liability() {
334 let data = r#"{
335 "jsonrpc": "2.0",
336 "id": 1,
337 "result": {
338 "currentOrders": [
339 {
340 "betId": "424009603606",
341 "marketId": "1.256134154",
342 "selectionId": 86018523,
343 "handicap": 0.0,
344 "priceSize": {
345 "price": 1.01,
346 "size": 0.0
347 },
348 "bspLiability": 2.0,
349 "side": "BACK",
350 "status": "EXECUTABLE",
351 "persistenceType": "MARKET_ON_CLOSE",
352 "orderType": "MARKET_ON_CLOSE",
353 "placedDate": "2026-04-03T00:51:29.000Z",
354 "averagePriceMatched": 0.0,
355 "sizeMatched": 0.0,
356 "sizeRemaining": 0.0,
357 "sizeLapsed": 0.0,
358 "sizeCancelled": 0.0,
359 "sizeVoided": 0.0
360 }
361 ],
362 "moreAvailable": false
363 }
364 }"#;
365 let resp: CurrentOrderSummaryReport = parse_jsonrpc(data);
366 let order = &resp.current_orders[0];
367
368 let report =
369 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default())
370 .unwrap();
371
372 assert_eq!(report.order_type, OrderType::Market);
373 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
374 assert_eq!(report.quantity, Quantity::from("2.00"));
375 }
376
377 #[rstest]
378 fn test_parse_current_order_zero_quantity_sources_fails() {
379 let data = r#"{
380 "jsonrpc": "2.0",
381 "id": 1,
382 "result": {
383 "currentOrders": [
384 {
385 "betId": "424009603607",
386 "marketId": "1.256134154",
387 "selectionId": 86018523,
388 "handicap": 0.0,
389 "priceSize": {
390 "price": 1.01,
391 "size": 0.0
392 },
393 "bspLiability": 0.0,
394 "side": "BACK",
395 "status": "EXECUTABLE",
396 "persistenceType": "LAPSE",
397 "orderType": "LIMIT",
398 "placedDate": "2026-04-03T00:51:29.000Z",
399 "averagePriceMatched": 0.0,
400 "sizeMatched": 0.0,
401 "sizeRemaining": 0.0,
402 "sizeLapsed": 0.0,
403 "sizeCancelled": 0.0,
404 "sizeVoided": 0.0
405 }
406 ],
407 "moreAvailable": false
408 }
409 }"#;
410 let resp: CurrentOrderSummaryReport = parse_jsonrpc(data);
411 let order = &resp.current_orders[0];
412
413 let result =
414 parse_current_order_report(order, AccountId::from("BETFAIR-001"), UnixNanos::default());
415
416 assert!(result.is_err());
417 assert!(
418 result
419 .unwrap_err()
420 .to_string()
421 .contains("failed to resolve positive quantity for current order 424009603607")
422 );
423 }
424
425 #[rstest]
426 fn test_parse_current_order_customer_order_ref() {
427 let data = load_test_json("rest/list_current_orders_lapsed.json");
428 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
429
430 let report1 = parse_current_order_report(
432 &resp.current_orders[0],
433 AccountId::from("BETFAIR-001"),
434 UnixNanos::default(),
435 )
436 .unwrap();
437 let report2 = parse_current_order_report(
438 &resp.current_orders[1],
439 AccountId::from("BETFAIR-001"),
440 UnixNanos::default(),
441 )
442 .unwrap();
443
444 assert_eq!(
445 report1.client_order_id,
446 Some(ClientOrderId::from("O-20210730-001"))
447 );
448 assert!(report2.client_order_id.is_none());
449 }
450
451 #[rstest]
452 fn test_parse_fill_report_matched_order() {
453 let data = load_test_json("rest/list_current_orders_execution_complete.json");
454 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
455
456 let order = &resp.current_orders[1];
458 let currency = Currency::from("GBP");
459 let report = parse_current_order_fill_report(
460 order,
461 AccountId::from("BETFAIR-001"),
462 currency,
463 UnixNanos::default(),
464 )
465 .unwrap();
466
467 assert_eq!(report.venue_order_id, VenueOrderId::from("228059821049"));
468 assert_eq!(report.order_side, OrderSide::Sell);
469 assert_eq!(report.last_qty, Quantity::from("10.00"));
470 assert_eq!(report.last_px, Price::from("1.90"));
471 assert_eq!(report.trade_id, TradeId::new("228059821049-10"));
472 assert_eq!(report.commission, Money::zero(currency));
473 assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
474 }
475
476 #[rstest]
477 fn test_parse_fill_report_unmatched_order_skips() {
478 let data = load_test_json("rest/list_current_orders_execution_complete.json");
479 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
480
481 let order = &resp.current_orders[0];
483 let report = parse_current_order_fill_report(
484 order,
485 AccountId::from("BETFAIR-001"),
486 Currency::from("GBP"),
487 UnixNanos::default(),
488 )
489 .unwrap();
490
491 assert_eq!(report.last_qty, Quantity::from("0.00"));
492 }
493
494 #[rstest]
495 fn test_parse_fill_report_lay_side() {
496 let data = load_test_json("rest/list_current_orders_execution_complete.json");
497 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
498
499 let order = &resp.current_orders[2];
501 let report = parse_current_order_fill_report(
502 order,
503 AccountId::from("BETFAIR-001"),
504 Currency::from("GBP"),
505 UnixNanos::default(),
506 )
507 .unwrap();
508
509 assert_eq!(report.order_side, OrderSide::Buy);
510 assert_eq!(report.last_qty, Quantity::from("10.00"));
511 assert_eq!(report.last_px, Price::from("1.92"));
512 }
513
514 #[rstest]
515 fn test_parse_fill_report_partially_matched() {
516 let data = load_test_json("rest/list_current_orders_lapsed.json");
517 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
518
519 let order = &resp.current_orders[1];
521 let report = parse_current_order_fill_report(
522 order,
523 AccountId::from("BETFAIR-001"),
524 Currency::from("GBP"),
525 UnixNanos::default(),
526 )
527 .unwrap();
528
529 assert_eq!(report.last_qty, Quantity::from("30.00"));
530 assert_eq!(report.last_px, Price::from("2.40"));
531 assert_eq!(report.trade_id, TradeId::new("229430281401-30"));
532 }
533
534 #[rstest]
535 fn test_parse_fill_report_customer_order_ref() {
536 let data = load_test_json("rest/list_current_orders_lapsed.json");
537 let resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
538
539 let order = &resp.current_orders[0];
541 let report = parse_current_order_fill_report(
542 order,
543 AccountId::from("BETFAIR-001"),
544 Currency::from("GBP"),
545 UnixNanos::default(),
546 )
547 .unwrap();
548
549 assert_eq!(
550 report.client_order_id,
551 Some(ClientOrderId::from("O-20210730-001"))
552 );
553
554 let order2 = &resp.current_orders[1];
556 let report2 = parse_current_order_fill_report(
557 order2,
558 AccountId::from("BETFAIR-001"),
559 Currency::from("GBP"),
560 UnixNanos::default(),
561 )
562 .unwrap();
563
564 assert!(report2.client_order_id.is_none());
565 }
566
567 #[rstest]
568 fn test_parse_current_order_blank_customer_order_ref_normalizes_to_none() {
569 let data = load_test_json("rest/list_current_orders_lapsed.json");
570 let mut resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
571
572 resp.current_orders[0].customer_order_ref = Some(String::new());
576
577 let report = parse_current_order_report(
578 &resp.current_orders[0],
579 AccountId::from("BETFAIR-001"),
580 UnixNanos::default(),
581 )
582 .unwrap();
583 assert!(report.client_order_id.is_none());
584 assert_eq!(report.venue_order_id, VenueOrderId::from("229430281400"));
585 assert_eq!(report.order_side, OrderSide::Sell);
586 assert_eq!(report.quantity, Quantity::from("20.00"));
587 assert_eq!(report.price.unwrap(), Price::from("6.00"));
588
589 let fill = parse_current_order_fill_report(
590 &resp.current_orders[0],
591 AccountId::from("BETFAIR-001"),
592 Currency::from("GBP"),
593 UnixNanos::default(),
594 )
595 .unwrap();
596 assert!(fill.client_order_id.is_none());
597 assert_eq!(fill.venue_order_id, VenueOrderId::from("229430281400"));
598 assert_eq!(fill.order_side, OrderSide::Sell);
599 assert_eq!(fill.last_qty, Quantity::from("0.00"));
600 assert_eq!(fill.last_px, Price::from("0.00"));
602 }
603}