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    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    // Include lapsed/voided in the closed quantity for status resolution
61    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    // Prefer lifecycle sum when price_size.size is zero. Use bsp_liability for
73    // on-close orders that report liability without stake/size.
74    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
152/// Parses a Betfair [`CurrentOrderSummary`] into a Nautilus [`FillReport`].
153///
154/// The caller supplies an incremental quantity and price derived from the
155/// cumulative order state.
156///
157/// # Errors
158///
159/// Returns an error if the order timestamps cannot be parsed.
160pub 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            // Executable orders are either Accepted or PartiallyFilled
248            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        // Fixture contains a mix of Executable and ExecutionComplete orders
263        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        // First order: BACK, fully lapsed, no matches
302        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        // Second order: LAY, sizeMatched=30, sizeLapsed=10, sizeVoided=10
320        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        // First order has customerOrderRef, second does not
475        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        // Second order: BACK, fully matched, sizeMatched=10, avgPx=1.9
501        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        // First order: sizeMatched=0, should still parse but with zero qty
520        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        // Third order: LAY side
532        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        // Second order: sizeMatched=30, avgPx=2.4
546        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        // First order has customerOrderRef
560        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        // Second order has no customerOrderRef
569        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        // Some venues serialize a blank string instead of omitting the field.
581        // ClientOrderId rejects empty strings, so the parser must treat blank
582        // refs as missing rather than panicking.
583        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        // Lapsed order has averagePriceMatched=0.0, so the fill report uses 0.00.
604        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}