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;
}
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
}
}
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
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)
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
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]
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
- Convert everything through one internal format. Decimal keeps the logic to two small functions.
- Validate the impossible ranges. Decimal at or below 1 and American between -100 and +100 are errors, not edge cases to round.
-
Search small denominators before rounding. It is the difference between
10/11and9091/10000. - 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)