What Can an Odds API Integration Teach Us About Rounding?

What Can an Odds API Integration Teach Us About Rounding?

November 2, 2026
8 min read
0 views
0

You add a format toggle to an odds comparison screen. The API returns a decimal price of 1.87. The user switches to American odds, and the screen shows -115.

So far, that looks fine.

The problem starts when another part of the application reads -115, converts it back to decimal, and uses the result in a calculation. The price is now 1.8695652173913042. The API never sent that number.

It is a small difference, but it exposes a useful question in an API integration: which value does the application treat as the source once it has something readable on screen?

An odds API makes that question easy to demonstrate. The same quote can have two sensible display labels, while neither label should replace the number behind it.

1. Start with the quote the API actually sent

Take a comparison screen that retrieves an event's bookmaker prices from an odds api. The snapshot request has this shape:

GET /v1/events/{event_id}/odds/snapshot?price_fields=odds
X-API-Key: <your-server-side-key>

The application makes that request from its server, then passes the required data to the screen. The key stays on the server.

The snapshot contains an items array. A shortened row from the public repository's built-in mock data looks like this:

{
"id": "sportsbet::moneyline::moneyline::0::::away::",
"bookmaker": "sportsbet",
"selection_key": "moneyline:away",
"odds": 1.87,
"is_available": true
}

There are a few details worth keeping together. The bookmaker identifies the source. The selection key identifies the outcome being compared. The availability flag tells the client whether that row is available. The odds field carries the bookmaker's decimal price.

The API can also supply odds_no_vig and fair_odds. Those are reference prices with different meanings. A display preference should not switch between those fields. For this comparison screen, requesting price_fields=odds keeps the input focused on the bookmaker quote.

The decimal and American labels should both come from that same odds value.

2. Follow the price through one round trip

For decimal odds below 2.00, the American conversion is:

American = -100 / (decimal - 1)

At 1.87, the unrounded result is approximately -114.942529. Rounding it to an integer gives the familiar -115 label.

Now reverse that rounded label:

Decimal = 1 + 100 / 115
= 1.8695652173913042

The conversion back works correctly. It is converting the rounded value it received. The information disappeared when the application reduced the American value to an integer.

That matters even if the decimal screen still looks right. Both 1.87 and 1.8695652173913042 display as 1.87 at two decimal places. A developer can switch formats, check the screen, and miss the changed value underneath it.

A simple implied-percentage calculation makes the difference visible:

i) Original API quote

  • Decimal value used: 1.87
  • 100 ÷ decimal: 53.475936%

ii) Reconstructed display label

  • Decimal value used: 1.8695652173913042
  • 100 ÷ decimal: 53.488372%

These are calculations from a mock quote, not measurements of a production incident. The useful finding is that a presentation choice has become a new calculation input.

A decimal arithmetic library cannot reconstruct information already discarded from the label. Keep the original numeric API value so there is no round trip to perform.

3. Give the formatter one job

The formatter can accept a decimal number and return a string. It does not need to update the quote, calculate a reference price, or know which bookmaker supplied it.

Save this as odds.ts:

export type OddsFormat = "decimal" | "american";
export type Quote = Readonly<{
id: string;
bookmaker: string;
selection_key: string;
odds: number | null;
is_available: boolean;
}>;

export function formatOdds(value: unknown, format: OddsFormat): string {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 1) {
throw new RangeError("Decimal odds must be a finite number greater than 1.");
}
if (format === "decimal") return value.toFixed(2);
const magnitude = value >= 2 ? (value - 1) * 100 : 100 / (value - 1);
if (!Number.isFinite(magnitude)) {
throw new RangeError("American display value is out of range.");
}
return `${value >= 2 ? "+" : "-"}${Math.round(magnitude)}`;
}

export function presentQuote(quote: Quote, format: OddsFormat) {
return {
id: quote.id,
bookmaker: quote.bookmaker,
selection_key: quote.selection_key,
sourceDecimal: quote.odds,
label: quote.is_available ? formatOdds(quote.odds, format) : "Unavailable",
};
}

The validation is deliberate. A decimal price must be a finite number greater than 1. Missing values, numeric strings, infinity and values outside that domain get rejected. Silently converting everything with Number(value) would accept some inputs the caller should handle explicitly.

The formatter rounds the American magnitude before adding its sign. It also checks the converted magnitude, because multiplying an extremely large finite input can overflow.

The TypeScript Readonly type catches direct assignments to quote fields during type checking. The separate presentation object keeps rendering code from needing to overwrite them. It is still the application's responsibility to validate a network response; a TypeScript declaration alone does not validate JSON.

4. Change the label when the user changes format

The screen can derive a presentation object whenever the format changes. The quote stays in application state, and calculations continue to read its original odds field.

Save this small mock demonstration as demo.ts:

import { presentQuote } from "./odds.ts";
import type { Quote } from "./odds.ts";

const quote: Quote = {
id: "sportsbet::moneyline::moneyline::0::::away::",
bookmaker: "sportsbet",
selection_key: "moneyline:away",
odds: 1.87,
is_available: true,
};

console.table([
presentQuote(quote, "decimal"),
presentQuote(quote, "american"),
]);

With Node.js 24, run node demo.ts. There are no package dependencies or credentials required for this example. Both output rows retain sourceDecimal: 1.87; their labels are 1.87 and -115.

In a browser component, the format setting determines which label gets rendered. Changing that setting should not make a new price record or parse the text already on screen.

Availability stays separate too. If the API marks a row unavailable while a numeric price remains in the record, presentQuote returns an unavailable label. A valid number does not override the row's status.

5. Two identical labels can hide different quotes

