1use crate::division::{DivisionId, NamedDivision};
4use crate::league::{LeagueId, NamedLeague};
5use crate::meta::StandingsType;
6use crate::request::{RequestURL, RequestURLBuilderExt};
7use crate::season::SeasonId;
8use crate::sport::SportId;
9use crate::stats::ThreeDecimalPlaceRateStat;
10use crate::team::NamedTeam;
11use crate::Copyright;
12use bon::Builder;
13use chrono::{NaiveDate, NaiveDateTime};
14use derive_more::{Add, AddAssign, Deref, DerefMut, Display};
15use itertools::Itertools;
16use serde::Deserialize;
17use std::cmp::Ordering;
18use std::fmt::{Debug, Display, Formatter};
19use std::marker::PhantomData;
20use std::str::FromStr;
21use serde::de::DeserializeOwned;
22use crate::hydrations::Hydrations;
23use crate::types::MLB_API_DATE_FORMAT;
24
25#[derive(Debug, Deserialize, PartialEq, Clone)]
29#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
30pub struct StandingsResponse<H: StandingsHydrations> {
31 pub copyright: Copyright,
32 #[serde(rename = "records")]
33 pub divisions: Vec<DivisionalStandings<H>>
34}
35
36#[derive(Debug, Deserialize, PartialEq, Clone)]
38#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
39pub struct DivisionalStandings<H: StandingsHydrations> {
40 pub standings_type: StandingsType,
41 #[serde(rename = "league")]
42 pub league_id: H::League,
43 #[serde(rename = "division")]
44 pub division_id: H::Division,
45 #[serde(rename = "sport")]
46 pub sport_id: H::Sport,
47 #[serde(deserialize_with = "crate::deserialize_datetime")]
48 pub last_updated: NaiveDateTime,
49 pub team_records: Vec<TeamRecord<H>>,
50}
51
52#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
54#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
55pub struct TeamRecord<H: StandingsHydrations> {
56 pub team: H::Team,
57 pub season: SeasonId,
58 pub games_played: usize,
59 pub runs_allowed: usize,
60 pub runs_scored: usize,
61 #[serde(rename = "divisionChamp")]
62 pub is_divisional_champion: bool,
63 #[serde(rename = "divisionLeader")]
64 pub is_divisional_leader: bool,
65 pub has_wildcard: bool,
66 #[serde(deserialize_with = "crate::deserialize_datetime")]
67 pub last_updated: NaiveDateTime,
68 pub streak: Streak,
69 #[serde(rename = "records")]
70 pub splits: RecordSplits,
71
72 #[serde(rename = "clinchIndicator", default)]
73 pub clinch_kind: ClinchKind,
74 pub games_back: GamesBack,
75 pub wild_card_games_back: GamesBack,
76 pub league_games_back: GamesBack,
77 #[serde(rename = "springLeagueGamesBack")]
78 pub spring_training_games_back: GamesBack,
79 pub sport_games_back: GamesBack,
80 pub division_games_back: GamesBack,
81 pub conference_games_back: GamesBack,
82 #[deref]
83 #[deref_mut]
84 #[serde(rename = "leagueRecord")]
85 pub record: Record,
86
87 #[serde(rename = "divisionRank", deserialize_with = "crate::try_from_str", default)]
88 pub divisional_rank: Option<usize>,
89 #[serde(deserialize_with = "crate::try_from_str", default)]
90 pub league_rank: Option<usize>,
91 #[serde(deserialize_with = "crate::try_from_str", default)]
92 pub sport_rank: Option<usize>,
93}
94
95impl<H: StandingsHydrations> TeamRecord<H> {
96 #[must_use]
98 pub fn expected_win_loss_pct(&self) -> ThreeDecimalPlaceRateStat {
99 const EXPONENT: f64 = 1.815;
105
106 let exponentified_runs_scored: f64 = (self.runs_scored as f64).powf(EXPONENT);
107 let exponentified_runs_allowed: f64 = (self.runs_allowed as f64).powf(EXPONENT);
108
109 (exponentified_runs_scored / (exponentified_runs_scored + exponentified_runs_allowed)).into()
110 }
111
112 #[must_use]
116 pub fn expected_end_of_season_record(&self) -> Record {
117 self.expected_end_of_season_record_with_total_games(162)
118 }
119
120 #[must_use]
122 pub fn expected_end_of_season_record_with_total_games(&self, total_games: usize) -> Record {
123 let expected_pct: f64 = self.expected_win_loss_pct().into();
124 let remaining_games = total_games.saturating_sub(self.record.games_played());
125 let wins = (remaining_games as f64 * expected_pct).round() as usize;
126 let losses = remaining_games - wins;
127
128 self.record + Record { wins, losses }
129 }
130
131 #[must_use]
133 pub const fn run_differential(&self) -> isize {
134 self.runs_scored as isize - self.runs_allowed as isize
135 }
136}
137
138#[derive(Debug, Deserialize, PartialEq, Clone)]
140pub struct RecordSplits {
141 #[serde(rename = "splitRecords", default)]
142 pub record_splits: Vec<RecordSplit>,
143 #[serde(rename = "divisionRecords", default)]
144 pub divisional_record_splits: Vec<DivisionalRecordSplit>,
145 #[serde(rename = "leagueRecords", default)]
146 pub league_record_splits: Vec<LeagueRecordSplit>,
147 #[serde(rename = "overallRecords", default)]
148 pub basic_record_splits: Vec<RecordSplit>,
149 #[serde(rename = "expectedRecords", default)]
150 pub expected_record_splits: Vec<RecordSplit>,
151}
152
153#[repr(u8)]
157#[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Default)]
158pub enum ClinchKind {
159 #[serde(rename = "z")]
161 Bye = 4,
162
163 #[serde(rename = "y")]
165 Divisional = 3,
166
167 #[serde(rename = "w")]
169 WildCard = 2,
170
171 #[serde(rename = "x")]
173 Postseason = 1,
174
175 #[default]
177 #[serde(skip)]
178 None = 0,
179
180 }
184
185impl ClinchKind {
186 #[must_use]
198 pub const fn clinched_postseason(self) -> bool {
199 self as u8 >= Self::Postseason as u8
200 }
201
202 #[must_use]
214 pub const fn is_final(self) -> bool {
215 self as u8 >= Self::WildCard as u8
216 }
217
218 #[must_use]
232 pub const fn guaranteed_in_wildcard(self) -> bool {
233 matches!(self, Self::WildCard | Self::Divisional)
234 }
235
236 #[must_use]
249 pub const fn potentially_in_wildcard(self) -> bool {
250 matches!(self, Self::WildCard | Self::Divisional | Self::Postseason | Self::None)
251 }
252}
253
254#[derive(Deserialize, PartialEq, Eq, Clone)]
255#[serde(try_from = "&str")]
256pub struct GamesBack {
257 games: isize,
263
264 half: bool,
266}
267
268impl PartialOrd for GamesBack {
269 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
270 Some(self.cmp(other))
271 }
272}
273
274impl Ord for GamesBack {
275 fn cmp(&self, other: &Self) -> Ordering {
276 self.games.cmp(&other.games).then_with(|| self.half.cmp(&other.half))
277 }
278}
279
280impl Display for GamesBack {
281 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
282 if self.games > 0 {
283 write!(f, "+")?;
284 }
285
286 if self.games != 0 {
287 write!(f, "{}", self.games.abs())?;
288 } else {
289 write!(f, "-")?;
290 }
291
292 write!(f, ".{c}", c = if self.half { '5' } else { '0' })?;
293
294 Ok(())
295 }
296}
297
298impl Debug for GamesBack {
299 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
300 <Self as Display>::fmt(self, f)
301 }
302}
303
304impl<'a> TryFrom<&'a str> for GamesBack {
305 type Error = <Self as FromStr>::Err;
306
307 fn try_from(value: &'a str) -> Result<Self, Self::Error> {
308 <Self as FromStr>::from_str(value)
309 }
310}
311
312impl FromStr for GamesBack {
313 type Err = &'static str;
314
315 fn from_str(mut s: &str) -> Result<Self, Self::Err> {
316 if s == "-" { return Ok(Self { games: 0, half: false }) }
317
318 let sign: isize = s.strip_prefix("+").map_or(-1, |s2| {
319 s = s2;
320 1
321 });
322
323 let (games, half) = s.split_once('.').unwrap_or((s, ""));
324 let games = games.parse::<usize>().map_err(|_| "invalid game quantity")?;
325 let half = half == "5";
326
327 Ok(Self {
328 games: games as isize * sign,
329 half,
330 })
331 }
332}
333
334#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Add, AddAssign)]
335pub struct Record {
336 wins: usize,
337 losses: usize,
338}
339
340impl Record {
341 #[must_use]
343 pub fn pct(self) -> ThreeDecimalPlaceRateStat {
344 (self.wins as f64 / self.games_played() as f64).into()
345 }
346
347 #[must_use]
349 pub const fn games_played(self) -> usize {
350 self.wins + self.losses
351 }
352}
353
354#[derive(Debug, Deserialize, PartialEq, Copy, Clone)]
356pub struct Streak {
357 #[serde(rename = "streakNumber")]
358 pub quantity: usize,
359 #[serde(rename = "streakType")]
360 pub kind: StreakKind,
361}
362
363impl Display for Streak {
364 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
365 write!(f, "{}{}", self.kind, self.quantity)
366 }
367}
368
369#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
371pub enum StreakKind {
372 #[serde(rename = "wins")]
374 #[display("W")]
375 Win,
376 #[serde(rename = "losses")]
378 #[display("L")]
379 Loss,
380}
381
382#[derive(Debug, Deserialize, PartialEq, Copy, Clone, Deref, DerefMut)]
384pub struct RecordSplit {
385 #[deref]
386 #[deref_mut]
387 #[serde(flatten)]
388 pub record: Record,
389 #[serde(rename = "type")]
390 pub kind: RecordSplitKind,
391}
392
393#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
394pub struct DivisionalRecordSplit {
395 #[deref]
396 #[deref_mut]
397 #[serde(flatten)]
398 pub record: Record,
399 pub division: NamedDivision,
400}
401
402#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
403pub struct LeagueRecordSplit {
404 #[deref]
405 #[deref_mut]
406 #[serde(flatten)]
407 pub record: Record,
408 pub league: NamedLeague,
409}
410
411#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Hash)]
412#[serde(rename_all = "camelCase")]
413pub enum RecordSplitKind {
414 Home,
416 Away,
418 Left,
422 LeftHome,
424 LeftAway,
426 Right,
429 RightHome,
431 RightAway,
433
434 LastTen,
437 #[serde(rename = "extraInning")]
439 ExtraInnings,
440 OneRun,
442
443 Winners,
445
446 Day,
448 Night,
450
451 Grass,
453 Turf,
455
456 #[allow(non_camel_case_types, reason = "proper case")]
460 #[serde(rename = "xWinLoss")]
461 xWinLoss,
462
463 #[allow(non_camel_case_types, reason = "proper case")]
467 #[serde(rename = "xWinLossSeason")]
468 xWinLossSeason,
469}
470
471pub trait StandingsHydrations: Hydrations<RequestData=()> {
472 type Team: Debug + DeserializeOwned + PartialEq + Clone;
473 type League: Debug + DeserializeOwned + PartialEq + Clone;
474 type Division: Debug + DeserializeOwned + PartialEq + Clone;
475 type Sport: Debug + DeserializeOwned + PartialEq + Clone;
476}
477
478impl StandingsHydrations for () {
479 type Team = NamedTeam;
480 type League = LeagueId;
481 type Division = DivisionId;
482 type Sport = SportId;
483}
484
485#[macro_export]
519macro_rules! standings_hydrations {
520 (@ inline_structs [team: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
521 ::pastey::paste! {
522 $crate::team_hydrations! {
523 $vis struct [<$name InlineTeam>] {
524 $($inline_tt)*
525 }
526 }
527
528 $crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
529 $vis struct $name {
530 $($field_tt)*
531 team: [<$name InlineTeam>],
532 }
533 }
534 }
535 };
536 (@ inline_structs [sport: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
537 ::pastey::paste! {
538 $crate::sports_hydrations! {
539 $vis struct [<$name InlineSport>] {
540 $($inline_tt)*
541 }
542 }
543
544 $crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
545 $vis struct $name {
546 $($field_tt)*
547 sport: [<$name InlineSport>],
548 }
549 }
550 }
551 };
552 (@ inline_structs [$_01:ident : { $($_02:tt)* $(, $($tt:tt)*)?}] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
553 ::core::compile_error!("Found unknown inline struct");
554 };
555 (@ inline_structs [$field:ident $(: $value:ty)? $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
556 $crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
557 $vis struct $name {
558 $($field_tt)*
559 $field $(: $value)?,
560 }
561 }
562 };
563 (@ inline_structs [] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
564 $crate::standings_hydrations!(@ actual $vis struct $name { $($field_tt)* });
565 };
566
567 (@ team) => { $crate::team::NamedTeam };
568 (@ team $team:ty) => { $crate::team::Team<$team> };
569
570 (@ league) => { $crate::league::NamedLeague };
571 (@ league ,) => { $crate::league::League };
572 (@ unknown_league) => { $crate::league::NamedLeague::unknown_league() };
573 (@ unknown_league ,) => { unimplemented!() }; (@ division) => { $crate::division::NamedDivision };
576 (@ division ,) => { $crate::division::Division };
577
578 (@ sport) => { $crate::sport::SportId };
579 (@ sport $hydrations:ty) => { $crate::sport::Sport<$hydrations> };
580
581 (@ actual $vis:vis struct $name:ident {
582 $(team: $team:ty ,)?
583 $(league $league_comma:tt)?
584 $(division $division_comma:tt)?
585 $(sport: $sport:ty ,)?
586 }) => {
587 $vis struct $name {}
588
589 impl $crate::standings::StandingsHydrations for $name {
590 type Team = $crate::standings_hydrations!(@ team $($team)?);
591 type League = $crate::standings_hydrations!(@ league $($league_comma)?);
592 type Division = $crate::standings_hydrations!(@ division $($division_comma)?);
593 type Sport = $crate::standings_hydrations!(@ sport $($sport)?);
594 }
595
596 impl $crate::hydrations::Hydrations for $name {
597 type RequestData = ();
598
599 fn hydration_text(&(): &Self::RequestData) -> ::std::borrow::Cow<'static, str> {
600 let text = ::std::borrow::Cow::Borrowed(::core::concat!(
601 $("league," $league_comma)?
602 $("division," $division_comma)?
603 ));
604
605 $(let text = ::std::borrow::Cow::Owned!(::std::format!("{text}team({}),", <$team as $crate::hydrations::Hydrations>::hydration_text(&())));)?;
606 $(let text = ::std::borrow::Cow::Owned!(::std::format!("{text}sport({}),", <$sport as $crate::hydrations::Hydrations>::hydration_text(&())));)?;
607
608 text
609 }
610 }
611 };
612 ($vis:vis struct $name:ident {
613 $($field_tt:tt)*
614 }) => {
615 $crate::standings_hydrations!(@ inline_structs [$($field_tt)*] $vis struct $name {})
616 };
617}
618
619#[derive(Builder)]
621#[builder(derive(Into))]
622pub struct StandingsRequest<H: StandingsHydrations> {
623 #[builder(into)]
624 league_id: LeagueId,
625 #[builder(into, default)]
626 season: SeasonId,
627 standings_types: Option<Vec<StandingsType>>,
628 #[builder(into)]
629 date: Option<NaiveDate>,
630 #[builder(skip)]
631 _marker: PhantomData<H>,
632}
633
634impl<H: StandingsHydrations, S: standings_request_builder::State + standings_request_builder::IsComplete> RequestURLBuilderExt for StandingsRequestBuilder<H, S> {
635 type Built = StandingsRequest<H>;
636}
637
638impl<H: StandingsHydrations> Display for StandingsRequest<H> {
639 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
640 let hydrations = Some(H::hydration_text(&())).filter(|s| !s.is_empty());
641 write!(f, "http://statsapi.mlb.com/api/v1/standings{params}", params = gen_params! {
642 "leagueId": self.league_id,
643 "season": self.season,
644 "standingsTypes"?: self.standings_types.as_ref().map(|x| x.iter().copied().join(",")),
645 "date"?: self.date.map(|x| x.format(MLB_API_DATE_FORMAT)),
646 "hydrate"?: hydrations,
647 })
648 }
649}
650
651impl<H: StandingsHydrations> RequestURL for StandingsRequest<H> {
652 type Response = StandingsResponse<H>;
653}
654
655#[cfg(test)]
656mod tests {
657 use chrono::NaiveDate;
658 use crate::league::LeagueId;
659 use crate::request::RequestURLBuilderExt;
660 use crate::standings::StandingsRequest;
661 use crate::TEST_YEAR;
662
663 #[tokio::test]
664 async fn all_mlb_leagues() {
665 for league_id in [LeagueId::new(103), LeagueId::new(104)] {
666 let _ = StandingsRequest::<()>::builder().season(TEST_YEAR).league_id(league_id).build_and_get().await.unwrap();
667 let _ = StandingsRequest::<()>::builder().season(TEST_YEAR).date(NaiveDate::from_ymd_opt(TEST_YEAR as _, 9, 26).unwrap()).league_id(league_id).build_and_get().await.unwrap();
668 }
669 }
670}