Skip to main content

nautilus_betfair/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing utilities that convert Betfair HTTP/REST responses into Nautilus domain models.
17
18use 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
38/// Parses a Betfair [`CurrentOrderSummary`] into a Nautilus [`OrderStatusReport`].
39///
40/// # Errors
41///
42/// Returns an error if the placed date cannot be parsed.
43pub 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    // The venue can report a non-BSP persistence type on SP bets; the
54    // on-close instruction defines the time in force
55    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    // Include lapsed/voided in the closed quantity for status resolution
68    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    // Prefer lifecycle sum when price_size.size is zero. Use bsp_liability for
82    // on-close orders that report liability without stake/size.
83    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
161// An SP bet rests as execution-complete with no matched, cancelled, lapsed,
162// or voided quantity, and cannot be cancelled until BSP reconciliation.
163fn 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
172/// Parses a Betfair [`CurrentOrderSummary`] into a Nautilus [`FillReport`].
173///
174/// The caller supplies an incremental quantity and price derived from the
175/// cumulative order state.
176///
177/// # Errors
178///
179/// Returns an error if the order timestamps cannot be parsed.
180pub 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            // Executable orders are either Accepted or PartiallyFilled
268            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        // Fixture contains a mix of Executable and ExecutionComplete orders
283        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        // First order: BACK, fully lapsed, no matches
322        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        // A resting SP bet reports execution-complete with zero size fields,
345        // but cannot be cancelled and is still open until BSP reconciliation
346        assert_eq!(report.order_status, OrderStatus::Accepted);
347        // SP identity survives the report despite persistenceType LAPSE
348        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        // Once matched at BSP reconciliation the same bet resolves Filled
365        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        // Second order: LAY, sizeMatched=30, sizeLapsed=10, sizeVoided=10
377        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        // First order has customerOrderRef, second does not
532        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        // Second order: BACK, fully matched, sizeMatched=10, avgPx=1.9
558        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        // First order: sizeMatched=0, should still parse but with zero qty
577        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        // Third order: LAY side
589        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        // Second order: sizeMatched=30, avgPx=2.4
603        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        // First order has customerOrderRef
617        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        // Second order has no customerOrderRef
626        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        // Some venues serialize a blank string instead of omitting the field.
638        // ClientOrderId rejects empty strings, so the parser must treat blank
639        // refs as missing rather than panicking.
640        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        // Lapsed order has averagePriceMatched=0.0, so the fill report uses 0.00.
661        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}