Surebet Analyzer

A read-only analysis tool. It collects public pre-match odds from six anonymised sources, works out which listings are the same match, and calculates how a budget would split across them, down to the kuruş, the hundredth of a lira.

Role
Personal project: collectors, matching, stake solver, API and PWA
Status
Code public on GitHub; demo mode runs on synthetic data
Stack
Python 3.12, FastAPI, OR-Tools CP-SAT, SQLite, React 18, TypeScript
Code
github.com/ozgurlook/surebet-analyzer, MIT licence

The screens come from demo mode: synthetic odds, with the sources named Source A to F.

Found opportunities in demo mode: the budget, the filters and a card with the net difference for one match
Source health in demo mode: Sources C, B, A and D with their feed types, Source C disabled
Opportunity detail in demo mode: the calculated net difference for one match, with the selected prices from Sources B, A and E and the stake for each

In short

Problem
Six sources list the same matches under different team names, league names and kickoff times, and their prices change from one collection to the next. Before two prices can be compared, the system has to know they belong to one match; after that, a budget has to be split exactly, in whole kuruş.
My part
A personal project I built end to end: collectors for three feed protocols, name normalisation and matching, the kuruş allocator and the CP-SAT model, the FastAPI backend, the React PWA and the Telegram alerts.
Outcome and status
Public on GitHub under the MIT licence. On every push and pull request, CI runs ruff, mypy in strict mode, 1,209 backend tests and the PWA's Vitest suite. The public code contains no source addresses, and demo mode runs the whole app on synthetic data.

The app, screen by screen

Six screens of the PWA in demo mode. The odds are synthetic: most fixtures are priced like a normal market, and a few carry a deliberate mispricing so that the scan has something to flag.

Screen 1 of 6

How it works

The path from feed to alert first, then a closer look at the two parts that decide whether a number can be trusted: matching listings across sources and splitting the budget.

From feed to alert

Every 30 seconds by default, one asyncio cycle collects all sources at once. Each batch is stored as a snapshot in SQLite, in WAL mode so the API keeps reading while collectors write. The snapshots are matched into fixtures and scanned for splits, and the results feed both the REST API behind the PWA and the per-user Telegram alerts.

Data path

  1. Odds feedsJSON and WAMP over WebSocket, JSON over HTTPS
  2. Collectorsasyncio, every 30 s, one error boundary per source
  3. SnapshotsSQLite in WAL mode, health per source
  4. League mappingprovider ids to canonical leagues
  5. Event matchernormalised names, kickoff ±3 h
  6. Stake solverinteger kuruş, closed form + one-kuruş repair
  7. PWA + TelegramREST API, per-user alerts

One match, many names

Sources disagree on almost everything that identifies a match. One adds the league's sponsor, another a country prefix; one writes Fenerbahçe SK, another Fenerbahce; kickoff times shift with how each feed handles time zones. A wrong match is worse than a missed one: two different games read as one show a price gap that does not exist. So the matcher is conservative by design.

  • Leagues first. Each source's competition is mapped to one canonical league, automatically when the provider id or the curated registry leaves no doubt, otherwise through the review queue. An unmapped competition never matches across sources.
  • Names are normalised before they are compared: Turkish letters folded, İ and ı included; suffixes such as SK, FC or A.Ş. dropped; Turkish city names mapped, so Münih and München both become munich; known aliases expanded.
  • Two listings match only inside the same league, with kickoffs at most 3 hours apart, and only when both the home and the away names agree: equal, one inside the other, one's words a subset of the other's, or a fuzzy similarity of at least 0.84. For one source whose kickoff times can be off, the football window widens to 24 hours, and then the names must be equal or one must contain the other.
  • A matched fixture holds at most one listing per source. Its id is a hash of the league, the normalised names and the kickoff date, so it stays the same from one cycle to the next.
  • Source C

    League as sent
    Trendyol Süper Lig
    Match as sent
    Galatasaray A.Ş. – Fenerbahçe SK
    Compared as
    galatasaray – fenerbahce
  • Source B

    League as sent
    Super Lig
    Match as sent
    Galatasaray – Fenerbahce
    Compared as
    galatasaray – fenerbahce
  • Source A

    League as sent
    Türkiye Süper Lig
    Match as sent
    Galatasaray Istanbul – Fenerbahce SK
    Compared as
    galatasaray – fenerbahce
  • same canonical league
  • kickoffs within 3 hours
  • home and away names agree

One fixture galatasaray – fenerbahce

prices from Sources A, B and C side by side

Three listings from the matcher's own test, merged into one fixture.

Splitting a budget to the kuruş

Decimal odds of 2.90 imply a probability of 1 ÷ 2.90, about 34.48%. Take one price for each outcome and add up the implied probabilities. A normal market comes to more than 100%; the excess is its margin. Below 100%, staking each outcome in proportion to 1 ÷ odds makes every outcome pay out the same amount: the budget divided by that sum.

