use crate::division::{DivisionId, NamedDivision};
use crate::league::{LeagueId, NamedLeague};
use crate::meta::StandingsType;
use crate::request::{RequestURL, RequestURLBuilderExt};
use crate::season::SeasonId;
use crate::sport::SportId;
use crate::stats::ThreeDecimalPlaceRateStat;
use crate::team::NamedTeam;
use crate::Copyright;
use bon::Builder;
use chrono::{DateTime, NaiveDate, Utc};
use derive_more::{Add, AddAssign, Deref, DerefMut, Display};
use itertools::Itertools;
use serde::Deserialize;
use std::cmp::Ordering;
use std::fmt::{Debug, Display, Formatter};
use std::marker::PhantomData;
use std::str::FromStr;
use serde::de::DeserializeOwned;
use crate::hydrations::Hydrations;
use crate::types::MLB_API_DATE_FORMAT;
#[derive(Debug, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
pub struct StandingsResponse<H: StandingsHydrations> {
pub copyright: Copyright,
#[serde(rename = "records")]
pub divisions: Vec<DivisionalStandings<H>>
}
#[derive(Debug, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
pub struct DivisionalStandings<H: StandingsHydrations> {
pub standings_type: StandingsType,
#[serde(rename = "league")]
pub league_id: H::League,
#[serde(rename = "division")]
pub division_id: H::Division,
#[serde(rename = "sport")]
pub sport_id: H::Sport,
#[serde(deserialize_with = "crate::deserialize_datetime")]
pub last_updated: DateTime<Utc>,
pub team_records: Vec<TeamRecord<H>>,
}
#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
pub struct TeamRecord<H: StandingsHydrations> {
pub team: H::Team,
pub season: SeasonId,
pub games_played: usize,
pub runs_allowed: usize,
pub runs_scored: usize,
#[serde(rename = "divisionChamp")]
pub is_divisional_champion: bool,
#[serde(rename = "divisionLeader")]
pub is_divisional_leader: bool,
pub has_wildcard: bool,
#[serde(deserialize_with = "crate::deserialize_datetime")]
pub last_updated: DateTime<Utc>,
pub streak: Streak,
#[serde(rename = "records")]
pub splits: RecordSplits,
#[serde(rename = "clinchIndicator", default)]
pub clinch_kind: ClinchKind,
pub games_back: GamesBack,
pub wild_card_games_back: GamesBack,
pub league_games_back: GamesBack,
#[serde(rename = "springLeagueGamesBack")]
pub spring_training_games_back: GamesBack,
pub sport_games_back: GamesBack,
pub division_games_back: GamesBack,
pub conference_games_back: GamesBack,
#[deref]
#[deref_mut]
#[serde(rename = "leagueRecord")]
pub record: Record,
#[serde(rename = "divisionRank", deserialize_with = "crate::try_from_str", default)]
pub divisional_rank: Option<usize>,
#[serde(deserialize_with = "crate::try_from_str", default)]
pub league_rank: Option<usize>,
#[serde(deserialize_with = "crate::try_from_str", default)]
pub sport_rank: Option<usize>,
}
impl<H: StandingsHydrations> TeamRecord<H> {
#[must_use]
pub fn expected_win_loss_pct(&self) -> ThreeDecimalPlaceRateStat {
const EXPONENT: f64 = 1.815;
let exponentified_runs_scored: f64 = (self.runs_scored as f64).powf(EXPONENT);
let exponentified_runs_allowed: f64 = (self.runs_allowed as f64).powf(EXPONENT);
(exponentified_runs_scored / (exponentified_runs_scored + exponentified_runs_allowed)).into()
}
#[must_use]
pub fn expected_end_of_season_record(&self) -> Record {
self.expected_end_of_season_record_with_total_games(162)
}
#[must_use]
pub fn expected_end_of_season_record_with_total_games(&self, total_games: usize) -> Record {
let expected_pct: f64 = self.expected_win_loss_pct().into();
let remaining_games = total_games.saturating_sub(self.record.games_played());
let wins = (remaining_games as f64 * expected_pct).round() as usize;
let losses = remaining_games - wins;
self.record + Record { wins, losses }
}
#[must_use]
pub const fn run_differential(&self) -> isize {
self.runs_scored as isize - self.runs_allowed as isize
}
}
#[derive(Debug, Deserialize, PartialEq, Clone)]
pub struct RecordSplits {
#[serde(rename = "splitRecords", default)]
pub record_splits: Vec<RecordSplit>,
#[serde(rename = "divisionRecords", default)]
pub divisional_record_splits: Vec<DivisionalRecordSplit>,
#[serde(rename = "leagueRecords", default)]
pub league_record_splits: Vec<LeagueRecordSplit>,
#[serde(rename = "overallRecords", default)]
pub basic_record_splits: Vec<RecordSplit>,
#[serde(rename = "expectedRecords", default)]
pub expected_record_splits: Vec<RecordSplit>,
}
#[repr(u8)]
#[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Default)]
pub enum ClinchKind {
#[serde(rename = "z")]
Bye = 4,
#[serde(rename = "y")]
Divisional = 3,
#[serde(rename = "w")]
WildCard = 2,
#[serde(rename = "x")]
Postseason = 1,
#[default]
#[serde(skip)]
None = 0,
}
impl ClinchKind {
#[must_use]
pub const fn clinched_postseason(self) -> bool {
self as u8 >= Self::Postseason as u8
}
#[must_use]
pub const fn is_final(self) -> bool {
self as u8 >= Self::WildCard as u8
}
#[must_use]
pub const fn guaranteed_in_wildcard(self) -> bool {
matches!(self, Self::WildCard | Self::Divisional)
}
#[must_use]
pub const fn potentially_in_wildcard(self) -> bool {
matches!(self, Self::WildCard | Self::Divisional | Self::Postseason | Self::None)
}
}
#[derive(Deserialize, PartialEq, Eq, Clone)]
#[serde(try_from = "&str")]
pub struct GamesBack {
games: isize,
half: bool,
}
impl PartialOrd for GamesBack {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for GamesBack {
fn cmp(&self, other: &Self) -> Ordering {
self.games.cmp(&other.games).then_with(|| self.half.cmp(&other.half))
}
}
impl Display for GamesBack {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if self.games > 0 {
write!(f, "+")?;
}
if self.games != 0 {
write!(f, "{}", self.games.abs())?;
} else {
write!(f, "-")?;
}
write!(f, ".{c}", c = if self.half { '5' } else { '0' })?;
Ok(())
}
}
impl Debug for GamesBack {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
<Self as Display>::fmt(self, f)
}
}
impl<'a> TryFrom<&'a str> for GamesBack {
type Error = <Self as FromStr>::Err;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
<Self as FromStr>::from_str(value)
}
}
impl FromStr for GamesBack {
type Err = &'static str;
fn from_str(mut s: &str) -> Result<Self, Self::Err> {
if s == "-" { return Ok(Self { games: 0, half: false }) }
let sign: isize = s.strip_prefix("+").map_or(-1, |s2| {
s = s2;
1
});
let (games, half) = s.split_once('.').unwrap_or((s, ""));
let games = games.parse::<usize>().map_err(|_| "invalid game quantity")?;
let half = half == "5";
Ok(Self {
games: games as isize * sign,
half,
})
}
}
#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Add, AddAssign)]
pub struct Record {
wins: usize,
losses: usize,
}
impl Record {
#[must_use]
pub fn pct(self) -> ThreeDecimalPlaceRateStat {
(self.wins as f64 / self.games_played() as f64).into()
}
#[must_use]
pub const fn games_played(self) -> usize {
self.wins + self.losses
}
}
#[derive(Debug, Deserialize, PartialEq, Copy, Clone)]
pub struct Streak {
#[serde(rename = "streakNumber")]
pub quantity: usize,
#[serde(rename = "streakType")]
pub kind: StreakKind,
}
impl Display for Streak {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.kind, self.quantity)
}
}
#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
pub enum StreakKind {
#[serde(rename = "wins")]
#[display("W")]
Win,
#[serde(rename = "losses")]
#[display("L")]
Loss,
}
#[derive(Debug, Deserialize, PartialEq, Copy, Clone, Deref, DerefMut)]
pub struct RecordSplit {
#[deref]
#[deref_mut]
#[serde(flatten)]
pub record: Record,
#[serde(rename = "type")]
pub kind: RecordSplitKind,
}
#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
pub struct DivisionalRecordSplit {
#[deref]
#[deref_mut]
#[serde(flatten)]
pub record: Record,
pub division: NamedDivision,
}
#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
pub struct LeagueRecordSplit {
#[deref]
#[deref_mut]
#[serde(flatten)]
pub record: Record,
pub league: NamedLeague,
}
#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Hash)]
#[serde(rename_all = "camelCase")]
pub enum RecordSplitKind {
Home,
Away,
Left,
LeftHome,
LeftAway,
Right,
RightHome,
RightAway,
LastTen,
#[serde(rename = "extraInning")]
ExtraInnings,
OneRun,
Winners,
Day,
Night,
Grass,
Turf,
#[allow(non_camel_case_types, reason = "proper case")]
#[serde(rename = "xWinLoss")]
xWinLoss,
#[allow(non_camel_case_types, reason = "proper case")]
#[serde(rename = "xWinLossSeason")]
xWinLossSeason,
}
pub trait StandingsHydrations: Hydrations<RequestData=()> {
type Team: Debug + DeserializeOwned + PartialEq + Clone;
type League: Debug + DeserializeOwned + PartialEq + Clone;
type Division: Debug + DeserializeOwned + PartialEq + Clone;
type Sport: Debug + DeserializeOwned + PartialEq + Clone;
}
impl StandingsHydrations for () {
type Team = NamedTeam;
type League = LeagueId;
type Division = DivisionId;
type Sport = SportId;
}
#[macro_export]
macro_rules! standings_hydrations {
(@ inline_structs [team: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
::pastey::paste! {
$crate::team_hydrations! {
$vis struct [<$name InlineTeam>] {
$($inline_tt)*
}
}
$crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
$vis struct $name {
$($field_tt)*
team: [<$name InlineTeam>],
}
}
}
};
(@ inline_structs [sport: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
::pastey::paste! {
$crate::sports_hydrations! {
$vis struct [<$name InlineSport>] {
$($inline_tt)*
}
}
$crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
$vis struct $name {
$($field_tt)*
sport: [<$name InlineSport>],
}
}
}
};
(@ inline_structs [$_01:ident : { $($_02:tt)* $(, $($tt:tt)*)?}] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
::core::compile_error!("Found unknown inline struct");
};
(@ inline_structs [$field:ident $(: $value:ty)? $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
$crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
$vis struct $name {
$($field_tt)*
$field $(: $value)?,
}
}
};
(@ inline_structs [] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
$crate::standings_hydrations!(@ actual $vis struct $name { $($field_tt)* });
};
(@ team) => { $crate::team::NamedTeam };
(@ team $team:ty) => { $crate::team::Team<$team> };
(@ league) => { $crate::league::NamedLeague };
(@ league ,) => { $crate::league::League };
(@ unknown_league) => { $crate::league::NamedLeague::unknown_league() };
(@ unknown_league ,) => { unimplemented!() };
(@ division) => { $crate::division::NamedDivision };
(@ division ,) => { $crate::division::Division };
(@ sport) => { $crate::sport::SportId };
(@ sport $hydrations:ty) => { $crate::sport::Sport<$hydrations> };
(@ actual $vis:vis struct $name:ident {
$(team: $team:ty ,)?
$(league $league_comma:tt)?
$(division $division_comma:tt)?
$(sport: $sport:ty ,)?
}) => {
$vis struct $name {}
impl $crate::standings::StandingsHydrations for $name {
type Team = $crate::standings_hydrations!(@ team $($team)?);
type League = $crate::standings_hydrations!(@ league $($league_comma)?);
type Division = $crate::standings_hydrations!(@ division $($division_comma)?);
type Sport = $crate::standings_hydrations!(@ sport $($sport)?);
}
impl $crate::hydrations::Hydrations for $name {
type RequestData = ();
fn hydration_text(&(): &Self::RequestData) -> ::std::borrow::Cow<'static, str> {
let text = ::std::borrow::Cow::Borrowed(::core::concat!(
$("league," $league_comma)?
$("division," $division_comma)?
));
$(let text = ::std::borrow::Cow::Owned!(::std::format!("{text}team({}),", <$team as $crate::hydrations::Hydrations>::hydration_text(&())));)?;
$(let text = ::std::borrow::Cow::Owned!(::std::format!("{text}sport({}),", <$sport as $crate::hydrations::Hydrations>::hydration_text(&())));)?;
text
}
}
};
($vis:vis struct $name:ident {
$($field_tt:tt)*
}) => {
$crate::standings_hydrations!(@ inline_structs [$($field_tt)*] $vis struct $name {})
};
}
/// Returns a [`StandingsResponse`].
#[derive(Builder)]
#[builder(derive(Into))]
pub struct StandingsRequest<H: StandingsHydrations> {
#[builder(into)]
league_id: LeagueId,
#[builder(into, default)]
season: SeasonId,
standings_types: Option<Vec<StandingsType>>,
#[builder(into)]
date: Option<NaiveDate>,
#[builder(skip)]
_marker: PhantomData<H>,
}
impl<H: StandingsHydrations, S: standings_request_builder::State + standings_request_builder::IsComplete> RequestURLBuilderExt for StandingsRequestBuilder<H, S> {
type Built = StandingsRequest<H>;
}
impl<H: StandingsHydrations> Display for StandingsRequest<H> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let hydrations = Some(H::hydration_text(&())).filter(|s| !s.is_empty());
write!(f, "http://statsapi.mlb.com/api/v1/standings{params}", params = gen_params! {
"leagueId": self.league_id,
"season": self.season,
"standingsTypes"?: self.standings_types.as_ref().map(|x| x.iter().copied().join(",")),
"date"?: self.date.map(|x| x.format(MLB_API_DATE_FORMAT)),
"hydrate"?: hydrations,
})
}
}
impl<H: StandingsHydrations> RequestURL for StandingsRequest<H> {
type Response = StandingsResponse<H>;
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use crate::league::LeagueId;
use crate::request::RequestURLBuilderExt;
use crate::standings::StandingsRequest;
use crate::TEST_YEAR;
#[tokio::test]
async fn all_mlb_leagues() {
for league_id in [LeagueId::new(103), LeagueId::new(104)] {
let _ = StandingsRequest::<()>::builder().season(TEST_YEAR).league_id(league_id).build_and_get().await.unwrap();
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();
}
}
}