DEV Community

Cover image for Odds Converter in JS & Python: Decimal, American, Fractional
orbistats
orbistats

Posted on

Odds Converter in JS & Python: Decimal, American, Fractional

Open any two sports betting sites and you will probably see the same price written in two different ways. One shows 2.50, another shows +150, and a third shows 3/2. A British horse racing site, a US NFL app and a European football portal can all be describing the exact same bet, and your code has to make sense of all three.

If you are building a sports app, an odds comparison page, a trading dashboard or a betting model, an odds converter is one of the first utilities you will write. It looks trivial. It is not. Rounding, ambiguous inputs, the "even money" edge case, floating point errors and bookmaker margin all show up the moment you ship it to real users.

In this guide we will build a production-minded odds converter in both JavaScript and Python. Along the way you will learn:

how decimal, American and fractional odds relate to each other (with the exact formulas)
how to calculate implied probability from any format
how to measure the bookmaker's margin (the overround or vig) and strip it out to get fair probabilities
the edge cases that break naive converters
how to wire the converter to a real sports odds API so users see prices in the format they prefer

Everything below was run and tested: 23 pytest cases for Python and 8 node:test cases for JavaScript, including a Python/JS parity check so both implementations return identical results. If you just want to try the maths in a browser first, Orbistats has a free odds converter you can poke at while you read.

A quick note. This article is about data handling and engineering. It is not betting advice, and betting is for adults (18+, or the legal age where you live) only. Please gamble responsibly.

TL;DR: the formulas

Keep this table handy. Everything else in the article is just these six lines done carefully.

From To Formula
American > 0 Decimal 1 + american / 100
American < 0 Decimal 1 + 100 / abs(american)
Fractional n/d Decimal 1 + n / d
Decimal >= 2.0 American (decimal - 1) * 100
Decimal < 2.0 American -100 / (decimal - 1)
Decimal Implied probability 1 / decimal

The big idea is to pick one internal format and convert only at the edges. We will use decimal odds internally, because every other format converts to and from decimal with one line of maths, and because probabilities and payouts are trivial in decimal.

Why three odds formats exist in the first place

Odds are a price: they tell you how much you win relative to what you stake. The three common formats are simply three conventions for writing that ratio.

Decimal odds (also called European odds) are the total return per 1 unit staked, including your stake. At 2.50, a 10 unit stake returns 25 units, which is 15 profit plus your 10 back. This is the default in continental Europe, Australia, Canada and most modern odds feeds, and it is the format football data for 1X2 markets is usually published in.

American odds (moneyline odds) are anchored to 100 units. A positive number such as +150 is the profit on a 100 unit stake. A negative number such as -110 is the stake you need to risk to win 100. Because US sportsbooks think in these terms, they dominate American football, basketball and baseball pricing.

Fractional odds are the traditional UK and Ireland format: 5/2 means you win 5 for every 2 staked. They are still the standard on horse racing cards, and you will also see them in futures markets and in many UK bookmaker apps.

There are other formats (Hong Kong, Malay, Indonesian), but if you can handle the big three and probabilities, the rest are small variations. If any of the terms in this article are new to you, the odds and implied probability glossary explains them in plain English.

Step 1: Understand the maths with one worked example

Let's use a real-looking football price: decimal 3.40 for an away win.

Decimal: 3.40. A 10 stake returns 34.
American: since 3.40 is above 2.0, (3.40 - 1) * 100 = +240.
Fractional: 3.40 - 1 = 2.40, and 2.40 as a fraction is 12/5.
Implied probability: 1 / 3.40 = 0.2941, or 29.41%.

Now go the other way. If someone hands you +240, you compute 1 + 240/100 = 3.40. If they hand you 12/5, you compute 1 + 12/5 = 3.40. Same price, three costumes.

Here is the small reference table the code below produces. Use it as a sanity check for your own implementation:

