DEV Community

CasinoKit
CasinoKit

Posted on Fully Autonomous

Building an odds converter in JavaScript: decimal, fractional, American and the hidden margin

An odds converter looks like a weekend exercise: three formulas and an input field. When we built one for our project, the formulas took ten minutes. The edge cases took the rest of the day: fractions that come out as 9091/10000, American odds between -100 and +100 that do not exist, and floating point turning a clean -110 into something nobody would ever print on a betting slip.

This post walks through the conversions, the traps, and one number every converter user should understand: the bookmaker's margin.

Note: this is about probability and code, not advice to bet. Gambling is 18+, and the bookmaker's margin means the odds are always slightly against you.

Four ways to write the same price

Format Example Read it as
Decimal 2.75 total return per 1 staked (stake included)
Fractional 7/4 profit / stake
American +175 profit on a 100 stake (or, if negative, stake needed to win 100)
Implied probability 36.36% the chance the price suggests

All four describe the same thing, so the clean way to build a converter is to pick one internal format and convert everything through it. Decimal is the natural choice: it is a single positive number, and every other format maps to it with one line.

Everything into decimal

function toDecimal(format, value) {
  switch (format) {
    case 'decimal': {
      const d = parseFloat(value);
      return d > 1 ? d : null;               // 1.00 would mean "win nothing"
    }
    case 'fractional': {
      const [num, den] = String(value).split('/').map(Number);
      if (!den || !(num / den > 0)) return null;
      return num / den + 1;                  // 7/4 -> 2.75
    }
    case 'american': {
      const a = parseFloat(value);
      if (Math.abs(a) < 100) return null;    // -99..+99 is not a valid American price
      return a > 0 ? a / 100 + 1 : 100 / Math.abs(a) + 1;  // +175 -> 2.75, -110 -> 1.909
    }
    case 'probability': {
      const p = parseFloat(value);
      if (!(p > 0 && p < 100)) return null;
      return 100 / p;                        // 36.36% -> 2.75
    }
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Two validation rules are easy to miss:

  • Decimal odds must be above 1. At exactly 1.00 you get your stake back and nothing else, so the price is meaningless.
  • American odds never sit between -100 and +100. Positive prices start at +100 (evens), negative ones at -100. A value like +50 is not "short odds", it is an input error, and the converter should say so instead of producing a number.

And back out again

function fromDecimal(format, d) {
  if (!(d > 1)) return '';
  switch (format) {
    case 'decimal':
      return d.toFixed(2);
    case 'american':
      return d >= 2
        ? `+${Math.round((d - 1) * 100)}`    // 2.75 -> +175
        : `${Math.round(-100 / (d - 1))}`;   // 1.909 -> -110
    case 'probability':
      return `${(100 / d).toFixed(2)}%`;
    case 'fractional':
      return toFraction(d - 1);              // the interesting one
  }
}
Enter fullscreen mode Exit fullscreen mode

The American branch switches at exactly 2.0 (evens). Above it you show the profit on 100, below it the stake needed to win 100, with a minus sign.

The fraction problem

The obvious way to turn 0.75 into a fraction is to scale and simplify:

const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
const simplify = (n, d) => { const g = gcd(n, d); return `${n / g}/${d / g}`; };

simplify(Math.round(0.75 * 10000), 10000);   // "3/4"  fine
Enter fullscreen mode Exit fullscreen mode

It works for 0.75, but try American -110. That is decimal 1.90909..., so the fractional part is 0.90909... Scaling by 10,000 gives 9091/10000, which is technically correct and completely useless. Every bookmaker prints that price as 10/11.

The fix is to look for a small denominator that reproduces the value exactly before falling back to rounding:

function toFraction(x) {
  for (let den = 1; den <= 1000; den++) {
    const num = Math.round(x * den);
    if (num > 0 && Math.abs(num / den - x) < 1e-9) return simplify(num, den);
  }
  return simplify(Math.round(x * 10000), 10000);   // fallback for odd decimals
}

toFraction(100 / 110);  // "10/11"
toFraction(0.75);       // "3/4"
toFraction(0.91);       // "91/100"  (decimal 1.91 really has no prettier fraction)
Enter fullscreen mode Exit fullscreen mode

The loop runs at most a thousand cheap iterations, which is nothing for a UI that converts on every keystroke, and the tolerance of 1e-9 absorbs the floating point noise from the American conversion.

The number the converter does not show you: the margin

Implied probability is where odds stop being a formatting problem. Take a football match priced at:

Outcome Decimal Implied probability
Home 2.10 47.62%
Draw 3.40 29.41%
Away 3.60 27.78%
Total 104.81%

The three outcomes cover every possibility, so the true probabilities must add up to exactly 100%. The extra 4.81% is the bookmaker's margin (also called the overround or vig). It is built into every price on the market.

const margin = (odds) => odds.reduce((s, o) => s + 1 / o, 0) - 1;

margin([2.10, 3.40, 3.60]);   // 0.0481 -> 4.81%
margin([1.91, 1.91]);         // 0.0471 -> 4.71% on a classic -110 / -110 line
Enter fullscreen mode Exit fullscreen mode

To estimate the "fair" price without the margin, scale the implied probabilities back to 100%:

const fair = (odds) => {
  const total = odds.reduce((s, o) => s + 1 / o, 0);
  return odds.map(o => +(total * o).toFixed(2));
};

fair([2.10, 3.40, 3.60]);   // [2.2, 3.56, 3.77]
Enter fullscreen mode Exit fullscreen mode

This proportional method is the simplest of several and assumes the margin is spread evenly across outcomes. In practice it tends to be loaded more on long shots, but as a first approximation it shows clearly how much each price has been shaded.

Takeaways

  1. Convert everything through one internal format. Decimal keeps the logic to two small functions.
  2. Validate the impossible ranges. Decimal at or below 1 and American between -100 and +100 are errors, not edge cases to round.
  3. Search small denominators before rounding. It is the difference between 10/11 and 9091/10000.
  4. Sum the implied probabilities. Anything above 100% is the margin you pay on that market.

If you want to try the conversions without writing any code, the odds converter on CasinoKit uses exactly this logic: type a price in any of the four formats and the other three update as you type.

Top comments (0)