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, 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
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 = resolve_order_status(order.status, size_matched, size_closed);
63
64    // Prefer lifecycle sum when price_size.size is zero. Use bsp_liability for
65    // on-close orders that report liability without stake/size.
66    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
144/// Parses a Betfair [`CurrentOrderSummary`] into a Nautilus [`FillReport`].
145///
146/// Uses cumulative `size_matched` and `average_price_matched` to produce a
147/// single fill representing the total execution. Trade IDs use the format
148/// `{bet_id}-{size_matched}` for deterministic uniqueness.
149///
150/// # Errors
151///
152/// Returns an error if timestamps or decimal values cannot be parsed.
153pub 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            // 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, 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, 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        // First order has customerOrderRef, second does not
431        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        // Second order: BACK, fully matched, sizeMatched=10, avgPx=1.9
457        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        // First order: sizeMatched=0, should still parse but with zero qty
482        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        // Third order: LAY side
500        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        // Second order: sizeMatched=30, avgPx=2.4
520        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        // First order has customerOrderRef
540        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        // Second order has no customerOrderRef
555        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        // Some venues serialize a blank string instead of omitting the field.
573        // ClientOrderId rejects empty strings, so the parser must treat blank
574        // refs as missing rather than panicking.
575        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        // Lapsed order has averagePriceMatched=0.0, so the fill report uses 0.00.
601        assert_eq!(fill.last_px, Price::from("0.00"));
602    }
603}