From bc1c17ce6bb5c8b55ca67d4ae8600ffaecc6e3c3 Mon Sep 17 00:00:00 2001 From: Michal Pokrywka Date: Mon, 22 Sep 2025 10:13:57 +0200 Subject: [PATCH] Added ConversionConfig --- .github/workflows/rust.yml | 12 +- src/config.rs | 62 ++++++++ src/convert.rs | 175 ++++++++++++++++----- src/lib.rs | 271 +------------------------------ src/lookup_tables.rs | 9 +- src/odds.rs | 315 +++++++++++++++++++++++++++++++++++++ src/testing_helpers.rs | 21 +++ 7 files changed, 550 insertions(+), 315 deletions(-) create mode 100644 src/config.rs create mode 100644 src/odds.rs create mode 100644 src/testing_helpers.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c120784..77f2dc9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -16,13 +16,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Fmt + run: cargo fmt --verbose --check --all - name: Build - run: cargo build --verbose + run: cargo build --verbose --all - name: Run tests - run: cargo test --verbose - - name: Run tests (no-default-features) - run: cargo test --verbose --no-default-features - - name: Run tests (no-default-features, lookup) - run: cargo test --verbose --no-default-features --features lookup - - name: Run tests (no-default-features, fractions_simplify) - run: cargo test --verbose --no-default-features --features fractions_simplify + run: cargo test --verbose --all diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..5273127 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,62 @@ +use rust_decimal::RoundingStrategy; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FractionStrategy { + /// Plain method (less precise, but faster, f. ex. 1.33 gives 33/100 instead of 1/3). + /// + /// Note that using None method but leaving lookup enabled can still return simplified fractions (f. ex. 1.33 -> 1/3) from the lookup tables (see README.md) + Plain, + /// Use a continued fraction algorithm for better precision and simple fractions. (1.33 gives 1/3 instead of 33/100) + Simplify, +} + +/// Configuration for conversion functions. +#[derive(Debug, Clone, Copy)] +pub struct ConversionConfig { + /// Use lookup tables first for conversion, then fallback to regular computations + /// Note: When using lookup tables feature, conversion from 1.67 or -150 gives 4/6 instead of 2/3 (see README.md) + pub use_lookup_tables: bool, + /// Fractions computing strategy + pub fraction_strategy: FractionStrategy, + /// Rounding method for Decimal type + pub rounding_strategy: RoundingStrategy, +} + +impl Default for ConversionConfig { + /// Provides standard settings. + /// + /// - lookup enabled + /// - fractions simplified + /// - MidpointAwayFromZero (RoundHalfUp) rounding strategy + fn default() -> Self { + DEFAULT_CONVERSION_CONFIG + } +} + +static DEFAULT_CONVERSION_CONFIG: ConversionConfig = ConversionConfig { + use_lookup_tables: true, + fraction_strategy: FractionStrategy::Simplify, + rounding_strategy: RoundingStrategy::MidpointAwayFromZero, // former RoundHalfUp +}; + +impl ConversionConfig { + pub fn no_lookup(&mut self) -> &mut Self { + self.use_lookup_tables = false; + self + } + + pub fn plain_fraction_strategy(&mut self) -> &mut Self { + self.fraction_strategy = FractionStrategy::Plain; + self + } + + pub fn fraction_strategy(&mut self, strategy: FractionStrategy) -> &mut Self { + self.fraction_strategy = strategy; + self + } + + pub fn rounding_strategy(&mut self, strategy: RoundingStrategy) -> &mut Self { + self.rounding_strategy = strategy; + self + } +} diff --git a/src/convert.rs b/src/convert.rs index 311acc4..07cc33d 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -1,18 +1,30 @@ use rust_decimal::{Decimal, prelude::ToPrimitive}; use rust_decimal_macros::dec; -#[cfg(feature = "lookup")] -use crate::lookup_tables::{ - get_american_to_decimal_map, get_american_to_fraction_map, get_decimal_to_fraction_map, +use crate::{ + ConversionConfig, FractionStrategy, + lookup_tables::{ + get_american_to_decimal_map, get_american_to_fraction_map, get_decimal_to_fraction_map, + }, }; +/// Convert from american to decimal using default parameters. pub fn american_to_decimal(value: i32) -> Result { + american_to_decimal_custom(value, &ConversionConfig::default()) +} + +/// Convert from american to decimal using custom parameters. +pub fn american_to_decimal_custom( + value: i32, + config: &ConversionConfig, +) -> Result { if value == 0 { return Err(ConversionError::AmericanZero); } - #[cfg(feature = "lookup")] - if let Some(ret) = get_american_to_decimal_map().get(&value) { + if config.use_lookup_tables + && let Some(ret) = get_american_to_decimal_map().get(&value) + { return Ok(*ret); } @@ -30,6 +42,7 @@ fn american_to_decimal_inner(value: i32) -> Result { } } +// Convert from fractional to decimal (doesn't use conversion parameters). pub fn fractional_to_decimal(num: u32, den: u32) -> Result { if den == 0 { Err(ConversionError::DenominatorZero) @@ -38,26 +51,44 @@ pub fn fractional_to_decimal(num: u32, den: u32) -> Result Result<(u32, u32), ConversionError> { - #[cfg(feature = "fractions_simplify")] - return decimal_to_fractional_complex(value); + decimal_to_fractional_custom(value, &ConversionConfig::default()) +} - #[cfg(not(feature = "fractions_simplify"))] - return decimal_to_fractional_complex(value); +// Convert from decimal to fractional using custom parameters. +pub fn decimal_to_fractional_custom( + value: Decimal, + config: &ConversionConfig, +) -> Result<(u32, u32), ConversionError> { + if config.use_lookup_tables + && let Some(ret) = get_decimal_to_fraction_map().get(&value) + { + return Ok(*ret); + } + + match config.fraction_strategy { + FractionStrategy::Plain => decimal_to_fractional_plain(value, config), + FractionStrategy::Simplify => decimal_to_fractional_simplify(value), + } } -pub fn decimal_to_fractional_simple(value: Decimal) -> Result<(u32, u32), ConversionError> { +/// Convert from decimal to fractional with plain fractional strategy. +/// +/// Bypasses look tables. +pub fn decimal_to_fractional_plain( + value: Decimal, + config: &ConversionConfig, +) -> Result<(u32, u32), ConversionError> { if value <= Decimal::ONE { return Err(ConversionError::InvalidDecimal); } - #[cfg(feature = "lookup")] - if let Some(ret) = get_decimal_to_fraction_map().get(&value) { - return Ok(*ret); - } - let numerator = (value - Decimal::ONE) * Decimal::ONE_THOUSAND; - let numerator = numerator.round().to_u64().unwrap_or_default(); + let numerator = numerator + .round_dp_with_strategy(0, config.rounding_strategy) + .to_u64() + .unwrap_or_default(); let divisor: u64 = num_integer::gcd(numerator, 100000); @@ -70,18 +101,14 @@ pub fn decimal_to_fractional_simple(value: Decimal) -> Result<(u32, u32), Conver )) } -// Conversion from decimal to fractional using a continued fraction algorithm -// to find the best rational approximation. -pub fn decimal_to_fractional_complex(value: Decimal) -> Result<(u32, u32), ConversionError> { +/// Conversion from decimal to fractional using a continued fraction algorithm to find the best rational approximation. +/// +/// This usually produce simplified fractions. Bypasses look tables. +pub fn decimal_to_fractional_simplify(value: Decimal) -> Result<(u32, u32), ConversionError> { if value <= Decimal::ONE { return Err(ConversionError::InvalidDecimal); } - #[cfg(feature = "lookup")] - if let Some(ret) = get_decimal_to_fraction_map().get(&value) { - return Ok(*ret); - } - let fractional_part = value - Decimal::ONE; // Set a practical limit for denominators in betting odds. @@ -139,9 +166,19 @@ pub fn decimal_to_fractional_complex(value: Decimal) -> Result<(u32, u32), Conve Ok((num as u32, den as u32)) } +/// Convert from american to fractional with default parameters. pub fn american_to_fractional(value: i32) -> Result<(u32, u32), ConversionError> { - #[cfg(feature = "lookup")] - if let Some(ret) = get_american_to_fraction_map().get(&value) { + american_to_fractional_custom(value, &ConversionConfig::default()) +} + +/// Convert from american to fractional with custom parameters. +pub fn american_to_fractional_custom( + value: i32, + config: &ConversionConfig, +) -> Result<(u32, u32), ConversionError> { + if config.use_lookup_tables + && let Some(ret) = get_american_to_fraction_map().get(&value) + { return Ok(*ret); } @@ -149,16 +186,25 @@ pub fn american_to_fractional(value: i32) -> Result<(u32, u32), ConversionError> decimal_to_fractional(decimal) } +/// Convert from decimal to american with default parameters. pub fn decimal_to_american(decimal: Decimal) -> Result { + decimal_to_american_custom(decimal, &ConversionConfig::default()) +} + +/// Convert from decimal to american with custom parameters. +pub fn decimal_to_american_custom( + decimal: Decimal, + config: &ConversionConfig, +) -> Result { if decimal >= Decimal::TWO { ((decimal - Decimal::ONE) * Decimal::ONE_HUNDRED) - .round() + .round_dp_with_strategy(0, config.rounding_strategy) .to_i32() .ok_or(ConversionError::DecimalOverflow) .map(normalize_american_odds) } else if decimal > Decimal::ONE { (-Decimal::ONE_HUNDRED / (decimal - Decimal::ONE)) - .round() + .round_dp_with_strategy(0, config.rounding_strategy) .to_i32() .ok_or(ConversionError::DecimalOverflow) } else { @@ -166,14 +212,25 @@ pub fn decimal_to_american(decimal: Decimal) -> Result { } } +/// Convert from fractional to american with default parameters. pub fn fractional_to_american(num: u32, den: u32) -> Result { + fractional_to_american_custom(num, den, &ConversionConfig::default()) +} + +/// Convert from fractional to american with custom parameters. +pub fn fractional_to_american_custom( + num: u32, + den: u32, + config: &ConversionConfig, +) -> Result { if den == 0 { return Err(ConversionError::DenominatorZero); } let decimal = Decimal::from(num) / Decimal::from(den) + Decimal::ONE; - decimal_to_american(decimal) + decimal_to_american_custom(decimal, config) } +/// Normalize american odds (converts 1-99 to negative values, -1-99 to positive values). pub fn normalize_american_odds(odds: i32) -> i32 { if odds > 0 && odds < 100 { // 1-99 -> -xxx @@ -188,18 +245,23 @@ pub fn normalize_american_odds(odds: i32) -> i32 { #[derive(Debug, PartialEq)] pub enum ConversionError { + /// American odds value cannot be zero. AmericanZero, + /// Denominator in fractional odds cannot be zero. DenominatorZero, + /// Ran into overflow while computing decimal from or to decimal value. DecimalOverflow, + /// Decimal odds cannot be less or equal 1.0 InvalidDecimal, } #[cfg(test)] mod tests { - use crate::assert_decimal_eq; + use rust_decimal_macros::dec; + + use crate::testing_helpers::assert_decimal_eq; use super::*; - use rust_decimal_macros::dec; // --- Tests for Individual Conversion Functions --- @@ -208,10 +270,12 @@ mod tests { // Real-world examples (Favorites) assert_decimal_eq(american_to_decimal(-110).unwrap(), dec!(1.91)); - #[cfg(feature = "lookup")] assert_decimal_eq(american_to_decimal(-150).unwrap(), dec!(1.67)); - #[cfg(not(feature = "lookup"))] - assert_decimal_eq(american_to_decimal(-150).unwrap(), dec!(1.666)); + + assert_decimal_eq( + american_to_decimal_custom(-150, ConversionConfig::default().no_lookup()).unwrap(), + dec!(1.666), + ); assert_decimal_eq(american_to_decimal(-200).unwrap(), dec!(1.5)); assert_decimal_eq(american_to_decimal(-500).unwrap(), dec!(1.2)); @@ -310,10 +374,14 @@ mod tests { assert_eq!(american_to_fractional(-200), Ok((1, 2))); assert_eq!(american_to_fractional(-500), Ok((1, 5))); - #[cfg(feature = "lookup")] + // Traditional UK fraction assert_eq!(american_to_fractional(-150), Ok((4, 6))); - #[cfg(not(feature = "lookup"))] - assert_eq!(american_to_fractional(-150), Ok((2, 3))); + + // The same without lookup table + assert_eq!( + american_to_fractional_custom(-150, ConversionConfig::default().no_lookup()), + Ok((2, 3)) + ); // Real-world examples (Underdogs) assert_eq!(american_to_fractional(100), Ok((1, 1))); @@ -337,7 +405,7 @@ mod tests { #[test] fn test_decimal_to_fractional() { - // // Existing tests + // Existing tests assert_eq!(super::decimal_to_fractional(dec!(1.3)), Ok((3, 10))); assert_eq!(super::decimal_to_fractional(dec!(1.33)), Ok((1, 3))); assert_eq!(super::decimal_to_fractional(dec!(1.333)), Ok((1, 3))); @@ -347,7 +415,36 @@ mod tests { assert_eq!(super::decimal_to_fractional(dec!(4.1)), Ok((31, 10))); assert_eq!(super::decimal_to_fractional(dec!(100.5)), Ok((199, 2))); - // // Additional real-world cases + // Gives 1/3 from lookup tables + assert_eq!( + super::decimal_to_fractional_custom( + dec!(1.33), + ConversionConfig::default().plain_fraction_strategy() + ), + Ok((1, 3)) + ); + + // Gives 33/100 with lookup tables disabled + assert_eq!( + super::decimal_to_fractional_custom( + dec!(1.33), + ConversionConfig::default() + .plain_fraction_strategy() + .no_lookup() + ), + Ok((33, 100)) + ); + + // No lookup for 1.333 + assert_eq!( + super::decimal_to_fractional_custom( + dec!(1.333), + ConversionConfig::default().plain_fraction_strategy() + ), + Ok((333, 1000)) + ); + + // Additional real-world cases assert_eq!(super::decimal_to_fractional(dec!(1.5)), Ok((1, 2))); assert_eq!(super::decimal_to_fractional(dec!(2.0)), Ok((1, 1))); assert_eq!(super::decimal_to_fractional(dec!(3.5)), Ok((5, 2))); diff --git a/src/lib.rs b/src/lib.rs index 9a4849f..90bf319 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,272 +1,13 @@ -use rust_decimal::Decimal; - -#[cfg(feature = "lookup")] -mod lookup_tables; +mod config; +pub use config::*; mod convert; pub use convert::*; -#[derive(Clone, Copy)] -pub enum Odds { - American(i32), - Decimal(Decimal), - Fractional { num: u32, den: u32 }, -} - -impl From for Odds { - fn from(value: i32) -> Self { - Self::American(value) - } -} - -impl From for Odds { - fn from(value: Decimal) -> Self { - Self::Decimal(value) - } -} - -impl From<(u32, u32)> for Odds { - fn from((num, den): (u32, u32)) -> Self { - Self::Fractional { num, den } - } -} - -impl Odds { - pub fn to_american(self) -> Result { - match self { - Odds::American(inner) => Ok(inner), - Odds::Decimal(decimal) => decimal_to_american(decimal), - Odds::Fractional { num, den } => fractional_to_american(num, den), - } - } - - pub fn to_fractional(self) -> Result<(u32, u32), ConversionError> { - match self { - Odds::American(inner) => american_to_fractional(inner), - Odds::Decimal(decimal) => decimal_to_fractional(decimal), - Odds::Fractional { num, den } => { - if den > 0 { - Ok((num, den)) - } else { - Err(ConversionError::DenominatorZero) - } - } - } - } - - pub fn to_decimal(self) -> Result { - match self { - Odds::American(inner) => american_to_decimal(inner), - Odds::Decimal(decimal) => { - if decimal > Decimal::ONE { - Ok(decimal) - } else { - Err(ConversionError::InvalidDecimal) - } - } - Odds::Fractional { num, den } => fractional_to_decimal(num, den), - } - } - - pub fn to_fractional_str(self) -> Result { - let (num, den) = self.to_fractional()?; - Ok(format!("{num}/{den}")) - } - - pub fn to_decimal_str(self) -> Result { - let decimal = self.to_decimal()?; - Ok(format!("{:.2}", decimal.round_dp(2))) - } -} +mod lookup_tables; -#[cfg(test)] -// --- Helper for comparing Decimals in tests --- -fn assert_decimal_eq(a: Decimal, b: Decimal) { - use rust_decimal_macros::dec; - let tolerance = dec!(0.001); - assert!( - (a - b).abs() < tolerance, - "Expected {} to be close to {}", - a, - b - ); -} +mod odds; +pub use odds::*; #[cfg(test)] -mod tests { - use super::*; - use rust_decimal_macros::dec; - - fn assert_decimal_ok_eq(a: Result, b: Decimal) { - assert!(a.is_ok()); - assert_decimal_eq(a.unwrap(), b); - } - - // --- Tests for From/Into Implementations --- - - #[test] - fn test_from_primitives_to_odds() { - // From - if let Odds::American(val) = Odds::from(150) { - assert_eq!(val, 150); - } else { - panic!("Expected Odds::American"); - } - - // From - if let Odds::Decimal(val) = Odds::from(dec!(3.33)) { - assert_eq!(val, dec!(3.33)); - } else { - panic!("Expected Odds::Decimal"); - } - - // From<(u32, u32)> - if let Odds::Fractional { num, den } = Odds::from((7, 2)) { - assert_eq!(num, 7); - assert_eq!(den, 2); - } else { - panic!("Expected Odds::Fractional"); - } - } - - #[test] - fn test_from_odds_to_i32() { - let american = Odds::American(-110); - let decimal = Odds::Decimal(dec!(3.5)); - let fractional = Odds::Fractional { num: 9, den: 1 }; - let invalid_decimal = Odds::Decimal(dec!(1.0)); - - assert_eq!(american.to_american(), Ok(-110)); - assert_eq!(decimal.to_american(), Ok(250)); - assert_eq!(fractional.to_american(), Ok(900)); - assert_eq!( - invalid_decimal.to_american(), - Err(ConversionError::InvalidDecimal) - ); - } - - #[test] - fn test_from_odds_to_fractional_tuple() { - let american = Odds::American(-150); - let decimal = Odds::Decimal(dec!(1.25)); - let fractional = Odds::Fractional { num: 9, den: 1 }; - let invalid_american = Odds::American(0); - - #[cfg(feature = "lookup")] - assert_eq!(american.to_fractional(), Ok((4, 6))); - #[cfg(not(feature = "lookup"))] - assert_eq!(american.to_fractional(), Ok((2, 3))); - - assert_eq!(decimal.to_fractional(), Ok((1, 4))); - assert_eq!(fractional.to_fractional(), Ok((9, 1))); - - assert_eq!( - invalid_american.to_fractional(), - Err(ConversionError::AmericanZero) - ); - } - - #[test] - fn test_from_odds_to_decimal() { - let american = Odds::American(200); - let decimal = Odds::Decimal(dec!(1.75)); - let fractional = Odds::Fractional { num: 1, den: 2 }; - let invalid_american = Odds::American(0); - - assert_decimal_ok_eq(american.to_decimal(), dec!(3.0)); - assert_decimal_ok_eq(decimal.to_decimal(), dec!(1.75)); - assert_decimal_ok_eq(fractional.to_decimal(), dec!(1.5)); - - assert_eq!( - invalid_american.to_decimal(), - Err(ConversionError::AmericanZero) - ); - } - - #[test] - fn test_to_fractional_str() { - // From American - assert_eq!(Odds::American(-200).to_fractional_str().unwrap(), "1/2"); - assert_eq!(Odds::American(250).to_fractional_str().unwrap(), "5/2"); - #[cfg(feature = "lookup")] - assert_eq!(Odds::American(-150).to_fractional_str().unwrap(), "4/6"); - #[cfg(not(feature = "lookup"))] - assert_eq!(Odds::American(-150).to_fractional_str().unwrap(), "2/3"); - - // From Decimal - assert_eq!(Odds::Decimal(dec!(1.5)).to_fractional_str().unwrap(), "1/2"); - assert_eq!(Odds::Decimal(dec!(3.5)).to_fractional_str().unwrap(), "5/2"); - assert_eq!( - Odds::Decimal(dec!(2.25)).to_fractional_str().unwrap(), - "5/4" - ); - - // From Fractional (passthrough) - assert_eq!( - Odds::Fractional { num: 7, den: 2 } - .to_fractional_str() - .unwrap(), - "7/2" - ); - assert_eq!( - Odds::Fractional { num: 1, den: 1 } - .to_fractional_str() - .unwrap(), - "1/1" - ); - - // Error cases - assert!(Odds::American(0).to_fractional_str().is_err()); - assert!(Odds::Decimal(dec!(0.9)).to_fractional_str().is_err()); - assert!( - Odds::Fractional { num: 1, den: 0 } - .to_fractional_str() - .is_err() - ); - } - - #[test] - fn test_to_decimal_str() { - // From American - assert_eq!(Odds::American(-500).to_decimal_str().unwrap(), "1.20"); - assert_eq!(Odds::American(200).to_decimal_str().unwrap(), "3.00"); - assert_eq!(Odds::American(-110).to_decimal_str().unwrap(), "1.91"); // Tests rounding - - // From Decimal - assert_eq!(Odds::Decimal(dec!(4.0)).to_decimal_str().unwrap(), "4.00"); - assert_eq!(Odds::Decimal(dec!(2.75)).to_decimal_str().unwrap(), "2.75"); - assert_eq!( - Odds::Decimal(dec!(1.3333)).to_decimal_str().unwrap(), - "1.33" - ); // Tests rounding - - // From Fractional - assert_eq!( - Odds::Fractional { num: 1, den: 2 } - .to_decimal_str() - .unwrap(), - "1.50" - ); - assert_eq!( - Odds::Fractional { num: 4, den: 1 } - .to_decimal_str() - .unwrap(), - "5.00" - ); - assert_eq!( - Odds::Fractional { num: 2, den: 3 } - .to_decimal_str() - .unwrap(), - "1.67" - ); // Tests rounding - - // Error cases - assert!(Odds::American(0).to_decimal_str().is_err()); - assert!(Odds::Decimal(dec!(-2.0)).to_decimal_str().is_err()); - assert!( - Odds::Fractional { num: 1, den: 0 } - .to_decimal_str() - .is_err() - ); - } -} +mod testing_helpers; diff --git a/src/lookup_tables.rs b/src/lookup_tables.rs index 7841c73..f5a5778 100644 --- a/src/lookup_tables.rs +++ b/src/lookup_tables.rs @@ -4,9 +4,10 @@ use std::sync::OnceLock; use rust_decimal::Decimal; use rust_decimal_macros::dec; -// --- Accessor for DECIMAL_TO_FRACTION Map --- +// Accessor for DECIMAL_TO_FRACTION Map static DECIMAL_TO_FRACTION: OnceLock> = OnceLock::new(); +/// Lookup table for conversion from decimal to fractional. pub fn get_decimal_to_fraction_map() -> &'static HashMap { DECIMAL_TO_FRACTION.get_or_init(|| { let mut m = HashMap::new(); @@ -83,9 +84,10 @@ pub fn get_decimal_to_fraction_map() -> &'static HashMap { }) } -// --- Accessor for AMERICAN_TO_FRACTION Map --- +// Accessor for AMERICAN_TO_FRACTION Map static AMERICAN_TO_FRACTION: OnceLock> = OnceLock::new(); +/// Lookup table for conversion from american to fractional. pub fn get_american_to_fraction_map() -> &'static HashMap { AMERICAN_TO_FRACTION.get_or_init(|| { let mut m = HashMap::new(); @@ -162,9 +164,10 @@ pub fn get_american_to_fraction_map() -> &'static HashMap { }) } -// --- Accessor for AMERICAN_TO_DECIMAL Map --- +// Accessor for AMERICAN_TO_DECIMAL Map static AMERICAN_TO_DECIMAL: OnceLock> = OnceLock::new(); +/// Lookup table for conversion from american to decimal. pub fn get_american_to_decimal_map() -> &'static HashMap { AMERICAN_TO_DECIMAL.get_or_init(|| { let mut m = HashMap::new(); diff --git a/src/odds.rs b/src/odds.rs new file mode 100644 index 0000000..f13153a --- /dev/null +++ b/src/odds.rs @@ -0,0 +1,315 @@ +use rust_decimal::Decimal; + +use crate::{ + ConversionConfig, ConversionError, american_to_decimal_custom, american_to_fractional_custom, + decimal_to_american_custom, decimal_to_fractional_custom, fractional_to_american_custom, + fractional_to_decimal, +}; + +#[derive(Clone, Copy)] +pub enum Odds { + American(i32), + Decimal(Decimal), + Fractional { num: u32, den: u32 }, +} + +impl From for Odds { + fn from(value: i32) -> Self { + Self::American(value) + } +} + +impl From for Odds { + fn from(value: Decimal) -> Self { + Self::Decimal(value) + } +} + +impl From<(u32, u32)> for Odds { + fn from((num, den): (u32, u32)) -> Self { + Self::Fractional { num, den } + } +} + +impl Odds { + /// Convert from decimal or fractional to american using default parameters. If already american, just return the value. + pub fn to_american(&self) -> Result { + self.to_american_custom(&ConversionConfig::default()) + } + + /// Convert from decimal or fractional to american using custom parameters. If already american, just return the value. + pub fn to_american_custom(&self, config: &ConversionConfig) -> Result { + match self { + Odds::American(inner) => Ok(*inner), + Odds::Decimal(decimal) => decimal_to_american_custom(*decimal, config), + Odds::Fractional { num, den } => fractional_to_american_custom(*num, *den, config), + } + } + + /// Convert from american or decimal to fractional using default parameters. If already fractional, just return the value. + pub fn to_fractional(&self) -> Result<(u32, u32), ConversionError> { + self.to_fractional_custom(&ConversionConfig::default()) + } + + /// Convert from american or decimal to fractional using custom parameters. If already fractional, just return the value. + pub fn to_fractional_custom( + &self, + config: &ConversionConfig, + ) -> Result<(u32, u32), ConversionError> { + match self { + Odds::American(inner) => american_to_fractional_custom(*inner, config), + Odds::Decimal(decimal) => decimal_to_fractional_custom(*decimal, config), + Odds::Fractional { num, den } => { + if *den > 0 { + Ok((*num, *den)) + } else { + Err(ConversionError::DenominatorZero) + } + } + } + } + + /// Convert from american or fractional to decimal using default parameters. If already decimal, just return the value. + pub fn to_decimal(&self) -> Result { + self.to_decimal_custom(&ConversionConfig::default()) + } + + /// Convert from american or fractional to decimal using custom parameters. If already decimal, just return the value. + pub fn to_decimal_custom(&self, config: &ConversionConfig) -> Result { + match self { + Odds::American(inner) => american_to_decimal_custom(*inner, config), + Odds::Decimal(decimal) => { + if *decimal > Decimal::ONE { + Ok(*decimal) + } else { + Err(ConversionError::InvalidDecimal) + } + } + Odds::Fractional { num, den } => fractional_to_decimal(*num, *den), + } + } + + /// Convert from american or decimal to fractional using default parameters + /// (if already fractional, just take the value) and format to string. + pub fn to_fractional_str(&self) -> Result { + self.to_fractional_str_custom(&ConversionConfig::default()) + } + + /// Convert from american or decimal to fractional using custom parameters + /// (if already fractional, just take the value) and format to string. + pub fn to_fractional_str_custom( + &self, + config: &ConversionConfig, + ) -> Result { + let (num, den) = self.to_fractional_custom(config)?; + Ok(format!("{num}/{den}")) + } + + /// Convert from american or fractional to decimal using default parameters + /// (if already decimal, just take the value) and format to string. + pub fn to_decimal_str(&self) -> Result { + self.to_decimal_str_custom(&ConversionConfig::default()) + } + + /// Convert from american or fractional to decimal using custom parameters + /// (if already decimal, just take the value) and format to string. + pub fn to_decimal_str_custom( + &self, + config: &ConversionConfig, + ) -> Result { + let decimal = self.to_decimal_custom(config)?; + Ok(format!( + "{:.2}", + decimal.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero) + )) + } +} + +#[cfg(test)] +mod tests { + use crate::testing_helpers::assert_decimal_ok_eq; + + use super::*; + use rust_decimal_macros::dec; + + // --- Tests for From/Into Implementations --- + + #[test] + fn test_from_primitives_to_odds() { + // From + if let Odds::American(val) = Odds::from(150) { + assert_eq!(val, 150); + } else { + panic!("Expected Odds::American"); + } + + // From + if let Odds::Decimal(val) = Odds::from(dec!(3.33)) { + assert_eq!(val, dec!(3.33)); + } else { + panic!("Expected Odds::Decimal"); + } + + // From<(u32, u32)> + if let Odds::Fractional { num, den } = Odds::from((7, 2)) { + assert_eq!(num, 7); + assert_eq!(den, 2); + } else { + panic!("Expected Odds::Fractional"); + } + } + + #[test] + fn test_from_odds_to_i32() { + let american = Odds::American(-110); + let decimal = Odds::Decimal(dec!(3.5)); + let fractional = Odds::Fractional { num: 9, den: 1 }; + let invalid_decimal = Odds::Decimal(dec!(1.0)); + + assert_eq!(american.to_american(), Ok(-110)); + assert_eq!(decimal.to_american(), Ok(250)); + assert_eq!(fractional.to_american(), Ok(900)); + assert_eq!( + invalid_decimal.to_american(), + Err(ConversionError::InvalidDecimal) + ); + } + + #[test] + fn test_from_odds_to_fractional_tuple() { + let american = Odds::American(-150); + let decimal = Odds::Decimal(dec!(1.25)); + let fractional = Odds::Fractional { num: 9, den: 1 }; + let invalid_american = Odds::American(0); + + assert_eq!(american.to_fractional(), Ok((4, 6))); + + assert_eq!( + american.to_fractional_custom(&ConversionConfig { + use_lookup_tables: false, + ..Default::default() + }), + Ok((2, 3)) + ); + + assert_eq!(decimal.to_fractional(), Ok((1, 4))); + assert_eq!(fractional.to_fractional(), Ok((9, 1))); + + assert_eq!( + invalid_american.to_fractional(), + Err(ConversionError::AmericanZero) + ); + } + + #[test] + fn test_from_odds_to_decimal() { + let american = Odds::American(200); + let decimal = Odds::Decimal(dec!(1.75)); + let fractional = Odds::Fractional { num: 1, den: 2 }; + let invalid_american = Odds::American(0); + + assert_decimal_ok_eq(american.to_decimal(), dec!(3.0)); + assert_decimal_ok_eq(decimal.to_decimal(), dec!(1.75)); + assert_decimal_ok_eq(fractional.to_decimal(), dec!(1.5)); + + assert_eq!( + invalid_american.to_decimal(), + Err(ConversionError::AmericanZero) + ); + } + + #[test] + fn test_to_fractional_str() { + // From American + assert_eq!(Odds::American(-200).to_fractional_str().unwrap(), "1/2"); + assert_eq!(Odds::American(250).to_fractional_str().unwrap(), "5/2"); + + assert_eq!(Odds::American(-150).to_fractional_str().unwrap(), "4/6"); + + assert_eq!( + Odds::American(-150) + .to_fractional_str_custom(&ConversionConfig { + use_lookup_tables: false, + ..Default::default() + }) + .unwrap(), + "2/3" + ); + + // From Decimal + assert_eq!(Odds::Decimal(dec!(1.5)).to_fractional_str().unwrap(), "1/2"); + assert_eq!(Odds::Decimal(dec!(3.5)).to_fractional_str().unwrap(), "5/2"); + assert_eq!( + Odds::Decimal(dec!(2.25)).to_fractional_str().unwrap(), + "5/4" + ); + + // From Fractional (passthrough) + assert_eq!( + Odds::Fractional { num: 7, den: 2 } + .to_fractional_str() + .unwrap(), + "7/2" + ); + assert_eq!( + Odds::Fractional { num: 1, den: 1 } + .to_fractional_str() + .unwrap(), + "1/1" + ); + + // Error cases + assert!(Odds::American(0).to_fractional_str().is_err()); + assert!(Odds::Decimal(dec!(0.9)).to_fractional_str().is_err()); + assert!( + Odds::Fractional { num: 1, den: 0 } + .to_fractional_str() + .is_err() + ); + } + + #[test] + fn test_to_decimal_str() { + // From American + assert_eq!(Odds::American(-500).to_decimal_str().unwrap(), "1.20"); + assert_eq!(Odds::American(200).to_decimal_str().unwrap(), "3.00"); + assert_eq!(Odds::American(-110).to_decimal_str().unwrap(), "1.91"); // Tests rounding + + // From Decimal + assert_eq!(Odds::Decimal(dec!(4.0)).to_decimal_str().unwrap(), "4.00"); + assert_eq!(Odds::Decimal(dec!(2.75)).to_decimal_str().unwrap(), "2.75"); + assert_eq!( + Odds::Decimal(dec!(1.3333)).to_decimal_str().unwrap(), + "1.33" + ); // Tests rounding + + // From Fractional + assert_eq!( + Odds::Fractional { num: 1, den: 2 } + .to_decimal_str() + .unwrap(), + "1.50" + ); + assert_eq!( + Odds::Fractional { num: 4, den: 1 } + .to_decimal_str() + .unwrap(), + "5.00" + ); + assert_eq!( + Odds::Fractional { num: 2, den: 3 } + .to_decimal_str() + .unwrap(), + "1.67" + ); // Tests rounding + + // Error cases + assert!(Odds::American(0).to_decimal_str().is_err()); + assert!(Odds::Decimal(dec!(-2.0)).to_decimal_str().is_err()); + assert!( + Odds::Fractional { num: 1, den: 0 } + .to_decimal_str() + .is_err() + ); + } +} diff --git a/src/testing_helpers.rs b/src/testing_helpers.rs new file mode 100644 index 0000000..c57e851 --- /dev/null +++ b/src/testing_helpers.rs @@ -0,0 +1,21 @@ +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +use crate::ConversionError; + +// --- Helper for comparing Decimals in tests --- +pub fn assert_decimal_eq(a: Decimal, b: Decimal) { + use rust_decimal_macros::dec; + let tolerance = dec!(0.001); + assert!( + (a - b).abs() < tolerance, + "Expected {} to be close to {}", + a, + b + ); +} + +pub fn assert_decimal_ok_eq(a: Result, b: Decimal) { + assert!(a.is_ok()); + assert_decimal_eq(a.unwrap(), b); +}