1use anyhow::Context;
19use chrono::DateTime;
20use nautilus_core::{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> {
79 let dt = DateTime::parse_from_rfc3339(s)
80 .or_else(|_| {
81 DateTime::parse_from_rfc3339(&s.replace(".000Z", "Z"))
83 })
84 .with_context(|| format!("invalid Betfair timestamp: {s}"))?;
85 Ok(UnixNanos::from(dt.timestamp_nanos_opt().unwrap() as u64))
86}
87
88#[must_use]
90pub fn parse_millis_timestamp(timestamp_ms: u64) -> UnixNanos {
91 UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
92}
93
94#[must_use]
99pub fn make_customer_order_ref(client_order_id: &str) -> String {
100 let len = client_order_id.len();
101 if len <= BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN {
102 client_order_id.to_string()
103 } else {
104 client_order_id[len - BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN..].to_string()
105 }
106}
107
108#[must_use]
113pub fn make_customer_order_ref_legacy(client_order_id: &str) -> String {
114 let len = client_order_id.len();
115 if len <= BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN {
116 client_order_id.to_string()
117 } else {
118 client_order_id[..BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN].to_string()
119 }
120}
121
122pub fn parse_market_catalogue(
130 catalogue: &MarketCatalogue,
131 currency: Currency,
132 ts_init: UnixNanos,
133 min_notional: Option<Money>,
134) -> anyhow::Result<Vec<InstrumentAny>> {
135 let runners = catalogue
136 .runners
137 .as_ref()
138 .context("MarketCatalogue missing runners")?;
139
140 let market_id = &catalogue.market_id;
141
142 let (event_type_id, event_type_name) = match &catalogue.event_type {
143 Some(et) => (
144 et.id
145 .as_ref()
146 .and_then(|id| id.parse::<u64>().ok())
147 .unwrap_or(0),
148 Ustr::from(et.name.as_deref().unwrap_or("")),
149 ),
150 None => (0, Ustr::from("")),
151 };
152
153 let (competition_id, competition_name) = match &catalogue.competition {
154 Some(c) => (
155 c.id.as_ref()
156 .and_then(|id| id.parse::<u64>().ok())
157 .unwrap_or(0),
158 Ustr::from(c.name.as_deref().unwrap_or("")),
159 ),
160 None => (0, Ustr::from("")),
161 };
162
163 let (event_id, event_name, event_country_code, event_open_date) = match &catalogue.event {
164 Some(e) => {
165 let eid =
166 e.id.as_ref()
167 .and_then(|id| id.parse::<u64>().ok())
168 .unwrap_or(0);
169 let ename = Ustr::from(e.name.as_deref().unwrap_or(""));
170 let cc = e.country_code.unwrap_or_else(|| Ustr::from(""));
171 let open_date = e
172 .open_date
173 .as_deref()
174 .and_then(|d| parse_betfair_timestamp(d).ok())
175 .unwrap_or_default();
176 (eid, ename, cc, open_date)
177 }
178 None => (0, Ustr::from(""), Ustr::from(""), UnixNanos::default()),
179 };
180
181 let (betting_type, market_type, market_base_rate) = match &catalogue.description {
182 Some(desc) => (
183 Ustr::from(&format!("{}", desc.betting_type)),
184 desc.market_type,
185 desc.market_base_rate,
186 ),
187 None => (
188 Ustr::from(DEFAULT_BETTING_TYPE),
189 Ustr::from(DEFAULT_MARKET_TYPE),
190 Decimal::ZERO,
191 ),
192 };
193
194 let market_name = Ustr::from(&catalogue.market_name);
195 let market_start_time = catalogue
196 .market_start_time
197 .as_deref()
198 .and_then(|t| parse_betfair_timestamp(t).ok())
199 .unwrap_or_default();
200
201 let fee_rate = market_base_rate / Decimal::ONE_HUNDRED;
203
204 let tick = Decimal::new(1, 2); let price_increment = Price::from_decimal_dp(tick, BETFAIR_PRICE_PRECISION)?;
206 let size_increment = Quantity::from_decimal_dp(tick, BETFAIR_QUANTITY_PRECISION)?;
207
208 let mut instruments = Vec::with_capacity(runners.len());
209
210 for runner in runners {
211 let handicap = runner.handicap;
212 let instrument_id = make_instrument_id(market_id, runner.selection_id, handicap);
213 let raw_symbol = make_symbol(market_id, runner.selection_id, handicap);
214
215 let instrument = BettingInstrument::new_checked(
216 instrument_id,
217 raw_symbol,
218 event_type_id,
219 event_type_name,
220 competition_id,
221 competition_name,
222 event_id,
223 event_name,
224 event_country_code,
225 event_open_date,
226 betting_type,
227 Ustr::from(market_id.as_str()),
228 market_name,
229 market_type,
230 market_start_time,
231 runner.selection_id,
232 Ustr::from(&runner.runner_name),
233 handicap.to_f64().unwrap_or(0.0),
234 currency,
235 BETFAIR_PRICE_PRECISION,
236 BETFAIR_QUANTITY_PRECISION,
237 price_increment,
238 size_increment,
239 None, None, None, min_notional, None, None, Some(Decimal::ONE), Some(Decimal::ONE), Some(fee_rate), Some(fee_rate), None, ts_init, ts_init, )
253 .with_context(|| {
254 format!(
255 "failed to create BettingInstrument for {market_id}/{}/{}",
256 runner.selection_id, runner.runner_name
257 )
258 })?;
259
260 instruments.push(InstrumentAny::Betting(instrument));
261 }
262
263 Ok(instruments)
264}
265
266pub fn parse_market_definition(
276 market_id: &str,
277 def: &MarketDefinition,
278 currency: Currency,
279 ts_init: UnixNanos,
280 min_notional: Option<Money>,
281) -> anyhow::Result<Vec<InstrumentAny>> {
282 let runners = def
283 .runners
284 .as_ref()
285 .context("MarketDefinition missing runners")?;
286
287 let event_type_id = def
288 .event_type_id
289 .as_deref()
290 .and_then(|id| id.parse::<u64>().ok())
291 .unwrap_or(0);
292 let event_type_name = def.event_type_name.unwrap_or_else(|| Ustr::from(""));
293
294 let competition_id = def
295 .competition_id
296 .as_deref()
297 .and_then(|id| id.parse::<u64>().ok())
298 .unwrap_or(0);
299 let competition_name = Ustr::from(def.competition_name.as_deref().unwrap_or(""));
300
301 let event_id = def
302 .event_id
303 .as_deref()
304 .and_then(|id| id.parse::<u64>().ok())
305 .unwrap_or(0);
306 let event_name = Ustr::from(def.event_name.as_deref().unwrap_or(""));
307 let event_country_code = def.country_code.unwrap_or_else(|| Ustr::from(""));
308 let event_open_date = def
309 .open_date
310 .as_deref()
311 .and_then(|d| parse_betfair_timestamp(d).ok())
312 .unwrap_or_default();
313
314 let betting_type = match &def.betting_type {
315 Some(bt) => Ustr::from(&format!("{bt}")),
316 None => Ustr::from(DEFAULT_BETTING_TYPE),
317 };
318 let market_name = Ustr::from(def.market_name.as_deref().unwrap_or(""));
319 let market_type = def
320 .market_type
321 .unwrap_or_else(|| Ustr::from(DEFAULT_MARKET_TYPE));
322 let market_start_time = def
323 .market_time
324 .as_deref()
325 .and_then(|t| parse_betfair_timestamp(t).ok())
326 .unwrap_or_default();
327
328 let fee_rate = def
329 .market_base_rate
330 .map(|r| r / Decimal::ONE_HUNDRED)
331 .unwrap_or_default();
332
333 let tick = Decimal::new(1, 2); let price_increment = Price::from_decimal_dp(tick, BETFAIR_PRICE_PRECISION)?;
335 let size_increment = Quantity::from_decimal_dp(tick, BETFAIR_QUANTITY_PRECISION)?;
336
337 let market_id_ustr = Ustr::from(market_id);
338
339 let mut instruments = Vec::with_capacity(runners.len());
340
341 for runner in runners {
342 let handicap = runner.hc.unwrap_or(Decimal::ZERO);
343
344 let instrument_id = make_instrument_id(market_id, runner.id, handicap);
345 let raw_symbol = make_symbol(market_id, runner.id, handicap);
346 let runner_name = Ustr::from(runner.name.as_deref().unwrap_or(""));
347
348 let instrument = BettingInstrument::new_checked(
349 instrument_id,
350 raw_symbol,
351 event_type_id,
352 event_type_name,
353 competition_id,
354 competition_name,
355 event_id,
356 event_name,
357 event_country_code,
358 event_open_date,
359 betting_type,
360 market_id_ustr,
361 market_name,
362 market_type,
363 market_start_time,
364 runner.id,
365 runner_name,
366 handicap.to_f64().unwrap_or(0.0),
367 currency,
368 BETFAIR_PRICE_PRECISION,
369 BETFAIR_QUANTITY_PRECISION,
370 price_increment,
371 size_increment,
372 None, None, None, min_notional, None, None, Some(Decimal::ONE), Some(Decimal::ONE), Some(fee_rate), Some(fee_rate), None, ts_init, ts_init, )
386 .with_context(|| {
387 format!(
388 "failed to create BettingInstrument for {market_id}/{}",
389 runner.id
390 )
391 })?;
392
393 instruments.push(InstrumentAny::Betting(instrument));
394 }
395
396 Ok(instruments)
397}
398
399pub fn parse_account_state(
405 funds: &AccountFundsResponse,
406 account_id: AccountId,
407 currency: Currency,
408 ts_event: UnixNanos,
409 ts_init: UnixNanos,
410) -> anyhow::Result<AccountState> {
411 let available = funds.available_to_bet_balance.unwrap_or_default();
412 let exposure = funds.exposure.unwrap_or_default().abs();
413 let total = available + exposure;
414
415 let balance = AccountBalance::from_total_and_locked(total, exposure, currency)?;
416
417 Ok(AccountState::new(
418 account_id,
419 AccountType::Betting,
420 vec![balance],
421 vec![],
422 true,
423 UUID4::new(),
424 ts_event,
425 ts_init,
426 Some(currency),
427 ))
428}
429
430pub fn extract_market_id(instrument_id: &InstrumentId) -> anyhow::Result<String> {
439 let symbol = instrument_id.symbol.as_str();
440 let parts: Vec<&str> = symbol.splitn(3, '-').collect();
441 if parts.len() >= 2 {
442 Ok(parts[0].to_string())
443 } else {
444 anyhow::bail!("Cannot extract market ID from {instrument_id}")
445 }
446}
447
448pub fn extract_selection_id(
454 instrument_id: &InstrumentId,
455) -> anyhow::Result<(SelectionId, Decimal)> {
456 let symbol = instrument_id.symbol.as_str();
457 let parts: Vec<&str> = symbol.splitn(3, '-').collect();
458 if parts.len() < 2 {
459 anyhow::bail!("Cannot extract selection ID from {instrument_id}");
460 }
461
462 let selection_id: SelectionId = parts[1]
463 .parse()
464 .with_context(|| format!("invalid selection ID in {instrument_id}"))?;
465
466 let handicap = if parts.len() == 3 {
467 parts[2]
468 .parse::<Decimal>()
469 .with_context(|| format!("invalid handicap in {instrument_id}"))?
470 } else {
471 Decimal::ZERO
472 };
473
474 Ok((selection_id, handicap))
475}
476
477#[cfg(test)]
478mod tests {
479 use rstest::rstest;
480
481 use super::*;
482 use crate::{common::testing::load_test_json, stream::messages::StreamMessage};
483
484 #[rstest]
485 fn test_make_instrument_id_no_handicap() {
486 let id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
487 assert_eq!(id.to_string(), "1.180737206-19248890.BETFAIR");
488 }
489
490 #[rstest]
491 fn test_make_instrument_id_with_handicap() {
492 let id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
493 assert_eq!(id.to_string(), "1.180737206-19248890-1.5.BETFAIR");
494 }
495
496 #[rstest]
497 fn test_make_symbol_no_handicap() {
498 let sym = make_symbol("1.180737206", 19248890, Decimal::ZERO);
499 assert_eq!(sym.to_string(), "1.180737206-19248890");
500 }
501
502 #[rstest]
503 fn test_make_symbol_with_handicap() {
504 let sym = make_symbol("1.180737206", 19248890, Decimal::new(-5, 1));
505 assert_eq!(sym.to_string(), "1.180737206-19248890--0.5");
506 }
507
508 #[rstest]
509 fn test_parse_betfair_timestamp_utc() {
510 let ts = parse_betfair_timestamp("2023-11-27T05:43:00Z").unwrap();
511 assert!(ts.as_u64() > 0);
512 }
513
514 #[rstest]
515 fn test_parse_betfair_timestamp_with_offset() {
516 let ts = parse_betfair_timestamp("2021-03-19T12:07:00+10:00").unwrap();
517 assert!(ts.as_u64() > 0);
518 }
519
520 #[rstest]
521 fn test_parse_betfair_timestamp_with_millis() {
522 let ts = parse_betfair_timestamp("2021-03-19T08:50:00.000Z").unwrap();
523 assert!(ts.as_u64() > 0);
524 }
525
526 #[rstest]
527 fn test_parse_millis_timestamp() {
528 let ts = parse_millis_timestamp(1_471_370_159_007);
529 assert_eq!(ts.as_u64(), 1_471_370_159_007 * 1_000_000);
530 }
531
532 #[rstest]
533 fn test_parse_market_catalogue() {
534 let data = load_test_json("rest/list_market_catalogue.json");
535 let catalogue: MarketCatalogue = serde_json::from_str(&data).unwrap();
536 let instruments =
537 parse_market_catalogue(&catalogue, Currency::GBP(), UnixNanos::default(), None)
538 .unwrap();
539
540 assert_eq!(instruments.len(), 3);
541
542 if let InstrumentAny::Betting(inst) = &instruments[0] {
544 assert_eq!(inst.market_id.as_str(), "1.221718403");
545 assert_eq!(inst.selection_id, 20075720);
546 assert_eq!(inst.selection_name.as_str(), "1. Searover");
547 assert_eq!(inst.event_type_name.as_str(), "Horse Racing");
548 assert_eq!(inst.event_name.as_str(), "Globe Derby (AUS) 27th Nov");
549 assert_eq!(inst.event_country_code.as_str(), "AU");
550 assert_eq!(inst.market_type.as_str(), "WIN");
551 assert_eq!(inst.betting_type.as_str(), "ODDS");
552 assert_eq!(inst.price_precision, 2);
553 assert_eq!(inst.size_precision, 2);
554 assert_eq!(inst.currency, Currency::GBP());
555 } else {
556 panic!("expected BettingInstrument");
557 }
558 }
559
560 #[rstest]
561 fn test_parse_market_catalogue_batch() {
562 let data = load_test_json("rest/betting_list_market_catalogue.json");
563 let catalogues: Vec<MarketCatalogue> = serde_json::from_str(&data).unwrap();
564
565 let mut total = 0;
566
567 for cat in &catalogues {
568 let instruments =
569 parse_market_catalogue(cat, Currency::GBP(), UnixNanos::default(), None).unwrap();
570 total += instruments.len();
571 }
572 assert!(total > 0);
573 }
574
575 #[rstest]
576 fn test_parse_market_definition_from_stream() {
577 let data = load_test_json("stream/mcm_SUB_IMAGE.json");
578 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
579
580 if let StreamMessage::MarketChange(mcm) = msg {
581 let mc = mcm.mc.as_ref().expect("market changes");
582 let change = &mc[0];
583 let def = change
584 .market_definition
585 .as_ref()
586 .expect("market definition");
587
588 let instruments = parse_market_definition(
589 &change.id,
590 def,
591 Currency::GBP(),
592 parse_millis_timestamp(mcm.pt),
593 None,
594 )
595 .unwrap();
596
597 assert_eq!(instruments.len(), 7);
598
599 if let InstrumentAny::Betting(inst) = &instruments[0] {
600 assert_eq!(inst.market_id.as_str(), "1.180737206");
601 assert_eq!(inst.market_type.as_str(), "WIN");
602 } else {
603 panic!("expected BettingInstrument");
604 }
605 } else {
606 panic!("expected MarketChange message");
607 }
608 }
609
610 #[rstest]
611 fn test_parse_account_state() {
612 let data = load_test_json("rest/account_funds_with_exposure.json");
613 let funds: AccountFundsResponse = serde_json::from_str(&data).unwrap();
614
615 let state = parse_account_state(
616 &funds,
617 AccountId::from("BETFAIR-001"),
618 Currency::GBP(),
619 UnixNanos::default(),
620 UnixNanos::default(),
621 )
622 .unwrap();
623
624 assert_eq!(state.account_type, AccountType::Betting);
625 assert_eq!(state.balances.len(), 1);
626 assert!(state.is_reported);
627 assert_eq!(state.base_currency, Some(Currency::GBP()));
628 }
629
630 #[rstest]
631 fn test_extract_market_id_no_handicap() {
632 let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
633 let market_id = extract_market_id(&instrument_id).unwrap();
634 assert_eq!(market_id, "1.180737206");
635 }
636
637 #[rstest]
638 fn test_extract_market_id_with_handicap() {
639 let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
640 let market_id = extract_market_id(&instrument_id).unwrap();
641 assert_eq!(market_id, "1.180737206");
642 }
643
644 #[rstest]
645 fn test_extract_selection_id_no_handicap() {
646 let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
647 let (selection_id, handicap) = extract_selection_id(&instrument_id).unwrap();
648 assert_eq!(selection_id, 19248890);
649 assert_eq!(handicap, Decimal::ZERO);
650 }
651
652 #[rstest]
653 fn test_extract_selection_id_with_handicap() {
654 let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
655 let (selection_id, handicap) = extract_selection_id(&instrument_id).unwrap();
656 assert_eq!(selection_id, 19248890);
657 assert_eq!(handicap, Decimal::new(15, 1));
658 }
659
660 #[rstest]
661 fn test_make_customer_order_ref_short_id() {
662 let result = make_customer_order_ref("O-20240101-001");
663 assert_eq!(result, "O-20240101-001");
664 }
665
666 #[rstest]
667 fn test_make_customer_order_ref_exactly_32_chars() {
668 let id = "12345678901234567890123456789012";
669 assert_eq!(id.len(), 32);
670 let result = make_customer_order_ref(id);
671 assert_eq!(result, id);
672 }
673
674 #[rstest]
675 fn test_make_customer_order_ref_truncates_to_last_32() {
676 let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
678 assert!(id.len() > 32);
679 let result = make_customer_order_ref(id);
680 assert_eq!(result.len(), 32);
681 assert_eq!(result, &id[id.len() - 32..]);
683 }
684
685 #[rstest]
686 fn test_make_customer_order_ref_legacy_short_id() {
687 let result = make_customer_order_ref_legacy("O-20240101-001");
688 assert_eq!(result, "O-20240101-001");
689 }
690
691 #[rstest]
692 fn test_make_customer_order_ref_legacy_truncates_to_first_32() {
693 let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
694 assert!(id.len() > 32);
695 let result = make_customer_order_ref_legacy(id);
696 assert_eq!(result.len(), 32);
697 assert_eq!(result, &id[..32]);
698 }
699
700 #[rstest]
701 fn test_legacy_and_current_differ_for_long_ids() {
702 let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
703 let current = make_customer_order_ref(id);
704 let legacy = make_customer_order_ref_legacy(id);
705 assert_ne!(current, legacy);
706 }
707
708 #[rstest]
709 fn test_legacy_and_current_same_for_short_ids() {
710 let id = "O-20240101-001";
711 let current = make_customer_order_ref(id);
712 let legacy = make_customer_order_ref_legacy(id);
713 assert_eq!(current, legacy);
714 }
715}