Skip to main content

nautilus_betfair/common/
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 payloads into Nautilus domain models.
17
18use anyhow::Context;
19use jiff::Timestamp;
20use nautilus_core::{Params, UUID4, UnixNanos, datetime::NANOSECONDS_IN_MILLISECOND};
21use nautilus_model::{
22    enums::AccountType,
23    events::AccountState,
24    identifiers::{AccountId, InstrumentId, Symbol},
25    instruments::{BettingInstrument, InstrumentAny},
26    types::{AccountBalance, Currency, Money, Price, Quantity},
27};
28use rust_decimal::{Decimal, prelude::ToPrimitive};
29use ustr::Ustr;
30
31use super::{
32    consts::{
33        BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN, BETFAIR_PRICE_PRECISION, BETFAIR_QUANTITY_PRECISION,
34        BETFAIR_VENUE, DEFAULT_BETTING_TYPE, DEFAULT_MARKET_TYPE,
35    },
36    types::SelectionId,
37};
38use crate::{
39    http::models::{AccountFundsResponse, MarketCatalogue},
40    stream::messages::MarketDefinition,
41};
42
43/// Constructs a Nautilus [`Symbol`] from Betfair market and selection identifiers.
44///
45/// Format: `"{market_id}-{selection_id}"` or `"{market_id}-{selection_id}-{handicap}"`
46/// when handicap is non-zero.
47#[must_use]
48pub fn make_symbol(market_id: &str, selection_id: u64, handicap: Decimal) -> Symbol {
49    if handicap == Decimal::ZERO {
50        Symbol::new(format!("{market_id}-{selection_id}"))
51    } else {
52        Symbol::new(format!("{market_id}-{selection_id}-{handicap}"))
53    }
54}
55
56/// Constructs a Nautilus [`InstrumentId`] from Betfair market and selection identifiers.
57///
58/// Format: `"{market_id}-{selection_id}.BETFAIR"` or
59/// `"{market_id}-{selection_id}-{handicap}.BETFAIR"` when handicap is non-zero.
60#[must_use]
61pub fn make_instrument_id(market_id: &str, selection_id: u64, handicap: Decimal) -> InstrumentId {
62    let symbol = make_symbol(market_id, selection_id, handicap);
63    InstrumentId::new(symbol, *BETFAIR_VENUE)
64}
65
66/// Parses an RFC 3339 / ISO 8601 timestamp string into [`UnixNanos`].
67///
68/// Handles both UTC (`"2023-11-27T05:43:00Z"`) and offset
69/// (`"2021-03-19T12:07:00+10:00"`) formats.
70///
71/// # Errors
72///
73/// Returns an error if the string is not a valid RFC 3339 datetime.
74pub fn parse_betfair_timestamp(s: &str) -> anyhow::Result<UnixNanos> {
75    let dt = s
76        .parse::<Timestamp>()
77        .or_else(|_| {
78            // Betfair sometimes uses ".000Z" millis suffix
79            s.replace(".000Z", "Z").parse::<Timestamp>()
80        })
81        .with_context(|| format!("invalid Betfair timestamp: {s}"))?;
82    let nanos = u64::try_from(dt.as_nanosecond())
83        .with_context(|| format!("Betfair timestamp is outside the UnixNanos range: {s}"))?;
84    Ok(UnixNanos::from(nanos))
85}
86
87/// Converts a millisecond epoch timestamp (as used in stream `pt` field) into [`UnixNanos`].
88#[must_use]
89pub fn parse_millis_timestamp(timestamp_ms: u64) -> UnixNanos {
90    UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
91}
92
93/// Converts a Betfair decimal price into a Nautilus [`Price`].
94///
95/// # Errors
96///
97/// Returns an error if the value cannot be represented at Betfair price precision.
98pub fn parse_betfair_price(price: Decimal) -> anyhow::Result<Price> {
99    Price::from_decimal_dp(price, BETFAIR_PRICE_PRECISION).map_err(Into::into)
100}
101
102/// Normalizes a Betfair price to Nautilus price precision.
103#[must_use]
104pub fn normalize_betfair_price(price: Decimal) -> Decimal {
105    parse_betfair_price(price).map_or(price, |price| price.as_decimal())
106}
107
108/// Converts a Betfair decimal quantity into a Nautilus [`Quantity`].
109///
110/// # Errors
111///
112/// Returns an error if the value cannot be represented at Betfair quantity precision.
113pub fn parse_betfair_quantity(quantity: Decimal) -> anyhow::Result<Quantity> {
114    Quantity::from_decimal_dp(quantity, BETFAIR_QUANTITY_PRECISION).map_err(Into::into)
115}
116
117/// Normalizes a Betfair quantity to Nautilus quantity precision.
118#[must_use]
119pub fn normalize_betfair_quantity(quantity: Decimal) -> Decimal {
120    parse_betfair_quantity(quantity).map_or(quantity, |qty| qty.as_decimal())
121}
122
123/// Truncates a client order ID to a Betfair `customer_order_ref`.
124///
125/// Takes the last 32 characters to preserve the high-entropy UUID suffix.
126/// Returns the full string if it is already 32 characters or shorter.
127#[must_use]
128pub fn make_customer_order_ref(client_order_id: &str) -> String {
129    let len = client_order_id.len();
130    if len <= BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN {
131        client_order_id.to_string()
132    } else {
133        client_order_id[len - BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN..].to_string()
134    }
135}
136
137/// Legacy truncation that takes the first 32 characters.
138///
139/// Pre-existing orders may use this format. Register both truncations
140/// on reconnect to match orders regardless of which convention was used.
141#[must_use]
142pub fn make_customer_order_ref_legacy(client_order_id: &str) -> String {
143    let len = client_order_id.len();
144    if len <= BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN {
145        client_order_id.to_string()
146    } else {
147        client_order_id[..BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN].to_string()
148    }
149}
150
151/// Parses a Betfair [`MarketCatalogue`] into a vec of [`InstrumentAny`].
152///
153/// Each runner in the catalogue becomes a separate [`BettingInstrument`].
154///
155/// # Errors
156///
157/// Returns an error if required fields are missing or instrument construction fails.
158pub fn parse_market_catalogue(
159    catalogue: &MarketCatalogue,
160    currency: Currency,
161    ts_init: UnixNanos,
162    min_notional: Option<Money>,
163) -> anyhow::Result<Vec<InstrumentAny>> {
164    let runners = catalogue
165        .runners
166        .as_ref()
167        .context("MarketCatalogue missing runners")?;
168
169    let market_id = &catalogue.market_id;
170
171    let (event_type_id, event_type_name) = match &catalogue.event_type {
172        Some(et) => (
173            et.id
174                .as_ref()
175                .and_then(|id| id.parse::<u64>().ok())
176                .unwrap_or(0),
177            Ustr::from(et.name.as_deref().unwrap_or("")),
178        ),
179        None => (0, Ustr::from("")),
180    };
181
182    let (competition_id, competition_name) = match &catalogue.competition {
183        Some(c) => (
184            c.id.as_ref()
185                .and_then(|id| id.parse::<u64>().ok())
186                .unwrap_or(0),
187            Ustr::from(c.name.as_deref().unwrap_or("")),
188        ),
189        None => (0, Ustr::from("")),
190    };
191
192    let (event_id, event_name, event_country_code, event_open_date) = match &catalogue.event {
193        Some(e) => {
194            let eid =
195                e.id.as_ref()
196                    .and_then(|id| id.parse::<u64>().ok())
197                    .unwrap_or(0);
198            let ename = Ustr::from(e.name.as_deref().unwrap_or(""));
199            let cc = e.country_code.unwrap_or_else(|| Ustr::from(""));
200            let open_date = e
201                .open_date
202                .as_deref()
203                .and_then(|d| parse_betfair_timestamp(d).ok())
204                .unwrap_or_default();
205            (eid, ename, cc, open_date)
206        }
207        None => (0, Ustr::from(""), Ustr::from(""), UnixNanos::default()),
208    };
209
210    let (betting_type, market_type, market_base_rate) = match &catalogue.description {
211        Some(desc) => (
212            Ustr::from(&format!("{}", desc.betting_type)),
213            desc.market_type,
214            desc.market_base_rate,
215        ),
216        None => (
217            Ustr::from(DEFAULT_BETTING_TYPE),
218            Ustr::from(DEFAULT_MARKET_TYPE),
219            Decimal::ZERO,
220        ),
221    };
222
223    let market_name = Ustr::from(&catalogue.market_name);
224    let market_start_time = catalogue
225        .market_start_time
226        .as_deref()
227        .and_then(|t| parse_betfair_timestamp(t).ok())
228        .unwrap_or_default();
229
230    // Convert market base rate from percentage to decimal fraction
231    let fee_rate = market_base_rate / Decimal::ONE_HUNDRED;
232
233    let tick = Decimal::new(1, 2); // 0.01
234    let price_increment = parse_betfair_price(tick)?;
235    let size_increment = parse_betfair_quantity(tick)?;
236
237    let mut instruments = Vec::with_capacity(runners.len());
238
239    for runner in runners {
240        let handicap = runner.handicap;
241        let instrument_id = make_instrument_id(market_id, runner.selection_id, handicap);
242        let raw_symbol = make_symbol(market_id, runner.selection_id, handicap);
243
244        let instrument = BettingInstrument::builder()
245            .instrument_id(instrument_id)
246            .raw_symbol(raw_symbol)
247            .event_type_id(event_type_id)
248            .event_type_name(event_type_name)
249            .competition_id(competition_id)
250            .competition_name(competition_name)
251            .event_id(event_id)
252            .event_name(event_name)
253            .event_country_code(event_country_code)
254            .event_open_date(event_open_date)
255            .betting_type(betting_type)
256            .market_id(Ustr::from(market_id.as_str()))
257            .market_name(market_name)
258            .market_type(market_type)
259            .market_start_time(market_start_time)
260            .selection_id(runner.selection_id)
261            .selection_name(Ustr::from(&runner.runner_name))
262            .selection_handicap(handicap.to_f64().unwrap_or(0.0))
263            .currency(currency)
264            .price_precision(BETFAIR_PRICE_PRECISION)
265            .size_precision(BETFAIR_QUANTITY_PRECISION)
266            .price_increment(price_increment)
267            .size_increment(size_increment)
268            .maybe_min_notional(min_notional)
269            // margin_init (pre-funded)
270            .margin_init(Decimal::ONE)
271            .margin_maint(Decimal::ONE)
272            .maker_fee(fee_rate)
273            .taker_fee(fee_rate)
274            .ts_event(ts_init)
275            .ts_init(ts_init)
276            .build()
277            .with_context(|| {
278                format!(
279                    "failed to create BettingInstrument for {market_id}/{}/{}",
280                    runner.selection_id, runner.runner_name
281                )
282            })?;
283
284        instruments.push(InstrumentAny::Betting(instrument));
285    }
286
287    Ok(instruments)
288}
289
290/// Parses a stream [`MarketDefinition`] into a vec of [`InstrumentAny`].
291///
292/// Each runner definition becomes a separate [`BettingInstrument`].
293/// Stream definitions have many optional fields - missing values are
294/// defaulted gracefully.
295///
296/// # Errors
297///
298/// Returns an error if runners are missing or instrument construction fails.
299pub fn parse_market_definition(
300    market_id: &str,
301    def: &MarketDefinition,
302    currency: Currency,
303    ts_event: UnixNanos,
304    ts_init: UnixNanos,
305    min_notional: Option<Money>,
306) -> anyhow::Result<Vec<InstrumentAny>> {
307    let runners = def
308        .runners
309        .as_ref()
310        .context("MarketDefinition missing runners")?;
311
312    let event_type_id = def
313        .event_type_id
314        .as_deref()
315        .and_then(|id| id.parse::<u64>().ok())
316        .unwrap_or(0);
317    let event_type_name = def.event_type_name.unwrap_or_else(|| Ustr::from(""));
318
319    let competition_id = def
320        .competition_id
321        .as_deref()
322        .and_then(|id| id.parse::<u64>().ok())
323        .unwrap_or(0);
324    let competition_name = Ustr::from(def.competition_name.as_deref().unwrap_or(""));
325
326    let event_id = def
327        .event_id
328        .as_deref()
329        .and_then(|id| id.parse::<u64>().ok())
330        .unwrap_or(0);
331    let event_name = Ustr::from(def.event_name.as_deref().unwrap_or(""));
332    let event_country_code = def.country_code.unwrap_or_else(|| Ustr::from(""));
333    let event_open_date = def
334        .open_date
335        .as_deref()
336        .and_then(|d| parse_betfair_timestamp(d).ok())
337        .unwrap_or_default();
338
339    let betting_type = match &def.betting_type {
340        Some(bt) => Ustr::from(&format!("{bt}")),
341        None => Ustr::from(DEFAULT_BETTING_TYPE),
342    };
343    let market_name = Ustr::from(def.market_name.as_deref().unwrap_or(""));
344    let market_type = def
345        .market_type
346        .unwrap_or_else(|| Ustr::from(DEFAULT_MARKET_TYPE));
347    let market_start_time = def
348        .market_time
349        .as_deref()
350        .and_then(|t| parse_betfair_timestamp(t).ok())
351        .unwrap_or_default();
352
353    let fee_rate = def
354        .market_base_rate
355        .map(|r| r / Decimal::ONE_HUNDRED)
356        .unwrap_or_default();
357
358    let tick = Decimal::new(1, 2); // 0.01
359    let price_increment = parse_betfair_price(tick)?;
360    let size_increment = parse_betfair_quantity(tick)?;
361
362    let market_id_ustr = Ustr::from(market_id);
363
364    let mut instruments = Vec::with_capacity(runners.len());
365
366    for runner in runners {
367        let handicap = runner.hc.unwrap_or(Decimal::ZERO);
368
369        let instrument_id = make_instrument_id(market_id, runner.id, handicap);
370        let raw_symbol = make_symbol(market_id, runner.id, handicap);
371        let runner_name = Ustr::from(runner.name.as_deref().unwrap_or(""));
372
373        let instrument = BettingInstrument::builder()
374            .instrument_id(instrument_id)
375            .raw_symbol(raw_symbol)
376            .event_type_id(event_type_id)
377            .event_type_name(event_type_name)
378            .competition_id(competition_id)
379            .competition_name(competition_name)
380            .event_id(event_id)
381            .event_name(event_name)
382            .event_country_code(event_country_code)
383            .event_open_date(event_open_date)
384            .betting_type(betting_type)
385            .market_id(market_id_ustr)
386            .market_name(market_name)
387            .market_type(market_type)
388            .market_start_time(market_start_time)
389            .selection_id(runner.id)
390            .selection_name(runner_name)
391            .selection_handicap(handicap.to_f64().unwrap_or(0.0))
392            .currency(currency)
393            .price_precision(BETFAIR_PRICE_PRECISION)
394            .size_precision(BETFAIR_QUANTITY_PRECISION)
395            .price_increment(price_increment)
396            .size_increment(size_increment)
397            .maybe_min_notional(min_notional)
398            .margin_init(Decimal::ONE)
399            .margin_maint(Decimal::ONE)
400            .maker_fee(fee_rate)
401            .taker_fee(fee_rate)
402            .ts_event(ts_event)
403            .ts_init(ts_init)
404            .build()
405            .with_context(|| {
406                format!(
407                    "failed to create BettingInstrument for {market_id}/{}",
408                    runner.id
409                )
410            })?;
411
412        instruments.push(InstrumentAny::Betting(instrument));
413    }
414
415    Ok(instruments)
416}
417
418/// Parses a Betfair [`AccountFundsResponse`] into a Nautilus [`AccountState`].
419///
420/// # Errors
421///
422/// Returns an error if monetary values cannot be converted.
423pub fn parse_account_state(
424    funds: &AccountFundsResponse,
425    account_id: AccountId,
426    currency: Currency,
427    ts_event: UnixNanos,
428    ts_init: UnixNanos,
429) -> anyhow::Result<AccountState> {
430    let available = funds.available_to_bet_balance.unwrap_or_default();
431    let exposure = funds.exposure.unwrap_or_default().abs();
432    let total = available + exposure;
433
434    let balance = AccountBalance::from_total_and_locked(total, exposure, currency)?;
435
436    let mut info = Params::new();
437    let mut push_decimal = |key: &str, val: Option<Decimal>| {
438        if let Some(decimal) = val {
439            info.insert(
440                key.to_string(),
441                serde_json::Value::from(decimal.to_string()),
442            );
443        }
444    };
445    push_decimal("available_to_bet_balance", funds.available_to_bet_balance);
446    push_decimal("exposure", funds.exposure);
447    push_decimal("retained_commission", funds.retained_commission);
448    push_decimal("exposure_limit", funds.exposure_limit);
449    push_decimal("discount_rate", funds.discount_rate);
450    if let Some(points) = funds.points_balance {
451        info.insert(
452            "points_balance".to_string(),
453            serde_json::Value::from(points),
454        );
455    }
456
457    if let Some(wallet) = funds.wallet {
458        info.insert(
459            "wallet".to_string(),
460            serde_json::Value::from(wallet.to_string()),
461        );
462    }
463    let info = if info.is_empty() { None } else { Some(info) };
464
465    Ok(AccountState::new(
466        account_id,
467        AccountType::Betting,
468        vec![balance],
469        vec![],
470        true,
471        UUID4::new(),
472        ts_event,
473        ts_init,
474        Some(currency),
475    )
476    .with_info(info))
477}
478
479/// Extracts the Betfair market ID from a Nautilus instrument ID.
480///
481/// Instrument IDs follow the format `{market_id}-{selection_id}.BETFAIR`
482/// or `{market_id}-{selection_id}-{handicap}.BETFAIR`.
483///
484/// # Errors
485///
486/// Returns an error if the symbol does not contain a hyphen separator.
487pub fn extract_market_id(instrument_id: &InstrumentId) -> anyhow::Result<String> {
488    let symbol = instrument_id.symbol.as_str();
489    let parts: Vec<&str> = symbol.splitn(3, '-').collect();
490    if parts.len() >= 2 {
491        Ok(parts[0].to_string())
492    } else {
493        anyhow::bail!("Cannot extract market ID from {instrument_id}")
494    }
495}
496
497/// Extracts the selection ID and handicap from a Nautilus instrument ID.
498///
499/// # Errors
500///
501/// Returns an error if the symbol cannot be parsed into the expected format.
502pub fn extract_selection_id(
503    instrument_id: &InstrumentId,
504) -> anyhow::Result<(SelectionId, Decimal)> {
505    let symbol = instrument_id.symbol.as_str();
506    let parts: Vec<&str> = symbol.splitn(3, '-').collect();
507    if parts.len() < 2 {
508        anyhow::bail!("Cannot extract selection ID from {instrument_id}");
509    }
510
511    let selection_id: SelectionId = parts[1]
512        .parse()
513        .with_context(|| format!("invalid selection ID in {instrument_id}"))?;
514
515    let handicap = if parts.len() == 3 {
516        parts[2]
517            .parse::<Decimal>()
518            .with_context(|| format!("invalid handicap in {instrument_id}"))?
519    } else {
520        Decimal::ZERO
521    };
522
523    Ok((selection_id, handicap))
524}
525
526#[cfg(test)]
527mod tests {
528    use rstest::rstest;
529
530    use super::*;
531    use crate::{common::testing::load_test_json, stream::messages::StreamMessage};
532
533    #[rstest]
534    fn test_make_instrument_id_no_handicap() {
535        let id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
536        assert_eq!(id.to_string(), "1.180737206-19248890.BETFAIR");
537    }
538
539    #[rstest]
540    fn test_make_instrument_id_with_handicap() {
541        let id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
542        assert_eq!(id.to_string(), "1.180737206-19248890-1.5.BETFAIR");
543    }
544
545    #[rstest]
546    fn test_make_symbol_no_handicap() {
547        let sym = make_symbol("1.180737206", 19248890, Decimal::ZERO);
548        assert_eq!(sym.to_string(), "1.180737206-19248890");
549    }
550
551    #[rstest]
552    fn test_make_symbol_with_handicap() {
553        let sym = make_symbol("1.180737206", 19248890, Decimal::new(-5, 1));
554        assert_eq!(sym.to_string(), "1.180737206-19248890--0.5");
555    }
556
557    #[rstest]
558    fn test_parse_betfair_timestamp_utc() {
559        let ts = parse_betfair_timestamp("2023-11-27T05:43:00Z").unwrap();
560        assert!(ts.as_u64() > 0);
561    }
562
563    #[rstest]
564    fn test_parse_betfair_timestamp_with_offset() {
565        let ts = parse_betfair_timestamp("2021-03-19T12:07:00+10:00").unwrap();
566        assert!(ts.as_u64() > 0);
567    }
568
569    #[rstest]
570    fn test_parse_betfair_timestamp_with_millis() {
571        let ts = parse_betfair_timestamp("2021-03-19T08:50:00.000Z").unwrap();
572        assert!(ts.as_u64() > 0);
573    }
574
575    #[rstest]
576    fn test_parse_millis_timestamp() {
577        let ts = parse_millis_timestamp(1_471_370_159_007);
578        assert_eq!(ts.as_u64(), 1_471_370_159_007 * 1_000_000);
579    }
580
581    #[rstest]
582    #[case(Decimal::new(242, 2), Decimal::new(242, 2))]
583    #[case(Decimal::new(1, 0), Decimal::new(100, 2))]
584    #[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
585    fn test_parse_betfair_price_uses_betfair_precision(
586        #[case] input: Decimal,
587        #[case] expected: Decimal,
588    ) {
589        let price = parse_betfair_price(input).unwrap();
590
591        assert_eq!(price.as_decimal(), expected);
592        assert_eq!(price.precision, BETFAIR_PRICE_PRECISION);
593    }
594
595    #[rstest]
596    #[case(Decimal::new(100, 0), Decimal::new(10000, 2))]
597    #[case(Decimal::ZERO, Decimal::ZERO)]
598    #[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
599    fn test_parse_betfair_quantity_uses_betfair_precision(
600        #[case] input: Decimal,
601        #[case] expected: Decimal,
602    ) {
603        let quantity = parse_betfair_quantity(input).unwrap();
604
605        assert_eq!(quantity.as_decimal(), expected);
606        assert_eq!(quantity.precision, BETFAIR_QUANTITY_PRECISION);
607    }
608
609    #[rstest]
610    #[case(Decimal::new(-1, 0))]
611    #[case(Decimal::new(-1, 2))]
612    fn test_parse_betfair_quantity_rejects_negative(#[case] input: Decimal) {
613        let result = parse_betfair_quantity(input);
614
615        assert!(result.is_err());
616    }
617
618    #[rstest]
619    #[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
620    #[case(Decimal::new(2555, 3), Decimal::new(256, 2))]
621    fn test_normalize_betfair_price_rounds_to_betfair_precision(
622        #[case] input: Decimal,
623        #[case] expected: Decimal,
624    ) {
625        let normalized = normalize_betfair_price(input);
626
627        assert_eq!(normalized, expected);
628    }
629
630    #[rstest]
631    #[case(Decimal::new(4_287_000_000_000_001, 14), Decimal::new(4287, 2))]
632    #[case(Decimal::new(2555, 3), Decimal::new(256, 2))]
633    fn test_normalize_betfair_quantity_rounds_to_betfair_precision(
634        #[case] input: Decimal,
635        #[case] expected: Decimal,
636    ) {
637        let normalized = normalize_betfair_quantity(input);
638
639        assert_eq!(normalized, expected);
640    }
641
642    #[rstest]
643    fn test_parse_market_catalogue() {
644        let data = load_test_json("rest/list_market_catalogue.json");
645        let catalogue: MarketCatalogue = serde_json::from_str(&data).unwrap();
646        let instruments =
647            parse_market_catalogue(&catalogue, Currency::GBP(), UnixNanos::default(), None)
648                .unwrap();
649
650        assert_eq!(instruments.len(), 3);
651
652        // Verify first instrument
653        if let InstrumentAny::Betting(inst) = &instruments[0] {
654            assert_eq!(inst.market_id.as_str(), "1.221718403");
655            assert_eq!(inst.selection_id, 20075720);
656            assert_eq!(inst.selection_name.as_str(), "1. Searover");
657            assert_eq!(inst.event_type_name.as_str(), "Horse Racing");
658            assert_eq!(inst.event_name.as_str(), "Globe Derby (AUS) 27th Nov");
659            assert_eq!(inst.event_country_code.as_str(), "AU");
660            assert_eq!(inst.market_type.as_str(), "WIN");
661            assert_eq!(inst.betting_type.as_str(), "ODDS");
662            assert_eq!(inst.price_precision, 2);
663            assert_eq!(inst.size_precision, 2);
664            assert_eq!(inst.currency, Currency::GBP());
665        } else {
666            panic!("expected BettingInstrument");
667        }
668    }
669
670    #[rstest]
671    fn test_parse_market_catalogue_batch() {
672        let data = load_test_json("rest/betting_list_market_catalogue.json");
673        let catalogues: Vec<MarketCatalogue> = serde_json::from_str(&data).unwrap();
674
675        let mut total = 0;
676
677        for cat in &catalogues {
678            let instruments =
679                parse_market_catalogue(cat, Currency::GBP(), UnixNanos::default(), None).unwrap();
680            total += instruments.len();
681        }
682        assert!(total > 0);
683    }
684
685    #[rstest]
686    fn test_parse_market_definition_from_stream() {
687        let data = load_test_json("stream/mcm_SUB_IMAGE.json");
688        let msg: StreamMessage = serde_json::from_str(&data).unwrap();
689
690        if let StreamMessage::MarketChange(mcm) = msg {
691            let mc = mcm.mc.as_ref().expect("market changes");
692            let change = &mc[0];
693            let ts_event = parse_millis_timestamp(mcm.pt);
694            let ts_init = UnixNanos::from(1_800_000_000_000_000_001);
695
696            let def = change
697                .market_definition
698                .as_ref()
699                .expect("market definition");
700
701            let instruments =
702                parse_market_definition(&change.id, def, Currency::GBP(), ts_event, ts_init, None)
703                    .unwrap();
704
705            assert_eq!(instruments.len(), 7);
706
707            if let InstrumentAny::Betting(inst) = &instruments[0] {
708                assert_eq!(inst.market_id.as_str(), "1.180737206");
709                assert_eq!(inst.market_type.as_str(), "WIN");
710                assert_eq!(inst.ts_event, ts_event);
711                assert_eq!(inst.ts_init, ts_init);
712            } else {
713                panic!("expected BettingInstrument");
714            }
715        } else {
716            panic!("expected MarketChange message");
717        }
718    }
719
720    #[rstest]
721    fn test_parse_account_state() {
722        let funds = AccountFundsResponse {
723            available_to_bet_balance: Some("1000.0000000000000001".parse().unwrap()),
724            exposure: Some("-100.0000000000000002".parse().unwrap()),
725            retained_commission: Some("3.0000000000000003".parse().unwrap()),
726            exposure_limit: Some("-15000.0000000000000004".parse().unwrap()),
727            discount_rate: Some("5.0000000000000005".parse().unwrap()),
728            points_balance: Some(10),
729            wallet: Some(Ustr::from("UK")),
730        };
731
732        let state = parse_account_state(
733            &funds,
734            AccountId::from("BETFAIR-001"),
735            Currency::GBP(),
736            UnixNanos::default(),
737            UnixNanos::default(),
738        )
739        .unwrap();
740
741        assert_eq!(state.account_type, AccountType::Betting);
742        assert_eq!(state.balances.len(), 1);
743        assert!(state.is_reported);
744        assert_eq!(state.base_currency, Some(Currency::GBP()));
745        let info = state.info.as_ref().unwrap();
746        assert_eq!(info.len(), 7);
747        assert_eq!(
748            info.get_str("available_to_bet_balance"),
749            Some("1000.0000000000000001")
750        );
751        assert_eq!(info.get_str("exposure"), Some("-100.0000000000000002"));
752        assert_eq!(
753            info.get_str("retained_commission"),
754            Some("3.0000000000000003")
755        );
756        assert_eq!(
757            info.get_str("exposure_limit"),
758            Some("-15000.0000000000000004")
759        );
760        assert_eq!(info.get_str("discount_rate"), Some("5.0000000000000005"));
761        assert_eq!(info.get_i64("points_balance"), Some(10));
762        assert_eq!(info.get_str("wallet"), Some("UK"));
763    }
764
765    #[rstest]
766    fn test_extract_market_id_no_handicap() {
767        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
768        let market_id = extract_market_id(&instrument_id).unwrap();
769        assert_eq!(market_id, "1.180737206");
770    }
771
772    #[rstest]
773    fn test_extract_market_id_with_handicap() {
774        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
775        let market_id = extract_market_id(&instrument_id).unwrap();
776        assert_eq!(market_id, "1.180737206");
777    }
778
779    #[rstest]
780    fn test_extract_selection_id_no_handicap() {
781        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
782        let (selection_id, handicap) = extract_selection_id(&instrument_id).unwrap();
783        assert_eq!(selection_id, 19248890);
784        assert_eq!(handicap, Decimal::ZERO);
785    }
786
787    #[rstest]
788    fn test_extract_selection_id_with_handicap() {
789        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
790        let (selection_id, handicap) = extract_selection_id(&instrument_id).unwrap();
791        assert_eq!(selection_id, 19248890);
792        assert_eq!(handicap, Decimal::new(15, 1));
793    }
794
795    #[rstest]
796    fn test_make_customer_order_ref_short_id() {
797        let result = make_customer_order_ref("O-20240101-001");
798        assert_eq!(result, "O-20240101-001");
799    }
800
801    #[rstest]
802    fn test_make_customer_order_ref_exactly_32_chars() {
803        let id = "12345678901234567890123456789012";
804        assert_eq!(id.len(), 32);
805        let result = make_customer_order_ref(id);
806        assert_eq!(result, id);
807    }
808
809    #[rstest]
810    fn test_make_customer_order_ref_truncates_to_last_32() {
811        // UUID-style ID longer than 32 chars
812        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
813        assert!(id.len() > 32);
814        let result = make_customer_order_ref(id);
815        assert_eq!(result.len(), 32);
816        // Should keep the last 32 characters (high-entropy UUID tail)
817        assert_eq!(result, &id[id.len() - 32..]);
818    }
819
820    #[rstest]
821    fn test_make_customer_order_ref_legacy_short_id() {
822        let result = make_customer_order_ref_legacy("O-20240101-001");
823        assert_eq!(result, "O-20240101-001");
824    }
825
826    #[rstest]
827    fn test_make_customer_order_ref_legacy_truncates_to_first_32() {
828        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
829        assert!(id.len() > 32);
830        let result = make_customer_order_ref_legacy(id);
831        assert_eq!(result.len(), 32);
832        assert_eq!(result, &id[..32]);
833    }
834
835    #[rstest]
836    fn test_legacy_and_current_differ_for_long_ids() {
837        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
838        let current = make_customer_order_ref(id);
839        let legacy = make_customer_order_ref_legacy(id);
840        assert_ne!(current, legacy);
841    }
842
843    #[rstest]
844    fn test_legacy_and_current_same_for_short_ids() {
845        let id = "O-20240101-001";
846        let current = make_customer_order_ref(id);
847        let legacy = make_customer_order_ref_legacy(id);
848        assert_eq!(current, legacy);
849    }
850}