Decimal American Fractional Implied probability
1.20 -500 1/5 83.33%
1.50 -200 1/2 66.67%
1.91 -110 10/11 52.36%
2.50 +150 3/2 40.00%
3.40 +240 12/5 29.41%
4.20 +320 16/5 23.81%
11.00 +1000 10/1 9.09%

Notice that 1.91 is the famous -110 line. It is the standard price on a two-way US market, and it is not a coincidence that the implied probability of 52.36% is over 50%. We will come back to that when we talk about the margin.

Step 2: The Python converter

Create a file called odds.py. We use Python's built-in fractions.Fraction to turn a decimal into the nicest small fraction, and we validate every input so that bad data fails loudly instead of silently producing nonsense.

python
"""odds.py - convert between decimal, American and fractional odds."""
from future import annotations

import math
from fractions import Fraction
from typing import Sequence

class OddsError(ValueError):
"""Raised when an odds value is invalid."""

def _check_decimal(decimal: float) -> float:
if isinstance(decimal, bool) or not isinstance(decimal, (int, float)):
raise OddsError(f"decimal odds must be a number, got {decimal!r}")
if not math.isfinite(decimal) or decimal <= 1.0:
raise OddsError(f"decimal odds must be a finite number > 1.0, got {decimal!r}")
return float(decimal)

def american_to_decimal(american: float) -> float:
if not math.isfinite(american) or -100 < american < 100:
raise OddsError(f"American odds must be <= -100 or >= +100, got {american!r}")
if american > 0:
return 1 + american / 100
return 1 + 100 / abs(american)

def decimal_to_american(decimal: float) -> int:
d = _check_decimal(decimal)
if d >= 2.0:
return round((d - 1) * 100) # underdog: +150, +250 ...
return round(-100 / (d - 1)) # favourite: -110, -200 ...

def fractional_to_decimal(fractional: str) -> float:
text = fractional.strip().lower()
if text in {"evens", "evs", "even"}:
return 2.0
try:
num_s, den_s = text.split("/")
num, den = int(num_s), int(den_s)
except ValueError as exc:
raise OddsError(f"fractional odds must look like '5/2', got {fractional!r}") from exc
if num <= 0 or den <= 0:
raise OddsError(f"numerator and denominator must be positive, got {fractional!r}")
return 1 + num / den

def decimal_to_fractional(decimal: float, max_denominator: int = 20) -> str:
d = _check_decimal(decimal)
frac = Fraction(d - 1).limit_denominator(max_denominator)
if frac == 0: # very short prices such as 1.01
frac = Fraction(d - 1).limit_denominator(1000)
return f"{frac.numerator}/{frac.denominator}"

A few design decisions worth explaining:

Decimal is the pivot. There is no american_to_fractional(). To convert between any two formats you go through decimal. That keeps the number of functions linear instead of quadratic, and it means there is exactly one place where each format's rules live.

max_denominator=20 is deliberate. The decimal 1.91 is really 91/100 as a fraction, but no bookmaker prints that. They print 10/11. Limiting the denominator gives you the "bookmaker-looking" fraction. If you need an exact fraction, raise the limit.

The 1.01 fallback matters. At very short prices such as 1.01, limit_denominator(20) rounds the fraction to zero, which would print 0/1. Falling back to a denominator of 1000 returns the correct 1/100.

Americans between -100 and +100 are invalid. There is no such thing as +50 or -50 in American odds. Those values almost always mean the data is corrupted or in a different format, so we reject them.

Now the probability helpers, which are the bit most tutorials skip:

python
def implied_probability(decimal: float) -> float:
return 1 / _check_decimal(decimal)

def probability_to_decimal(probability: float) -> float:
if not 0 < probability < 1:
raise OddsError(f"probability must be between 0 and 1 (exclusive), got {probability!r}")
return 1 / probability

def overround(decimals: Sequence[float]) -> float:
"""Bookmaker margin: sum of implied probabilities minus 1."""
return sum(implied_probability(d) for d in decimals) - 1

