1use 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#[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#[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
67pub fn parse_betfair_timestamp(s: &str) -> anyhow::Result<UnixNanos> {
76 let dt = s
77 .parse::<Timestamp>()
78 .or_else(|_| {
79 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#[must_use]
90pub fn parse_millis_timestamp(timestamp_ms: u64) -> UnixNanos {
91 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
92}
93
94pub 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#[must_use]
105pub fn normalize_betfair_price(price: Decimal) -> Decimal {
106 parse_betfair_price(price).map_or(price, |price| price.as_decimal())
107}
108
109pub 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#[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#[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#[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
152pub 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); 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(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
284pub 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); 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
405pub 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
472pub 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
490pub 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 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 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 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}