There is another reason to keep comparisons away from display strings.

Take two deliberately chosen test prices: 2.1101 and 2.1149. With this formatter, both display as 2.11 in decimal format. Both also display as +111 in American format.

They look equal in either view. Their underlying values are different.

The public TypeScript SDK's findBestOdds helper compares the original numeric odds values. It groups rows by selection key where available, skips unavailable rows, and retains the original winning row. Formatting happens separately.

That gives the comparison screen a sensible order of operations: identify comparable selections, check availability, compare source prices, then render the results. Grouping rows by their labels would confuse unrelated outcomes. Comparing rounded numbers could turn two different quotes into an apparent tie.

The four-decimal prices here are synthetic test inputs. They show what rounding can discard; they do not imply that every bookmaker or endpoint supplies that precision.

6. Decide what happens at even money

Exactly 2.00 is the boundary between the two American conversion branches. This formatter displays it as +100.

It chooses the branch using the original decimal value, before rounding:

i) Source decimal - 1.999

  • Decimal label: 2.00
  • American label: -100

ii) Source decimal - 2.000

  • Decimal label: 2.00
  • American label: +100

iii) Source decimal - 2.001

  • Decimal label: 2.00
  • American label: +100

Both -100 and +100 represent even money. The different signs in this example follow the chosen display policy; neither label preserves the exact source price.

The trap is deciding the branch after rounding the decimal input to two places. At that point, all three inputs have become 2.00. The formatter has lost the distinction before it starts the American conversion.

Other products may choose to normalize rounded even-money labels to +100. That is a display decision to document and test. It should leave the API quote untouched.

7. Test the change in state, as well as the labels

A useful check needs to prove that switching format preserves the source record. Checking that 1.87 produces -115 only tests the label.

Save this as odds.test.ts beside odds.ts:

import assert from "node:assert/strict";
import test from "node:test";
import { formatOdds, presentQuote } from "./odds.ts";
import type { Quote } from "./odds.ts";

const quote: Quote = Object.freeze({
id: "sportsbet::moneyline::moneyline::0::::away::",
bookmaker: "sportsbet",
selection_key: "moneyline:away",
odds: 1.87,
is_available: true,
});

test("switching display preserves the source quote", () => {
const before = structuredClone(quote);
assert.equal(presentQuote(quote, "american").label, "-115");
assert.equal(presentQuote(quote, "decimal").label, "1.87");
assert.equal(presentQuote(quote, "american").sourceDecimal, 1.87);
assert.deepEqual(quote, before);
});

test("a rounded American label cannot recover the source price", () => {
const label = formatOdds(quote.odds, "american");
const reconstructed = 1 + 100 / Math.abs(Number(label));
assert.notEqual(reconstructed, quote.odds);
});

test("distinct synthetic prices can have identical labels", () => {
const values = [2.1101, 2.1149];
assert.deepEqual(values.map(v => formatOdds(v, "decimal")), ["2.11", "2.11"]);
assert.deepEqual(values.map(v => formatOdds(v, "american")), ["+111", "+111"]);
assert.ok(values[1] > values[0]);
});

test("the even-money display policy uses the original value", () => {
assert.equal(formatOdds(1.999, "american"), "-100");
assert.equal(formatOdds(2, "american"), "+100");
assert.equal(formatOdds(2.001, "american"), "+100");
});

test("missing and invalid numbers are rejected without coercion", () => {
for (const value of [null, undefined, "", "2.10", NaN, Infinity, -Infinity, 0, 1, -2]) {
assert.throws(() => formatOdds(value, "american"), RangeError);
}
assert.throws(() => formatOdds(Number.MAX_VALUE, "american"), RangeError);
});

test("an unavailable row keeps its status when a number remains", () => {
const unavailable = { ...quote, is_available: false };
assert.equal(presentQuote(unavailable, "decimal").label, "Unavailable");
assert.equal(presentQuote(unavailable, "american").label, "Unavailable");
});

Run node --test odds.test.ts with Node.js 24. The six checks cover source preservation, rounding loss, display ties, the boundary, invalid inputs and availability.

The same checks belong beside the code that handles the real snapshot. When new prices arrive, replace the source quote with the new API data and derive its label again. When only the user's display preference changes, keep the quote already received.

That is the useful lesson from the 1.87 example. The API supplied one price. The application can show it in several ways, but the next calculation should still start with that price.

Was this article helpful?Vote to let the author know
0
Share this article

Loading comments...

Related Articles

Top Features Every RWA Tokenization Platform Should Include in 2026

Top Features Every RWA Tokenization Platform Should Include in 2026

Real-world asset tokenization is changing how businesses manage and offer ownership of physical and financial assets. From real estate and gold to bonds and other valuable assets, companies are exploring ways to bring traditional assets onto blockchain networks. But launching a tokenization platform involves more than simply converting an asset into digital tokens. Businesses need a platform that can manage assets, handle investors, support secure transactions, and provide transparency throughout the process. This is where RWA Tokenization Platform Development becomes important. A well-built platform should offer the right combination of blockchain technology, smart contracts, asset management tools, and investor-friendly features.

6 views
Read More
Read full article: Top Features Every RWA Tokenization Platform Should Include in 2026
How Businesses Can Benefit from Real-World Asset Tokenization

How Businesses Can Benefit from Real-World Asset Tokenization

Real-world asset tokenization uses blockchain technology to represent assets digitally, helping businesses explore new funding opportunities, improve asset management, automate transactions, and increase transparency. The article explains its key benefits, practical applications, and important legal and security considerations.

11 views
Read More
Read full article: How Businesses Can Benefit from Real-World Asset Tokenization