That is the real-number answer. Real stakes are whole kuruş, a payout is rounded down to the kuruş, and each source has its own minimum stake, maximum stake and balance. The equal split is only where the search starts.

Illustrative numbers

Try a split

The default prices come from the project's own tests. Change any of them and the split is recomputed in your browser with the live scan's seed and repair steps, minus the adjustment the scan makes for a few sources. It fetches no prices and sends nothing anywhere.

Amounts are in TL. The exact share is the real-number split, cut at four decimals; the stake is in whole kuruş; a payout is stake × odds rounded down to the kuruş.

Try a split
Home (1)Draw (X)Away (2)
Odds
Implied probability34.48%27.78%32.26%
Exact share364.8251293.8868341.2880
Stake364.82293.89341.29
Payout if it wins1,057.971,058.001,057.99
Payout − budget+57.97 (worst outcome)+58.00+57.99

The implied probabilities add up to 94.52%, under 100%.

Without rounding, every outcome would pay out 1,057.9928 TL. In whole kuruş, the smallest payout is 1,057.97 TL.

No one-kuruş move improved the largest-remainder seed.

At 850 TL the seed leaves the worst outcome one kuruş lower than it could be, and the repair moves one kuruş to fix it.

In the live scan

Each cycle tries every combination of sources for every matched fixture. Instead of a CP-SAT model per combination, the scan uses the closed form: one kuruş reserved per outcome, the rest shared by 1 ÷ odds and floored, the kuruş left over given to the largest remainders, then one-kuruş moves between outcomes while they improve the worst outcome. Its tests compare the result with an exhaustive search over small budgets. For a few sources the scan also adjusts that share for source-specific terms; the calculator above runs the same steps without that adjustment.

In the exact model

For manual analysis, an OR-Tools CP-SAT model solves the problem with everything the closed form leaves out: integer stakes, payouts as stake × odds ÷ 10,000 rounded down (a division constraint on odds scaled to integers), per-source minimum and maximum stakes, balances and payout caps. It maximises the worst outcome, then minimises the total stake, then fixes home, draw and away in turn, with one worker and a fixed seed, so the same input always gives the same split. An independent integer check recomputes every outcome before the answer is accepted.

Where the arithmetic stops

  • It holds only for the prices as they were read, and prices move between collections.
  • A source can refuse or cap a stake. The tool never places one; it only reads public prices.
  • Two brands on the same backend count as one operator, so the scan skips combinations that only spread a budget across brands of one operator.

When a source misbehaves

The collectors run concurrently in one asyncio cycle, each inside its own error boundary. A timeout or a refused connection is written against that source, and the others carry on. Each source's health is a small state machine.

  • Every price carries the time it was read; anything older than the staleness limit is left out of the scan.
  • Fixtures leave the snapshots 15 minutes after kickoff; the tool only looks at pre-match prices.
  • A refresh from the app runs at most once every 10 seconds, so the interface cannot hammer the sources.
  • Accounts are invite-only. Passwords are hashed with Argon2id, every write carries a CSRF token, and logins are rate-limited.

Unavailable

Has never delivered a batch. A source with no endpoint configured stays here.

From Unavailable to Healthy: first good batch

Healthy

The last run succeeded; its batch is the one in use.

From Healthy to Degraded: a failed run

From Degraded back to Healthy: next good batch

Degraded

The last run failed. The last good batch is kept, and alerts that depend on this source wait.

Source health as a state machine

One Telegram message per opportunity

Alerts are per user: each person sets a threshold and the leagues to follow. Every opportunity keeps one message that is edited as prices move, instead of a stream of new ones. The card's fingerprint uses the percentage shown on it, rounded to 25 basis points, and leaves out the ticking price age, so small wobbles do not cause an edit, and two edits of one message are at least 60 seconds apart. The PWA shows the same opportunities, can be installed, and says when it is offline.

New
First seen above the user's threshold; the card is sent.
Active
Still above the threshold; the same message is edited, with a reply under it when the card's percentage moves enough.
Watch
Below the threshold, but above the close floor at 80% of it.
Stale
A source behind it is not healthy; the card waits instead of closing.
Closed
Under the close floor for 180 seconds in a row, or the match kicked off. If it comes back within 15 minutes, the same message reopens.

In numbers

1,209
backend tests collected by pytest: unit, property, integration and acceptance
21
Vitest test files for the PWA
6
sources, anonymised as Source A to F
3
hours: the kickoff window for two listings to count as one match
10,000
the factor odds are scaled by, so every payout is integer arithmetic
60
seconds at least between two edits of one Telegram message

Decisions and what they cost

About this project

Surebet Analyzer is a technical study in data matching and exact arithmetic. It only reads public odds: it never logs in to an account, places a bet or moves money. The sources are anonymised as Source A to F, the public code ships no provider addresses, and this page names and links no bookmaker. Nothing here is advice or a promise of any return.

Next projectBlindyA dating app where two students talk anonymously for seven minutes before either of them sees a face.