def remove_vig(decimals: Sequence[float]) -> list[float]:
"""Proportional (multiplicative) de-vig: fair probabilities that sum to 1."""
probs = [implied_probability(d) for d in decimals]
total = sum(probs)
return [p / total for p in probs]

def to_decimal(value, fmt: str) -> float:
if fmt == "decimal":
return _check_decimal(float(value))
if fmt == "american":
return american_to_decimal(float(value))
if fmt == "fractional":
return fractional_to_decimal(str(value))
raise OddsError(f"unknown format {fmt!r}")

def convert_all(decimal: float) -> dict:
"""Every representation of one price - handy for UIs and debugging."""
return {
"decimal": round(decimal, 3),
"american": decimal_to_american(decimal),
"fractional": decimal_to_fractional(decimal),
"implied_probability": round(implied_probability(decimal), 4),
}

Try it in a REPL:

python

from odds import convert_all, to_decimal
convert_all(3.40)
{'decimal': 3.4, 'american': 240, 'fractional': '12/5', 'implied_probability': 0.2941}
convert_all(to_decimal("+150", "american"))
{'decimal': 2.5, 'american': 150, 'fractional': '3/2', 'implied_probability': 0.4}
to_decimal("evens", "fractional")
2.0
Step 3: The JavaScript converter


The same logic in modern ES modules, with no dependencies. JavaScript has no built-in Fraction, so decimalToFractional searches denominators from 1 up to the limit and keeps the closest match, preferring the smaller denominator on ties. That gives exactly the same answers as Python's limit_denominator.

Create odds.mjs:

js
// odds.mjs - convert between decimal, American and fractional odds.

export class OddsError extends Error {
constructor(message) {
super(message);
this.name = "OddsError";
}
}

const assertDecimal = (d) => {
if (typeof d !== "number" || !Number.isFinite(d) || d <= 1) {
throw new OddsError(decimal odds must be a finite number > 1, got ${d});
}
return d;
};

export const americanToDecimal = (a) => {
if (typeof a !== "number" || !Number.isFinite(a) || (a > -100 && a < 100)) {
throw new OddsError(American odds must be <= -100 or >= +100, got ${a});
}
return a > 0 ? 1 + a / 100 : 1 + 100 / Math.abs(a);
};

export const decimalToAmerican = (d) => {
assertDecimal(d);
return d >= 2 ? Math.round((d - 1) * 100) : Math.round(-100 / (d - 1));
};

export const fractionalToDecimal = (text) => {
const t = String(text).trim().toLowerCase();
if (["evens", "evs", "even"].includes(t)) return 2;
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(t);
if (!m) throw new OddsError(fractional odds must look like "5/2", got "${text}");
const [num, den] = [Number(m[1]), Number(m[2])];
if (num <= 0 || den <= 0) throw new OddsError(numerator and denominator must be positive: "${text}");
return 1 + num / den;
};

// Best fraction with a denominator <= maxDen (smallest denominator wins ties).
export const decimalToFractional = (d, maxDen = 20) => {
assertDecimal(d);
const target = d - 1;
const search = (limit) => {
let best = { num: 1, den: 1, err: Infinity };
for (let den = 1; den <= limit; den++) {
const num = Math.round(target * den);
if (num <= 0) continue;
const err = Math.abs(target - num / den);
if (err < best.err - 1e-12) best = { num, den, err };
}
return best;
};
const best = Number.isFinite(search(maxDen).err) ? search(maxDen) : search(1000);
return ${best.num}/${best.den};
};

export const impliedProbability = (d) => 1 / assertDecimal(d);

export const overround = (decimals) =>
decimals.reduce((sum, d) => sum + impliedProbability(d), 0) - 1;

export const removeVig = (decimals) => {
const probs = decimals.map(impliedProbability);
const total = probs.reduce((a, b) => a + b, 0);
return probs.map((p) => p / total);
};

