Skip to content

Commit a87d1a1

Browse files
committed
Improve Betfair Decimal parsing
- Normalize Betfair stream quantities before fill checks - Keep Betfair custom data price and size values as Decimal - Lower unsupported Betfair unsubscribes to debug no-ops - Add Decimal parsing and custom data round-trip tests
1 parent 241582d commit a87d1a1

10 files changed

Lines changed: 423 additions & 201 deletions

File tree

RELEASES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ None
4646
- Fixed Betfair Rust adapter snapshot book deltas emitting zero-volume `Add` entries
4747
- Fixed Betfair Rust adapter traded volume cache to handle bet voids and non-runner adjustments
4848
- Fixed Betfair Rust adapter RCM custom data `ts_init` parity between live and historical streams
49+
- Fixed Betfair Rust adapter overfill checks for rounded stream matched sizes
50+
- Fixed Betfair Rust adapter unsupported unsubscribe commands logging above debug
4951
- Fixed dYdX rate limiter being skipped due to missing keys (#4091), thanks @filipmacek
5052
- Fixed Hyperliquid `Alo` limit order status reports being parsed as trigger orders
5153
- Fixed `LiveNode` signal handling during startup connection wait (#4102), thanks @filipmacek

crates/adapters/betfair/src/common/parse.rs

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,36 @@ pub fn parse_millis_timestamp(timestamp_ms: u64) -> UnixNanos {
9191
UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
9292
}
9393

94+
/// Converts a Betfair decimal price into a Nautilus [`Price`].
95+
///
96+
/// # Errors
97+
///
98+
/// Returns an error if the value cannot be represented at Betfair price precision.
99+
pub fn parse_betfair_price(price: Decimal) -> anyhow::Result<Price> {
100+
Price::from_decimal_dp(price, BETFAIR_PRICE_PRECISION).map_err(Into::into)
101+
}
102+
103+
/// Normalizes a Betfair price to Nautilus price precision.
104+
#[must_use]
105+
pub fn normalize_betfair_price(price: Decimal) -> Decimal {
106+
parse_betfair_price(price).map_or(price, |price| price.as_decimal())
107+
}
108+
109+
/// Converts a Betfair decimal quantity into a Nautilus [`Quantity`].
110+
///
111+
/// # Errors
112+
///
113+
/// Returns an error if the value cannot be represented at Betfair quantity precision.
114+
pub fn parse_betfair_quantity(quantity: Decimal) -> anyhow::Result<Quantity> {
115+
Quantity::from_decimal_dp(quantity, BETFAIR_QUANTITY_PRECISION).map_err(Into::into)
116+
}
117+
118+
/// Normalizes a Betfair quantity to Nautilus quantity precision.
119+
#[must_use]
120+
pub fn normalize_betfair_quantity(quantity: Decimal) -> Decimal {
121+
parse_betfair_quantity(quantity).map_or(quantity, |qty| qty.as_decimal())
122+
}
123+
94124
/// Truncates a client order ID to a Betfair `customer_order_ref`.
95125
///
96126
/// Takes the last 32 characters to preserve the high-entropy UUID suffix.
@@ -202,8 +232,8 @@ pub fn parse_market_catalogue(
202232
let fee_rate = market_base_rate / Decimal::ONE_HUNDRED;
203233

204234
let tick = Decimal::new(1, 2); // 0.01
205-
let price_increment = Price::from_decimal_dp(tick, BETFAIR_PRICE_PRECISION)?;
206-
let size_increment = Quantity::from_decimal_dp(tick, BETFAIR_QUANTITY_PRECISION)?;
235+
let price_increment = parse_betfair_price(tick)?;
236+
let size_increment = parse_betfair_quantity(tick)?;
207237

208238
let mut instruments = Vec::with_capacity(runners.len());
209239

@@ -331,8 +361,8 @@ pub fn parse_market_definition(
331361
.unwrap_or_default();
332362

333363
let tick = Decimal::new(1, 2); // 0.01
334-
let price_increment = Price::from_decimal_dp(tick, BETFAIR_PRICE_PRECISION)?;
335-
let size_increment = Quantity::from_decimal_dp(tick, BETFAIR_QUANTITY_PRECISION)?;
364+
let price_increment = parse_betfair_price(tick)?;
365+
let size_increment = parse_betfair_quantity(tick)?;
336366

337367
let market_id_ustr = Ustr::from(market_id);
338368

@@ -529,6 +559,67 @@ mod tests {
529559
assert_eq!(ts.as_u64(), 1_471_370_159_007 * 1_000_000);
530560
}
531561

562+
#[rstest]
563+
#[case(Decimal::new(242, 2), Decimal::new(242, 2))]
564+
#[case(Decimal::new(1, 0), Decimal::new(100, 2))]
565+
#[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
566+
fn test_parse_betfair_price_uses_betfair_precision(
567+
#[case] input: Decimal,
568+
#[case] expected: Decimal,
569+
) {
570+
let price = parse_betfair_price(input).unwrap();
571+
572+
assert_eq!(price.as_decimal(), expected);
573+
assert_eq!(price.precision, BETFAIR_PRICE_PRECISION);
574+
}
575+
576+
#[rstest]
577+
#[case(Decimal::new(100, 0), Decimal::new(10000, 2))]
578+
#[case(Decimal::ZERO, Decimal::ZERO)]
579+
#[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
580+
fn test_parse_betfair_quantity_uses_betfair_precision(
581+
#[case] input: Decimal,
582+
#[case] expected: Decimal,
583+
) {
584+
let quantity = parse_betfair_quantity(input).unwrap();
585+
586+
assert_eq!(quantity.as_decimal(), expected);
587+
assert_eq!(quantity.precision, BETFAIR_QUANTITY_PRECISION);
588+
}
589+
590+
#[rstest]
591+
#[case(Decimal::new(-1, 0))]
592+
#[case(Decimal::new(-1, 2))]
593+
fn test_parse_betfair_quantity_rejects_negative(#[case] input: Decimal) {
594+
let result = parse_betfair_quantity(input);
595+
596+
assert!(result.is_err());
597+
}
598+
599+
#[rstest]
600+
#[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
601+
#[case(Decimal::new(2555, 3), Decimal::new(256, 2))]
602+
fn test_normalize_betfair_price_rounds_to_betfair_precision(
603+
#[case] input: Decimal,
604+
#[case] expected: Decimal,
605+
) {
606+
let normalized = normalize_betfair_price(input);
607+
608+
assert_eq!(normalized, expected);
609+
}
610+
611+
#[rstest]
612+
#[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
613+
#[case(Decimal::new(2555, 3), Decimal::new(256, 2))]
614+
fn test_normalize_betfair_quantity_rounds_to_betfair_precision(
615+
#[case] input: Decimal,
616+
#[case] expected: Decimal,
617+
) {
618+
let normalized = normalize_betfair_quantity(input);
619+
620+
assert_eq!(normalized, expected);
621+
}
622+
532623
#[rstest]
533624
fn test_parse_market_catalogue() {
534625
let data = load_test_json("rest/list_market_catalogue.json");

crates/adapters/betfair/src/config.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use nautilus_model::{
2222
identifiers::{AccountId, TraderId},
2323
types::{Currency, Money},
2424
};
25+
use rust_decimal::Decimal;
2526
use serde::{Deserialize, Serialize};
2627

2728
use crate::{
@@ -38,8 +39,11 @@ fn parse_currency(code: &str) -> anyhow::Result<Currency> {
3839
.map_err(|_| anyhow::anyhow!("Invalid account currency code: {code}"))
3940
}
4041

41-
fn make_min_notional(value: Option<f64>, currency: Currency) -> Option<Money> {
42-
value.map(|amount| Money::new(amount, currency))
42+
fn make_min_notional(value: Option<Decimal>, currency: Currency) -> anyhow::Result<Option<Money>> {
43+
value
44+
.map(|amount| Money::from_decimal(amount, currency))
45+
.transpose()
46+
.map_err(Into::into)
4347
}
4448

4549
fn validate_market_start_time(label: &str, value: &Option<String>) -> anyhow::Result<()> {
@@ -123,7 +127,7 @@ pub struct BetfairDataConfig {
123127
#[builder(default = 5)]
124128
pub request_rate_per_second: u32,
125129
/// Optional default minimum notional in `account_currency`.
126-
pub default_min_notional: Option<f64>,
130+
pub default_min_notional: Option<Decimal>,
127131
/// Optional event type ID filter.
128132
pub event_type_ids: Option<Vec<String>>,
129133
/// Optional event type name filter.
@@ -212,7 +216,7 @@ impl BetfairDataConfig {
212216
/// Returns an error if the account currency code is invalid.
213217
pub fn min_notional(&self) -> anyhow::Result<Option<Money>> {
214218
let currency = self.currency()?;
215-
Ok(make_min_notional(self.default_min_notional, currency))
219+
make_min_notional(self.default_min_notional, currency)
216220
}
217221

218222
/// Returns the navigation filter for instrument loading.
@@ -631,12 +635,15 @@ mod tests {
631635
#[rstest]
632636
fn test_data_config_min_notional() {
633637
let config = BetfairDataConfig {
634-
default_min_notional: Some(2.0),
638+
default_min_notional: Some(Decimal::new(2, 0)),
635639
..Default::default()
636640
};
637641

638642
let min_notional = config.min_notional().unwrap();
639-
assert_eq!(min_notional, Some(Money::new(2.0, Currency::GBP())));
643+
assert_eq!(
644+
min_notional,
645+
Some(Money::from_decimal(Decimal::new(2, 0), Currency::GBP()).unwrap())
646+
);
640647
}
641648

642649
#[rstest]

crates/adapters/betfair/src/data.rs

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,20 @@ use nautilus_model::{
4444
},
4545
identifiers::{ClientId, InstrumentId, TradeId, Venue},
4646
instruments::{Instrument, InstrumentAny},
47-
types::{Currency, Money, Price, Quantity},
47+
types::{Currency, Money},
4848
};
4949
use nautilus_network::socket::TcpMessageHandler;
5050
use rust_decimal::Decimal;
5151
use tokio::task::JoinHandle;
5252

5353
use crate::{
5454
common::{
55-
consts::{
56-
BETFAIR_PRICE_PRECISION, BETFAIR_QUANTITY_PRECISION, BETFAIR_RACE_STREAM_HOST,
57-
BETFAIR_VENUE,
58-
},
55+
consts::{BETFAIR_RACE_STREAM_HOST, BETFAIR_VENUE},
5956
credential::BetfairCredential,
6057
enums::{MarketDataFilterField, MarketStatus},
6158
parse::{
62-
extract_market_id, make_instrument_id, parse_market_definition, parse_millis_timestamp,
59+
extract_market_id, make_instrument_id, parse_betfair_price, parse_betfair_quantity,
60+
parse_market_definition, parse_millis_timestamp,
6361
},
6462
},
6563
config::BetfairDataConfig,
@@ -331,20 +329,14 @@ impl BetfairDataClient {
331329
let trade_volume = pv.volume - prev_volume;
332330
volumes.insert(key, pv.volume);
333331

334-
let price = match Price::from_decimal_dp(
335-
pv.price,
336-
BETFAIR_PRICE_PRECISION,
337-
) {
332+
let price = match parse_betfair_price(pv.price) {
338333
Ok(p) => p,
339334
Err(e) => {
340335
log::warn!("Invalid trade price: {e}");
341336
continue;
342337
}
343338
};
344-
let size = match Quantity::from_decimal_dp(
345-
trade_volume,
346-
BETFAIR_QUANTITY_PRECISION,
347-
) {
339+
let size = match parse_betfair_quantity(trade_volume) {
348340
Ok(q) => q,
349341
Err(e) => {
350342
log::warn!("Invalid trade size: {e}");
@@ -867,8 +859,8 @@ impl DataClient for BetfairDataClient {
867859
}
868860

869861
fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
870-
log::warn!(
871-
"Unsubscribe book deltas not supported for Betfair: {}",
862+
log::debug!(
863+
"Skipping unsubscribe book deltas for Betfair: {}",
872864
cmd.instrument_id
873865
);
874866
Ok(())
@@ -884,8 +876,8 @@ impl DataClient for BetfairDataClient {
884876
}
885877

886878
fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
887-
log::info!(
888-
"Unsubscribe trades not supported for Betfair: {}",
879+
log::debug!(
880+
"Skipping unsubscribe trades for Betfair: {}",
889881
cmd.instrument_id
890882
);
891883
Ok(())
@@ -907,8 +899,8 @@ impl DataClient for BetfairDataClient {
907899
&mut self,
908900
cmd: &UnsubscribeInstrumentStatus,
909901
) -> anyhow::Result<()> {
910-
log::info!(
911-
"Unsubscribe instrument status not supported for Betfair: {}",
902+
log::debug!(
903+
"Skipping unsubscribe instrument status for Betfair: {}",
912904
cmd.instrument_id
913905
);
914906
Ok(())
@@ -928,44 +920,41 @@ impl DataClient for BetfairDataClient {
928920
&mut self,
929921
cmd: &UnsubscribeInstrumentClose,
930922
) -> anyhow::Result<()> {
931-
log::info!(
932-
"Unsubscribe instrument close not supported for Betfair: {}",
923+
log::debug!(
924+
"Skipping unsubscribe instrument close for Betfair: {}",
933925
cmd.instrument_id
934926
);
935927
Ok(())
936928
}
937929

938930
fn unsubscribe(&mut self, _cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
939-
log::info!("Unsubscribe custom data not applicable for Betfair");
931+
log::debug!("Skipping unsubscribe custom data for Betfair");
940932
Ok(())
941933
}
942934

943935
fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
944-
log::info!(
945-
"Unsubscribe instrument not applicable for Betfair: {}",
936+
log::debug!(
937+
"Skipping unsubscribe instrument for Betfair: {}",
946938
cmd.instrument_id
947939
);
948940
Ok(())
949941
}
950942

951943
fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
952-
log::info!("Unsubscribe instruments not applicable for Betfair");
944+
log::debug!("Skipping unsubscribe instruments for Betfair");
953945
Ok(())
954946
}
955947

956948
fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
957-
log::info!(
958-
"Unsubscribe quotes not applicable for Betfair: {}",
949+
log::debug!(
950+
"Skipping unsubscribe quotes for Betfair: {}",
959951
cmd.instrument_id
960952
);
961953
Ok(())
962954
}
963955

964956
fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
965-
log::info!(
966-
"Unsubscribe bars not applicable for Betfair: {}",
967-
cmd.bar_type
968-
);
957+
log::debug!("Skipping unsubscribe bars for Betfair: {}", cmd.bar_type);
969958
Ok(())
970959
}
971960
}

0 commit comments

Comments
 (0)