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