Event-driven research and paper-trading system for one tennis prediction-market hypothesis: that markets overreact when a trailing underdog breaks serve.
It joins live tennis scores to live prediction-market prices, labels breaks of serve, measures the breaker's implied-probability jump, and fades sufficiently large moves subject to spread, liquidity, position, exposure, and stop-loss controls.
It runs on public data only. No exchange account, no API key, no credentials of any kind. Execution is paper-only and there is no live-order endpoint.
The headline result comes first because it is the point of the project.
Measured over 794 settled ATP and WTA match markets (July–September 2026), the hypothesis does not hold up. After an underdog contract jumps sharply — the price signature of the underdog breaking serve — the move does not systematically revert:
| Horizon | Jumps | Controls | Matches | Effect | Std err | t |
|---|---|---|---|---|---|---|
| 5 min | 5369 | 72378 | 547 | +0.0037 | 0.0031 | +1.21 |
| 10 min | 5128 | 71227 | 543 | +0.0014 | 0.0045 | +0.31 |
| 15 min | 4867 | 70244 | 542 | +0.0059 | 0.0059 | +0.99 |
| 20 min | 4588 | 69294 | 537 | +0.0055 | 0.0069 | +0.80 |
| 30 min | 4054 | 67597 | 516 | +0.0095 | 0.0083 | +1.14 |
effect is the mean change in the underdog's own YES mid over the horizon, minus a
control drawn from quiet minutes in the same five-point probability bucket. Negative
would mean reversion, which is what fading needs. Every estimate here is positive
— mild continuation — and none is statistically distinguishable from zero.
Two things make this conclusive enough to act on:
- The sign is wrong. The strategy buys the opposite side of the jump. A positive effect means that trade loses on drift, not just on costs.
- Every effect is smaller than the spread. The mean bid/ask spread at the entry minute is 0.0169 and the median is 0.0100. The largest effect in the table, +0.0095 at thirty minutes, does not cover one crossing of the book, let alone a round trip.
Raising the jump threshold to 0.12 flips some signs negative (−0.0095 at ten minutes,
t = −1.26) but that is one cell out of twenty tested at four thresholds, it stays inside
noise, and it still does not clear the spread. Reproduce any of it with
breakpoint fetch-history && breakpoint assess.
The one place an edge could still hide is below this study's resolution. Kalshi's historical candlesticks are one-minute bars, while the strategy's observation window is seconds. An overreaction that fully decays inside sixty seconds would be invisible here. Testing that needs sub-minute data, which nobody publishes — you have to record it. That is what live mode does, and why it writes every observation to the event log.
So: run it as a data-collection and research harness. Do not run it expecting edge.
| What | Source | Auth |
|---|---|---|
| Market prices | Kalshi REST market data | none |
| Historical prices | Kalshi candlesticks, 1-minute | none |
| Live scores and server | ESPN public tennis scoreboard | none |
Kalshi's REST market-data endpoints — /markets, /orderbook, /trades,
/candlesticks — are unauthenticated and cover every market on the exchange. Its
WebSocket feed authenticates even for public channels, which is why this project polls
REST instead: one request per interval returns every market being watched, comfortably
inside the documented ~30 requests/second cap for public market data.
/markets reports the book in YES terms already (yes_bid_dollars, yes_ask_dollars),
so no NO-side conversion happens anywhere in the price path. That matters, because the
two sides of a Kalshi book are quoted in different denominations: the no side carries
NO bids, and a YES/NO pair sums to one, so a naive reading of the raw order book as if
both sides were YES prices produces an "ask" below the bid and silently publishes nothing
at all.
There is no free live point-by-point tennis feed. Vendor free tiers exclude it, and the scraped endpoints that carry it are blocked. None of that is needed, because a break of serve is visible in game-level scores:
- ESPN publishes per-set game counts and a
possessionflag naming the server. - Service alternates every game for the whole match, so one populated
possessionreading plus the number of games played fixes the parity, and the server of every later game follows from the game count alone. - A completed game is a game-count increment between polls. A break is a completed game the receiver won.
possession is only populated in roughly two thirds of polls, and often not at the
moment a game ends, which is exactly why the parity is derived rather than read. Parity
readings accumulate as votes rather than the newest one winning: a scoreboard briefly
disagrees with itself around changeovers, updating possession for the next game before
the line score records the last one, and a single stale reading that flipped the parity
would invert the hold/break label on every remaining game of the match. When the vote is
close to a coin flip, no label is emitted at all.
This was validated against a live match (Zverev vs Khachanov, US Open semifinal, 11 September 2026): the majority vote correctly labelled a hold that the newest-reading approach had mislabelled as a break.
Python 3.11 or newer.
git clone https://github.com/abelianbee/breakpoint.git
cd breakpoint
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
breakpoint serveOpen http://127.0.0.1:8000. Demo mode runs a deterministic scenario in which a trailing underdog breaks and the paper broker places the contrarian trade. API docs are at /docs.
To watch real matches — still no credentials:
breakpoint markets # open ATP/WTA match markets
BREAKPOINT_MODE=live breakpoint serve # pair live matches, poll prices, paper-tradeOther commands:
breakpoint demo # deterministic scenario, throwaway database
breakpoint backtest data/demo.jsonl # replay a JSONL event file
breakpoint fetch-history # cache settled-market candlesticks
breakpoint assess data/history # run the hypothesis test
breakpoint init-db
pytest -q
ruff check . && ruff format --check .
docker build -t breakpoint . && docker run --rm -p 8000:8000 breakpointpython -m breakpoint is equivalent to the breakpoint script.
BREAKPOINT_MODE selects where data comes from. Score and price events can be POSTed
over HTTP in every mode.
| Mode | Scores | Prices |
|---|---|---|
demo |
built-in simulated match | simulated |
manual |
POST /api/points |
POST /api/prices |
live |
ESPN scoreboard | Kalshi REST |
For each completed service game, BreakPoint captures the latest market midpoint. A break becomes eligible only when the receiver:
- was behind on the lexicographic
(sets, games)score before the game; - had less than 50% implied win probability before the break; and
- gained at least
BREAKPOINT_MIN_BREAKER_JUMPprobability points once the observation window closes.
If eligible, the strategy buys the opposite match outcome at the displayed ask. A wide spread, no displayed size, or any portfolio limit turns the proposed order into a logged skip. Every non-trade is retained, so research is not biased toward fills.
The strategy uses the player mapped to the market's YES outcome. If player B is the YES player, the probability transformation and order direction reverse automatically.
breakpoint assess exists because the obvious version of this test is wrong.
Underdog contracts drift toward zero on their own, because underdogs usually lose and these markets settle at 0 or 1. So "the price fell after the jump" is what a market with no inefficiency whatsoever would also look like, and measuring only post-jump returns would manufacture a signal out of nothing.
Each jump minute is therefore compared against quiet minutes from the same five-point probability bucket, and the reported effect is the difference between them. The control is reweighted to the jump sample's probability distribution. Standard errors cluster by match, because minutes within one match are not independent draws — treating them as independent would shrink the standard errors several-fold and turn noise into significance. Only one of the two mirror-image markets per match is kept, for the same reason.
breakpoint backtest consumes newline-delimited JSON in event-time order: a
registration record per match, then price and point records. See
data/demo.jsonl. Replay calls the same engine methods as live
ingestion, including all risk checks and event logging.
ESPN scoreboard ──> ServeTracker ──> MatchWatcher ──> GameEvent ─┐
(serve parity) (game diffs) ├─> BreakFadeStrategy
Kalshi REST ──────> normalized YES top of book ──────────────────┘ │
v
Risk ──> PaperBroker
│
SQLite event log <───────────┘
│
API + dashboard
feeds/espn.py— public scoreboard, serve parity, completed-game detectionfeeds/kalshi_rest.py— public market data, normalized YES priceslive.py— pairs scoreboard matches to markets by player identityscoring.py— point-driven state machine and break labelsstrategy.py— the hypothesis as auditable eligibility rulesexecution.py— paper fills and portfolio constraintsengine.py— shared point-driven and snapshot-driven pipelineresearch/— historical collection and the hypothesis testdatabase.py— append-only SQLite persistenceapp.py— ingestion API and dashboard service
Match pairing refuses partial matches. A market that resolves only one of the two players ESPN is reporting is skipped rather than guessed at, because a mis-paired market would trade the wrong side of a match.
All settings use the BREAKPOINT_ prefix; see .env.example.
Probabilities are decimals from 0 to 1, exposure and stop loss are dollar amounts, and
order size and position limits are contract counts.
ModuleNotFoundError: No module named 'breakpoint' after pip install -e . —
Python 3.13 and newer silently skip .pth files carrying the macOS hidden flag, which
breaks editable installs. Some developer tools set that flag on every gitignored path,
.venv included, and re-set it after you clear it. Check with:
ls -lO .venv/lib/python3.*/site-packages/_editable_impl_breakpoint_trading.pthIf it says hidden, chflags nohidden <that path> fixes it until the tool runs again.
The durable fix is a virtualenv outside the repository, where nothing marks it:
python3 -m venv ~/.virtualenvs/breakpoint
~/.virtualenvs/breakpoint/bin/pip install -e '.[dev]'Live mode registers no matches — there may be no ATP or WTA singles match in
progress; check breakpoint markets. Pairing also needs both of a match's Kalshi
markets to resolve to the two players ESPN names.
No breaks appear during a live match — break labels are withheld until the serve parity settles, and while more than one game elapses between polls. Both are logged.
- Minute-resolution history cannot see sub-minute overreaction, the one regime left untested.
- The historical study uses price jumps as a proxy for breaks. It cannot separate a break from an injury, a medical timeout, or a weather suspension.
- Three months of data from one exchange, one sport, mid-2026.
- Mid-price accounting throughout: no exchange fees, queue position, or fill probability.
- Markets settle at 0 or 1; dashboard P&L is unrealized mark-to-market, not settlement.
- ESPN's endpoint is public but undocumented and carries no stability guarantee.
- Bind to
127.0.0.1unless you add authentication and TLS in front of ingestion. - One process per SQLite database.
MIT.
Engineering research and paper trading only. Prediction-market access and sports-wagering rules vary by jurisdiction. Verify applicable platform terms and law before extending this to live execution.