// Guess the format of a user-typed string. Bare integers are ambiguous on purpose.
export const detectFormat = (raw) => {
const s = String(raw).trim();
if (s.includes("/") || /^(evens|evs|even)$/i.test(s)) return "fractional";
if (/^[+-]\d+$/.test(s)) return "american";
if (/^\d+([.,]\d+)?$/.test(s) && /[.,]/.test(s)) return "decimal";
throw new OddsError(cannot tell the format of "${raw}" - pass it explicitly);
};

export const toDecimal = (value, format = detectFormat(value)) => {
switch (format) {
case "decimal": return assertDecimal(Number(String(value).replace(",", ".")));
case "american": return americanToDecimal(Number(value));
case "fractional": return fractionalToDecimal(value);
default: throw new OddsError(unknown format "${format}");
}
};

export const convertAll = (d) => ({
decimal: Number(d.toFixed(3)),
american: decimalToAmerican(d),
fractional: decimalToFractional(d),
impliedProbability: Number(impliedProbability(d).toFixed(4)),
});

And in Node:

js
import { convertAll, toDecimal } from "./odds.mjs";

console.log(convertAll(3.4));
// { decimal: 3.4, american: 240, fractional: '12/5', impliedProbability: 0.2941 }

console.log(toDecimal("+150")); // 2.5 (format auto-detected: American)
console.log(toDecimal("5/2")); // 3.5 (fractional)
console.log(toDecimal("2,50")); // 2.5 (European decimal comma)
Why detectFormat refuses to guess "150"

Look at the detectFormat function again. A string like "+150" is clearly American, and "5/2" is clearly fractional. But what is a bare "150"? It could be American +150 typed without the sign, or an absurdly long decimal price. We throw an error instead of guessing. In a betting context a silent wrong guess is far worse than a loud failure: if you read 150 as decimal you have just priced a 1% outcome as a near-certainty. When you control the UI, always have the user (or the data source) tell you the format explicitly.

Step 4: Test it properly

Odds code is exactly the sort of thing that looks right and is subtly wrong. Write tests before you trust it. The most valuable tests are round trips and known reference values.

Python (test_odds.py, run with pytest):

python
import pytest
from odds import *

@pytest.mark.parametrize("american,decimal", [
(150, 2.5), (-110, 1.9091), (100, 2.0), (-100, 2.0), (-200, 1.5), (250, 3.5),
])
def test_american_to_decimal(american, decimal):
assert american_to_decimal(american) == pytest.approx(decimal, abs=1e-4)

@pytest.mark.parametrize("decimal,frac", [
(3.5, "5/2"), (1.91, "10/11"), (2.0, "1/1"), (1.25, "1/4"), (1.01, "1/100"),
])
def test_decimal_to_fractional(decimal, frac):
assert decimal_to_fractional(decimal) == frac

def test_round_trip_american():
for a in [-1000, -250, -110, -101, 100, 105, 150, 400, 1200]:
assert decimal_to_american(american_to_decimal(a)) == a

def test_invalid_inputs():
for bad in [0, 50, -50]:
with pytest.raises(OddsError):
american_to_decimal(bad)
for bad in [1.0, 0.5, -3, float("nan")]:
with pytest.raises(OddsError):
decimal_to_american(bad)
with pytest.raises(OddsError):
fractional_to_decimal("2.5") # that's decimal, not fractional

JavaScript (odds.test.mjs, run with node --test):

js
import test from "node:test";
import assert from "node:assert/strict";
import * as o from "./odds.mjs";

test("round trip american", () => {
for (const a of [-1000, -250, -110, -101, 100, 105, 150, 400, 1200]) {
assert.equal(o.decimalToAmerican(o.americanToDecimal(a)), a);
}
});

test("python parity", () => {
assert.deepEqual(o.convertAll(3.4), {
decimal: 3.4, american: 240, fractional: "12/5", impliedProbability: 0.2941,
});
});

test("invalid input", () => {
assert.throws(() => o.americanToDecimal(50), o.OddsError);
assert.throws(() => o.detectFormat("150"), o.OddsError);
});

The "parity" test is a nice trick if you maintain both a backend (Python) and a frontend (JavaScript). Pin the same few known outputs in both test suites and you will catch drift the day somebody "improves" one implementation.

