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