An arbitrage scanner is only as good as its answer to a boring question: are these two listings the same match? Our scanner reads esports odds from 27 books — sportsbooks, crypto sportsbooks, Polymarket and Kalshi. The same CS2 match shows up with different team spellings, different IDs, different tournament names and start times that disagree by minutes. Get the match wrong in one direction and you miss arbs. Get it wrong in the other and you show an "arb" between two different matches — and someone bets real money on it. A false merge is worse than a split, and most of the design follows from that.
The pipeline is a pure function
Every tick, the resolver hands the matcher the full current snapshot of all books and gets back a world of clusters plus proposals for a human. The matcher does no I/O, reads no database, and does not look at the clock. The same input always produces the same world.
That was not always true. The previous core kept a long-lived index in Postgres, mutated it during the cycle and minted IDs into it, so the result depended on event order and on what had already been written. Recomputing the world from scratch every tick turned out to be cheaper to reason about and fast enough: the whole working set is in memory anyway.
The stages: normalize names → resolve discipline → apply identity rules → group into time slots → find splits by anchor → decide → merge for this tick only.
1. Normalize names, but not too much
Team names go through Unicode NFKD, combining marks are stripped, full case-folding is applied, punctuation becomes spaces, and whitespace is collapsed. Then a small set of organisation tokens that do not identify a roster is removed — esports, esport, gaming, team, club — so "PaiN" and "PaiN Gaming" meet. Duo-team connectors ("A x B" vs "A & B") are dropped, and different conventions for women's rosters are mapped to one marker.
Just as important is what is not removed: academy, junior and digits stay, because they mean a different team. Normalization is cached, with the cache reset when it overflows — keys come from third-party feeds, and one broken feed can stream unique strings forever. One small performance lesson: creating a Unicode case-folder per call cost 1.47 s in a resolver profile; pooling them removed it.
2. Time slots anchored to the first event
Within a pair of normalized names, events are cut into time slots with a 90-minute window. The slot boundary is measured from the slot's anchor, not chained from event to event; otherwise events an hour apart would chain into one slot of unbounded width.
Live events get a directional tolerance of six hours backwards, because some books report a live match without an honest start time. That tolerance created a subtle bug: with "first slot that fits", the live leg of game two landed in game one's slot, and the cluster combined prematch odds from one game with live odds from another. The fix is to choose the nearest compatible slot, which also means the normal window always beats the tolerance.
3. Find splits by a shared opponent, not by similar names
String similarity fails in both directions. "NAVI" vs "Natus Vincere" is 18% similar and obviously the same team. "Evil Geniuses" vs "Evil Geniuses GC" is 81% similar and a different roster.
So the matcher first looks for evidence that does not depend on spelling: two groups with a common opponent, the same discipline and close start times, but different names on the other side. That is almost certainly one match listed two ways. Each group is registered under both of its canonical keys in an inverted index, so finding candidates is two hash lookups — a team plays one or two matches at a time. The previous implementation scanned the whole index on every miss, every tick.
Two rules keep the anchor honest:
- Placeholders are never anchors. "TBD" and "Bye" appear in every unfilled bracket slot; letting them in made the candidate check quadratic and proved nothing.
- If one book shows both sides, they are two events (games of a series), not a split.
4. Similarity only decides how fast to merge
Once an anchor says "probably the same match", similarity decides whether to merge now or ask a human. It is deliberately conservative:
- an initialism matches only if one name's token equals the initials of all tokens of the other ("ic" does not match "inner circle academy");
- both names must be at least 5 characters;
- a differing token from a block list —
academy,junior,youth,women, colour markers likeblue/white— means a different team; - a one-sided addition (all tokens of one name contained in the other, like "gc") is vetoed;
- a diff made only of organisation suffixes is fine;
- otherwise, normalized Levenshtein similarity of at least 0.7.
5. Let automation write rules — with guardrails
Merges from a single tick are ephemeral. The durable truth about team identity is one rules file with exactly one writer, the resolver. Humans add rules through bot buttons; automation may also add one, but only with strong evidence that does not depend on spelling:
- starts within 5 minutes — on a sample of 185 multi-book clusters, 90% of real matches started within five minutes of each other across books and 53% in the same minute, while repeated games of the same pairing are about a quarter of an hour apart;
- the books agree on a meaningful tournament token;
- at least two independent feed groups on each side (mirrors of one vendor's line do not count twice);
- at most 20 automatic writes per hour, and a human "never" is an absolute veto.
The one-sided-addition veto exists because of an incident: two names, one being the other plus a regional suffix, were merged and split back and forth, rewriting the file 86 times per second. With the veto, that pair is never auto-merged; and structurally, after a real merge both sides normalize to the same key, so there is nothing left to flip.
6. Decide on the outcome, not on the intention
One last lesson cost us a silent failure. The matcher used to suppress the "please confirm this merge" question whenever it decided an automatic merge would happen. But between that decision and the file on disk sit the hourly limit, the human veto and the write itself — any of them can refuse. When one did, nobody got a question or a summary, and the split stayed invisible. Now the proposal is always created, and it is suppressed only after the write is confirmed.
What it powers
The matched world feeds a server-side arb engine that recomputes every market each cycle, with exchange fees applied per market. Per-book and per-pair statistics from the result are public and update daily: bookmakers, pairs. Why mirrors of one vendor are kept apart: feed groups. The exchange side of the pipeline is in our earlier post on streaming Polymarket and Kalshi order books.
Dozor is an esports arbitrage scanner covering 25 sportsbooks plus Polymarket and Kalshi. It is an information tool, not a bookmaker. 18+.
Top comments (0)