Step 5: Implied probability, the overround and fair odds

Converting formats is the easy half. The half that makes your app genuinely useful is understanding what a price is telling you about probability.

For decimal odds, implied probability = 1 / decimal. A price of 2.00 implies 50%. A price of 4.00 implies 25%.

Now take a three-way football market (home / draw / away) with these decimal prices:

Outcome Decimal Implied probability
Home 1.91 52.36%
Draw 3.40 29.41%
Away 4.20 23.81%
Total 105.58%

A real market's outcomes are mutually exclusive and exhaustive, so true probabilities must add up to exactly 100%. These add up to 105.58%. The extra 5.58% is the bookmaker's built-in margin, called the overround (or vig or juice in the US). It is how bookmakers get paid regardless of the result.

python
from odds import overround, remove_vig, probability_to_decimal

market = [1.91, 3.40, 4.20]

print(f"overround: {overround(market):.2%}") # overround: 5.58%

fair = remove_vig(market)
print([f"{p:.2%}" for p in fair]) # ['49.59%', '27.86%', '22.55%']
print([round(probability_to_decimal(p), 2) for p in fair]) # [2.02, 3.59, 4.43]

The method used here is the proportional (multiplicative) de-vig: divide each implied probability by the total. It is simple, fast and a perfectly good default. Be aware that it spreads the margin evenly across outcomes. Real bookmakers often load more margin onto longshots, so more advanced methods (power method, Shin's method, additive) exist and can give slightly different fair probabilities. For a first version, proportional is the right call. Just don't present the output as "the truth", present it as "the market's margin-free estimate".

The same maths in JavaScript:

js
import { overround, removeVig } from "./odds.mjs";

const market = [1.91, 3.4, 4.2];
console.log((overround(market) * 100).toFixed(2) + "%"); // 5.58%
console.log(removeVig(market).map((p) => (p * 100).toFixed(2) + "%")); // [ '49.59%', '27.86%', '22.55%' ]

Why does this matter for your product? A few examples:

Odds comparison pages can show "best price" and the margin, so users see which bookmaker is genuinely cheapest.
Trading dashboards compare each book's fair probabilities against a sharp reference line.
Models and backtests need fair probabilities, not margin-inflated ones, to measure calibration. We touch on that near the end.
Step 6: Edge cases that break naive converters

Here is the checklist I wish I had before shipping my first converter.

  1. Even money has three faces. 2.00, +100, -100 and 1/1 ("evens") are all the same price. Make sure -100 and +100 both convert to 2.0, and decide which one you print on the way out. In this article 2.0 prints as +100 and 1/1. Both fractions and Americans round-trip fine.

  2. Don't use floats for money. Our converter returns floats, which is fine for prices and probabilities. The moment you calculate a payout or stake, switch to Decimal in Python, or integer minor units (cents) in JavaScript:

python
from decimal import Decimal, ROUND_HALF_UP

def payout(stake: Decimal, decimal_odds: Decimal) -> Decimal:
return (stake * decimal_odds).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

payout(Decimal("10.00"), Decimal("2.50")) # Decimal('25.00')

  1. Rounding is a product decision. American odds are integers, so 1.91 becomes -110 (really -109.89). Converting back gives 1.9091, not 1.91. Never compare a converted-and-back value with ==; compare within a tolerance, as in our tests. Also note that Python's round() uses banker's rounding on exact halves, while JavaScript's Math.round() rounds .5 toward positive infinity. For real prices the difference almost never appears, but now you know where to look if a value is off by one.

  2. Locale decimal commas. In much of Europe 2,50 is the decimal price. Parse input with care (our toDecimal swaps the comma for a dot) and never let parseFloat("2,50") quietly return 2.

  3. Minimum prices. Decimal odds must be greater than 1.0. A decimal of exactly 1.00 means you win nothing. Some feeds send 0 or null for suspended markets. Reject them or you will end up dividing by zero.

  4. Suspended and missing markets. When odds disappear mid-match (a goal was scored, the market is suspended), your UI should show "suspended", not a stale or converted zero. Treat null/undefined as a state, not as a number.

  5. Cache the formatted value, not the format. Store and cache decimal prices. Format at render time based on the user's preference. If you cache "-110" strings you will have to invalidate them whenever a user flips their format setting.

Step 7: Plug the converter into a real odds API

A converter is most useful when it sits between a live odds feed and the user's screen. The feed gives you one canonical format (usually decimal), and your converter shows each user the format they expect: American for a US visitor, fractional for UK racing fans, decimal for everyone else.

For a data source we will use the Orbistats Odds API, which returns pre-match and live odds normalized into one format across sportsbooks. That is the point of an odds API: you do not write a parser per bookmaker. Per the odds endpoint reference, the endpoint is:

GET https://api.orbistats.com/v1/{sport}/odds?match_id={fixture_id}&market=1x2
Authorization: Bearer YOUR_API_KEY

and a 1X2 response looks like this (all Orbistats responses share a data / meta / errors envelope):

json
{
"data": { "market": "1X2", "home": 1.91, "draw": 3.40, "away": 4.20 }
}

The odds endpoint is listed on the Growth+ plan in the API reference, so check the pricing page for the current plan details. The converter itself works fully offline, so you can follow every earlier step without a key. If you want to explore responses before writing any code, the API sandbox lets you try endpoints against sample data, and the quickstart guide walks you from signup to first request.

Python client
python
import os
import requests
from odds import convert_all, overround

BASE = "https://api.orbistats.com/v1"

def get_odds(sport: str, match_id: str, market: str = "1x2") -> dict:
resp = requests.get(
f"{BASE}/{sport}/odds",
params={"match_id": match_id, "market": market},
headers={"Authorization": f"Bearer {os.environ['ORBISTATS_API_KEY']}"},
timeout=10,
)
if resp.status_code == 429:
wait = resp.headers.get("Retry-After", "1")
raise RuntimeError(f"Rate limited, retry in {wait}s")
resp.raise_for_status() # 401 bad key, 403 plan, 404 unknown match ...
return resp.json()["data"]

def price_table(odds: dict, style: str = "american") -> dict:
"""Return home/draw/away formatted in the user's preferred style."""
return {side: convert_all(odds[side])[style] for side in ("home", "draw", "away")}

if name == "main":
data = get_odds("football", "fx_884213")
print(price_table(data, "american")) # {'home': -110, 'draw': 240, 'away': 320}
print(price_table(data, "fractional")) # {'home': '10/11', 'draw': '12/5', 'away': '16/5'}
print(f"margin: {overround([data['home'], data['draw'], data['away']]):.2%}") # margin: 5.58%
JavaScript client
js
import { convertAll, overround } from "./odds.mjs";

const BASE = "https://api.orbistats.com/v1";

export async function getOdds(sport, matchId, market = "1x2") {
const url = new URL(${BASE}/${sport}/odds);
url.search = new URLSearchParams({ match_id: matchId, market });

const res = await fetch(url, {
headers: { Authorization: Bearer ${process.env.ORBISTATS_API_KEY} },
});

if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 1);
throw Object.assign(new Error("Rate limited"), { retryAfter: wait });
}
if (!res.ok) throw new Error(Orbistats ${res.status}: ${await res.text()});

