1use 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#[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#[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
66pub fn parse_betfair_timestamp(s: &str) -> anyhow::Result<UnixNanos> {
75 let dt = s
76 .parse::<Timestamp>()
77 .or_else(|_| {
78 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#[must_use]
89pub fn parse_millis_timestamp(timestamp_ms: u64) -> UnixNanos {
90 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
91}
92
93pub 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#[must_use]
104pub fn normalize_betfair_price(price: Decimal) -> Decimal {
105 parse_betfair_price(price).map_or(price, |price| price.as_decimal())
106}
107
108pub 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#[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#[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#[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
151pub 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); 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(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
283pub 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); 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
404pub 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
465pub 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
483pub 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 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 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 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}