DEV Community

OddsFrance
OddsFrance

Posted on

Comparing French bookmaker margins in Python

Disclosure first: I built the scrapers that produced this data (OddsFrance on Apify). The CSV used below is free and needs no account.

French licensed sportsbooks are oddly absent from the usual odds APIs. I wanted a simple answer to a simple question: how much margin does each French book take on a football 1X2 market? Here is how to get it from one daily snapshot.

The data

One row per outcome: bookmaker, sport, competition, teams, start time (UTC), market (1x2 or match_winner), outcome, decimal odds.

import pandas as pd

url = ("https://huggingface.co/datasets/oddsfrance/french-bookmakers-odds/"
       "resolve/main/data/french_bookmakers_odds_2026-11-13.csv")
df = pd.read_csv(url)
df = df[df["oddsDecimal"] > 1]
print(len(df), df["bookmaker"].value_counts().to_dict())
Enter fullscreen mode Exit fullscreen mode

The 13 November snapshot has 1,722 rows. Winamax lists far more matches than the other two, which matters below.

Margin (overround) per bookmaker

For a 1X2 market the margin is 1/home + 1/draw + 1/away - 1. Keep only events where the three outcomes are present:

x12 = df[(df["sport"] == "football") & (df["marketType"] == "1x2")]
full = x12.groupby(["bookmaker", "eventName"]).filter(
    lambda g: set(g["outcome"]) == {"home", "draw", "away"})
margin = full.groupby(["bookmaker", "eventName"])["oddsDecimal"].apply(lambda o: (1 / o).sum() - 1)
print((margin.groupby("bookmaker").median() * 100).round(1))
Enter fullscreen mode Exit fullscreen mode
Bookmaker Median 1X2 margin Events
PokerStars Sports 9.7 % 23
NetBet 12.1 % 35
Winamax 16.5 % 516

Read it with care. Winamax prices hundreds of small leagues, where every book charges more, so its median is pulled up. The fair comparison is on the same matches, which is the next step.

Same outcome, several books

Match outcomes across books on home team, away team and start time:

key = ["homeTeam", "awayTeam", "startTime", "marketType", "outcome"]
multi = df.groupby(key).filter(lambda g: g["bookmaker"].nunique() >= 2)
best = (multi.sort_values("oddsDecimal", ascending=False)
        .groupby(key).agg(best=("oddsDecimal", "first"), worst=("oddsDecimal", "last"),
                          book=("bookmaker", "first")))
best["gap_%"] = ((best["best"] / best["worst"] - 1) * 100).round(1)
print(len(best), best["gap_%"].median())
print(best.sort_values("gap_%", ascending=False).head(5))
Enter fullscreen mode Exit fullscreen mode

96 outcomes were priced by at least two books. The median gap between best and worst price was 3.6 %, and on a few long shots it went above 20 %. Team names are written the French way by all three books (« Écosse », « Pays de Galles »), which is why a naive join works here; across other books you need a mapping table.

What this is not

One snapshot during an international break is not a study. Margins move with the competition, the time to kick-off and the market. Stack daily files (a new one lands every morning) to see that. And none of this is betting advice: 18+, and check the bookmaker's own page before any decision.

Links

Drafted with AI assistance; the code was run and every number checked against the data before publishing.

If you know a cleaner way to match team names across books, I would like to read it.

Top comments (0)