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