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