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, 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
36/// Parses a Betfair [`CurrentOrderSummary`] into a Nautilus [`OrderStatusReport`].
37///
38/// # Errors
39///
40/// Returns an error if the placed date cannot be parsed.
41pub 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    // Include lapsed/voided in the closed quantity for status resolution
59    let size_closed = size_cancelled + size_lapsed + size_voided;
60    let order_status = resolve_order_status(order.status, size_matched, size_closed);
61
62    // Prefer lifecycle sum when price_size.size is zero. Use bsp_liability for
63    // on-close orders that report liability without stake/size.
64    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
138/// Parses a Betfair [`CurrentOrderSummary`] into a Nautilus [`FillReport`].
139///
140/// Uses cumulative `size_matched` and `average_price_matched` to produce a
141/// single fill representing the total execution. Trade IDs use the format
142/// `{bet_id}-{size_matched}` for deterministic uniqueness.
143///
144/// # Errors
145///
146/// Returns an error if timestamps or decimal values cannot be parsed.
147pub 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            // Executable orders are either Accepted or PartiallyFilled
235            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        // Fixture contains a mix of Executable and ExecutionComplete orders
250        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        // First order: BACK, fully lapsed, no matches
289        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        // Second order: LAY, sizeMatched=30, sizeLapsed=10, sizeVoided=10
307        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        // First order has customerOrderRef, second does not
418        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        // Second order: BACK, fully matched, sizeMatched=10, avgPx=1.9
444        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        // First order: sizeMatched=0, should still parse but with zero qty
469        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        // Third order: LAY side
487        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        // Second order: sizeMatched=30, avgPx=2.4
507        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        // First order has customerOrderRef
527        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        // Second order has no customerOrderRef
542        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}