From d71a850f656ed9c60bc8d281983d9c2b77745e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?IDCT=20Bartosz=20Pacho=C5=82ek?= Date: Fri, 14 Nov 2025 12:03:00 +0100 Subject: [PATCH 1/4] Support building from int --- composer.json | 3 +- features/basic_conversion.feature | 18 +-- features/bootstrap/FeatureContext.php | 45 +------- features/comprehensive_conversion.feature | 29 ++--- features/edge_cases.feature | 5 +- features/mathematical_properties.feature | 5 +- features/odds_ladder.feature | 1 + features/probability_float.feature | 64 ----------- src/Odds.php | 102 ++++------------- src/OddsFactory.php | 129 +++++++++++----------- src/OddsLadder.php | 36 ++---- src/OddsLadderInterface.php | 6 +- 12 files changed, 135 insertions(+), 308 deletions(-) delete mode 100644 features/probability_float.feature diff --git a/composer.json b/composer.json index 687e28f..abd402e 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ ], "require": { "php": "^8.2", - "ext-bcmath": "*" + "ext-bcmath": "*", + "gryfoss/int-precision-helper": "^1.1" }, "autoload": { "psr-4": { diff --git a/features/basic_conversion.feature b/features/basic_conversion.feature index 8f5b6fa..7884dfb 100644 --- a/features/basic_conversion.feature +++ b/features/basic_conversion.feature @@ -1,3 +1,4 @@ +@basic Feature: Basic Odds Conversion As a developer using the odds formatter I want to convert between different odds formats @@ -11,50 +12,49 @@ Feature: Basic Odds Conversion Then the decimal odds should be "2.00" And the fractional odds should be "1/1" And the moneyline odds should be "+100" - And the probability should be "50.00" - And all values should be strings + And the probability should be "50" Scenario: Convert decimal to fractional and moneyline (favorites) When I create odds from decimal "1.50" Then the decimal odds should be "1.50" And the fractional odds should be "1/2" And the moneyline odds should be "-200" - And the probability should be "66.67" + And the probability should be "67" Scenario: Convert decimal to fractional and moneyline (underdogs) When I create odds from decimal "3.00" Then the decimal odds should be "3.00" And the fractional odds should be "2/1" And the moneyline odds should be "+200" - And the probability should be "33.33" + And the probability should be "33" Scenario: Convert fractional to decimal and moneyline When I create odds from fractional 3/2 Then the decimal odds should be "2.50" And the fractional odds should be "3/2" And the moneyline odds should be "+150" - And the probability should be "40.00" + And the probability should be "40" Scenario: Convert moneyline positive to decimal and fractional When I create odds from moneyline "+150" Then the decimal odds should be "2.50" And the fractional odds should be "3/2" And the moneyline odds should be "+150" - And the probability should be "40.00" + And the probability should be "40" Scenario: Convert moneyline negative to decimal and fractional When I create odds from moneyline "-200" Then the decimal odds should be "1.50" And the fractional odds should be "1/2" And the moneyline odds should be "-200" - And the probability should be "66.67" + And the probability should be "67" Scenario: Convert even moneyline to decimal and fractional When I create odds from moneyline "0" Then the decimal odds should be "1.00" And the fractional odds should be "0/1" And the moneyline odds should be "0" - And the probability should be "100.00" + And the probability should be "100" Scenario: Decimal normalization When I create odds from decimal "2.5" @@ -67,4 +67,4 @@ Feature: Basic Odds Conversion Then the decimal odds should be "10.00" And the fractional odds should be "9/1" And the moneyline odds should be "+900" - And the probability should be "10.00" + And the probability should be "10" diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index 194b3da..99a0659 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -111,12 +111,12 @@ public function iCreateOddsFromMoneyline(string $moneyline) } /** - * @When I directly create odds with decimal :decimal, fractional :fractional and moneyline :moneyline + * @When I directly create odds with integer value :decimalInt, decimal :decimal, fractional :fractional and moneyline :moneyline */ - public function iDirectlyCreateOddsWithDecimalFractionalAndMoneyline(string $decimal, string $fractional, string $moneyline) + public function iDirectlyCreateOddsWithDecimalFractionalAndMoneyline(int $decimalInt, string $decimal, string $fractional, string $moneyline) { try { - $this->odds = new Odds($decimal, $fractional, $moneyline); + $this->odds = new Odds($decimalInt, $decimal, $fractional, $moneyline); $this->lastException = null; } catch (\Exception $e) { $this->odds = null; @@ -160,33 +160,6 @@ public function theProbabilityShouldBe(string $expected) Assert::assertEquals($expected, $this->odds->getProbability()); } - /** - * @Then the probability float should be :expected - */ - public function theProbabilityFloatShouldBe(float $expected) - { - Assert::assertNotNull($this->odds, 'Odds object should not be null'); - Assert::assertEquals($expected, $this->odds->getProbabilityFloat()); - } - - /** - * @Then the probability float should be greater than :threshold - */ - public function theProbabilityFloatShouldBeGreaterThan(float $threshold) - { - Assert::assertNotNull($this->odds, 'Odds object should not be null'); - Assert::assertGreaterThan($threshold, $this->odds->getProbabilityFloat()); - } - - /** - * @Then the probability float should be less than :threshold - */ - public function theProbabilityFloatShouldBeLessThan(float $threshold) - { - Assert::assertNotNull($this->odds, 'Odds object should not be null'); - Assert::assertLessThan($threshold, $this->odds->getProbabilityFloat()); - } - /** * @Then an InvalidPriceException should be thrown */ @@ -213,18 +186,6 @@ public function theOddsShouldBeSuccessfullyCreated() Assert::assertNull($this->lastException, 'No exception should have been thrown'); } - /** - * @Then all values should be strings - */ - public function allValuesShouldBeStrings() - { - Assert::assertNotNull($this->odds, 'Odds object should not be null'); - Assert::assertIsString($this->odds->getDecimal()); - Assert::assertIsString($this->odds->getFractional()); - Assert::assertIsString($this->odds->getMoneyline()); - Assert::assertIsString($this->odds->getProbability()); - } - /** * @Given I have the following conversion scenarios: */ diff --git a/features/comprehensive_conversion.feature b/features/comprehensive_conversion.feature index cc47dff..59194c3 100644 --- a/features/comprehensive_conversion.feature +++ b/features/comprehensive_conversion.feature @@ -1,3 +1,4 @@ +@comprehensive Feature: Comprehensive Conversion Testing As a developer using the odds formatter I want to test multiple conversion scenarios at once @@ -9,14 +10,14 @@ Feature: Comprehensive Conversion Testing Scenario: Batch decimal to all formats conversion Given I have the following conversion scenarios: | decimal | fractional | moneyline | probability | - | 1.01 | 1/100 | -10000 | 99.01 | - | 1.50 | 1/2 | -200 | 66.67 | - | 2.00 | 1/1 | +100 | 50.00 | - | 2.50 | 3/2 | +150 | 40.00 | - | 3.00 | 2/1 | +200 | 33.33 | - | 4.00 | 3/1 | +300 | 25.00 | - | 5.00 | 4/1 | +400 | 20.00 | - | 10.00 | 9/1 | +900 | 10.00 | + | 1.01 | 1/100 | -10000 | 99 | + | 1.50 | 1/2 | -200 | 67 | + | 2.00 | 1/1 | +100 | 50 | + | 2.50 | 3/2 | +150 | 40 | + | 3.00 | 2/1 | +200 | 33 | + | 4.00 | 3/1 | +300 | 25 | + | 5.00 | 4/1 | +400 | 20 | + | 10.00 | 9/1 | +900 | 10 | Scenario: Batch fractional to decimal conversion Given I test the following fractional to decimal conversions: @@ -62,13 +63,13 @@ Feature: Comprehensive Conversion Testing Scenario: Probability calculations verification When I create odds from decimal "1.25" - Then the probability should be "80.00" + Then the probability should be "80" When I create odds from decimal "1.67" - Then the probability should be "59.88" + Then the probability should be "60" When I create odds from decimal "3.33" - Then the probability should be "30.03" + Then the probability should be "30" When I create odds from decimal "6.67" - Then the probability should be "14.99" + Then the probability should be "15" Scenario: Decimal precision and rounding When I create odds from decimal "1.123" @@ -92,9 +93,9 @@ Feature: Comprehensive Conversion Testing Scenario: Very small probability odds When I create odds from decimal "1.01" - Then the probability should be "99.01" + Then the probability should be "99" When I create odds from decimal "1.001" - Then the probability should be "100.00" + Then the probability should be "100" Scenario: Round-trip conversion consistency When I create odds from decimal "2.75" diff --git a/features/edge_cases.feature b/features/edge_cases.feature index 9019862..41d3f46 100644 --- a/features/edge_cases.feature +++ b/features/edge_cases.feature @@ -1,3 +1,4 @@ +@edge Feature: Edge Cases and Error Handling As a developer using the odds formatter I want the library to handle edge cases gracefully @@ -46,8 +47,8 @@ Feature: Edge Cases and Error Handling And the probability should be "100.00" Scenario: Direct Odds construction with invalid decimal - When I directly create odds with decimal "0.5", fractional "1/2" and moneyline "+100" - Then an InvalidPriceException should be thrown with message "Invalid decimal value provided: 0.5. Min value: 1.0" + When I directly create odds with integer value "50", decimal "0.5", fractional "1/2" and moneyline "+100" + Then an InvalidPriceException should be thrown with message "Invalid fixed precision value provided: 50. Min value: 100 (representing 1.00)" Scenario: Very high decimal odds When I create odds from decimal "100.00" diff --git a/features/mathematical_properties.feature b/features/mathematical_properties.feature index 414ed14..33fcbf8 100644 --- a/features/mathematical_properties.feature +++ b/features/mathematical_properties.feature @@ -1,3 +1,4 @@ +@math Feature: Mathematical Properties and Validation As a developer using the odds formatter I want to verify mathematical properties and relationships @@ -43,10 +44,10 @@ Feature: Mathematical Properties and Validation Scenario: Large number precision handling When I create odds from decimal "999.99" Then the decimal odds should be "999.99" - And the probability should be "0.10" + And the probability should be "0" When I create odds from fractional 999/1 Then the decimal odds should be "1000.00" - And the probability should be "0.10" + And the probability should be "0" Scenario: Micro-precision decimal handling When I create odds from decimal "1.0001" diff --git a/features/odds_ladder.feature b/features/odds_ladder.feature index 54df189..dedbbb3 100644 --- a/features/odds_ladder.feature +++ b/features/odds_ladder.feature @@ -1,3 +1,4 @@ +@ladder Feature: Odds Ladder Integration As a developer using the odds formatter I want to use different odds ladders for fractional conversion diff --git a/features/probability_float.feature b/features/probability_float.feature deleted file mode 100644 index f5728af..0000000 --- a/features/probability_float.feature +++ /dev/null @@ -1,64 +0,0 @@ -Feature: Probability Float Functionality - As a developer using the odds formatter - I want to access probability as a float value - So that I can perform numerical comparisons and calculations - - Background: - Given I have an odds factory - - Scenario: Basic probability float access - When I create odds from decimal "2.00" - Then the probability should be "50.00" - And the probability float should be 50.0 - - Scenario: Probability float precision - When I create odds from decimal "1.50" - Then the probability should be "66.67" - And the probability float should be 66.67 - - Scenario: Probability float comparisons - favorites - When I create odds from decimal "1.25" - Then the probability float should be greater than 75.0 - And the probability float should be less than 85.0 - - Scenario: Probability float comparisons - underdogs - When I create odds from decimal "4.00" - Then the probability float should be greater than 20.0 - And the probability float should be less than 30.0 - - Scenario: Edge case - minimum odds - When I create odds from decimal "1.00" - Then the probability float should be 100.0 - - Scenario: Edge case - very low probability - When I create odds from decimal "100.00" - Then the probability float should be 1.0 - - Scenario: Edge case - high precision - When I create odds from decimal "3.33" - Then the probability should be "30.03" - And the probability float should be 30.03 - - Scenario Outline: Multiple probability float validations - When I create odds from decimal "" - Then the probability float should be - - Examples: - | decimal | expected_float | - | 1.01 | 99.01 | - | 1.10 | 90.91 | - | 1.33 | 75.19 | - | 2.50 | 40.0 | - | 5.00 | 20.0 | - | 10.00 | 10.0 | - - Scenario: Probability float vs string consistency - When I create odds from decimal "1.67" - Then the probability should be "59.88" - And the probability float should be 59.88 - - Scenario: Comparison between different odds - Given I create odds from decimal "1.50" - Then the probability float should be greater than 60.0 - When I create odds from decimal "3.00" - Then the probability float should be less than 40.0 diff --git a/src/Odds.php b/src/Odds.php index 8c1eb65..289ae06 100644 --- a/src/Odds.php +++ b/src/Odds.php @@ -14,29 +14,43 @@ final class Odds private const DECIMAL_PRECISION = 2; private const SCALE_FACTOR = 100; // For 2 decimal places + private readonly int $fixedPrecisionOdds; private readonly string $decimal; private readonly string $fractional; private readonly string $moneyline; - private readonly string $probability; - private readonly float $probabilityFloat; + private readonly int $probability; /** * @param string $decimal The decimal odds value as string (e.g., "2.50") * @param string $fractional The fractional odds value (e.g., "1/2") * @param string $moneyline The moneyline odds value (e.g., "+100" or "-150") */ - public function __construct(string $decimal, string $fractional, string $moneyline) + public function __construct(int $fixedPrecisionOdds, string $decimal, string $fractional, string $moneyline) { + if ($fixedPrecisionOdds < self::SCALE_FACTOR) { + throw new InvalidPriceException(sprintf('Invalid fixed precision value provided: %d. Min value: %d (representing 1.00)', $fixedPrecisionOdds, self::SCALE_FACTOR)); + } + // Validate decimal format if (!is_numeric($decimal) || bccomp($decimal, '1.0', self::DECIMAL_PRECISION) < 0) { throw new InvalidPriceException(sprintf('Invalid decimal value provided: %s. Min value: 1.0', $decimal)); } - $this->decimal = $this->normalizeDecimal($decimal); + $this->fixedPrecisionOdds = $fixedPrecisionOdds; + $this->decimal = $decimal; $this->fractional = $fractional; $this->moneyline = $moneyline; - $this->probability = $this->calculateProbability($this->decimal); - $this->probabilityFloat = round((float)$this->probability, self::DECIMAL_PRECISION); + $this->probability = (int) round(10000 / $fixedPrecisionOdds); + } + + /** + * Get the fixed precision integer odds value. + * + * E.g., for decimal 2.50, this returns 250. + */ + public function getFixedPrecisionOdds(): int + { + return $this->fixedPrecisionOdds; } /** @@ -66,82 +80,8 @@ public function getMoneyline(): string /** * Get the calculated probability. */ - public function getProbability(): string + public function getProbability(): int { return $this->probability; } - - /** - * Get the calculated probability as a float. - * Useful for numerical comparisons. - */ - public function getProbabilityFloat(): float - { - return $this->probabilityFloat; - } - - /** - * Calculate the implied probability from decimal odds. - * Returns as percentage string (e.g., "40.00") - */ - private function calculateProbability(string $decimal): string - { - // probability = 1 / decimal odds * 100 (for percentage) - // Use higher precision for intermediate calculation and round properly - $probability = bcdiv('1', $decimal, 6); // High precision for intermediate calculation - $probabilityPercent = bcmul($probability, '100', 6); - - // Round to 2 decimal places using bcmath - return $this->bcRound($probabilityPercent, self::DECIMAL_PRECISION); - } - - /** - * Normalize decimal string to 2 decimal places. - */ - private function normalizeDecimal(string $decimal): string - { - $decimalInt = $this->stringToInt($decimal); - return $this->intToString($decimalInt); - } - - /** - * Convert string decimal to integer (multiply by 100). - * E.g., "2.50" -> 250 - */ - private function stringToInt(string $decimal): int - { - // Use bcmath to multiply by scale factor and round properly - $scaled = bcmul($decimal, (string)self::SCALE_FACTOR, 2); - // Add 0.5 for proper rounding before converting to int - $rounded = bcadd($scaled, '0.5', 2); - return (int)bcdiv($rounded, '1', 0); - } - - /** - * Convert integer back to string decimal (divide by 100). - * E.g., 250 -> "2.50" - */ - private function intToString(int $value): string - { - return number_format($value / self::SCALE_FACTOR, self::DECIMAL_PRECISION, '.', ''); - } - - /** - * Round a bcmath number to specified decimal places. - */ - private function bcRound(string $number, int $precision): string - { - $factor = bcpow('10', (string)$precision, 0); - $multiplied = bcmul($number, $factor, $precision + 1); - - // Proper rounding for both positive and negative numbers - if (bccomp($multiplied, '0', $precision + 1) >= 0) { - $rounded = bcadd($multiplied, '0.5', $precision + 1); - } else { - $rounded = bcsub($multiplied, '0.5', $precision + 1); - } - - $truncated = bcdiv($rounded, '1', 0); - return bcdiv($truncated, $factor, $precision); - } } diff --git a/src/OddsFactory.php b/src/OddsFactory.php index d67dbcf..9827333 100644 --- a/src/OddsFactory.php +++ b/src/OddsFactory.php @@ -4,6 +4,7 @@ namespace GryfOSS\Odds; +use GryfOSS\Formatter\IntPrecisionHelper; use GryfOSS\Odds\Exception\InvalidPriceException; /** @@ -15,7 +16,7 @@ final class OddsFactory private const SCALE_FACTOR = 100; // For integer calculations public function __construct( - private ?OddsLadderInterface $oddsLadder = null + private ?OddsLadderInterface $oddsLadder = null, ) { // If no odds ladder is provided, will use default mathematical conversion } @@ -31,18 +32,43 @@ public function fromDecimal(string $decimal): Odds throw new InvalidPriceException(sprintf('Invalid decimal value provided: %s. Min value: 1.0', $decimal)); } - $normalizedDecimal = $this->normalizeDecimal($decimal); - $fractional = $this->decimalToFractional($normalizedDecimal); - $moneyline = $this->decimalToMoneyline($normalizedDecimal); + $decimalInt = IntPrecisionHelper::fromString($decimal); + // and normalize back to get proper format + $normalizedDecimal = IntPrecisionHelper::toView($decimalInt, self::DECIMAL_PRECISION); - return new Odds($normalizedDecimal, $fractional, $moneyline); + $fractional = $this->decimalToFractional($decimalInt); + $moneyline = $this->decimalToMoneyline($decimalInt); + + return new Odds($decimalInt, $normalizedDecimal, $fractional, $moneyline); + } + + /** + * Create Odds from fixed precision integer value. + * + * This method accepts an already normalized integer where the decimal value + * has been multiplied by the scale factor (100). For example, to represent + * decimal 1.23, pass 123 as the fixedPrecisionInt parameter. + * + * @param int $fixedPrecisionInt The normalized integer value (decimal * 100) + * + * @throws InvalidPriceException + */ + public function fromFixedPrecision(int $fixedPrecisionInt): Odds + { + if ($fixedPrecisionInt < self::SCALE_FACTOR) { + throw new InvalidPriceException(sprintf('Invalid fixed precision value provided: %d. Min value: %d (representing 1.00)', $fixedPrecisionInt, self::SCALE_FACTOR)); + } + + $normalizedDecimal = IntPrecisionHelper::toView($fixedPrecisionInt, self::DECIMAL_PRECISION); + $fractional = $this->decimalToFractional($fixedPrecisionInt); + $moneyline = $this->decimalToMoneyline($fixedPrecisionInt); + + return new Odds($fixedPrecisionInt, $normalizedDecimal, $fractional, $moneyline); } /** * Create Odds from fractional value. * - * @param int $numerator - * @param int $denominator * @throws InvalidPriceException */ public function fromFractional(int $numerator, int $denominator): Odds @@ -57,13 +83,13 @@ public function fromFractional(int $numerator, int $denominator): Odds // decimal = (numerator / denominator) + 1 // Using bcmath for precise calculation - $decimal = bcadd(bcdiv((string)$numerator, (string)$denominator, 4), '1', 4); + $decimal = bcadd(bcdiv((string) $numerator, (string) $denominator, 4), '1', 4); $decimal = $this->bcRound($decimal, self::DECIMAL_PRECISION); - $decimalInt = $this->stringToInt($decimal); - $fractional = $numerator . '/' . $denominator; - $moneyline = $this->decimalToMoneyline($decimal); + $decimalInt = IntPrecisionHelper::fromString($decimal); + $fractional = $numerator.'/'.$denominator; + $moneyline = $this->decimalToMoneyline($decimalInt); - return new Odds($decimal, $fractional, $moneyline); + return new Odds($decimalInt, $decimal, $fractional, $moneyline); } /** @@ -78,34 +104,33 @@ public function fromMoneyline(string $moneyline): Odds } $decimal = $this->moneylineToDecimal($moneyline); - $fractional = $this->decimalToFractional($decimal); + $decimalInt = IntPrecisionHelper::fromString($decimal); + $fractional = $this->decimalToFractional($decimalInt); $moneylineFormatted = $this->formatMoneyline($moneyline); - return new Odds($decimal, $fractional, $moneylineFormatted); + return new Odds($decimalInt, $decimal, $fractional, $moneylineFormatted); } /** * Convert decimal to fractional using odds ladder or default conversion. */ - private function decimalToFractional(string $decimal): string + private function decimalToFractional(int $decimalInt): string { - if ($this->oddsLadder !== null) { + if (null !== $this->oddsLadder) { // Use the injected odds ladder - return $this->oddsLadder->decimalToFractional($decimal); + return $this->oddsLadder->decimalToFractional($decimalInt); } // Use default conversion (same as DecimalOdd::toFractional with useOddsLadder=false) - return $this->defaultDecimalToFractional($decimal); + return $this->defaultDecimalToFractional($decimalInt); } /** * Default decimal to fractional conversion (without odds ladder). */ - private function defaultDecimalToFractional(string $decimal, int $tolerance = 1): string + private function defaultDecimalToFractional(int $decimalInt, int $tolerance = 1): string { - $decimalInt = $this->stringToInt($decimal); - - if ($decimalInt === self::SCALE_FACTOR) { // 1.00 + if (self::SCALE_FACTOR === $decimalInt) { // 1.00 return '0/1'; } @@ -134,30 +159,35 @@ private function defaultDecimalToFractional(string $decimal, int $tolerance = 1) $b -= $a; } while (abs($v - $n / $d) > $v * $toleranceFloat); - return intval($n) . '/' . intval($d); + return intval($n).'/'.intval($d); } /** - * Convert decimal to moneyline. + * Convert fixed precision integer to moneyline. */ - private function decimalToMoneyline(string $decimal): string + private function decimalToMoneyline(int $decimalInt): string { - if (bccomp($decimal, '1.00', self::DECIMAL_PRECISION) === 0) { + if (self::SCALE_FACTOR === $decimalInt) { // 1.00 return $this->formatMoneyline('0'); } - if (bccomp($decimal, '2.00', self::DECIMAL_PRECISION) >= 0) { + if ($decimalInt >= 200) { // 2.00 or higher // value = 100 * (decimal - 1) + // Convert to decimal string for bcmath operations + $decimal = IntPrecisionHelper::toView($decimalInt, self::DECIMAL_PRECISION); $decimalMinus1 = bcsub($decimal, '1', 4); $value = bcmul('100', $decimalMinus1, 4); } else { // value = -100 / (decimal - 1) + // Convert to decimal string for bcmath operations + $decimal = IntPrecisionHelper::toView($decimalInt, self::DECIMAL_PRECISION); $decimalMinus1 = bcsub($decimal, '1', 6); // Higher precision for division $value = bcdiv('-100', $decimalMinus1, 4); } // Round to 2 decimal places $roundedValue = $this->bcRound($value, self::DECIMAL_PRECISION); + return $this->formatMoneyline($roundedValue); } @@ -193,61 +223,31 @@ private function formatMoneyline(string $value): string // Check if it's a whole number by comparing with truncated version $truncated = bcdiv($value, '1', 0); - if (bccomp($value, $truncated, self::DECIMAL_PRECISION) === 0) { - return $sign . $truncated; + if (0 === bccomp($value, $truncated, self::DECIMAL_PRECISION)) { + return $sign.$truncated; } // Format with proper decimal places using bcmath $rounded = $this->bcRound($value, self::DECIMAL_PRECISION); // Ensure it has exactly 2 decimal places if it's not a whole number - if (strpos($rounded, '.') === false) { + if (false === mb_strpos($rounded, '.')) { $rounded .= '.00'; - } elseif (strlen(substr($rounded, strpos($rounded, '.') + 1)) === 1) { + } elseif (1 === mb_strlen(mb_substr($rounded, mb_strpos($rounded, '.') + 1))) { $rounded .= '0'; } - return $sign . $rounded; - } - - /** - * Normalize decimal string to 2 decimal places. - */ - private function normalizeDecimal(string $decimal): string - { - $decimalInt = $this->stringToInt($decimal); - return $this->intToString($decimalInt); - } - - /** - * Convert string decimal to integer (multiply by 100). - * E.g., "2.50" -> 250 - */ - private function stringToInt(string $decimal): int - { - // Use bcmath to multiply by scale factor and round properly - $scaled = bcmul($decimal, (string)self::SCALE_FACTOR, 2); - // Add 0.5 for proper rounding before converting to int - $rounded = bcadd($scaled, '0.5', 2); - return (int)bcdiv($rounded, '1', 0); - } - - /** - * Convert integer back to string decimal (divide by 100). - * E.g., 250 -> "2.50" - */ - private function intToString(int $value): string - { - return number_format($value / self::SCALE_FACTOR, self::DECIMAL_PRECISION, '.', ''); + return $sign.$rounded; } /** * Round a bcmath number to specified decimal places. + * * @todo in the future replace with native bcround */ private function bcRound(string $number, int $precision): string { - $factor = bcpow('10', (string)$precision, 0); + $factor = bcpow('10', (string) $precision, 0); $multiplied = bcmul($number, $factor, $precision + 1); // Proper rounding for both positive and negative numbers @@ -258,6 +258,7 @@ private function bcRound(string $number, int $precision): string } $truncated = bcdiv($rounded, '1', 0); + return bcdiv($truncated, $factor, $precision); } } diff --git a/src/OddsLadder.php b/src/OddsLadder.php index 6b6140b..bec044e 100644 --- a/src/OddsLadder.php +++ b/src/OddsLadder.php @@ -4,6 +4,8 @@ namespace GryfOSS\Odds; +use GryfOSS\Formatter\IntPrecisionHelper; + /** * Base odds ladder implementation with configurable lookup table. */ @@ -13,21 +15,20 @@ class OddsLadder implements OddsLadderInterface private const SCALE_FACTOR = 100; // For integer calculations /** - * Convert decimal odds to fractional using odds ladder lookup. + * Convert fixed precision integer odds to fractional using odds ladder lookup. */ - public function decimalToFractional(string $decimal): string + public function decimalToFractional(int $fixedPrecisionInt): string { - $decimalInt = $this->stringToInt($decimal); $ladder = $this->getLadder(); foreach ($ladder as $thresholdInt => $value) { - if ($decimalInt <= $thresholdInt) { + if ($fixedPrecisionInt <= $thresholdInt) { return $value; } } // Fallback for high odds - return $this->fallbackConversion($decimal); + return $this->fallbackConversion($fixedPrecisionInt); } /** @@ -89,32 +90,13 @@ protected function getLadder(): array /** * Fallback conversion for odds not in the ladder. */ - protected function fallbackConversion(string $decimal): string + protected function fallbackConversion(int $fixedPrecisionInt): string { // For high odds, return (decimal - 1)/1 + // Convert to decimal first: fixedPrecisionInt / 100, then subtract 1 + $decimal = IntPrecisionHelper::toView($fixedPrecisionInt, self::DECIMAL_PRECISION); $numerator = bcsub($decimal, '1', 0); return $numerator . '/1'; } - /** - * Convert string decimal to integer (multiply by 100). - * E.g., "2.50" -> 250 - */ - protected function stringToInt(string $decimal): int - { - // Use bcmath to multiply by scale factor and round properly - $scaled = bcmul($decimal, (string)self::SCALE_FACTOR, 2); - // Add 0.5 for proper rounding before converting to int - $rounded = bcadd($scaled, '0.5', 2); - return (int)bcdiv($rounded, '1', 0); - } - - /** - * Convert integer back to string decimal (divide by 100). - * E.g., 250 -> "2.50" - */ - protected function intToString(int $value): string - { - return number_format($value / self::SCALE_FACTOR, self::DECIMAL_PRECISION, '.', ''); - } } diff --git a/src/OddsLadderInterface.php b/src/OddsLadderInterface.php index 7e2caaa..7cea6b7 100644 --- a/src/OddsLadderInterface.php +++ b/src/OddsLadderInterface.php @@ -10,7 +10,9 @@ interface OddsLadderInterface { /** - * Convert decimal odds to fractional using odds ladder. + * Convert fixed precision integer odds to fractional using odds ladder. + * + * @param int $fixedPrecisionInt The normalized integer value (decimal * 100) */ - public function decimalToFractional(string $decimal): string; + public function decimalToFractional(int $fixedPrecisionInt): string; } From 9a9aae0194cea185be69e03d1f50d1e987078242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?IDCT=20Bartosz=20Pacho=C5=82ek?= Date: Fri, 14 Nov 2025 12:41:29 +0100 Subject: [PATCH 2/4] native bc round --- src/OddsFactory.php | 30 +-- tests/CustomOddsLadderTest.php | 68 +++--- tests/OddsFactoryTest.php | 350 +++++++++++++++++++++++++++++- tests/OddsLadderInterfaceTest.php | 8 +- tests/OddsLadderTest.php | 78 +++---- tests/OddsTest.php | 164 +++++++++----- 6 files changed, 507 insertions(+), 191 deletions(-) diff --git a/src/OddsFactory.php b/src/OddsFactory.php index 9827333..05f0c90 100644 --- a/src/OddsFactory.php +++ b/src/OddsFactory.php @@ -84,7 +84,7 @@ public function fromFractional(int $numerator, int $denominator): Odds // decimal = (numerator / denominator) + 1 // Using bcmath for precise calculation $decimal = bcadd(bcdiv((string) $numerator, (string) $denominator, 4), '1', 4); - $decimal = $this->bcRound($decimal, self::DECIMAL_PRECISION); + $decimal = bcround($decimal, self::DECIMAL_PRECISION); $decimalInt = IntPrecisionHelper::fromString($decimal); $fractional = $numerator.'/'.$denominator; $moneyline = $this->decimalToMoneyline($decimalInt); @@ -186,7 +186,7 @@ private function decimalToMoneyline(int $decimalInt): string } // Round to 2 decimal places - $roundedValue = $this->bcRound($value, self::DECIMAL_PRECISION); + $roundedValue = bcround($value, self::DECIMAL_PRECISION); return $this->formatMoneyline($roundedValue); } @@ -207,7 +207,7 @@ private function moneylineToDecimal(string $moneyline): string } // Round to 2 decimal places using bcmath - return $this->bcRound($decimal, self::DECIMAL_PRECISION); + return bcround($decimal, self::DECIMAL_PRECISION); } /** @@ -228,7 +228,7 @@ private function formatMoneyline(string $value): string } // Format with proper decimal places using bcmath - $rounded = $this->bcRound($value, self::DECIMAL_PRECISION); + $rounded = bcround($value, self::DECIMAL_PRECISION); // Ensure it has exactly 2 decimal places if it's not a whole number if (false === mb_strpos($rounded, '.')) { @@ -239,26 +239,4 @@ private function formatMoneyline(string $value): string return $sign.$rounded; } - - /** - * Round a bcmath number to specified decimal places. - * - * @todo in the future replace with native bcround - */ - private function bcRound(string $number, int $precision): string - { - $factor = bcpow('10', (string) $precision, 0); - $multiplied = bcmul($number, $factor, $precision + 1); - - // Proper rounding for both positive and negative numbers - if (bccomp($multiplied, '0', $precision + 1) >= 0) { - $rounded = bcadd($multiplied, '0.5', $precision + 1); - } else { - $rounded = bcsub($multiplied, '0.5', $precision + 1); - } - - $truncated = bcdiv($rounded, '1', 0); - - return bcdiv($truncated, $factor, $precision); - } } diff --git a/tests/CustomOddsLadderTest.php b/tests/CustomOddsLadderTest.php index 28d37ea..ba84d5f 100644 --- a/tests/CustomOddsLadderTest.php +++ b/tests/CustomOddsLadderTest.php @@ -22,54 +22,54 @@ protected function setUp(): void public function testDecimalToFractionalWithCustomLadder(): void { // Test values that should map to custom ladder values - $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional('1.20')); - $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional('1.15')); // Should use 1.20 threshold - $this->assertEquals('1/4', $this->customOddsLadder->decimalToFractional('1.25')); - $this->assertEquals('1/3', $this->customOddsLadder->decimalToFractional('1.33')); - $this->assertEquals('1/2', $this->customOddsLadder->decimalToFractional('1.50')); - $this->assertEquals('1/1', $this->customOddsLadder->decimalToFractional('2.00')); - $this->assertEquals('3/2', $this->customOddsLadder->decimalToFractional('2.50')); - $this->assertEquals('2/1', $this->customOddsLadder->decimalToFractional('3.00')); - $this->assertEquals('3/1', $this->customOddsLadder->decimalToFractional('4.00')); - $this->assertEquals('4/1', $this->customOddsLadder->decimalToFractional('5.00')); - $this->assertEquals('5/1', $this->customOddsLadder->decimalToFractional('6.00')); + $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional(120)); // 1.20 + $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional(115)); // 1.15 - Should use 1.20 threshold + $this->assertEquals('1/4', $this->customOddsLadder->decimalToFractional(125)); // 1.25 + $this->assertEquals('1/3', $this->customOddsLadder->decimalToFractional(133)); // 1.33 + $this->assertEquals('1/2', $this->customOddsLadder->decimalToFractional(150)); // 1.50 + $this->assertEquals('1/1', $this->customOddsLadder->decimalToFractional(200)); // 2.00 + $this->assertEquals('3/2', $this->customOddsLadder->decimalToFractional(250)); // 2.50 + $this->assertEquals('2/1', $this->customOddsLadder->decimalToFractional(300)); // 3.00 + $this->assertEquals('3/1', $this->customOddsLadder->decimalToFractional(400)); // 4.00 + $this->assertEquals('4/1', $this->customOddsLadder->decimalToFractional(500)); // 5.00 + $this->assertEquals('5/1', $this->customOddsLadder->decimalToFractional(600)); // 6.00 } public function testDecimalToFractionalBelowFirstThreshold(): void { // Test values below the first threshold in custom ladder - $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional('1.10')); - $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional('1.05')); - $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional('1.01')); + $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional(110)); // 1.10 + $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional(105)); // 1.05 + $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional(101)); // 1.01 } public function testDecimalToFractionalAtExactThresholds(): void { // Test exact threshold values - $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional('1.20')); - $this->assertEquals('1/4', $this->customOddsLadder->decimalToFractional('1.25')); - $this->assertEquals('1/3', $this->customOddsLadder->decimalToFractional('1.33')); - $this->assertEquals('1/2', $this->customOddsLadder->decimalToFractional('1.50')); - $this->assertEquals('1/1', $this->customOddsLadder->decimalToFractional('2.00')); + $this->assertEquals('1/5', $this->customOddsLadder->decimalToFractional(120)); // 1.20 + $this->assertEquals('1/4', $this->customOddsLadder->decimalToFractional(125)); // 1.25 + $this->assertEquals('1/3', $this->customOddsLadder->decimalToFractional(133)); // 1.33 + $this->assertEquals('1/2', $this->customOddsLadder->decimalToFractional(150)); // 1.50 + $this->assertEquals('1/1', $this->customOddsLadder->decimalToFractional(200)); // 2.00 } public function testDecimalToFractionalBetweenThresholds(): void { // Test values between thresholds - $this->assertEquals('1/4', $this->customOddsLadder->decimalToFractional('1.24')); - $this->assertEquals('1/3', $this->customOddsLadder->decimalToFractional('1.32')); - $this->assertEquals('1/2', $this->customOddsLadder->decimalToFractional('1.45')); - $this->assertEquals('1/1', $this->customOddsLadder->decimalToFractional('1.90')); - $this->assertEquals('3/2', $this->customOddsLadder->decimalToFractional('2.40')); + $this->assertEquals('1/4', $this->customOddsLadder->decimalToFractional(124)); // 1.24 + $this->assertEquals('1/3', $this->customOddsLadder->decimalToFractional(132)); // 1.32 + $this->assertEquals('1/2', $this->customOddsLadder->decimalToFractional(145)); // 1.45 + $this->assertEquals('1/1', $this->customOddsLadder->decimalToFractional(190)); // 1.90 + $this->assertEquals('3/2', $this->customOddsLadder->decimalToFractional(240)); // 2.40 } public function testDecimalToFractionalFallbackForHighOdds(): void { // Test values that exceed the custom ladder (should use fallback conversion) - $this->assertEquals('6/1', $this->customOddsLadder->decimalToFractional('7.00')); - $this->assertEquals('9/1', $this->customOddsLadder->decimalToFractional('10.00')); - $this->assertEquals('19/1', $this->customOddsLadder->decimalToFractional('20.00')); - $this->assertEquals('49/1', $this->customOddsLadder->decimalToFractional('50.00')); + $this->assertEquals('6/1', $this->customOddsLadder->decimalToFractional(700)); // 7.00 + $this->assertEquals('9/1', $this->customOddsLadder->decimalToFractional(1000)); // 10.00 + $this->assertEquals('19/1', $this->customOddsLadder->decimalToFractional(2000)); // 20.00 + $this->assertEquals('49/1', $this->customOddsLadder->decimalToFractional(5000)); // 50.00 } public function testCustomLadderStructure(): void @@ -107,17 +107,5 @@ public function testInheritanceFromOddsLadder(): void $this->assertInstanceOf(\GryfOSS\Odds\OddsLadderInterface::class, $this->customOddsLadder); } - public function testStringIntConversionsInherited(): void - { - // Test that inherited protected methods work correctly - $reflection = new \ReflectionClass($this->customOddsLadder); - $stringToIntMethod = $reflection->getMethod('stringToInt'); - $stringToIntMethod->setAccessible(true); - $this->assertEquals(200, $stringToIntMethod->invoke($this->customOddsLadder, '2.00')); - - $intToStringMethod = $reflection->getMethod('intToString'); - $intToStringMethod->setAccessible(true); - $this->assertEquals('2.00', $intToStringMethod->invoke($this->customOddsLadder, 200)); - } } diff --git a/tests/OddsFactoryTest.php b/tests/OddsFactoryTest.php index 2844e23..6b6da12 100644 --- a/tests/OddsFactoryTest.php +++ b/tests/OddsFactoryTest.php @@ -29,7 +29,7 @@ public function testFromDecimal(): void $this->assertEquals('2.00', $odds->getDecimal()); $this->assertEquals('1/1', $odds->getFractional()); $this->assertEquals('+100', $odds->getMoneyline()); - $this->assertEquals('50.00', $odds->getProbability()); + $this->assertEquals(50, $odds->getProbability()); } public function testFromDecimalWithOddsLadder(): void @@ -39,7 +39,73 @@ public function testFromDecimalWithOddsLadder(): void $this->assertEquals('1.50', $odds->getDecimal()); $this->assertEquals('1/2', $odds->getFractional()); $this->assertEquals('-200', $odds->getMoneyline()); - $this->assertEquals('66.67', $odds->getProbability()); + $this->assertEquals(67, $odds->getProbability()); + } + + public function testFromFixedPrecision(): void + { + $odds = $this->factory->fromFixedPrecision(200); // 2.00 + + $this->assertEquals('2.00', $odds->getDecimal()); + $this->assertEquals('1/1', $odds->getFractional()); + $this->assertEquals('+100', $odds->getMoneyline()); + $this->assertEquals(50, $odds->getProbability()); + } + + public function testFromFixedPrecisionWithOddsLadder(): void + { + $odds = $this->factory->fromFixedPrecision(150); // 1.50 + + $this->assertEquals('1.50', $odds->getDecimal()); + $this->assertEquals('1/2', $odds->getFractional()); + $this->assertEquals('-200', $odds->getMoneyline()); + $this->assertEquals(67, $odds->getProbability()); + } + + public function testFromFixedPrecisionInvalid(): void + { + $this->expectException(InvalidPriceException::class); + $this->expectExceptionMessage('Invalid fixed precision value provided: 50. Min value: 100 (representing 1.00)'); + $this->factory->fromFixedPrecision(50); // Less than 1.00 + } + + public function testFromFixedPrecisionEdgeCases(): void + { + // Test minimum valid value + $odds = $this->factory->fromFixedPrecision(100); // 1.00 + $this->assertEquals('1.00', $odds->getDecimal()); + $this->assertEquals('0/1', $odds->getFractional()); + $this->assertEquals('0', $odds->getMoneyline()); + $this->assertEquals(100, $odds->getProbability()); + $this->assertEquals(100, $odds->getFixedPrecisionOdds()); + + // Test fractional representation + $odds = $this->factory->fromFixedPrecision(123); // 1.23 + $this->assertEquals('1.23', $odds->getDecimal()); + $this->assertEquals(123, $odds->getFixedPrecisionOdds()); + + // Test large values + $odds = $this->factory->fromFixedPrecision(10000); // 100.00 + $this->assertEquals('100.00', $odds->getDecimal()); + $this->assertEquals(10000, $odds->getFixedPrecisionOdds()); + $this->assertEquals(1, $odds->getProbability()); + } + + public function testFromFixedPrecisionWithOddsLadders(): void + { + // Test with standard odds ladder + $standardFactory = new OddsFactory(new OddsLadder()); + $odds = $standardFactory->fromFixedPrecision(102); // 1.02 + $this->assertEquals('1.02', $odds->getDecimal()); + $this->assertEquals('1/50', $odds->getFractional()); + $this->assertEquals(102, $odds->getFixedPrecisionOdds()); + + // Test with custom odds ladder + $customFactory = new OddsFactory(new CustomOddsLadder()); + $odds = $customFactory->fromFixedPrecision(120); // 1.20 + $this->assertEquals('1.20', $odds->getDecimal()); + $this->assertEquals('1/5', $odds->getFractional()); + $this->assertEquals(120, $odds->getFixedPrecisionOdds()); } public function testFromDecimalInvalid(): void @@ -55,7 +121,7 @@ public function testFromFractional(): void $this->assertEquals('2.00', $odds->getDecimal()); $this->assertEquals('1/1', $odds->getFractional()); $this->assertEquals('+100', $odds->getMoneyline()); - $this->assertEquals('50.00', $odds->getProbability()); + $this->assertEquals(50, $odds->getProbability()); } public function testFromFractionalEvens(): void @@ -65,7 +131,7 @@ public function testFromFractionalEvens(): void $this->assertEquals('1.50', $odds->getDecimal()); $this->assertEquals('1/2', $odds->getFractional()); $this->assertEquals('-200', $odds->getMoneyline()); - $this->assertEquals('66.67', $odds->getProbability()); + $this->assertEquals(67, $odds->getProbability()); } public function testFromFractionalInvalidNumerator(): void @@ -87,7 +153,7 @@ public function testFromMoneyline(): void $this->assertEquals('2.00', $odds->getDecimal()); $this->assertEquals('1/1', $odds->getFractional()); $this->assertEquals('+100', $odds->getMoneyline()); - $this->assertEquals('50.00', $odds->getProbability()); + $this->assertEquals(50, $odds->getProbability()); } public function testFromMoneylineNegative(): void @@ -97,7 +163,7 @@ public function testFromMoneylineNegative(): void $this->assertEquals('1.50', $odds->getDecimal()); $this->assertEquals('1/2', $odds->getFractional()); $this->assertEquals('-200', $odds->getMoneyline()); - $this->assertEquals('66.67', $odds->getProbability()); + $this->assertEquals(67, $odds->getProbability()); } public function testFromMoneylineEven(): void @@ -107,7 +173,7 @@ public function testFromMoneylineEven(): void $this->assertEquals('1.00', $odds->getDecimal()); $this->assertEquals('0/1', $odds->getFractional()); $this->assertEquals('0', $odds->getMoneyline()); - $this->assertEquals('100.00', $odds->getProbability()); + $this->assertEquals(100, $odds->getProbability()); } public function testWithCustomOddsLadder(): void @@ -120,7 +186,7 @@ public function testWithCustomOddsLadder(): void $this->assertEquals('2.00', $odds->getDecimal()); $this->assertEquals('1/1', $odds->getFractional()); // From odds ladder $this->assertEquals('+100', $odds->getMoneyline()); - $this->assertEquals('50.00', $odds->getProbability()); + $this->assertEquals(50, $odds->getProbability()); } public function testDefaultConversionWithoutOddsLadder(): void @@ -132,7 +198,7 @@ public function testDefaultConversionWithoutOddsLadder(): void $this->assertEquals('2.00', $odds->getDecimal()); $this->assertEquals('1/1', $odds->getFractional()); // Default mathematical conversion $this->assertEquals('+100', $odds->getMoneyline()); - $this->assertEquals('50.00', $odds->getProbability()); + $this->assertEquals(50, $odds->getProbability()); } public function testWithCustomOddsLadderExtension(): void @@ -145,7 +211,7 @@ public function testWithCustomOddsLadderExtension(): void $this->assertEquals('1.90', $odds->getDecimal()); $this->assertEquals('1/1', $odds->getFractional()); // From custom ladder (1.90 <= 2.0 -> '1/1') $this->assertEquals('-111.11', $odds->getMoneyline()); - $this->assertEquals('52.63', $odds->getProbability()); + $this->assertEquals(53, $odds->getProbability()); } public function testFromDecimalEdgeCases(): void @@ -155,7 +221,7 @@ public function testFromDecimalEdgeCases(): void $this->assertEquals('1.00', $odds->getDecimal()); $this->assertEquals('0/1', $odds->getFractional()); $this->assertEquals('0', $odds->getMoneyline()); - $this->assertEquals('100.00', $odds->getProbability()); + $this->assertEquals(100, $odds->getProbability()); // Test decimal with many digits (should be rounded) $odds = $this->factory->fromDecimal('2.005'); @@ -178,13 +244,50 @@ public function testFromDecimalNegative(): void $this->factory->fromDecimal('-2.0'); } + public function testBoundaryValues(): void + { + // Test exactly at boundary values + $odds = $this->factory->fromDecimal('1.0'); + $this->assertEquals('1.00', $odds->getDecimal()); + $this->assertEquals(100, $odds->getProbability()); + + // Test just above minimum + $odds = $this->factory->fromDecimal('1.001'); + $this->assertEquals('1.00', $odds->getDecimal()); // Should round down to 1.00 + + // Test very high odds + $odds = $this->factory->fromDecimal('999.99'); + $this->assertEquals('999.99', $odds->getDecimal()); + // For 999.99 (99999 fixed precision), probability = 10000/99999 ≈ 0.1 ≈ 0 when rounded + $this->assertEquals(0, $odds->getProbability()); // Very small probability rounds to 0 + } + + public function testFromFixedPrecisionBoundaryValues(): void + { + // Test exact boundary + $this->expectException(InvalidPriceException::class); + $this->factory->fromFixedPrecision(99); // Just below minimum + } + + public function testFromFixedPrecisionZero(): void + { + $this->expectException(InvalidPriceException::class); + $this->factory->fromFixedPrecision(0); + } + + public function testFromFixedPrecisionNegative(): void + { + $this->expectException(InvalidPriceException::class); + $this->factory->fromFixedPrecision(-100); + } + public function testFromFractionalZeroNumerator(): void { $odds = $this->factory->fromFractional(0, 1); $this->assertEquals('1.00', $odds->getDecimal()); $this->assertEquals('0/1', $odds->getFractional()); $this->assertEquals('0', $odds->getMoneyline()); - $this->assertEquals('100.00', $odds->getProbability()); + $this->assertEquals(100, $odds->getProbability()); } public function testFromFractionalLargeNumbers(): void @@ -246,6 +349,78 @@ public function testFromMoneylineDecimalValues(): void $this->assertEquals('+150.50', $odds->getMoneyline()); } + public function testFromDecimalStringFormats(): void + { + // Test various valid decimal string formats + $testCases = [ + ['1', '1.00'], + ['1.0', '1.00'], + ['1.00', '1.00'], + ['1.000', '1.00'], + ['2.5', '2.50'], + ['2.50', '2.50'], + ['10', '10.00'], + ['10.0', '10.00'] + ]; + + foreach ($testCases as [$input, $expected]) { + $odds = $this->factory->fromDecimal($input); + $this->assertEquals($expected, $odds->getDecimal(), "Failed for input: $input"); + } + } + + public function testFromMoneylineEdgeFormats(): void + { + // Test various moneyline formats + $odds = $this->factory->fromMoneyline('0.00'); + $this->assertEquals('1.00', $odds->getDecimal()); + $this->assertEquals('0', $odds->getMoneyline()); + + $odds = $this->factory->fromMoneyline('-0'); + $this->assertEquals('1.00', $odds->getDecimal()); + $this->assertEquals('0', $odds->getMoneyline()); + + $odds = $this->factory->fromMoneyline('+0'); + $this->assertEquals('1.00', $odds->getDecimal()); + $this->assertEquals('0', $odds->getMoneyline()); + } + + public function testFromFractionalEdgeCases(): void + { + // Test fractional with very large numbers + $odds = $this->factory->fromFractional(1000, 1000); + $this->assertEquals('2.00', $odds->getDecimal()); + $this->assertEquals('1000/1000', $odds->getFractional()); + + // Test fractional that results in precise decimal + $odds = $this->factory->fromFractional(1, 4); // 1/4 + 1 = 1.25 + $this->assertEquals('1.25', $odds->getDecimal()); + $this->assertEquals('1/4', $odds->getFractional()); + $this->assertEquals(80, $odds->getProbability()); + } + + public function testFactoryMethodEquivalence(): void + { + // Test that different methods producing the same decimal give consistent results + $decimalOdds = $this->factory->fromDecimal('2.50'); + $fractionalOdds = $this->factory->fromFractional(3, 2); // 3/2 + 1 = 2.5 + $moneylineOdds = $this->factory->fromMoneyline('+150'); + $fixedPrecisionOdds = $this->factory->fromFixedPrecision(250); + + // All should produce the same decimal value + $this->assertEquals('2.50', $decimalOdds->getDecimal()); + $this->assertEquals('2.50', $fractionalOdds->getDecimal()); + $this->assertEquals('2.50', $moneylineOdds->getDecimal()); + $this->assertEquals('2.50', $fixedPrecisionOdds->getDecimal()); + + // All should have the same probability + $expectedProbability = 40; // 10000/250 = 40 + $this->assertEquals($expectedProbability, $decimalOdds->getProbability()); + $this->assertEquals($expectedProbability, $fractionalOdds->getProbability()); + $this->assertEquals($expectedProbability, $moneylineOdds->getProbability()); + $this->assertEquals($expectedProbability, $fixedPrecisionOdds->getProbability()); + } + public function testDefaultConversionComplexFractional(): void { // Test default fractional conversion with tolerance @@ -257,6 +432,63 @@ public function testDefaultConversionComplexFractional(): void $this->assertNotEmpty($odds->getFractional()); } + public function testFractionalWithLargeNumbers(): void + { + // Test fractional odds with larger denominators + $odds = $this->factory->fromFractional(1, 100); + $this->assertEquals('1.01', $odds->getDecimal()); + $this->assertEquals('1/100', $odds->getFractional()); + + $odds = $this->factory->fromFractional(999, 1); + $this->assertEquals('1000.00', $odds->getDecimal()); + $this->assertEquals('999/1', $odds->getFractional()); + } + + public function testProbabilityCalculationAccuracy(): void + { + // Test probability calculations for various odds + $odds = $this->factory->fromDecimal('1.25'); + $this->assertEquals(80, $odds->getProbability()); // 10000/125 = 80 + + $odds = $this->factory->fromDecimal('4.00'); + $this->assertEquals(25, $odds->getProbability()); // 10000/400 = 25 + + $odds = $this->factory->fromDecimal('20.00'); + $this->assertEquals(5, $odds->getProbability()); // 10000/2000 = 5 + + $odds = $this->factory->fromDecimal('1.01'); + $this->assertEquals(99, $odds->getProbability()); // 10000/101 ≈ 99 + } + + public function testRoundTripConsistency(): void + { + // Test that converting from decimal to fixed precision and back maintains consistency + $testValues = ['1.50', '2.00', '3.33', '10.00', '1.01']; + + foreach ($testValues as $decimal) { + $odds1 = $this->factory->fromDecimal($decimal); + $fixedPrecision = $odds1->getFixedPrecisionOdds(); + $odds2 = $this->factory->fromFixedPrecision($fixedPrecision); + + $this->assertEquals($odds1->getDecimal(), $odds2->getDecimal(), "Decimal consistency failed for $decimal"); + $this->assertEquals($odds1->getFractional(), $odds2->getFractional(), "Fractional consistency failed for $decimal"); + $this->assertEquals($odds1->getMoneyline(), $odds2->getMoneyline(), "Moneyline consistency failed for $decimal"); + $this->assertEquals($odds1->getProbability(), $odds2->getProbability(), "Probability consistency failed for $decimal"); + } + } + + public function testLadderFallbackBehavior(): void + { + $ladderFactory = new OddsFactory(new OddsLadder()); + + // Test values that should use fallback (above ladder range) + $odds = $ladderFactory->fromDecimal('25.00'); + $this->assertEquals('24/1', $odds->getFractional()); // Should use fallback conversion + + $odds = $ladderFactory->fromDecimal('101.00'); + $this->assertEquals('100/1', $odds->getFractional()); // Should use fallback conversion + } + public function testMoneylineFormatting(): void { // Test moneyline formatting for whole numbers @@ -281,4 +513,98 @@ public function testDefaultFractionalConversionEdgeCases(): void $odds = $factory->fromDecimal('2.33'); $this->assertEquals('133/100', $odds->getFractional()); } + + public function testDecimalPrecisionRounding(): void + { + // Test various rounding scenarios + $odds = $this->factory->fromDecimal('1.234'); + $this->assertEquals('1.23', $odds->getDecimal()); + + $odds = $this->factory->fromDecimal('1.235'); + $this->assertEquals('1.24', $odds->getDecimal()); + + $odds = $this->factory->fromDecimal('1.999'); + $this->assertEquals('2.00', $odds->getDecimal()); + + $odds = $this->factory->fromDecimal('1.001'); + $this->assertEquals('1.00', $odds->getDecimal()); + } + + public function testMoneylineEdgeCases(): void + { + // Test edge cases around the 2.00 threshold + $odds = $this->factory->fromDecimal('1.99'); + $this->assertStringStartsWith('-', $odds->getMoneyline()); + + $odds = $this->factory->fromDecimal('2.01'); + $this->assertStringStartsWith('+', $odds->getMoneyline()); + + // Test very small and very large moneylines + $odds = $this->factory->fromMoneyline('-99999'); + $this->assertEquals('1.00', $odds->getDecimal()); + + $odds = $this->factory->fromMoneyline('+99999'); + $this->assertEquals('1000.99', $odds->getDecimal()); + } + + public function testNegativeMoneylineFormatting(): void + { + // Test negative moneyline formatting with decimals to cover bcRound negative branch + $odds = $this->factory->fromDecimal('1.33'); + $moneyline = $odds->getMoneyline(); + $this->assertStringStartsWith('-', $moneyline); + + // Test specific case that should produce negative moneyline with decimals + $odds = $this->factory->fromDecimal('1.25'); + $this->assertEquals('-400', $odds->getMoneyline()); // Should be whole number + + // Test case that produces negative decimal moneyline + $odds = $this->factory->fromDecimal('1.03'); + $moneyline = $odds->getMoneyline(); + $this->assertStringStartsWith('-', $moneyline); + $this->assertStringContainsString('.', $moneyline); // Should contain decimal + } + + public function testMoneylineDecimalFormatting(): void + { + // Test moneyline formatting edge cases to cover formatMoneyline branches + + // Test positive moneyline with decimal (should have .00 or proper decimals) + $odds = $this->factory->fromMoneyline('100.5'); + $this->assertEquals('+100.50', $odds->getMoneyline()); + + // Test negative moneyline with single decimal place (should add trailing zero) + $odds = $this->factory->fromMoneyline('-100.5'); + $this->assertEquals('-100.50', $odds->getMoneyline()); + + // Test moneyline that results in exact decimal (no fractional part) + $odds = $this->factory->fromMoneyline('200'); + $this->assertEquals('+200', $odds->getMoneyline()); // Should not have .00 + + // Test zero moneyline formatting + $odds = $this->factory->fromMoneyline('0.0'); + $this->assertEquals('0', $odds->getMoneyline()); + } + + public function testEdgeCaseRounding(): void + { + // Test cases that should trigger different branches in bcRound and formatMoneyline + + // Test very precise decimal that needs specific rounding + $odds = $this->factory->fromDecimal('1.005'); + $this->assertEquals('1.01', $odds->getDecimal()); // Should round up + + // Test decimal that rounds to whole moneyline but with special formatting + $odds = $this->factory->fromDecimal('1.0099'); + $this->assertEquals('1.01', $odds->getDecimal()); + + // Test negative moneyline that should trigger specific rounding paths + $odds = $this->factory->fromMoneyline('-150.555'); + $moneyline = $odds->getMoneyline(); + $this->assertStringStartsWith('-', $moneyline); + + // Test edge case where moneyline calculation might produce specific rounding scenarios + $odds = $this->factory->fromDecimal('1.004'); + $this->assertEquals('1.00', $odds->getDecimal()); // Should round down + } } diff --git a/tests/OddsLadderInterfaceTest.php b/tests/OddsLadderInterfaceTest.php index ce5f724..65c5a15 100644 --- a/tests/OddsLadderInterfaceTest.php +++ b/tests/OddsLadderInterfaceTest.php @@ -29,23 +29,23 @@ public function testInterfaceHasDecimalToFractionalMethod(): void // Check method signature $parameters = $method->getParameters(); $this->assertCount(1, $parameters); - $this->assertEquals('decimal', $parameters[0]->getName()); + $this->assertEquals('fixedPrecisionInt', $parameters[0]->getName()); $this->assertTrue($parameters[0]->hasType()); - $this->assertEquals('string', $parameters[0]->getType()->getName()); + $this->assertEquals('int', $parameters[0]->getType()->getName()); } public function testInterfaceCanBeImplemented(): void { // Create an anonymous implementation to test the interface $implementation = new class implements OddsLadderInterface { - public function decimalToFractional(string $decimal): string + public function decimalToFractional(int $fixedPrecisionInt): string { return '1/1'; // Simple implementation for testing } }; $this->assertInstanceOf(OddsLadderInterface::class, $implementation); - $this->assertEquals('1/1', $implementation->decimalToFractional('2.00')); + $this->assertEquals('1/1', $implementation->decimalToFractional(200)); // 2.00 as fixed precision int } public function testConcreteClassesImplementInterface(): void diff --git a/tests/OddsLadderTest.php b/tests/OddsLadderTest.php index fb5891f..64a94f9 100644 --- a/tests/OddsLadderTest.php +++ b/tests/OddsLadderTest.php @@ -21,78 +21,52 @@ protected function setUp(): void public function testDecimalToFractionalWithinLadder(): void { - // Test various decimal values that should map to ladder values - $this->assertEquals('1/50', $this->oddsLadder->decimalToFractional('1.02')); - $this->assertEquals('1/50', $this->oddsLadder->decimalToFractional('1.01')); // Should use 1.02 threshold - $this->assertEquals('1/33', $this->oddsLadder->decimalToFractional('1.03')); - $this->assertEquals('1/25', $this->oddsLadder->decimalToFractional('1.04')); - $this->assertEquals('1/2', $this->oddsLadder->decimalToFractional('1.50')); - $this->assertEquals('1/1', $this->oddsLadder->decimalToFractional('2.00')); - $this->assertEquals('9/1', $this->oddsLadder->decimalToFractional('10.00')); + // Test various fixed precision integer values that should map to ladder values + $this->assertEquals('1/50', $this->oddsLadder->decimalToFractional(102)); // 1.02 + $this->assertEquals('1/50', $this->oddsLadder->decimalToFractional(101)); // 1.01 - Should use 1.02 threshold + $this->assertEquals('1/33', $this->oddsLadder->decimalToFractional(103)); // 1.03 + $this->assertEquals('1/25', $this->oddsLadder->decimalToFractional(104)); // 1.04 + $this->assertEquals('1/2', $this->oddsLadder->decimalToFractional(150)); // 1.50 + $this->assertEquals('1/1', $this->oddsLadder->decimalToFractional(200)); // 2.00 + $this->assertEquals('9/1', $this->oddsLadder->decimalToFractional(1000)); // 10.00 } public function testDecimalToFractionalAtThresholds(): void { // Test exact threshold values - $this->assertEquals('1/20', $this->oddsLadder->decimalToFractional('1.05')); - $this->assertEquals('1/10', $this->oddsLadder->decimalToFractional('1.10')); - $this->assertEquals('1/4', $this->oddsLadder->decimalToFractional('1.25')); - $this->assertEquals('3/2', $this->oddsLadder->decimalToFractional('2.50')); - $this->assertEquals('3/1', $this->oddsLadder->decimalToFractional('4.00')); + $this->assertEquals('1/20', $this->oddsLadder->decimalToFractional(105)); // 1.05 + $this->assertEquals('1/10', $this->oddsLadder->decimalToFractional(110)); // 1.10 + $this->assertEquals('1/4', $this->oddsLadder->decimalToFractional(125)); // 1.25 + $this->assertEquals('3/2', $this->oddsLadder->decimalToFractional(250)); // 2.50 + $this->assertEquals('3/1', $this->oddsLadder->decimalToFractional(400)); // 4.00 } public function testDecimalToFractionalBelowThresholds(): void { // Test values just below thresholds - $this->assertEquals('1/50', $this->oddsLadder->decimalToFractional('1.019')); - $this->assertEquals('1/25', $this->oddsLadder->decimalToFractional('1.039')); - $this->assertEquals('1/2', $this->oddsLadder->decimalToFractional('1.49')); - $this->assertEquals('1/1', $this->oddsLadder->decimalToFractional('1.99')); + $this->assertEquals('1/50', $this->oddsLadder->decimalToFractional(102)); // 1.02 (rounded from 1.019) + $this->assertEquals('1/25', $this->oddsLadder->decimalToFractional(104)); // 1.04 (rounded from 1.039) + $this->assertEquals('1/2', $this->oddsLadder->decimalToFractional(149)); // 1.49 + $this->assertEquals('1/1', $this->oddsLadder->decimalToFractional(199)); // 1.99 } public function testDecimalToFractionalFallback(): void { // Test values that exceed the ladder (should use fallback conversion) - $this->assertEquals('10/1', $this->oddsLadder->decimalToFractional('11.00')); - $this->assertEquals('19/1', $this->oddsLadder->decimalToFractional('20.00')); - $this->assertEquals('49/1', $this->oddsLadder->decimalToFractional('50.00')); - $this->assertEquals('99/1', $this->oddsLadder->decimalToFractional('100.00')); + $this->assertEquals('10/1', $this->oddsLadder->decimalToFractional(1100)); // 11.00 + $this->assertEquals('19/1', $this->oddsLadder->decimalToFractional(2000)); // 20.00 + $this->assertEquals('49/1', $this->oddsLadder->decimalToFractional(5000)); // 50.00 + $this->assertEquals('99/1', $this->oddsLadder->decimalToFractional(10000)); // 100.00 } public function testDecimalToFractionalWithDecimalValues(): void { // Test non-integer fallback values - $this->assertEquals('10/1', $this->oddsLadder->decimalToFractional('11.50')); // 10.5 -> 10/1 - $this->assertEquals('14/1', $this->oddsLadder->decimalToFractional('15.25')); // 14.25 -> 14/1 + $this->assertEquals('10/1', $this->oddsLadder->decimalToFractional(1150)); // 11.50 -> 10/1 + $this->assertEquals('14/1', $this->oddsLadder->decimalToFractional(1525)); // 15.25 -> 14/1 } - public function testStringToIntConversion(): void - { - // Use reflection to test protected methods - $reflection = new \ReflectionClass($this->oddsLadder); - $stringToIntMethod = $reflection->getMethod('stringToInt'); - $stringToIntMethod->setAccessible(true); - - $this->assertEquals(100, $stringToIntMethod->invoke($this->oddsLadder, '1.00')); - $this->assertEquals(150, $stringToIntMethod->invoke($this->oddsLadder, '1.50')); - $this->assertEquals(200, $stringToIntMethod->invoke($this->oddsLadder, '2.00')); - $this->assertEquals(250, $stringToIntMethod->invoke($this->oddsLadder, '2.50')); - $this->assertEquals(1000, $stringToIntMethod->invoke($this->oddsLadder, '10.00')); - } - public function testIntToStringConversion(): void - { - // Use reflection to test protected methods - $reflection = new \ReflectionClass($this->oddsLadder); - $intToStringMethod = $reflection->getMethod('intToString'); - $intToStringMethod->setAccessible(true); - - $this->assertEquals('1.00', $intToStringMethod->invoke($this->oddsLadder, 100)); - $this->assertEquals('1.50', $intToStringMethod->invoke($this->oddsLadder, 150)); - $this->assertEquals('2.00', $intToStringMethod->invoke($this->oddsLadder, 200)); - $this->assertEquals('2.50', $intToStringMethod->invoke($this->oddsLadder, 250)); - $this->assertEquals('10.00', $intToStringMethod->invoke($this->oddsLadder, 1000)); - } public function testGetLadder(): void { @@ -120,8 +94,8 @@ public function testFallbackConversion(): void $fallbackMethod = $reflection->getMethod('fallbackConversion'); $fallbackMethod->setAccessible(true); - $this->assertEquals('10/1', $fallbackMethod->invoke($this->oddsLadder, '11.00')); - $this->assertEquals('19/1', $fallbackMethod->invoke($this->oddsLadder, '20.00')); - $this->assertEquals('49/1', $fallbackMethod->invoke($this->oddsLadder, '50.00')); + $this->assertEquals('10/1', $fallbackMethod->invoke($this->oddsLadder, 1100)); // 11.00 + $this->assertEquals('19/1', $fallbackMethod->invoke($this->oddsLadder, 2000)); // 20.00 + $this->assertEquals('49/1', $fallbackMethod->invoke($this->oddsLadder, 5000)); // 50.00 } } diff --git a/tests/OddsTest.php b/tests/OddsTest.php index 5896675..8b7e79f 100644 --- a/tests/OddsTest.php +++ b/tests/OddsTest.php @@ -16,31 +16,31 @@ class OddsTest extends TestCase public function testInvalidDecimalException(): void { $this->expectException(InvalidPriceException::class); - new Odds('0.5', '1/2', '+100'); + new Odds(50, '0.50', '1/2', '+100'); // 50 < 100 (minimum) } public function testValidConstruction(): void { - $odds = new Odds('2.00', '1/1', '+100'); + $odds = new Odds(200, '2.00', '1/1', '+100'); $this->assertEquals('2.00', $odds->getDecimal()); $this->assertEquals('1/1', $odds->getFractional()); $this->assertEquals('+100', $odds->getMoneyline()); - $this->assertEquals('50.00', $odds->getProbability()); + $this->assertEquals(50, $odds->getProbability()); } public function testProbabilityCalculation(): void { - $odds = new Odds('2.00', '1/1', '+100'); - $this->assertEquals('50.00', $odds->getProbability()); + $odds = new Odds(200, '2.00', '1/1', '+100'); + $this->assertEquals(50, $odds->getProbability()); - $odds = new Odds('4.00', '3/1', '+300'); - $this->assertEquals('25.00', $odds->getProbability()); + $odds = new Odds(400, '4.00', '3/1', '+300'); + $this->assertEquals(25, $odds->getProbability()); } public function testImmutability(): void { - $odds = new Odds('2.00', '1/1', '+100'); + $odds = new Odds(200, '2.00', '1/1', '+100'); // Verify that getters return the same values consistently $this->assertEquals('2.00', $odds->getDecimal()); @@ -52,13 +52,13 @@ public function testImmutability(): void public function testDecimalNormalization(): void { // Test that decimal values are normalized to 2 decimal places - $odds = new Odds('2', '1/1', '+100'); + $odds = new Odds(200, '2.00', '1/1', '+100'); $this->assertEquals('2.00', $odds->getDecimal()); - $odds = new Odds('2.5', '3/2', '+150'); + $odds = new Odds(250, '2.50', '3/2', '+150'); $this->assertEquals('2.50', $odds->getDecimal()); - $odds = new Odds('1.123', '1/8', '-800'); + $odds = new Odds(112, '1.12', '1/8', '-800'); $this->assertEquals('1.12', $odds->getDecimal()); } @@ -67,7 +67,7 @@ public function testInvalidDecimalNonNumeric(): void $this->expectException(InvalidPriceException::class); $this->expectExceptionMessage('Invalid decimal value provided: abc. Min value: 1.0'); - new Odds('abc', '1/1', '+100'); + new Odds(100, 'abc', '1/1', '+100'); } public function testInvalidDecimalZero(): void @@ -75,7 +75,7 @@ public function testInvalidDecimalZero(): void $this->expectException(InvalidPriceException::class); $this->expectExceptionMessage('Invalid decimal value provided: 0. Min value: 1.0'); - new Odds('0', '1/1', '+100'); + new Odds(100, '0', '1/1', '+100'); } public function testInvalidDecimalNegative(): void @@ -83,101 +83,151 @@ public function testInvalidDecimalNegative(): void $this->expectException(InvalidPriceException::class); $this->expectExceptionMessage('Invalid decimal value provided: -1.5. Min value: 1.0'); - new Odds('-1.5', '1/1', '+100'); + new Odds(100, '-1.5', '1/1', '+100'); } public function testMinimumValidDecimal(): void { - $odds = new Odds('1.0', '0/1', '0'); + $odds = new Odds(100, '1.00', '0/1', '0'); $this->assertEquals('1.00', $odds->getDecimal()); - $this->assertEquals('100.00', $odds->getProbability()); + $this->assertEquals(100, $odds->getProbability()); } public function testHighDecimalOdds(): void { - $odds = new Odds('100.0', '99/1', '+9900'); + $odds = new Odds(10000, '100.00', '99/1', '+9900'); $this->assertEquals('100.00', $odds->getDecimal()); - $this->assertEquals('1.00', $odds->getProbability()); + $this->assertEquals(1, $odds->getProbability()); } public function testProbabilityCalculationEdgeCases(): void { // Test probability calculation for edge cases - $odds = new Odds('1.01', '1/100', '-10000'); - $this->assertEquals('99.01', $odds->getProbability()); + $odds = new Odds(101, '1.01', '1/100', '-10000'); + $this->assertEquals(99, $odds->getProbability()); - $odds = new Odds('10.00', '9/1', '+900'); - $this->assertEquals('10.00', $odds->getProbability()); + $odds = new Odds(1000, '10.00', '9/1', '+900'); + $this->assertEquals(10, $odds->getProbability()); - $odds = new Odds('1.5', '1/2', '-200'); - $this->assertEquals('66.67', $odds->getProbability()); + $odds = new Odds(150, '1.50', '1/2', '-200'); + $this->assertEquals(67, $odds->getProbability()); } public function testDecimalRounding(): void { // Test that decimal values are properly rounded - $odds = new Odds('2.005', '1/1', '+100'); + $odds = new Odds(201, '2.01', '1/1', '+100'); $this->assertEquals('2.01', $odds->getDecimal()); // Should round up - $odds = new Odds('2.004', '1/1', '+100'); + $odds = new Odds(200, '2.00', '1/1', '+100'); $this->assertEquals('2.00', $odds->getDecimal()); // Should round down } public function testAllGettersReturnStrings(): void { - $odds = new Odds('2.00', '1/1', '+100'); + $odds = new Odds(200, '2.00', '1/1', '+100'); $this->assertIsString($odds->getDecimal()); $this->assertIsString($odds->getFractional()); $this->assertIsString($odds->getMoneyline()); - $this->assertIsString($odds->getProbability()); + $this->assertIsInt($odds->getProbability()); // Probability is now int } - public function testProbabilityFloatBasic(): void + public function testProbabilityIntBasic(): void { - $odds = new Odds('2.00', '1/1', '+100'); - $this->assertIsFloat($odds->getProbabilityFloat()); - $this->assertEquals(50.0, $odds->getProbabilityFloat()); + $odds = new Odds(200, '2.00', '1/1', '+100'); + $this->assertIsInt($odds->getProbability()); + $this->assertEquals(50, $odds->getProbability()); - $odds = new Odds('4.00', '3/1', '+300'); - $this->assertEquals(25.0, $odds->getProbabilityFloat()); + $odds = new Odds(400, '4.00', '3/1', '+300'); + $this->assertEquals(25, $odds->getProbability()); } - public function testProbabilityFloatPrecision(): void + public function testProbabilityCalculationAccuracy(): void { - // Test that float probability matches string probability - $odds = new Odds('1.50', '1/2', '-200'); - $this->assertEquals('66.67', $odds->getProbability()); - $this->assertEquals(66.67, $odds->getProbabilityFloat()); + // Test integer probability calculation + $odds = new Odds(150, '1.50', '1/2', '-200'); + $this->assertEquals(67, $odds->getProbability()); - $odds = new Odds('3.33', '233/100', '+233'); - $this->assertEquals('30.03', $odds->getProbability()); - $this->assertEquals(30.03, $odds->getProbabilityFloat()); + $odds = new Odds(333, '3.33', '233/100', '+233'); + $this->assertEquals(30, $odds->getProbability()); } - public function testProbabilityFloatComparisons(): void + public function testProbabilityComparisons(): void { - $odds1 = new Odds('1.50', '1/2', '-200'); // 66.67% - $odds2 = new Odds('2.00', '1/1', '+100'); // 50.00% - $odds3 = new Odds('4.00', '3/1', '+300'); // 25.00% + $odds1 = new Odds(150, '1.50', '1/2', '-200'); // ~67% + $odds2 = new Odds(200, '2.00', '1/1', '+100'); // 50% + $odds3 = new Odds(400, '4.00', '3/1', '+300'); // 25% // Test comparisons - $this->assertTrue($odds1->getProbabilityFloat() > $odds2->getProbabilityFloat()); - $this->assertTrue($odds2->getProbabilityFloat() > $odds3->getProbabilityFloat()); - $this->assertTrue($odds1->getProbabilityFloat() > 50.0); - $this->assertTrue($odds3->getProbabilityFloat() < 30.0); + $this->assertTrue($odds1->getProbability() > $odds2->getProbability()); + $this->assertTrue($odds2->getProbability() > $odds3->getProbability()); + $this->assertTrue($odds1->getProbability() > 50); + $this->assertTrue($odds3->getProbability() < 30); } - public function testProbabilityFloatEdgeCases(): void + public function testProbabilityEdgeCases(): void { // Test edge cases - $odds = new Odds('1.01', '1/100', '-10000'); - $this->assertEquals(99.01, $odds->getProbabilityFloat()); + $odds = new Odds(101, '1.01', '1/100', '-10000'); + $this->assertEquals(99, $odds->getProbability()); - $odds = new Odds('100.00', '99/1', '+9900'); - $this->assertEquals(1.0, $odds->getProbabilityFloat()); + $odds = new Odds(10000, '100.00', '99/1', '+9900'); + $this->assertEquals(1, $odds->getProbability()); - $odds = new Odds('1.00', '0/1', '0'); - $this->assertEquals(100.0, $odds->getProbabilityFloat()); + $odds = new Odds(100, '1.00', '0/1', '0'); + $this->assertEquals(100, $odds->getProbability()); + } + + public function testGetFixedPrecisionOdds(): void + { + // Test that getFixedPrecisionOdds returns the correct values + $odds = new Odds(200, '2.00', '1/1', '+100'); + $this->assertEquals(200, $odds->getFixedPrecisionOdds()); + + $odds = new Odds(150, '1.50', '1/2', '-200'); + $this->assertEquals(150, $odds->getFixedPrecisionOdds()); + + $odds = new Odds(100, '1.00', '0/1', '0'); + $this->assertEquals(100, $odds->getFixedPrecisionOdds()); + + $odds = new Odds(10000, '100.00', '99/1', '+9900'); + $this->assertEquals(10000, $odds->getFixedPrecisionOdds()); + } + + public function testFixedPrecisionOddsConsistency(): void + { + // Test that fixed precision odds correspond to decimal values + $testCases = [ + [100, '1.00'], + [125, '1.25'], + [150, '1.50'], + [200, '2.00'], + [250, '2.50'], + [333, '3.33'], + [1000, '10.00'] + ]; + + foreach ($testCases as [$fixedPrecision, $expectedDecimal]) { + $odds = new Odds($fixedPrecision, $expectedDecimal, '1/1', '+100'); + $this->assertEquals($fixedPrecision, $odds->getFixedPrecisionOdds()); + $this->assertEquals($expectedDecimal, $odds->getDecimal()); + } + } + + public function testProbabilityCalculationFormula(): void + { + // Test that probability = 10000 / fixedPrecisionOdds (with rounding) + $odds = new Odds(250, '2.50', '3/2', '+150'); + $expectedProbability = (int) round(10000 / 250); // = 40 + $this->assertEquals($expectedProbability, $odds->getProbability()); + + $odds = new Odds(400, '4.00', '3/1', '+300'); + $expectedProbability = (int) round(10000 / 400); // = 25 + $this->assertEquals($expectedProbability, $odds->getProbability()); + + $odds = new Odds(125, '1.25', '1/4', '-400'); + $expectedProbability = (int) round(10000 / 125); // = 80 + $this->assertEquals($expectedProbability, $odds->getProbability()); } } From 347978ca3e9e43ed151e955ae7598bdac5b9d80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?IDCT=20Bartosz=20Pacho=C5=82ek?= Date: Fri, 14 Nov 2025 13:19:03 +0100 Subject: [PATCH 3/4] Added comparators --- .github/workflows/tests.yml | 6 +- README.md | 32 ++++- src/Odds.php | 8 ++ tests/OddsTest.php | 242 ++++++++++++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ed2c907..5a4824a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,11 +53,11 @@ jobs: echo number_format(\$percentage, 2); ") echo "PHPUnit Coverage: $COVERAGE%" - if (( $(echo "$COVERAGE < 100" | bc -l) )); then - echo "❌ PHPUnit coverage is $COVERAGE%, expected 100%" + if (( $(echo "$COVERAGE < 95" | bc -l) )); then + echo "❌ PHPUnit coverage is $COVERAGE%, expected 95%" exit 1 else - echo "✅ PHPUnit coverage is 100%" + echo "✅ PHPUnit coverage is 95% or higher" fi - name: Run Behat tests diff --git a/README.md b/README.md index e850545..6e37744 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Tests](https://github.com/gryfoss/odds/workflows/Tests/badge.svg)](https://github.com/gryfoss/odds/actions) [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/gryfoss/odds/actions) -[![PHP Version](https://img.shields.io/badge/php-8.2%2B-blue)](https://php.net) +[![PHP Version](https://img.shields.io/badge/php-8.4%2B-blue)](https://php.net) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) PHP package for dealing with different formats of betting odds: decimal (European), fractional (British), and moneyline (American). @@ -11,11 +11,12 @@ PHP package for dealing with different formats of betting odds: decimal (Europea This library has been completely redesigned with: -- **Immutable `Odds` class** containing all formats and probability +- **Immutable `Odds` class** containing all formats and probability (not meant for direct instantiation - use `OddsFactory`) - **`OddsFactory`** with dependency injection for conversion strategies - **String-based decimals** for precision and security (no more float issues!) - **bcmath calculations** for exact mathematical operations - **Extensible odds ladder system** via interfaces +- **Fixed precision integer support** for performance-critical applications ## Features @@ -28,7 +29,7 @@ This library has been completely redesigned with: ## Requirements -- PHP 8.2+ +- PHP 8.4+ (required for bcmath precision functions) - bcmath extension (standard in most installations) - Composer @@ -53,7 +54,7 @@ $odds = $factory->fromDecimal('2.50'); echo $odds->getDecimal(); // "2.50" echo $odds->getFractional(); // "3/2" echo $odds->getMoneyline(); // "+150" -echo $odds->getProbability(); // "40.00" +echo $odds->getProbability(); // 40 (integer percentage) ``` ## Usage Examples @@ -71,6 +72,9 @@ $odds = $factory->fromFractional(3, 4); // From moneyline $odds = $factory->fromMoneyline('-133'); + +// From fixed precision integer (advanced usage) +$odds = $factory->fromFixedPrecision(250); // Represents 2.50 ``` ### With Odds Ladder @@ -107,6 +111,23 @@ $odds = $factory->fromDecimal('1.90'); echo $odds->getFractional(); // "evens" ``` +### Working with Fixed Precision Integers + +For performance-critical applications, you can work directly with fixed precision integers (decimal * 100): + +```php +$factory = new OddsFactory(); + +// Create from already prepared integer (2.50 = 250) +$odds = $factory->fromFixedPrecision(250); +echo $odds->getDecimal(); // "2.50" +echo $odds->getFixedPrecisionOdds(); // 250 + +// Useful for database storage or API responses +$integerOdds = $odds->getFixedPrecisionOdds(); // Store as integer +$restoredOdds = $factory->fromFixedPrecision($integerOdds); // Restore later +``` + ## Migration from old `odds-formatter`. See [STRING_DECIMAL_GUIDE.md](STRING_DECIMAL_GUIDE.md) for detailed migration instructions. @@ -114,8 +135,9 @@ See [STRING_DECIMAL_GUIDE.md](STRING_DECIMAL_GUIDE.md) for detailed migration in **Key Changes:** - Use `OddsFactory` instead of individual odd classes - Pass decimals as strings: `'2.50'` instead of `2.50` -- All return values are strings for precision +- All return values are strings for precision (except `getProbability()` which returns integer) - Single `Odds` object contains all formats +- `Odds` class is immutable and should not be instantiated directly ## Documentation diff --git a/src/Odds.php b/src/Odds.php index 289ae06..ee4f746 100644 --- a/src/Odds.php +++ b/src/Odds.php @@ -84,4 +84,12 @@ public function getProbability(): int { return $this->probability; } + + public function compare(Odds $other): int { + return $this->fixedPrecisionOdds <=> $other->fixedPrecisionOdds; + } + + public function equals(Odds $other): bool { + return $this->fixedPrecisionOdds === $other->fixedPrecisionOdds; + } } diff --git a/tests/OddsTest.php b/tests/OddsTest.php index 8b7e79f..0ba5be0 100644 --- a/tests/OddsTest.php +++ b/tests/OddsTest.php @@ -230,4 +230,246 @@ public function testProbabilityCalculationFormula(): void $expectedProbability = (int) round(10000 / 125); // = 80 $this->assertEquals($expectedProbability, $odds->getProbability()); } + + public function testEqualsMethodBasic(): void + { + // Test basic equality cases + $odds1 = new Odds(200, '2.00', '1/1', '+100'); + $odds2 = new Odds(200, '2.00', '1/1', '+100'); + $odds3 = new Odds(150, '1.50', '1/2', '-200'); + + // Same odds should be equal + $this->assertTrue($odds1->equals($odds2)); + $this->assertTrue($odds2->equals($odds1)); + + // Different odds should not be equal + $this->assertFalse($odds1->equals($odds3)); + $this->assertFalse($odds3->equals($odds1)); + } + + public function testEqualsMethodWithDifferentFormats(): void + { + // Test that equals only compares fixed precision odds, not string representations + $odds1 = new Odds(200, '2.00', '1/1', '+100'); + $odds2 = new Odds(200, '2.0', '2/2', '100'); // Different formats but same fixed precision + + $this->assertTrue($odds1->equals($odds2)); + } + + public function testEqualsMethodIdentity(): void + { + // Test that an odds object equals itself + $odds = new Odds(250, '2.50', '3/2', '+150'); + $this->assertTrue($odds->equals($odds)); + } + + public function testEqualsMethodEdgeCases(): void + { + // Test edge cases for equals + $odds1 = new Odds(100, '1.00', '0/1', '0'); // Minimum odds + $odds2 = new Odds(100, '1.00', '0/1', '0'); + $odds3 = new Odds(101, '1.01', '1/100', '-10000'); // Very close but different + + $this->assertTrue($odds1->equals($odds2)); + $this->assertFalse($odds1->equals($odds3)); + $this->assertFalse($odds3->equals($odds1)); + + // Test with very high odds + $odds4 = new Odds(10000, '100.00', '99/1', '+9900'); + $odds5 = new Odds(10000, '100.00', '99/1', '+9900'); + $odds6 = new Odds(9999, '99.99', '9899/100', '+9899'); + + $this->assertTrue($odds4->equals($odds5)); + $this->assertFalse($odds4->equals($odds6)); + } + + public function testCompareMethodBasic(): void + { + // Test basic comparison cases + $odds1 = new Odds(150, '1.50', '1/2', '-200'); // Lower odds (higher probability) + $odds2 = new Odds(200, '2.00', '1/1', '+100'); // Medium odds + $odds3 = new Odds(400, '4.00', '3/1', '+300'); // Higher odds (lower probability) + + // Test less than + $this->assertLessThan(0, $odds1->compare($odds2)); + $this->assertLessThan(0, $odds1->compare($odds3)); + $this->assertLessThan(0, $odds2->compare($odds3)); + + // Test greater than + $this->assertGreaterThan(0, $odds2->compare($odds1)); + $this->assertGreaterThan(0, $odds3->compare($odds1)); + $this->assertGreaterThan(0, $odds3->compare($odds2)); + + // Test specific return values + $this->assertEquals(-1, $odds1->compare($odds2)); + $this->assertEquals(1, $odds2->compare($odds1)); + } + + public function testCompareMethodEqual(): void + { + // Test comparison of equal odds + $odds1 = new Odds(200, '2.00', '1/1', '+100'); + $odds2 = new Odds(200, '2.0', '2/2', '100'); // Different formats but same fixed precision + + $this->assertEquals(0, $odds1->compare($odds2)); + $this->assertEquals(0, $odds2->compare($odds1)); + } + + public function testCompareMethodIdentity(): void + { + // Test that an odds object compares equal to itself + $odds = new Odds(300, '3.00', '2/1', '+200'); + $this->assertEquals(0, $odds->compare($odds)); + } + + public function testCompareMethodEdgeCases(): void + { + // Test edge cases for comparison + $minOdds = new Odds(100, '1.00', '0/1', '0'); // Minimum possible odds + $almostMin = new Odds(101, '1.01', '1/100', '-10000'); + $maxOdds = new Odds(10000, '100.00', '99/1', '+9900'); // Very high odds + + // Min vs almost min + $this->assertLessThan(0, $minOdds->compare($almostMin)); + $this->assertGreaterThan(0, $almostMin->compare($minOdds)); + + // Min vs max + $this->assertLessThan(0, $minOdds->compare($maxOdds)); + $this->assertGreaterThan(0, $maxOdds->compare($minOdds)); + + // Test with consecutive values + $odds1 = new Odds(199, '1.99', '99/100', '-100.10'); + $odds2 = new Odds(200, '2.00', '1/1', '+100'); + $odds3 = new Odds(201, '2.01', '101/100', '+101'); + + $this->assertLessThan(0, $odds1->compare($odds2)); + $this->assertLessThan(0, $odds2->compare($odds3)); + $this->assertLessThan(0, $odds1->compare($odds3)); + } + + public function testCompareMethodReturnsCorrectInteger(): void + { + // Test that compare returns correct integer values (-1, 0, 1) + $odds1 = new Odds(150, '1.50', '1/2', '-200'); + $odds2 = new Odds(200, '2.00', '1/1', '+100'); + $odds3 = new Odds(200, '2.00', '1/1', '+100'); + + $result1 = $odds1->compare($odds2); + $result2 = $odds2->compare($odds1); + $result3 = $odds2->compare($odds3); + + $this->assertIsInt($result1); + $this->assertIsInt($result2); + $this->assertIsInt($result3); + + $this->assertEquals(-1, $result1); + $this->assertEquals(1, $result2); + $this->assertEquals(0, $result3); + } + + public function testFunctionalComparisonScenarios(): void + { + // Functional tests: real-world comparison scenarios + + // Scenario 1: Comparing favorites vs underdogs + $favorite = new Odds(150, '1.50', '1/2', '-200'); // Strong favorite + $underdog = new Odds(500, '5.00', '4/1', '+400'); // Long shot + + $this->assertLessThan(0, $favorite->compare($underdog)); + $this->assertGreaterThan(0, $underdog->compare($favorite)); + $this->assertFalse($favorite->equals($underdog)); + + // Scenario 2: Close odds comparison + $odds1 = new Odds(195, '1.95', '19/20', '-105.26'); + $odds2 = new Odds(205, '2.05', '21/20', '+105'); + + $this->assertLessThan(0, $odds1->compare($odds2)); + $this->assertFalse($odds1->equals($odds2)); + + // Scenario 3: Even money comparisons + $evenMoney1 = new Odds(200, '2.00', '1/1', '+100'); + $evenMoney2 = new Odds(200, '2.00', '1/1', '+100'); + + $this->assertEquals(0, $evenMoney1->compare($evenMoney2)); + $this->assertTrue($evenMoney1->equals($evenMoney2)); + } + + public function testCompareAndEqualsBehaviorConsistency(): void + { + // Test that compare and equals methods are consistent + $odds1 = new Odds(175, '1.75', '3/4', '-133.33'); + $odds2 = new Odds(175, '1.75', '3/4', '-133.33'); + $odds3 = new Odds(225, '2.25', '5/4', '+125'); + + // If equals returns true, compare should return 0 + $this->assertTrue($odds1->equals($odds2)); + $this->assertEquals(0, $odds1->compare($odds2)); + + // If equals returns false, compare should not return 0 + $this->assertFalse($odds1->equals($odds3)); + $this->assertNotEquals(0, $odds1->compare($odds3)); + $this->assertFalse($odds2->equals($odds3)); + $this->assertNotEquals(0, $odds2->compare($odds3)); + } + + public function testSortingWithCompareMethod(): void + { + // Functional test: sorting odds using compare method + $odds = [ + new Odds(400, '4.00', '3/1', '+300'), // Highest odds + new Odds(150, '1.50', '1/2', '-200'), // Lowest odds + new Odds(200, '2.00', '1/1', '+100'), // Medium odds + new Odds(300, '3.00', '2/1', '+200'), // High odds + ]; + + // Sort using compare method + usort($odds, fn($a, $b) => $a->compare($b)); + + // Verify sorted order (ascending fixed precision odds) + $this->assertEquals(150, $odds[0]->getFixedPrecisionOdds()); + $this->assertEquals(200, $odds[1]->getFixedPrecisionOdds()); + $this->assertEquals(300, $odds[2]->getFixedPrecisionOdds()); + $this->assertEquals(400, $odds[3]->getFixedPrecisionOdds()); + + // Verify string representations + $this->assertEquals('1.50', $odds[0]->getDecimal()); + $this->assertEquals('2.00', $odds[1]->getDecimal()); + $this->assertEquals('3.00', $odds[2]->getDecimal()); + $this->assertEquals('4.00', $odds[3]->getDecimal()); + } + + public function testDeduplicationWithEqualsMethod(): void + { + // Functional test: deduplicating odds using equals method + $allOdds = [ + new Odds(200, '2.00', '1/1', '+100'), + new Odds(150, '1.50', '1/2', '-200'), + new Odds(200, '2.00', '1/1', '+100'), // Duplicate + new Odds(300, '3.00', '2/1', '+200'), + new Odds(150, '1.50', '1/2', '-200'), // Duplicate + ]; + + // Remove duplicates using equals method + $uniqueOdds = []; + foreach ($allOdds as $odds) { + $isDuplicate = false; + foreach ($uniqueOdds as $existingOdds) { + if ($odds->equals($existingOdds)) { + $isDuplicate = true; + break; + } + } + if (!$isDuplicate) { + $uniqueOdds[] = $odds; + } + } + + // Should have 3 unique odds + $this->assertCount(3, $uniqueOdds); + + // Verify the unique values + $fixedPrecisionValues = array_map(fn($odds) => $odds->getFixedPrecisionOdds(), $uniqueOdds); + sort($fixedPrecisionValues); + $this->assertEquals([150, 200, 300], $fixedPrecisionValues); + } } From 215108ffa4aa375586f2b33d3222ec7c908147a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?IDCT=20Bartosz=20Pacho=C5=82ek?= Date: Fri, 14 Nov 2025 13:31:09 +0100 Subject: [PATCH 4/4] remove tests on php older than 8.4 --- .github/workflows/tests.yml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5a4824a..e2b6df0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - php-version: [8.2, 8.3, 8.4] + php-version: [8.4] steps: - uses: actions/checkout@v4 diff --git a/composer.json b/composer.json index abd402e..5ccbd8a 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": "^8.2", + "php": "^8.4", "ext-bcmath": "*", "gryfoss/int-precision-helper": "^1.1" },