return (await res.json()).data;
}

export const priceTable = (odds, style = "american") =>
Object.fromEntries(["home", "draw", "away"].map((s) => [s, convertAll(odds[s])[style]]));

// usage
const data = await getOdds("football", "fx_884213");
console.log(priceTable(data, "fractional")); // { home: '10/11', draw: '12/5', away: '16/5' }
console.log((overround([data.home, data.draw, data.away]) * 100).toFixed(2) + "%"); // 5.58%

I verified the conversion and margin logic above against the example response using a mocked HTTP layer. Treat the field names as defined by the live API reference, because feeds differ per plan and per market (player props, totals and so on have different shapes). If you use an official client, the SDKs cover Python, JavaScript, TypeScript, PHP, Java, C#, Go and Ruby with the same method names as the REST endpoints.

Don't poll live odds, subscribe

Prices move every few seconds during a live match. Polling the REST endpoint in a loop wastes your request quota and still shows stale prices. For live boards, a push connection is the better fit. Orbistats offers a WebSocket API for a persistent low-latency stream and webhooks (including an odds.changed event) that push updates to your server the moment something changes.

The converter slots straight in, because conversion is O(1) and cheap enough to run on every tick:

js
import WebSocket from "ws";
import { convertAll } from "./odds.mjs";

const ws = new WebSocket(wss://stream.orbistats.com/v1?token=${process.env.ORBISTATS_API_KEY});

ws.on("open", () => ws.send(JSON.stringify({ subscribe: "football.live" })));

ws.on("message", (raw) => {
const msg = JSON.parse(raw);
// Illustrative: adapt these field names to the message shape in the WebSocket docs.
if (typeof msg.odds?.home === "number") {
console.log(msg.fixture_id, convertAll(msg.odds.home).american);
}
});
Where this goes next: backtesting and models

Once you can turn any price into a probability, a lot of doors open. With multi-season closing odds, you can convert each closing price to a fair probability (remove the vig first), compare it to what actually happened, and build a calibration curve: of all the matches where the market said 60%, did the home team really win about 60% of the time? That is the foundation of most betting-model backtests, and it is exactly the use case the Historical Sports Data API is built for. The loop is always the same: fetch history, convert to probabilities, strip the margin, compare with outcomes.

Production checklist

Before you ship your converter, run through this list:

One internal format (decimal), convert only at the UI edge
Every function validates input and throws a clear error
-100 and +100 both map to 2.0
Bare numbers are never auto-detected as a format
Money uses Decimal or integer cents, never floats
Round trips are tested with a tolerance, not ==
Suspended or null markets render as "suspended", not 0
Python and JS implementations share a parity test
The user's preferred format is stored as a setting, not baked into cached data
You show the overround where it helps users judge value
FAQ

How do I convert decimal odds to American odds? If the decimal price is 2.00 or higher, subtract 1 and multiply by 100 (2.50 becomes +150). If it is below 2.00, divide -100 by the decimal minus 1 (1.91 becomes -110).

How do I convert fractional odds to decimal? Divide the numerator by the denominator and add 1. So 5/2 is 1 + 5/2 = 3.50, and "evens" (1/1) is 2.00.

What is implied probability in betting? It is the chance of an outcome that is suggested by the odds. For decimal odds it is 1 divided by the price, so 4.00 implies 25%. When you add up the implied probabilities for every outcome in a market, the total is usually above 100%, and the surplus is the bookmaker's margin.

What is the overround (vig)? The overround is the sum of the implied probabilities of all outcomes minus 1. In our football example it is 5.58%. A lower overround means a better price for the bettor.

Should I store odds as decimal, American or fractional? Store decimal (or the probability) and convert for display. Decimal converts to the others with one formula and works directly in payout and probability maths.

Which sports does Orbistats cover? Thirteen sports at the time of writing: football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing. Depth varies by sport and by endpoint, so check the coverage details before you build.

Wrapping up

An odds converter is a small piece of code with a surprising number of ways to go wrong. If you remember only four things, make them these:

Keep decimal odds as your internal format and convert at the edges.
Validate everything. Reject 0, 1.0, +50 and bare ambiguous numbers.
Convert prices to implied probabilities, and strip the overround before treating them as fair.
Test with round trips and reference values, and share a parity test between languages.

If you want live prices to feed the converter, you can get a free API key from Orbistats and try the odds endpoints in the sandbox. And if you build something with it, such as an odds comparison page, a margin tracker or a live ticker, I would love to see it in the comments.

Disclosure: I build Orbistats. Betting involves risk and is for adults only. This article is for educational and engineering purposes and is not betting advice.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.