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