Convert Fractional Odds to Decimal in Code

Convert fractional odds to decimal in code: the num/den + 1 formula, a battle-tested parser for SP, evens and odds-on, GCD reduction and the UK ladder.
Divide the numerator by the denominator and add one: decimal = num / den + 1. 11/4 becomes 3.75. 1/2 becomes 1.5. That is the entire fractional to decimal odds formula, and it is not what will break your code.
What breaks is everything wrapped around it — the string SP arriving where you expected a number, the ON suffix that silently inverts a price, and a display layer that writes its rounded fraction back into the database. This post is about that mess.
The short definition
Fractional odds express profit per unit staked. Decimal odds express total return per unit staked, stake included. That single difference is the whole conversion: a +1 going one way, a -1 coming back.
fractional → decimal: d = num/den + 1
decimal → fractional: num/den = d - 1 (then reduce)
Both directions, worked:
| Fractional | Maths | Decimal |
|---|---|---|
| 1/2 | 0.5 + 1 | 1.50 |
| evens (1/1) | 1 + 1 | 2.00 |
| 5/2 | 2.5 + 1 | 3.50 |
| 11/4 | 2.75 + 1 | 3.75 |
| 100/30 | 3.333… + 1 | 4.33 |
| 4/9 | 0.444… + 1 | 1.44 |
Look at 100/30. It isn't reduced. It is deliberately not reduced, because UK racing boards have shown it that way for a century and 10/3 reads worse at a glance. If your code "helpfully" reduces it, you have changed nothing mathematically and broken the thing a punter recognises. Reducing is a choice, not an obligation — build the flag in.
What it gets confused with
Three conversions get tangled together in betting codebases. Keep them apart.
American (moneyline) odds are also profit-based, but scaled to 100. Positive is profit on a 100 stake; negative is the stake needed to win 100.
function americanToDecimal(a) {
return a > 0 ? a / 100 + 1 : 100 / Math.abs(a) + 1;
}
// +250 → 3.5 -200 → 1.5
Implied probability is the reciprocal of the decimal price, p = 1 / d. Straight from a fraction it's p = den / (num + den), so 11/4 implies 4/15 = 26.7%.
That last one is where people talk themselves into imaginary edge. The implied probability of a posted price includes the book's margin, so it is not a fair probability — it's a fair probability plus vig. This is exactly why an odds-drop alert from our feed carries a separate nvp field alongside the price: the no-vig fair number is a different quantity and deserves a different field. Field shapes are in the docs. Don't derive a "true" probability from a raw fractional string and call it an edge.
Why parsing is the hard part
The arithmetic is a division and an addition. The input is a string typed by a human or scraped off a page, and it shows up as:
11/4,11-4,11 / 4evens,evs,EVS,1/1SP— starting price, not a number at all9/2f— the trailingfmarks the favourite2/1 ON— odds-on, meaning 1/2, and it inverts the price silently
A naive split('/') gets maybe half of these right. For the rest it produces NaN, or worse, something plausible and wrong. Here's a parser that has survived real inputs:
const WORDS = { evens: [1, 1], evs: [1, 1], even: [1, 1], "1/1": [1, 1] };
function parseFractional(raw) {
if (typeof raw !== "string") throw new TypeError("expected string");
let s = raw.trim().toLowerCase();
if (s === "sp" || s === "") return null; // no price yet — not an error
const onSuffix = /\bon\b$/.test(s); // "2/1 on" == 1/2
s = s.replace(/\b(on|f|jf|cf)\b/g, "").trim(); // strip favourite markers
if (WORDS[s]) return { num: WORDS[s][0], den: WORDS[s][1] };
const m = s.match(/^(\d+)\s*[\/\-\s]\s*(\d+)$/);
if (!m) throw new Error(`unparseable fractional odds: ${raw}`);
let num = Number(m[1]);
let den = Number(m[2]);
if (den === 0) throw new Error(`zero denominator: ${raw}`);
if (onSuffix) [num, den] = [den, num];
return { num, den };
}
function toDecimal(raw) {
const f = parseFractional(raw);
return f === null ? null : f.num / f.den + 1;
}
toDecimal("11/4"); // 3.75
toDecimal("100-30"); // 4.333333333333333
toDecimal("2/1 on"); // 1.5
toDecimal("SP"); // null
Two decisions there I'll defend. SP returns null instead of throwing, because an unpriced runner is a normal state and throwing on it means one horse takes down the render for the whole race. Unparseable input, on the other hand, throws loudly rather than falling back to 1.0 or 2.0. A silent default in an odds pipeline is how you settle bets at a price nobody offered.
Reducing with GCD, and why it disappoints
Going the other way needs d - 1 as a ratio of integers, then reduced. Euclid, four lines:
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
function reduceFraction(num, den) {
const g = gcd(num, den) || 1;
return [num / g, den / g];
}
reduceFraction(100, 30); // [10, 3]
reduceFraction(6, 4); // [3, 2]
Fine in the clean case. 3.75 gives exactly 2.75 = 11/4. But a price of 2.87 gives 1.87 = 187/100, which reduces to… 187/100. Nobody has ever printed 187/100 on a board.
So the honest approach is to snap to the traditional UK ladder instead of computing an exact ratio:
const LADDER = [
[1,10],[1,8],[1,6],[1,5],[2,9],[1,4],[2,7],[3,10],[1,3],[4,11],[2,5],[4,9],
[1,2],[8,15],[4,7],[8,13],[4,6],[8,11],[4,5],[5,6],[10,11],[1,1],[11,10],
[6,5],[5,4],[11,8],[6,4],[13,8],[7,4],[15,8],[2,1],[9,4],[5,2],[11,4],[3,1],
[10,3],[7,2],[4,1],[9,2],[5,1],[11,2],[6,1],[13,2],[7,1],[15,2],[8,1],[9,1],
[10,1],[11,1],[12,1],[14,1],[16,1],[20,1],[25,1],[33,1],[40,1],[50,1],
[66,1],[100,1]
];
function decimalToFractional(d) {
const target = d - 1;
let best = LADDER[0], bestErr = Infinity;
for (const [n, den] of LADDER) {
const err = Math.abs(n / den - target);
if (err < bestErr) { bestErr = err; best = [n, den]; }
}
return best[0] === 1 && best[1] === 1 ? "evens" : `${best[0]}/${best[1]}`;
}
decimalToFractional(3.75); // "11/4"
decimalToFractional(2.87); // "15/8"
decimalToFractional(2.00); // "evens"
The snap is lossy, and that is the point. Fractional display is an approximation of the real price, which leads straight to the rule I'd argue for hardest.
Store decimals. Render fractions. Never read them back.
Fractional odds are a presentation format. Decimal is the storage and arithmetic format. In practice:
- Every price in your database, cache and message bus is decimal.
- Money maths — returns, liability, parlay legs, CLV — runs in decimal, ideally with a fixed-precision type or scaled integers (3.75 stored as
3750thousandths) so you never compare floats for equality. - The conversion to
11/4happens in the view layer, at the last possible moment, and the result is never parsed back into a price.
Here's the failure mode in one line. Round-trip 2.87 through the ladder and it comes back 2.875, because 15/8 was the nearest rung. Do that on every tick and you manufacture phantom line movement — and if you're running a drop detector over that stream, it will dutifully alert on movement that never happened.
This is also why feeds ship decimals. pinnodds carries Pinnacle's markets in decimal across /kit/v1/markets, /kit/v1/prematch/lines and the /ws/feed WebSocket, and from_price / to_price on an SSE odds-drop alert are decimal too. There is no fractional representation in the wire format, because there shouldn't be one.
The honest caveats
Sharp prices don't sit on the ladder. Pinnacle prices two-way markets tightly — 1.909, 1.952, 2.04 are routine. Snap 1.952 and you get 20/21, which nobody uses, or 10/11 on a coarser ladder, which is 1.909. That is roughly a 2% error in the displayed price. If you're showing sharp-book prices to a UK audience, default to two decimal places and make fractions a toggle. Fractions as the default format for a feed that was never priced on a fractional ladder is a decision you'll regret in a support ticket.
Deep odds-on doesn't render cleanly. A 1.05 shot is 1/20. Racing displays go to 1/25 and beyond, but the rungs thin out fast and the rounding error as a percentage of profit gets ugly.
SP and NR are not prices. Any parser that returns a number for them is lying to the caller. Model them as a sum type — { kind: "priced", num, den } | { kind: "sp" } | { kind: "withdrawn" } — and force the call site to handle the non-priced cases.
Handicap lines are not odds. A soccer total of 2.75 or a handicap of -1.25 is a line. Running it through a fractional converter is a category error I have genuinely found in production code, and it produces a number that looks like a price. pinnodds delivers every quarter line Pinnacle prices — 1.75 / 2.25 / 2.75 / 3.25 totals, -0.75 / -1.25 / -1.75 / -2.25 handicaps — and those values live in different fields from the price. Keep the types distinct so the compiler catches the mistake instead of your users.
Our API will not do this for you. There is no fractional-format flag on any endpoint, and there isn't one planned. If your product needs UK fractional display, the ladder table above is your job, not the API's. What you get from us is a clean decimal price you don't have to normalise.
A test table worth writing
Conversion bugs are cheap to catch and expensive to ship. The minimum:
const CASES = [
["1/2", 1.5], ["evens", 2.0], ["EVS", 2.0], ["5/2", 3.5],
["11/4", 3.75], ["11-4", 3.75], ["9/2f", 5.5], ["2/1 on", 1.5],
["4/9", 1.4444444444444444], ["100/30", 4.333333333333333]
];
for (const [input, expected] of CASES) {
const got = toDecimal(input);
console.assert(Math.abs(got - expected) < 1e-9, `${input}: ${got} != ${expected}`);
}
Then one property test that matters more than the rest: for every rung on the ladder, decimalToFractional(toDecimal(f)) === f. If that round-trip is stable, your display layer can't invent movement. If it isn't, you'll find out at 3am when the drop alerts start firing on a quiet Tuesday.
Takeaway
num/den + 1 will never be the line that fails. The failures are a parser that swallows SP and hands back 2.0, and a renderer whose rounded fraction leaks back into storage. Parse defensively, reduce with GCD only when you're snapping to a real ladder, keep decimal as the single source of truth, and let the fraction live exactly as long as it takes to render a <span>.
Wiring this against a live feed? The decimal prices come off /kit/v1/markets and /ws/feed ready to use — no conversion, no ladder, no rounding. Auth and field shapes are in the docs; rate limits and push access differ by plan on the pricing page.
Frequently asked questions
How do you convert fractional odds to decimal?
Divide the numerator by the denominator and add 1: decimal = num / den + 1. So 11/4 is 11 ÷ 4 + 1 = 3.75, and 1/2 is 0.5 + 1 = 1.5.
What is 11/4 in decimal odds?
3.75. Fractional odds show profit per unit staked (2.75 profit on 1), while decimal odds include the stake in the total return.
How do I convert decimal odds back to fractional?
Take d - 1 and express it as a ratio, then reduce with GCD. In practice you should snap the result to the traditional UK fractional ladder, because exact ratios like 187/100 are never displayed.
How should I handle SP or non-numeric fractional odds in code?
Return a distinct non-price value rather than a number — null, or better, a tagged type like { kind: "sp" }. Returning 2.0 or 1.0 for an unpriced runner is how wrong prices reach settlement.
Does the pinnodds API return fractional odds?
No. All prices come through as decimals on /kit/v1/markets, /kit/v1/prematch/lines and the /ws/feed WebSocket, including from_price and to_price on odds-drop alerts. There is no fractional-format flag; convert in your view layer.
Get real-time Pinnacle odds in your code
Live & prematch markets with instant odds-drop alerts over SSE and WebSocket. Free trial key in seconds — no card.
Start free trial